├── .gitignore ├── LICENSE ├── README.md ├── bun.lockb ├── index.html ├── package.json ├── public ├── _redirects ├── app.png └── logo.png ├── src ├── App.vue ├── app.scss ├── assets │ └── img │ │ ├── 404.png │ │ └── logo.png ├── bus.ts ├── components │ ├── Cue.vue │ ├── Discover.vue │ ├── Feed.vue │ ├── Likes.vue │ ├── Main.vue │ ├── Menu.vue │ ├── Page404.vue │ └── Search.vue ├── hooks │ └── index.ts ├── icons.ts ├── main.ts ├── services │ ├── api.ts │ └── cache.ts ├── static │ └── GenresMap.ts ├── store.ts ├── types.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Bouaggad Moez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TuneIn 🎶 2 | 3 | This is a javascript project developed with VueJs. 4 | 5 | Simple and elegant music discovery app 6 | 7 | ⭐ the repo if you like it. 8 | 9 | ## Getting Started 🚀 10 | 11 | - Clone the repo 12 | - Install the dependencies with `npm install` 13 | - Start the development server with `npm run dev` 14 | The App should be running on localhost port 8080 15 | 16 | ## Preview 📸 17 | 18 | ### 19 | 20 | 21 | 22 | ## Contact me 📧 23 | 24 | ### Email : 25 | 26 | ### Website : 27 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/tuneIn/ad391295bc1c3d81ad2c3d9b8a7e48631296f99c/bun.lockb -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | TuneIn 9 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tunein", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build" 8 | }, 9 | "dependencies": { 10 | "boxicons": "^2.0.5", 11 | "fetch-jsonp": "^1.1.3", 12 | "mitt": "^3.0.1", 13 | "pinia": "^2.2.6", 14 | "sass-embedded": "^1.81.0", 15 | "svg-web-component": "^1.0.1", 16 | "vue": "^3.5.13", 17 | "vue-mq": "^1.0.1", 18 | "vue3-mq": "^4.0.0", 19 | "vuex": "^3.1.2" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^22.9.0", 23 | "@vitejs/plugin-vue": "^5.2.0", 24 | "typescript": "^5.6.3", 25 | "vite": "^5.4.11", 26 | "vue-tsc": "^2.1.10" 27 | }, 28 | "browserslist": [ 29 | "> 1%", 30 | "last 2 versions", 31 | "not ie <= 8" 32 | ] 33 | } -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /public/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/tuneIn/ad391295bc1c3d81ad2c3d9b8a7e48631296f99c/public/app.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/tuneIn/ad391295bc1c3d81ad2c3d9b8a7e48631296f99c/public/logo.png -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | 19 | 145 | -------------------------------------------------------------------------------- /src/app.scss: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | margin: 0; 5 | padding: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | font-family: 'Open Sans', sans-serif; 11 | font-weight: 400; 12 | line-height: 1.6; 13 | color: white; 14 | } 15 | 16 | .header { 17 | display: flex; 18 | align-items: center; 19 | &__label { 20 | text-transform: uppercase; 21 | color: #f0e6e8; 22 | font-weight: 400; 23 | padding: 2.5px 0; 24 | font-size: 20px; 25 | letter-spacing: 1px; 26 | } 27 | } 28 | 29 | .fadeIn { 30 | animation: fadeIn 1s both; 31 | } 32 | 33 | .spin { 34 | animation: spin 15s linear infinite; 35 | } 36 | 37 | .signal { 38 | position: absolute; 39 | width: 50px; 40 | height: 50px; 41 | top: 50%; 42 | left: 50%; 43 | border-radius: 50%; 44 | border: 5px solid #db1d40; 45 | margin: -15px 0 0 -15px; 46 | opacity: 0; 47 | animation: pulsate 1s ease-out infinite; 48 | } 49 | 50 | @keyframes spin { 51 | 100% { 52 | transform: rotate(360deg); 53 | } 54 | } 55 | @keyframes loader { 56 | 0% { 57 | transform: rotate(0deg); 58 | } 59 | 100% { 60 | transform: rotate(360deg); 61 | } 62 | } 63 | 64 | @keyframes fadeIn { 65 | from { 66 | opacity: 0; 67 | } 68 | 69 | to { 70 | opacity: 1; 71 | } 72 | } 73 | 74 | @keyframes pulsate { 75 | 0% { 76 | transform: scale(0.1); 77 | opacity: 0; 78 | } 79 | 50% { 80 | opacity: 1; 81 | } 82 | 100% { 83 | transform: scale(1.2); 84 | opacity: 0; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/assets/img/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/tuneIn/ad391295bc1c3d81ad2c3d9b8a7e48631296f99c/src/assets/img/404.png -------------------------------------------------------------------------------- /src/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Datlyfe/tuneIn/ad391295bc1c3d81ad2c3d9b8a7e48631296f99c/src/assets/img/logo.png -------------------------------------------------------------------------------- /src/bus.ts: -------------------------------------------------------------------------------- 1 | import mitt from "mitt"; 2 | import type { Song } from "./types"; 3 | 4 | type Events = { 5 | newCue: Song; 6 | }; 7 | 8 | export const bus = mitt(); 9 | -------------------------------------------------------------------------------- /src/components/Cue.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 81 | 82 | 201 | -------------------------------------------------------------------------------- /src/components/Discover.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 38 | 39 | 74 | -------------------------------------------------------------------------------- /src/components/Feed.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 94 | 95 | 177 | -------------------------------------------------------------------------------- /src/components/Likes.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 45 | 46 | 99 | -------------------------------------------------------------------------------- /src/components/Main.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 31 | -------------------------------------------------------------------------------- /src/components/Menu.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 129 | 130 | 295 | -------------------------------------------------------------------------------- /src/components/Page404.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/Search.vue: -------------------------------------------------------------------------------- 1 | 39 | 68 | 69 | 180 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { watch, ref, onUnmounted, type Ref } from "vue"; 2 | export const useHasImageLoaded = ({ 3 | srcRef, 4 | onLoadRef = ref(null), 5 | onErrorRef = ref(null), 6 | }: { 7 | srcRef: Ref; 8 | // biome-ignore lint/complexity/noBannedTypes: 9 | onLoadRef?: Ref; 10 | // biome-ignore lint/complexity/noBannedTypes: 11 | onErrorRef?: Ref; 12 | }) => { 13 | const hasLoaded = ref(false); 14 | const isMounted = ref(true); 15 | 16 | watch([srcRef, onLoadRef, onErrorRef], ([src, onLoad, onError]) => { 17 | if (!src) return; 18 | const image = new window.Image(); 19 | image.src = src; 20 | 21 | image.onload = (event) => { 22 | if (isMounted.value) { 23 | hasLoaded.value = true; 24 | onLoad?.(event); 25 | } 26 | }; 27 | 28 | image.onerror = (event) => { 29 | if (isMounted.value) { 30 | hasLoaded.value = false; 31 | onError?.(event); 32 | } 33 | }; 34 | }); 35 | 36 | onUnmounted(() => { 37 | isMounted.value = false; 38 | }); 39 | 40 | return hasLoaded; 41 | }; 42 | 43 | export const useHasAudioLoaded = ({ 44 | srcRef, 45 | onLoad, 46 | onError, 47 | }: { 48 | srcRef: Ref; 49 | // biome-ignore lint/complexity/noBannedTypes: 50 | onLoad?: Function; 51 | // biome-ignore lint/complexity/noBannedTypes: 52 | onError?: Function; 53 | }) => { 54 | const hasLoaded = ref(false); 55 | const isMounted = ref(true); 56 | 57 | watch(srcRef, (src) => { 58 | if (!src) return; 59 | hasLoaded.value = false; 60 | const audio = document.createElement("audio"); 61 | audio.src = src; 62 | audio.addEventListener("canplay", () => { 63 | if (isMounted.value) { 64 | hasLoaded.value = true; 65 | onLoad?.(event); 66 | audio.remove(); 67 | } 68 | }); 69 | 70 | audio.onerror = (event) => { 71 | if (isMounted.value) { 72 | hasLoaded.value = false; 73 | onError?.(event); 74 | audio.remove(); 75 | } 76 | }; 77 | }); 78 | 79 | onUnmounted(() => { 80 | isMounted.value = false; 81 | }); 82 | 83 | return hasLoaded; 84 | }; 85 | -------------------------------------------------------------------------------- /src/icons.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | github: ` 3 | `, 4 | search: ` 5 | `, 6 | album: ` 7 | `, 8 | play: ` 9 | `, 10 | pause: ` 11 | `, 12 | cog: ` 13 | `, 14 | star: ` 15 | `, 16 | circle: ` 17 | `, 18 | heart: ` 19 | `, 20 | }; 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | // @ts-expect-error 4 | import { Vue3Mq } from "vue3-mq"; 5 | // @ts-expect-error 6 | import swc from "svg-web-component"; 7 | import "./app.scss"; 8 | import { createPinia } from "pinia"; 9 | import icons from "./icons"; 10 | swc.load(icons); 11 | 12 | const app = createApp(App); 13 | const pinia = createPinia(); 14 | app.use(pinia); 15 | 16 | app.use(Vue3Mq, { 17 | global: true, 18 | }); 19 | 20 | app.mount("#app"); 21 | -------------------------------------------------------------------------------- /src/services/api.ts: -------------------------------------------------------------------------------- 1 | import jsonp from "fetch-jsonp"; 2 | import store from "./cache"; 3 | 4 | const API_BASE_URL = "https://deezer-proxy-d6blegb47.now.sh/"; 5 | 6 | export class ApiService { 7 | call(url: string) { 8 | if (store.has(url)) return store.get(url); 9 | return jsonp(url, { 10 | timeout: 10000, 11 | }) 12 | .then((res) => res.json()) 13 | .then((response) => store.set(response.data, url)); 14 | } 15 | playlist(id: string) { 16 | const url = `${API_BASE_URL}chart/${id}/tracks?output=jsonp`; 17 | return this.call(url); 18 | } 19 | search(term: string) { 20 | const url = `${API_BASE_URL}search?q=${term}&output=jsonp&limit=24`; 21 | return this.call(url); 22 | } 23 | } 24 | 25 | export default new ApiService(); 26 | -------------------------------------------------------------------------------- /src/services/cache.ts: -------------------------------------------------------------------------------- 1 | export default (() => { 2 | const store: Record = {}; 3 | return { 4 | all: () => store, 5 | has: (url: string) => !!store[url], 6 | set: (data: unknown, url: string) => { 7 | store[url] = JSON.stringify(data); 8 | return Promise.resolve(data); 9 | }, 10 | get: (url: string) => Promise.resolve(JSON.parse(store[url])), 11 | }; 12 | })(); 13 | -------------------------------------------------------------------------------- /src/static/GenresMap.ts: -------------------------------------------------------------------------------- 1 | const genres = [ 2 | { 3 | id: "132", 4 | name: "Pop", 5 | playlistId: 2098157264, 6 | }, 7 | { 8 | id: "116", 9 | name: "Rap/Hip Hop", 10 | playlistId: 1450184495, 11 | }, 12 | { 13 | id: "152", 14 | name: "Rock", 15 | playlistId: 1306931615, 16 | }, 17 | { 18 | id: "113", 19 | name: "Dance", 20 | playlistId: 2249258602, 21 | }, 22 | { 23 | id: "165", 24 | name: "R&B", 25 | playlistId: 1282495565, 26 | }, 27 | { 28 | id: "85", 29 | name: "Alternative", 30 | playlistId: 1910358422, 31 | }, 32 | { 33 | id: "106", 34 | name: "Electro", 35 | playlistId: 1807219322, 36 | }, 37 | { 38 | id: "466", 39 | name: "Folk", 40 | playlistId: 1390327745, 41 | }, 42 | { 43 | id: "144", 44 | name: "Reggae", 45 | playlistId: 1273315391, 46 | }, 47 | { 48 | id: "129", 49 | name: "Jazz", 50 | playlistId: 1257789321, 51 | }, 52 | { 53 | id: "52", 54 | name: "French Chanson", 55 | playlistId: 1288071965, 56 | }, 57 | { 58 | id: "98", 59 | name: "Classical", 60 | playlistId: 1787912442, 61 | }, 62 | { 63 | id: "173", 64 | name: "Films/Games", 65 | playlistId: 2158831582, 66 | }, 67 | { 68 | id: "464", 69 | name: "Metal", 70 | playlistId: 1050179021, 71 | }, 72 | { 73 | id: "169", 74 | name: "Soul & Funk", 75 | playlistId: 1257789321, 76 | }, 77 | { 78 | id: "2", 79 | name: "African Music", 80 | playlistId: 2708423744, 81 | }, 82 | { 83 | id: "12", 84 | name: "Arabic Music", 85 | playlistId: 4128412802, 86 | }, 87 | { 88 | id: "16", 89 | name: "Asian Music", 90 | playlistId: 3030483942, 91 | }, 92 | { 93 | id: "153", 94 | name: "Blues", 95 | playlistId: 1689177971, 96 | }, 97 | { 98 | id: "75", 99 | name: "Brazilian Music", 100 | playlistId: 2988016026, 101 | }, 102 | { 103 | id: "81", 104 | name: "Indian Music", 105 | playlistId: 3969048206, 106 | }, 107 | { 108 | id: "95", 109 | name: "Kids", 110 | playlistId: 2134531422, 111 | }, 112 | { 113 | id: "197", 114 | name: "Latin Music", 115 | playlistId: 1128625503, 116 | }, 117 | ]; 118 | 119 | export default genres; 120 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import type { Song } from "./types"; 3 | 4 | export const useStore = defineStore("store", { 5 | state: () => { 6 | return { 7 | currentView: "discover", 8 | likes: JSON.parse(localStorage.getItem("likes") ?? "[]") as Song[], 9 | }; 10 | }, 11 | 12 | actions: { 13 | setCurrentView(view: string) { 14 | this.currentView = view; 15 | }, 16 | likeSong(song: Song) { 17 | this.likes.push(song); 18 | localStorage.setItem("likes", JSON.stringify(this.likes)); 19 | }, 20 | unlike(song: Song) { 21 | this.likes = this.likes.filter((tune) => tune.id !== song.id); 22 | localStorage.setItem("likes", JSON.stringify(this.likes)); 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Song { 2 | id: string; 3 | title: string; 4 | artist: { 5 | name: string; 6 | }; 7 | preview: string; 8 | album: { 9 | cover_medium: string; 10 | cover_small: string; 11 | }; 12 | } 13 | 14 | export interface Playlist { 15 | data: Song[]; 16 | } 17 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["src"], 27 | "references": [{ "path": "./tsconfig.node.json" }] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { resolve } from "node:path"; 3 | import vue from "@vitejs/plugin-vue"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | vue({ 8 | template: { 9 | compilerOptions: { 10 | isCustomElement: (tag) => tag.includes("svg-icon"), 11 | }, 12 | }, 13 | }), 14 | ], 15 | resolve: { 16 | alias: { 17 | "@": resolve(__dirname, "src"), 18 | }, 19 | }, 20 | }); 21 | --------------------------------------------------------------------------------