├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── screenshots │ ├── artist.png │ ├── browse.png │ ├── light-theme.png │ ├── liked-tracks.png │ ├── playlist.png │ ├── radio-gen.png │ ├── search-2.png │ └── search.png └── workflows │ ├── publish.yaml │ └── quality-checks.yaml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.yaml ├── README.md ├── build ├── entitlements.mac.plist ├── icon.icns ├── icon.ico └── icon.png ├── electron-builder.yml ├── electron.vite.config.ts ├── package-lock.json ├── package.json ├── resources ├── app-icon │ ├── dark-192.png │ ├── dark-500.ico │ ├── dark-500.png │ ├── dark-big.png │ ├── light-192.png │ ├── light-500.ico │ ├── light-500.png │ └── light-big.png └── media-icon │ ├── dark-nexticon.png │ ├── dark-pauseicon.png │ ├── dark-playicon.png │ ├── dark-previcon.png │ ├── light-nexticon.png │ ├── light-pauseicon.png │ ├── light-playicon.png │ └── light-previcon.png ├── src ├── main │ ├── AuthFunctions.ts │ ├── Directories.ts │ ├── NodeFunctions.ts │ ├── index.ts │ ├── ipcFunctions.ts │ └── utils.ts ├── preload │ ├── index.d.ts │ └── index.ts └── renderer │ ├── assets │ ├── app-icon │ │ ├── dark-500.png │ │ └── light-500.png │ ├── cover.jpg │ ├── liked │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ ├── 6.png │ │ └── 7.png │ ├── notfound │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ ├── 6.png │ │ └── 7.png │ └── user │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ ├── 6.png │ │ └── 7.png │ ├── index.html │ └── src │ ├── App.vue │ ├── components │ ├── ArtistsSpan.vue │ ├── Authentication.vue │ ├── CollectionButtons.vue │ ├── GlowImage.vue │ ├── HorizontalScroller.vue │ ├── LineChart.vue │ ├── PlaylistHeader.vue │ ├── QueueButton.vue │ ├── Spacer.vue │ ├── dialogs │ │ ├── CreatePlaylistDialog.vue │ │ ├── Dialogs.vue │ │ ├── EditInfoDialog.vue │ │ ├── ItemContextMenu.vue │ │ ├── Notification.vue │ │ └── SourceDialog.vue │ ├── item │ │ ├── HighlightCard.vue │ │ ├── ItemCard.vue │ │ ├── ItemMenu.vue │ │ ├── ItemPlayButton.vue │ │ └── LikeButton.vue │ ├── main-ui │ │ ├── LeftNavigation.vue │ │ ├── TopMenu.vue │ │ └── UserMenu.vue │ ├── player │ │ ├── BottomMusicPlayer.vue │ │ ├── CompactProgressBar.vue │ │ ├── MusicPlayer.vue │ │ ├── PlayButton.vue │ │ ├── ProgressBar.vue │ │ ├── SimplePlayer.vue │ │ ├── SimpleProgressBar.vue │ │ └── SimpleYtPlayer.vue │ ├── search │ │ ├── SearchBar.vue │ │ ├── SearchSuggestionSection.vue │ │ └── SearchSuggestions.vue │ └── track-list │ │ ├── TrackList.vue │ │ ├── TrackListExpander.vue │ │ ├── TrackListItem.vue │ │ └── TrackListVirtual.vue │ ├── env.d.ts │ ├── main.ts │ ├── scripts │ ├── database.ts │ ├── image-sources.ts │ ├── item-utils.ts │ ├── router.ts │ ├── theme.ts │ ├── types.ts │ └── utils.ts │ ├── store │ ├── UI │ │ ├── UIStore.ts │ │ ├── dialogStore.ts │ │ └── update.ts │ ├── backupStore.ts │ ├── base.ts │ ├── electron.ts │ ├── library.ts │ ├── player │ │ ├── playStats.ts │ │ ├── player.ts │ │ └── trackLoader.ts │ ├── ruurd-auth.ts │ ├── search.ts │ ├── spotify-api.ts │ └── spotify-auth.ts │ └── views │ ├── Home.vue │ ├── Library.vue │ ├── Login.vue │ ├── Search.vue │ ├── Settings.vue │ ├── Wrapped.vue │ ├── browse │ ├── Browse.vue │ ├── Category.vue │ ├── Radio.vue │ └── Tune.vue │ ├── item │ ├── Album.vue │ ├── Artist.vue │ ├── Playlist.vue │ └── User.vue │ └── library │ ├── Albums.vue │ ├── Artists.vue │ ├── Playlists.vue │ └── Tracks.vue ├── tsconfig.json ├── tsconfig.node.json └── tsconfig.web.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .gitignore 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:vue/vue3-recommended', 8 | '@electron-toolkit', 9 | '@electron-toolkit/eslint-config-ts/eslint-recommended', 10 | '@vue/eslint-config-typescript/recommended', 11 | '@vue/eslint-config-prettier' 12 | ], 13 | rules: { 14 | 'vue/require-default-prop': 'off', 15 | 'vue/multi-word-component-names': 'off' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/screenshots/artist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/.github/screenshots/artist.png -------------------------------------------------------------------------------- /.github/screenshots/browse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/.github/screenshots/browse.png -------------------------------------------------------------------------------- /.github/screenshots/light-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/.github/screenshots/light-theme.png -------------------------------------------------------------------------------- /.github/screenshots/liked-tracks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/.github/screenshots/liked-tracks.png -------------------------------------------------------------------------------- /.github/screenshots/playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/.github/screenshots/playlist.png -------------------------------------------------------------------------------- /.github/screenshots/radio-gen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/.github/screenshots/radio-gen.png -------------------------------------------------------------------------------- /.github/screenshots/search-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/.github/screenshots/search-2.png -------------------------------------------------------------------------------- /.github/screenshots/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/.github/screenshots/search.png -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Quality Checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '23' 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Publish GitHub release. 25 | env: 26 | GH_TOKEN: ${{github_token_here}} 27 | run: npm run publish:linux 28 | -------------------------------------------------------------------------------- /.github/workflows/quality-checks.yaml: -------------------------------------------------------------------------------- 1 | name: Quality Checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint-prettier-type-check: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '23' 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Run lint 25 | run: npm run lint 26 | 27 | - name: Run prettier check 28 | run: npm run prettier:check 29 | 30 | - name: Run type-check 31 | run: npm run type-check 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .DS_Store 5 | *.log* 6 | .idea 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | electron_mirror=https://npmmirror.com/mirrors/electron/ 2 | electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 100 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruurd Music 2 | 3 | Music player built with Electron, Vue 3, and Vuetify. 4 | 5 | - Listen to your Spotify library without ads, no premium account needed. 6 | - Add YouTube tracks to your liked tracks, in case they're not available on Spotify. 7 | - MP3 files with album art and metadata are stored for every track, so you can take them anywhere. 8 | - Includes Spotify features such as: 9 | - Track and artist radio 10 | - Access discover weekly and other discovery playlists 11 | - Curated homepage, showing relevant playlists, recently played, recommended content, and new releases. 12 | - Browse categories, such as workout, where you can find many workout playlists 13 | - Generate a custom radio, based on given genres and many more options 14 | 15 | ## How to use 16 | 17 | 1. Get the latest release for your OS from https://github.com/RuurdBijlsma/Music/releases 18 | 19 | 2. Create a Spotify api project with one of the redirect urls being: http://localhost:38900 20 | 21 | - Explanation: https://developer.spotify.com/documentation/web-api 22 | 23 | 3. On startup of the application fill in your api keys and log in. 24 | 25 | ### Extra installation steps, only for Linux 26 | 27 | 1. When using Arch, install `libxcrypt-compat` 28 | 2. Make sure `ffmpeg` is in your PATH 29 | 30 | ### Liked tracks 31 | 32 | ![Liked tracks](/.github/screenshots/liked-tracks.png?raw=true 'Homepage') 33 | 34 | ### Light and dark theme support 35 | 36 | ![Homepage](/.github/screenshots/light-theme.png?raw=true 'Home page') 37 | 38 | ### Playlist page 39 | 40 | ![Playlist tracks](/.github/screenshots/playlist.png?raw=true 'Playlist') 41 | 42 | ### Search 43 | 44 | #### Suggestions 45 | 46 | ![Search suggestions](/.github/screenshots/search.png?raw=true 'Search suggestions') 47 | 48 | #### Search includes liked tracks, Spotify tracks, playlists, albums, artists and YouTube tracks. 49 | 50 | ![Search](/.github/screenshots/search-2.png?raw=true 'Search page') 51 | 52 | ### Artist page 53 | 54 | ![Artist page](/.github/screenshots/artist.png?raw=true 'Artist page') 55 | 56 | ### Browse page 57 | 58 | ![Browse tracks](/.github/screenshots/browse.png?raw=true 'Browse') 59 | 60 | ### Custom radio generator 61 | 62 | ![Custom radio generator](/.github/screenshots/radio-gen.png?raw=true 'Custom radio generator') 63 | -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/build/icon.ico -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/build/icon.png -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: dev.ruurd.music 2 | productName: 'Ruurd Music' 3 | directories: 4 | buildResources: build 5 | files: 6 | - '!**/.vscode/*' 7 | - '!src/*' 8 | - '!electron.vite.config.{js,ts,mjs,cjs}' 9 | - '!{.eslintrc.cjs,dev-app-update.yml,CHANGELOG.md,README.md}' 10 | - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' 11 | - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' 12 | asarUnpack: 13 | - resources/** 14 | win: 15 | executableName: ruurd-music 16 | nsis: 17 | artifactName: ${name}-${version}-setup.${ext} 18 | shortcutName: ${productName} 19 | uninstallDisplayName: ${productName} 20 | createDesktopShortcut: always 21 | mac: 22 | entitlementsInherit: build/entitlements.mac.plist 23 | extendInfo: 24 | - NSCameraUsageDescription: Application requests access to the device's camera. 25 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 26 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 27 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 28 | notarize: false 29 | dmg: 30 | artifactName: ${name}-${version}.${ext} 31 | linux: 32 | target: 33 | - AppImage 34 | - snap 35 | - deb 36 | maintainer: ruurd.dev 37 | category: Utility 38 | appImage: 39 | artifactName: ${name}-${version}.${ext} 40 | npmRebuild: false 41 | publish: 42 | provider: github 43 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import vuetify from 'vite-plugin-vuetify' 5 | 6 | export default defineConfig({ 7 | main: { 8 | plugins: [externalizeDepsPlugin()] 9 | }, 10 | preload: { 11 | plugins: [externalizeDepsPlugin()] 12 | }, 13 | renderer: { 14 | resolve: { 15 | alias: { 16 | '@renderer': resolve('src/renderer/src') 17 | } 18 | }, 19 | plugins: [vue(), vuetify({ autoImport: true })] 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ruurd-music", 3 | "version": "6.11.3", 4 | "description": "A streaming music player", 5 | "main": "./out/main/index.js", 6 | "keywords": [ 7 | "Music", 8 | "Streaming", 9 | "Spotify" 10 | ], 11 | "author": { 12 | "email": "ruurd@bijlsma.dev", 13 | "name": "Ruurd Bijlsma", 14 | "url": "ruurd.dev" 15 | }, 16 | "repository": "https://github.com/ruurdbijlsma/Music", 17 | "homepage": "https://github.com/ruurdbijlsma/Music", 18 | "scripts": { 19 | "format": "prettier --write .", 20 | "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix", 21 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", 22 | "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", 23 | "typecheck": "npm run typecheck:node && npm run typecheck:web", 24 | "start": "electron-vite preview", 25 | "dev": "electron-vite dev", 26 | "build": "electron-vite build", 27 | "postinstall": "electron-builder install-app-deps", 28 | "build:unpack": "npm run build && electron-builder --dir", 29 | "build:win": "npm run build && electron-builder --win", 30 | "build:mac": "npm run build && electron-builder --mac", 31 | "build:linux": "npm run build && electron-builder --linux", 32 | "publish:win": "npm run build && electron-builder --win --config --publish always", 33 | "publish:linux": "npm run build && electron-builder --linux --config --publish always", 34 | "publish:mac": "npm run build && electron-builder --mac --config --publish always" 35 | }, 36 | "dependencies": { 37 | "@electron-toolkit/preload": "^3.0.0", 38 | "@electron-toolkit/utils": "^3.0.0", 39 | "chart.js": "^4.4.4", 40 | "chartjs-adapter-date-fns": "^3.0.0", 41 | "colorthief": "^2.4.0", 42 | "date-fns": "^4.1.0", 43 | "electron-log": "^5.2.0", 44 | "electron-updater": "^6.1.7", 45 | "events": "^3.3.0", 46 | "express": "^4.21.0", 47 | "ffbinaries": "^1.1.6", 48 | "filenamify": "^6.0.0", 49 | "iconv-lite": "^0.6.3", 50 | "idb": "^8.0.0", 51 | "pinia": "^2.2.4", 52 | "replace-special-characters": "^1.2.7", 53 | "spotify-web-api-js": "github:RuurdBijlsma/spotify-web-api-js", 54 | "vite-plugin-vuetify": "^2.0.4", 55 | "vue-chartjs": "^5.3.1", 56 | "vue-router": "^4.4.5", 57 | "vuetify": "^3.3.23", 58 | "youtube-search-api": "^1.2.2", 59 | "yt-dlp-wrap": "^2.3.12" 60 | }, 61 | "devDependencies": { 62 | "@electron-toolkit/eslint-config": "^1.0.2", 63 | "@electron-toolkit/eslint-config-ts": "^2.0.0", 64 | "@electron-toolkit/tsconfig": "^1.0.1", 65 | "@mdi/font": "^7.4.47", 66 | "@rushstack/eslint-patch": "^1.10.3", 67 | "@types/node": "^20.14.8", 68 | "@vitejs/plugin-vue": "^5.0.5", 69 | "@vue/eslint-config-prettier": "^9.0.0", 70 | "@vue/eslint-config-typescript": "^13.0.0", 71 | "electron": "^31.0.2", 72 | "electron-builder": "^24.13.3", 73 | "electron-vite": "^2.3.0", 74 | "eslint": "^8.57.0", 75 | "eslint-plugin-vue": "^9.26.0", 76 | "less": "^4.2.0", 77 | "prettier": "^3.3.2", 78 | "typescript": "^5.5.2", 79 | "vite": "^5.3.1", 80 | "vue": "^3.4.30", 81 | "vue-tsc": "^2.0.22" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /resources/app-icon/dark-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/app-icon/dark-192.png -------------------------------------------------------------------------------- /resources/app-icon/dark-500.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/app-icon/dark-500.ico -------------------------------------------------------------------------------- /resources/app-icon/dark-500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/app-icon/dark-500.png -------------------------------------------------------------------------------- /resources/app-icon/dark-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/app-icon/dark-big.png -------------------------------------------------------------------------------- /resources/app-icon/light-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/app-icon/light-192.png -------------------------------------------------------------------------------- /resources/app-icon/light-500.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/app-icon/light-500.ico -------------------------------------------------------------------------------- /resources/app-icon/light-500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/app-icon/light-500.png -------------------------------------------------------------------------------- /resources/app-icon/light-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/app-icon/light-big.png -------------------------------------------------------------------------------- /resources/media-icon/dark-nexticon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/media-icon/dark-nexticon.png -------------------------------------------------------------------------------- /resources/media-icon/dark-pauseicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/media-icon/dark-pauseicon.png -------------------------------------------------------------------------------- /resources/media-icon/dark-playicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/media-icon/dark-playicon.png -------------------------------------------------------------------------------- /resources/media-icon/dark-previcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/media-icon/dark-previcon.png -------------------------------------------------------------------------------- /resources/media-icon/light-nexticon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/media-icon/light-nexticon.png -------------------------------------------------------------------------------- /resources/media-icon/light-pauseicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/media-icon/light-pauseicon.png -------------------------------------------------------------------------------- /resources/media-icon/light-playicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/media-icon/light-playicon.png -------------------------------------------------------------------------------- /resources/media-icon/light-previcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/resources/media-icon/light-previcon.png -------------------------------------------------------------------------------- /src/main/AuthFunctions.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, shell } from 'electron' 2 | import express from 'express' 3 | import http, { Server } from 'http' 4 | import log from 'electron-log/main' 5 | 6 | interface AuthToken { 7 | code: null | string 8 | access: null | string 9 | refresh: null | string 10 | expiryDate: null | number 11 | } 12 | 13 | export default class SpotifyAuth { 14 | private win: BrowserWindow 15 | private server: Server | null = null 16 | 17 | constructor(win: BrowserWindow) { 18 | this.win = win 19 | } 20 | 21 | resetSpotifyLogin() { 22 | if (this.server !== null) { 23 | this.server.close() 24 | this.server = null 25 | } 26 | } 27 | 28 | async getAuthByCode( 29 | redirectUrl: string, 30 | code: string, 31 | clientId: string, 32 | secret: string 33 | ): Promise { 34 | const result = await ( 35 | await fetch(`https://accounts.spotify.com/api/token`, { 36 | method: 'post', 37 | body: 38 | `grant_type=authorization_code&code=${code}&redirect_uri=${redirectUrl}&client_id=` + 39 | `${clientId}&client_secret=${secret}`, 40 | headers: { 41 | 'Content-Type': 'application/x-www-form-urlencoded' 42 | } 43 | }) 44 | ).text() 45 | try { 46 | const parsed = JSON.parse(result) 47 | if (parsed.error) { 48 | log.warn('Get auth by code error', parsed) 49 | return {} as AuthToken 50 | } 51 | return { 52 | code: code, 53 | access: parsed.access_token, 54 | refresh: parsed.refresh_token, 55 | expiryDate: +new Date() + parsed.expires_in * 1000 56 | } 57 | } catch (e: any) { 58 | log.info('Error', e.message, 't = ', result) 59 | } 60 | return {} as AuthToken 61 | } 62 | 63 | async firstLogin(spotifyAuth: { 64 | hasCredentials: boolean 65 | clientId: string 66 | requestedScopes: string 67 | secret: string 68 | }): Promise { 69 | return new Promise(async (resolve) => { 70 | if (!spotifyAuth.hasCredentials) { 71 | log.warn("Can't log in, keys are not set") 72 | return 73 | } 74 | const port = 38900 75 | const redirectUrl = 'http://localhost:' + port 76 | const url = 77 | `https://accounts.spotify.com/authorize?client_id=${spotifyAuth.clientId}` + 78 | `&response_type=code&redirect_uri=${redirectUrl}&scope=${encodeURIComponent( 79 | spotifyAuth.requestedScopes 80 | )}` 81 | await shell.openExternal(url) 82 | 83 | if (this.server !== null) this.server.close() 84 | 85 | const app = express() 86 | this.server = http.createServer(app) 87 | 88 | app.get('/', async (req: any, res: any) => { 89 | if (req.query.hasOwnProperty('code')) { 90 | if (this.server !== null) this.server.close() 91 | this.server = null 92 | const auth = await this.getAuthByCode( 93 | redirectUrl, 94 | req.query.code, 95 | spotifyAuth.clientId, 96 | spotifyAuth.secret 97 | ) 98 | this.win.focus() 99 | resolve(auth) 100 | } 101 | res.send(` 102 | 103 | Logging in... 104 | 105 | 108 | 109 | 110 | `) 111 | }) 112 | 113 | this.server.listen(port, () => 0) 114 | }) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/main/Directories.ts: -------------------------------------------------------------------------------- 1 | import electron from 'electron' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import os from 'os' 5 | 6 | type PathType = 7 | | 'temp' 8 | | 'home' 9 | | 'appData' 10 | | 'userData' 11 | | 'sessionData' 12 | | 'exe' 13 | | 'module' 14 | | 'desktop' 15 | | 'documents' 16 | | 'downloads' 17 | | 'music' 18 | | 'pictures' 19 | | 'videos' 20 | | 'recent' 21 | | 'logs' 22 | | 'crashDumps' 23 | 24 | class Directories { 25 | public temp: string 26 | public files: string 27 | public music: string 28 | private readonly storeFile: string 29 | 30 | constructor() { 31 | if (os.platform() === 'linux') { 32 | this.temp = this.initializeDir('appData', 'ruurd-music-temp') 33 | } else { 34 | this.temp = this.initializeDir('temp', 'ruurd-music') 35 | } 36 | this.files = this.initializeDir('appData', 'ruurd-music-files') 37 | 38 | this.storeFile = path.join(this.files, 'store.json') 39 | this.music = this.getDir('music', '') 40 | if (fs.existsSync(this.storeFile)) { 41 | const store = JSON.parse(fs.readFileSync(this.storeFile).toString()) 42 | if (fs.existsSync(store.music)) this.music = store.music 43 | } 44 | } 45 | 46 | changeMusicFolder(folder: string) { 47 | this.music = folder 48 | fs.writeFileSync(this.storeFile, JSON.stringify({ music: folder })) 49 | } 50 | 51 | initializeDir(base: PathType, dir: string) { 52 | const fullDir = this.getDir(base, dir) 53 | this.createDir(fullDir) 54 | return fullDir 55 | } 56 | 57 | getDir(base: PathType = 'music', dir = 'files') { 58 | let app = electron.app 59 | if (electron.hasOwnProperty('remote')) app = electron.app 60 | return path.join(app.getPath(base), dir) 61 | } 62 | 63 | createDir(dir: string) { 64 | if (!fs.existsSync(dir)) fs.mkdirSync(dir) 65 | } 66 | } 67 | 68 | export default new Directories() 69 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, session } from 'electron' 2 | import { join } from 'path' 3 | import { electronApp, is, optimizer } from '@electron-toolkit/utils' 4 | import icon from '../../resources/app-icon/dark-500.png?asset' 5 | import log from 'electron-log/main' 6 | import { handleIpc } from './ipcFunctions' 7 | 8 | log.initialize({ preload: true }) 9 | log.errorHandler.startCatching({ showDialog: true }) 10 | 11 | function createWindow(): void { 12 | // Create the browser window. 13 | const mainWindow = new BrowserWindow({ 14 | title: 'Ruurd Music', 15 | icon, 16 | width: 1400, 17 | height: 930, 18 | minWidth: 521, 19 | minHeight: 640, 20 | autoHideMenuBar: true, 21 | frame: false, 22 | show: false, 23 | ...(process.platform === 'linux' ? { icon } : {}), 24 | webPreferences: { 25 | preload: join(__dirname, '../preload/index.js'), 26 | sandbox: false, 27 | webSecurity: false 28 | } 29 | }) 30 | 31 | mainWindow.on('ready-to-show', () => { 32 | mainWindow.show() 33 | }) 34 | 35 | mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) 36 | 37 | // HMR for renderer base on electron-vite cli. 38 | // Load the remote URL for development or the local html file for production. 39 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { 40 | mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) 41 | mainWindow.webContents.openDevTools() 42 | } else { 43 | mainWindow.loadFile(join(__dirname, '../renderer/index.html')) 44 | } 45 | 46 | handleIpc(ipcMain, mainWindow) 47 | } 48 | 49 | // This method will be called when Electron has finished 50 | // initialization and is ready to create browser windows. 51 | // Some APIs can only be used after this event occurs. 52 | app.whenReady().then(() => { 53 | session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { 54 | if (details.url.includes('tools.keycdn.com/geo.json')) { 55 | details.requestHeaders['User-Agent'] = 'keycdn-tools:https://github.com/RuurdBijlsma/Music' 56 | } 57 | // Continue the request with the modified headers 58 | callback({ cancel: false, requestHeaders: details.requestHeaders }) 59 | }) 60 | 61 | // Set app user model id for windows 62 | electronApp.setAppUserModelId('dev.ruurd.music') 63 | 64 | // Default open or close DevTools by F12 in development 65 | // and ignore CommandOrControl + R in production. 66 | // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils 67 | app.on('browser-window-created', (_, window) => { 68 | optimizer.watchWindowShortcuts(window) 69 | optimizer.registerFramelessWindowIpc() 70 | }) 71 | 72 | createWindow() 73 | 74 | app.on('activate', function () { 75 | // On macOS, it's common to re-create a window in the app when the 76 | // dock icon is clicked and there are no other windows open. 77 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 78 | }) 79 | }) 80 | 81 | // Quit when all windows are closed, except on macOS. There, it's common 82 | // for applications and their menu bar to stay active until the user quits 83 | // explicitly with Cmd + Q. 84 | app.on('window-all-closed', () => { 85 | if (process.platform !== 'darwin') { 86 | app.quit() 87 | } 88 | }) 89 | -------------------------------------------------------------------------------- /src/main/ipcFunctions.ts: -------------------------------------------------------------------------------- 1 | import Directories from './Directories' 2 | import NodeFunctions from './NodeFunctions' 3 | import { BrowserWindow } from 'electron' 4 | import AuthFunctions from './AuthFunctions' 5 | 6 | export function handleIpc(ipcMain: Electron.IpcMain, win: BrowserWindow) { 7 | const nf = new NodeFunctions(win) 8 | const auth = new AuthFunctions(win) 9 | 10 | ipcMain.handle('ytInfoById', (_, id: string) => nf.youTubeInfoById(id)) 11 | ipcMain.handle('getDominantColor', (_, imgUrl: string) => nf.getDominantColor(imgUrl)) 12 | ipcMain.handle('setPlatformPlaying', (_, value: boolean, darkTheme: boolean) => 13 | nf.setPlatformPlaying(value, darkTheme) 14 | ) 15 | ipcMain.handle('stopPlatformPlaying', (_) => nf.stopPlatformPlaying()) 16 | ipcMain.handle('getOutputDirectory', (_) => nf.getOutputDirectory()) 17 | ipcMain.handle( 18 | 'getSaveFilePath', 19 | (_, filename?: string, buttonLabel = 'Save', filterJson = true) => 20 | nf.getSaveFilePath(filename, buttonLabel, filterJson) 21 | ) 22 | ipcMain.handle('getOpenFilePath', (_, buttonLabel = 'Save', filterJson = true) => 23 | nf.getOpenFilePath(buttonLabel, filterJson) 24 | ) 25 | ipcMain.handle('downloadAsJpg', (_, imgUrl: string) => nf.downloadAsJpg(imgUrl)) 26 | ipcMain.handle('getVolumeStats', (_, trackFile: string) => nf.getVolumeStats(trackFile)) 27 | ipcMain.handle('getDirectories', () => Directories) 28 | ipcMain.handle('setTheme', (_, theme: 'dark' | 'light') => nf.setTheme(theme)) 29 | ipcMain.handle('updateYtdlp', () => nf.updateYtdlp()) 30 | ipcMain.handle( 31 | 'downloadYt', 32 | async (_, id: string, outPath: string, tags: any, imageFile: string) => 33 | nf.downloadYouTube(id, outPath, tags, imageFile) 34 | ) 35 | ipcMain.handle('searchYtdlp', async (_, query: string, limit: number) => 36 | nf.searchYtdlp(query, limit) 37 | ) 38 | 39 | ipcMain.handle('fileSize', (_, file: string) => nf.fileSize(file)) 40 | ipcMain.handle('checkFileExists', (_, file: string) => nf.checkFileExists(file)) 41 | ipcMain.handle('copyIfExists', (_, fromPath: string, toDirectory: string) => 42 | nf.copyIfExists(fromPath, toDirectory) 43 | ) 44 | ipcMain.handle('copyFile', (_, from: string, to: string) => nf.copyFile(from, to)) 45 | ipcMain.handle('deleteFile', (_, file: string) => nf.deleteFile(file)) 46 | ipcMain.handle('checkTracksDownloaded', (_, filenames: string[]) => 47 | nf.checkTracksDownloaded(filenames) 48 | ) 49 | ipcMain.handle('saveStringToFile', (_, file: string, contents: string) => 50 | nf.saveStringToFile(file, contents) 51 | ) 52 | ipcMain.handle('getFileContents', (_, file: string) => nf.getFileContents(file)) 53 | ipcMain.handle('downloadFile', (_, url: string, file: string) => nf.downloadFile(url, file)) 54 | ipcMain.handle('getAppVersion', (_) => nf.getAppVersion()) 55 | ipcMain.handle('setMusicFolder', (_, folder: string) => nf.setMusicFolder(folder)) 56 | 57 | ipcMain.handle( 58 | 'firstLogin', 59 | ( 60 | _, 61 | spotifyAuth: { 62 | hasCredentials: boolean 63 | clientId: string 64 | requestedScopes: string 65 | secret: string 66 | } 67 | ) => auth.firstLogin(spotifyAuth) 68 | ) 69 | ipcMain.handle('resetSpotifyLogin', () => auth.resetSpotifyLogin()) 70 | } 71 | -------------------------------------------------------------------------------- /src/main/utils.ts: -------------------------------------------------------------------------------- 1 | // A function to calculate the relative luminance of an RGB color 2 | export function getRelativeLuminance(rgb: number[]): number { 3 | // Apply a linear transformation to each component 4 | const [r, g, b] = rgb.map((c) => { 5 | c /= 255 // Normalize to [0, 1] 6 | return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 7 | }) 8 | // Return the weighted sum of the components 9 | return 0.2126 * r + 0.7152 * g + 0.0722 * b 10 | } 11 | 12 | export function RGBToHSL(r: number, g: number, b: number) { 13 | r /= 255 14 | g /= 255 15 | b /= 255 16 | const l = Math.max(r, g, b) 17 | const s = l - Math.min(r, g, b) 18 | const h = s ? (l === r ? (g - b) / s : l === g ? 2 + (b - r) / s : 4 + (r - g) / s) : 0 19 | return [ 20 | 60 * h < 0 ? 60 * h + 360 : 60 * h, 21 | 100 * (s ? (l <= 0.5 ? s / (2 * l - s) : s / (2 - (2 * l - s))) : 0), 22 | (100 * (2 * l - s)) / 2 23 | ] 24 | } 25 | 26 | export function RGBToHex(r: number, g: number, b: number): string { 27 | return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('') 28 | } 29 | 30 | // A function to calculate the contrast ratio between two RGB colors 31 | export function getContrastRatio(rgb1: number[], rgb2: number[]): number { 32 | // Get the relative luminance of each color 33 | const l1 = getRelativeLuminance(rgb1) 34 | const l2 = getRelativeLuminance(rgb2) 35 | // Return the ratio of the larger luminance to the smaller luminance 36 | return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05) 37 | } 38 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ElectronAPI } from '@electron-toolkit/preload' 2 | 3 | declare global { 4 | interface Window { 5 | electron: ElectronAPI 6 | api: unknown 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron' 2 | 3 | // Custom APIs for renderer 4 | const api = { 5 | ytInfoById: (id: string) => ipcRenderer.invoke('ytInfoById', id), 6 | searchYtdlp: (query: string, limit: number) => ipcRenderer.invoke('searchYtdlp', query, limit), 7 | getDominantColor: (imgUrl: string) => ipcRenderer.invoke('getDominantColor', imgUrl), 8 | setPlatformPlaying: (value: boolean, darkTheme: boolean) => 9 | ipcRenderer.invoke('setPlatformPlaying', value, darkTheme), 10 | stopPlatformPlaying: () => ipcRenderer.invoke('stopPlatformPlaying'), 11 | getOutputDirectory: () => ipcRenderer.invoke('getOutputDirectory'), 12 | getSaveFilePath: (filename?: string, buttonLabel = 'Save', filterJson = true) => 13 | ipcRenderer.invoke('getSaveFilePath', filename, buttonLabel, filterJson), 14 | getOpenFilePath: (buttonLabel = 'Save', filterJson = true) => 15 | ipcRenderer.invoke('getOpenFilePath', buttonLabel, filterJson), 16 | downloadAsJpg: (imgUrl: string) => ipcRenderer.invoke('downloadAsJpg', imgUrl), 17 | getVolumeStats: (trackFile: string) => ipcRenderer.invoke('getVolumeStats', trackFile), 18 | getDirectories: () => ipcRenderer.invoke('getDirectories'), 19 | setTheme: (theme: 'dark' | 'light') => ipcRenderer.invoke('setTheme', theme), 20 | downloadYt: (id: string, outPath: string, tags: any, imageFile: string) => 21 | ipcRenderer.invoke('downloadYt', id, outPath, tags, imageFile), 22 | updateYtdlp: () => ipcRenderer.invoke('updateYtdlp'), 23 | 24 | fileSize: (file: string) => ipcRenderer.invoke('fileSize', file), 25 | checkFileExists: (file: string) => ipcRenderer.invoke('checkFileExists', file), 26 | deleteFile: (file: string) => ipcRenderer.invoke('deleteFile', file), 27 | copyIfExists: (fromPath: string, toDirectory: string) => 28 | ipcRenderer.invoke('copyIfExists', fromPath, toDirectory), 29 | copyFile: (from: string, to: string) => ipcRenderer.invoke('copyFile', from, to), 30 | checkTracksDownloaded: (files: string[]) => ipcRenderer.invoke('checkTracksDownloaded', files), 31 | saveStringToFile: (file: string, contents: string) => 32 | ipcRenderer.invoke('saveStringToFile', file, contents), 33 | getFileContents: (file: string) => ipcRenderer.invoke('getFileContents', file), 34 | setMusicFolder: (folder: string) => ipcRenderer.invoke('setMusicFolder', folder), 35 | 36 | firstLogin: (spotifyAuth: { 37 | hasCredentials: boolean 38 | clientId: string 39 | requestedScopes: string 40 | secret: string 41 | }) => ipcRenderer.invoke('firstLogin', spotifyAuth), 42 | resetSpotifyLogin: () => ipcRenderer.invoke('resetSpotifyLogin'), 43 | getAppVersion: () => ipcRenderer.invoke('getAppVersion'), 44 | downloadFile: (url: string, file: string) => ipcRenderer.invoke('downloadFile', url, file), 45 | 46 | minimizeWindow: () => ipcRenderer.send('win:invoke', 'min'), 47 | toggleMaximize: () => ipcRenderer.send('win:invoke', 'max'), 48 | closeWindow: () => ipcRenderer.send('win:invoke', 'close') 49 | } 50 | 51 | const listeners = new Map>() 52 | 53 | const events = { 54 | on(channel: string, func: Function) { 55 | if (!listeners.has(channel)) listeners.set(channel, []) 56 | const functions = listeners.get(channel) 57 | if (functions === undefined || functions.includes(func)) return 58 | functions.push(func) 59 | }, 60 | off(channel: string, func: Function) { 61 | if (!listeners.has(channel)) return 62 | const functions = listeners.get(channel) 63 | if (functions === undefined) return 64 | const index = functions.indexOf(func) 65 | if (index === -1) return 66 | functions.splice(index, 1) 67 | }, 68 | emit(channel, ...args) { 69 | if (!listeners.has(channel)) return 70 | const functions = listeners.get(channel) 71 | if (functions === undefined) return 72 | functions.forEach((f) => f(...args)) 73 | } 74 | } 75 | ipcRenderer.on('toggleFavorite', () => events.emit('toggleFavorite')) 76 | ipcRenderer.on('ffmpegPath', () => events.emit('ffmpegPath')) 77 | ipcRenderer.on('play', () => events.emit('play')) 78 | ipcRenderer.on('pause', () => events.emit('pause')) 79 | ipcRenderer.on('skip', (_, n: number) => events.emit('skip', n)) 80 | ipcRenderer.on('log', (_, ...args: any[]) => events.emit('log', ...args)) 81 | ipcRenderer.on('progress', (_, data: { id: string; progress: { percent: number } }) => 82 | events.emit(data.id + 'progress', data.progress.percent) 83 | ) 84 | 85 | if (process.contextIsolated) { 86 | try { 87 | contextBridge.exposeInMainWorld('api', api) 88 | contextBridge.exposeInMainWorld('events', events) 89 | } catch (error) { 90 | console.error(error) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/renderer/assets/app-icon/dark-500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/app-icon/dark-500.png -------------------------------------------------------------------------------- /src/renderer/assets/app-icon/light-500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/app-icon/light-500.png -------------------------------------------------------------------------------- /src/renderer/assets/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/cover.jpg -------------------------------------------------------------------------------- /src/renderer/assets/liked/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/liked/1.png -------------------------------------------------------------------------------- /src/renderer/assets/liked/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/liked/2.png -------------------------------------------------------------------------------- /src/renderer/assets/liked/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/liked/3.png -------------------------------------------------------------------------------- /src/renderer/assets/liked/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/liked/4.png -------------------------------------------------------------------------------- /src/renderer/assets/liked/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/liked/5.png -------------------------------------------------------------------------------- /src/renderer/assets/liked/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/liked/6.png -------------------------------------------------------------------------------- /src/renderer/assets/liked/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/liked/7.png -------------------------------------------------------------------------------- /src/renderer/assets/notfound/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/notfound/1.png -------------------------------------------------------------------------------- /src/renderer/assets/notfound/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/notfound/2.png -------------------------------------------------------------------------------- /src/renderer/assets/notfound/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/notfound/3.png -------------------------------------------------------------------------------- /src/renderer/assets/notfound/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/notfound/4.png -------------------------------------------------------------------------------- /src/renderer/assets/notfound/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/notfound/5.png -------------------------------------------------------------------------------- /src/renderer/assets/notfound/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/notfound/6.png -------------------------------------------------------------------------------- /src/renderer/assets/notfound/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/notfound/7.png -------------------------------------------------------------------------------- /src/renderer/assets/user/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/user/1.png -------------------------------------------------------------------------------- /src/renderer/assets/user/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/user/2.png -------------------------------------------------------------------------------- /src/renderer/assets/user/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/user/3.png -------------------------------------------------------------------------------- /src/renderer/assets/user/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/user/4.png -------------------------------------------------------------------------------- /src/renderer/assets/user/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/user/5.png -------------------------------------------------------------------------------- /src/renderer/assets/user/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/user/6.png -------------------------------------------------------------------------------- /src/renderer/assets/user/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuurdBijlsma/Music/6bcd80648ac6c94f1a582effd4e05c756d79242e/src/renderer/assets/user/7.png -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Electron 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/renderer/src/components/ArtistsSpan.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | -------------------------------------------------------------------------------- /src/renderer/src/components/GlowImage.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 64 | 65 | 81 | -------------------------------------------------------------------------------- /src/renderer/src/components/HorizontalScroller.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 73 | 74 | 106 | -------------------------------------------------------------------------------- /src/renderer/src/components/LineChart.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 120 | 121 | 135 | -------------------------------------------------------------------------------- /src/renderer/src/components/PlaylistHeader.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 58 | 59 | 96 | -------------------------------------------------------------------------------- /src/renderer/src/components/QueueButton.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 80 | 81 | 106 | -------------------------------------------------------------------------------- /src/renderer/src/components/Spacer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/renderer/src/components/dialogs/CreatePlaylistDialog.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 93 | 94 | 109 | -------------------------------------------------------------------------------- /src/renderer/src/components/dialogs/Dialogs.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 30 | 31 | 42 | -------------------------------------------------------------------------------- /src/renderer/src/components/dialogs/EditInfoDialog.vue: -------------------------------------------------------------------------------- 1 | 100 | 101 | 149 | 179 | -------------------------------------------------------------------------------- /src/renderer/src/components/dialogs/ItemContextMenu.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/renderer/src/components/dialogs/Notification.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 38 | 39 | 79 | -------------------------------------------------------------------------------- /src/renderer/src/components/item/HighlightCard.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 51 | 52 | 118 | -------------------------------------------------------------------------------- /src/renderer/src/components/item/ItemCard.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 86 | 87 | 151 | -------------------------------------------------------------------------------- /src/renderer/src/components/item/ItemPlayButton.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /src/renderer/src/components/item/LikeButton.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/renderer/src/components/main-ui/LeftNavigation.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 73 | 74 | 159 | -------------------------------------------------------------------------------- /src/renderer/src/components/main-ui/TopMenu.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 55 | 56 | 114 | -------------------------------------------------------------------------------- /src/renderer/src/components/main-ui/UserMenu.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 113 | 114 | 143 | -------------------------------------------------------------------------------- /src/renderer/src/components/player/BottomMusicPlayer.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 95 | 96 | 208 | -------------------------------------------------------------------------------- /src/renderer/src/components/player/CompactProgressBar.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 64 | 65 | 101 | -------------------------------------------------------------------------------- /src/renderer/src/components/player/PlayButton.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/renderer/src/components/player/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 59 | 60 | 71 | -------------------------------------------------------------------------------- /src/renderer/src/components/player/SimplePlayer.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 167 | 168 | 175 | -------------------------------------------------------------------------------- /src/renderer/src/components/player/SimpleProgressBar.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 82 | 83 | 126 | -------------------------------------------------------------------------------- /src/renderer/src/components/player/SimpleYtPlayer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /src/renderer/src/components/search/SearchBar.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | 33 | 57 | -------------------------------------------------------------------------------- /src/renderer/src/components/search/SearchSuggestionSection.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/renderer/src/components/search/SearchSuggestions.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 124 | 125 | 174 | -------------------------------------------------------------------------------- /src/renderer/src/components/track-list/TrackList.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 54 | 55 | 95 | -------------------------------------------------------------------------------- /src/renderer/src/components/track-list/TrackListExpander.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 88 | 89 | 136 | -------------------------------------------------------------------------------- /src/renderer/src/components/track-list/TrackListItem.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 81 | 82 | 159 | -------------------------------------------------------------------------------- /src/renderer/src/components/track-list/TrackListVirtual.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 79 | 80 | 116 | -------------------------------------------------------------------------------- /src/renderer/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import '@mdi/font/css/materialdesignicons.css' 4 | import 'vuetify/styles' 5 | import router from './scripts/router' 6 | import { createPinia } from 'pinia' 7 | import { vuetify } from './scripts/theme' 8 | 9 | const app = createApp(App) 10 | 11 | app.use(createPinia()) 12 | app.use(router) 13 | app.use(vuetify) 14 | 15 | app.mount('#app') 16 | 17 | if (localStorage.getItem('lastRoute') !== null) { 18 | router.replace(localStorage.lastRoute).then() 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/src/scripts/database.ts: -------------------------------------------------------------------------------- 1 | import { IDBPDatabase, IDBPObjectStore, openDB, StoreNames } from 'idb' 2 | import { ref } from 'vue' 3 | 4 | export function createStore( 5 | db: IDBPDatabase, 6 | transaction: any, 7 | storeName: string, 8 | storeOptions: { keyPath?: string } = {}, 9 | indices: { name: string; keyPath: string; unique: boolean }[] = [] 10 | ) { 11 | let store: IDBPObjectStore>, string, 'versionchange'> 12 | if (!db.objectStoreNames.contains(storeName)) { 13 | store = db.createObjectStore(storeName, storeOptions) 14 | } else { 15 | store = transaction.objectStore(storeName) 16 | } 17 | for (const index of indices) { 18 | if (!store.indexNames.contains(index.name)) { 19 | store.createIndex(index.name, index.keyPath, { 20 | unique: index.unique 21 | }) 22 | } 23 | } 24 | return store 25 | } 26 | 27 | export const baseDb = openDB('base', 11, { 28 | upgrade(db, _, __, transaction) { 29 | createStore(db, transaction, 'spotify') 30 | createStore(db, transaction, 'cache') 31 | 32 | createStore(db, transaction, 'artistStats', { keyPath: 'id' }, [ 33 | { name: 'listenMinutes', keyPath: 'listenMinutes', unique: false }, 34 | { name: 'skips', keyPath: 'skips', unique: false } 35 | ]) 36 | createStore(db, transaction, 'trackStats', { keyPath: 'id' }, [ 37 | { name: 'listenMinutes', keyPath: 'listenMinutes', unique: false }, 38 | { name: 'listenCount', keyPath: 'listenCount', unique: false }, 39 | { name: 'skips', keyPath: 'skips', unique: false } 40 | ]) 41 | createStore(db, transaction, 'collectionStats', { keyPath: 'id' }, [ 42 | { name: 'listenMinutes', keyPath: 'listenMinutes', unique: false }, 43 | { name: 'skips', keyPath: 'skips', unique: false } 44 | ]) 45 | 46 | createStore(db, transaction, 'statistics') 47 | createStore(db, transaction, 'trackMetadata', { keyPath: 'id' }) 48 | 49 | createStore(db, transaction, 'tracks', { keyPath: 'id' }, [ 50 | { 51 | name: 'searchString', 52 | keyPath: 'searchString', 53 | unique: false 54 | }, 55 | { 56 | name: 'title', 57 | keyPath: 'title', 58 | unique: false 59 | }, 60 | { 61 | name: 'artist', 62 | keyPath: 'artistString', 63 | unique: false 64 | }, 65 | { 66 | name: 'duration', 67 | keyPath: 'track.duration_ms', 68 | unique: false 69 | }, 70 | { 71 | name: 'oldToNew', 72 | keyPath: 'added_at', 73 | unique: false 74 | }, 75 | { 76 | name: 'newToOld', 77 | keyPath: 'added_at_reverse', 78 | unique: false 79 | } 80 | ]) 81 | } 82 | }) 83 | 84 | export const dbLoaded = ref(false) 85 | baseDb.then(() => { 86 | dbLoaded.value = true 87 | }) 88 | -------------------------------------------------------------------------------- /src/renderer/src/scripts/image-sources.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import user1 from '../../assets/user/1.png?asset' 3 | // @ts-ignore 4 | import user2 from '../../assets/user/2.png?asset' 5 | // @ts-ignore 6 | import user3 from '../../assets/user/3.png?asset' 7 | // @ts-ignore 8 | import user4 from '../../assets/user/4.png?asset' 9 | // @ts-ignore 10 | import user5 from '../../assets/user/5.png?asset' 11 | // @ts-ignore 12 | import user6 from '../../assets/user/6.png?asset' 13 | // @ts-ignore 14 | import user7 from '../../assets/user/7.png?asset' 15 | 16 | // @ts-ignore 17 | import liked1 from '../../assets/liked/1.png?asset' 18 | // @ts-ignore 19 | import liked2 from '../../assets/liked/2.png?asset' 20 | // @ts-ignore 21 | import liked3 from '../../assets/liked/3.png?asset' 22 | // @ts-ignore 23 | import liked4 from '../../assets/liked/4.png?asset' 24 | // @ts-ignore 25 | import liked5 from '../../assets/liked/5.png?asset' 26 | // @ts-ignore 27 | import liked6 from '../../assets/liked/6.png?asset' 28 | // @ts-ignore 29 | import liked7 from '../../assets/liked/7.png?asset' 30 | 31 | // @ts-ignore 32 | import notfound1 from '../../assets/notfound/1.png?asset' 33 | // @ts-ignore 34 | import notfound2 from '../../assets/notfound/2.png?asset' 35 | // @ts-ignore 36 | import notfound3 from '../../assets/notfound/3.png?asset' 37 | // @ts-ignore 38 | import notfound4 from '../../assets/notfound/4.png?asset' 39 | // @ts-ignore 40 | import notfound7 from '../../assets/notfound/7.png?asset' 41 | // @ts-ignore 42 | import notfound5 from '../../assets/notfound/5.png?asset' 43 | // @ts-ignore 44 | import notfound6 from '../../assets/notfound/6.png?asset' 45 | 46 | const date = new Date() 47 | 48 | export function randomNotFound() { 49 | return [notfound1, notfound2, notfound3, notfound4, notfound5, notfound6, notfound7][ 50 | date.getDate() % 7 51 | ] 52 | } 53 | 54 | export function randomLiked() { 55 | return [liked1, liked2, liked3, liked4, liked5, liked6, liked7][date.getDate() % 7] 56 | } 57 | 58 | export function randomUser() { 59 | return [user1, user2, user3, user4, user5, user6, user7][date.getDate() % 7] 60 | } 61 | -------------------------------------------------------------------------------- /src/renderer/src/scripts/item-utils.ts: -------------------------------------------------------------------------------- 1 | import { Item, ItemCollection } from './types' 2 | import { randomNotFound } from './image-sources' 3 | import { caps, encodeUrlName } from './utils' 4 | 5 | export function albumString(item: SpotifyApi.AlbumObjectFull | any) { 6 | return `${item.total_tracks} track${item.total_tracks === 1 ? '' : 's'} • ${item.artists 7 | .map((a) => a.name) 8 | .join(', ')} • ${item.release_date.substring(0, 4)} • ${caps(item.album_type)}` 9 | } 10 | 11 | export function itemDescription(item: Item) { 12 | if (item.type === 'album') { 13 | return `${caps(item?.album_type ?? '')} • ${(item?.artists ?? []) 14 | .map((a) => a.name) 15 | .join(', ')}` 16 | } 17 | if (item.type !== 'playlist') return '' 18 | return item.description ?? '' 19 | } 20 | 21 | export function itemImage(item: Item) { 22 | let image 23 | if (item.type === 'track') { 24 | image = item?.album?.images?.[0]?.url 25 | } else { 26 | image = item.images?.[0]?.url 27 | } 28 | if (image === null || image === undefined) { 29 | return randomNotFound() 30 | } 31 | return image 32 | } 33 | 34 | export function itemUrl(item: Item | any) { 35 | if (item === null) return '' 36 | if ('buttonText' in item) { 37 | return item.to 38 | } 39 | const type = item.type || 'category' 40 | let name = type === 'user' ? item.display_name : item.name 41 | name ??= '' 42 | if (type === 'category') return `${type}/${encodeUrlName(name)}/${item.id}` 43 | if (type === 'radio') return '' 44 | if (type === 'search') return item.to ?? '/' 45 | if (type === 'liked') return '/library/tracks' 46 | return `/${type}/${encodeUrlName(name)}/${item.id}` 47 | } 48 | 49 | export function itemCollection(item: Item, tracks: SpotifyApi.TrackObjectFull[] | null = null) { 50 | if (item.type === 'playlist') { 51 | if (tracks === null) 52 | tracks = item.tracks.items.map((t) => t.track as SpotifyApi.TrackObjectFull) 53 | return { 54 | id: item.id ?? 'playlist', 55 | tracks: tracks, 56 | type: 'playlist', 57 | context: item, 58 | name: item.name ?? 'Playlist', 59 | buttonText: 'Playlist', 60 | to: itemUrl(item) 61 | } as ItemCollection 62 | } else if (item.type === 'artist') { 63 | return { 64 | id: item.id ?? 'artist', 65 | tracks: tracks ?? [], 66 | type: 'artist', 67 | context: item, 68 | name: item.name ?? 'Artist', 69 | buttonText: 'Artist', 70 | to: itemUrl(item) 71 | } as ItemCollection 72 | } else if (item.type === 'album') { 73 | if (tracks === null && item.tracks !== undefined) 74 | tracks = item.tracks.items as SpotifyApi.TrackObjectFull[] 75 | return { 76 | id: item.id ?? 'album', 77 | tracks: tracks ?? [], 78 | type: 'album', 79 | context: item, 80 | name: item.name ?? 'Album', 81 | buttonText: 'Album', 82 | to: itemUrl(item) 83 | } as ItemCollection 84 | } 85 | return null 86 | } 87 | 88 | export const radioId = () => Math.random().toString().replace('.', '') 89 | -------------------------------------------------------------------------------- /src/renderer/src/scripts/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router' 2 | import Home from '../views/Home.vue' 3 | import { watch } from 'vue' 4 | import { useSpotifyAuthStore } from '../store/spotify-auth' 5 | import { baseDb } from './database' 6 | 7 | const routes = [ 8 | { path: '/', component: Home }, 9 | { path: '/settings', component: () => import('../views/Settings.vue') }, 10 | { path: '/browse', component: () => import('../views/browse/Browse.vue') }, 11 | { 12 | path: '/playlist/:name/:id', 13 | component: () => import('../views/item/Playlist.vue') 14 | }, 15 | { 16 | path: '/album/:name/:id', 17 | component: () => import('../views/item/Album.vue') 18 | }, 19 | { 20 | path: '/user/:name?/:id?', 21 | component: () => import('../views/item/User.vue') 22 | }, 23 | { 24 | path: '/artist/:name/:id', 25 | component: () => import('../views/item/Artist.vue') 26 | }, 27 | { path: '/library/:lib?', component: () => import('../views/Library.vue') }, 28 | { path: '/search/:query', component: () => import('../views/Search.vue') }, 29 | { path: '/login', component: () => import('../views/Login.vue') }, 30 | { 31 | path: '/category/:name/:id', 32 | component: () => import('../views/browse/Category.vue') 33 | }, 34 | { path: '/tune', component: () => import('../views/browse/Tune.vue') }, 35 | { path: '/radio', component: () => import('../views/browse/Radio.vue') }, 36 | { path: '/wrapped', component: () => import('../views/Wrapped.vue') } 37 | ] 38 | 39 | const router = createRouter({ 40 | history: createWebHashHistory(), 41 | routes 42 | }) 43 | 44 | watch(router.currentRoute, () => { 45 | localStorage.lastRoute = router.currentRoute.value.fullPath 46 | }) 47 | 48 | router.beforeEach(async (to) => { 49 | const spotifyAuth = useSpotifyAuthStore() 50 | await baseDb 51 | 52 | if (!spotifyAuth.isLoggedIn && to.path !== '/login') { 53 | return { path: '/login' } 54 | } 55 | return true 56 | }) 57 | 58 | export default router 59 | -------------------------------------------------------------------------------- /src/renderer/src/scripts/theme.ts: -------------------------------------------------------------------------------- 1 | import { createVuetify } from 'vuetify' 2 | import { aliases, mdi } from 'vuetify/iconsets/mdi' 3 | 4 | function timeUntilSwitch(lightTime: string, darkTime: string) { 5 | const now = new Date() 6 | const nowHours = now.getHours() 7 | const nowMinutes = now.getMinutes() 8 | const [darkHours, darkMinutes] = darkTime.split(':').map((p) => +p) 9 | const [lightHours, lightMinutes] = lightTime.split(':').map((p) => +p) 10 | let msUntilDark = (darkHours - nowHours) * 60 * 60 * 1000 + (darkMinutes - nowMinutes) * 60 * 1000 11 | let msUntilLight = 12 | (lightHours - nowHours) * 60 * 60 * 1000 + (lightMinutes - nowMinutes) * 60 * 1000 13 | if (msUntilDark <= 0) msUntilDark += 1000 * 60 * 60 * 24 14 | if (msUntilLight <= 0) msUntilLight += 1000 * 60 * 60 * 24 15 | return { msUntilDark, msUntilLight } 16 | } 17 | 18 | export function getThemeFromLocalStorage() { 19 | let themeString: 'light' | 'dark' = 'dark' 20 | let msToSwitch = -1 21 | if (localStorage.getItem('theme') === null) localStorage.theme = 'system' 22 | switch (localStorage.theme) { 23 | case 'dark': 24 | case 'light': 25 | themeString = localStorage.theme 26 | break 27 | case 'system': 28 | themeString = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 29 | break 30 | case 'schedule': 31 | let lightTime: string, darkTime: string 32 | if (localStorage.useSunSchedule === 'true') { 33 | if (localStorage.getItem('sunTimes') === null) break 34 | const { rise, set } = JSON.parse(localStorage.sunTimes) 35 | lightTime = rise 36 | darkTime = set 37 | } else { 38 | if ( 39 | localStorage.getItem('lightOnTime') === null || 40 | localStorage.getItem('darkOnTime') === null 41 | ) 42 | break 43 | lightTime = localStorage.lightOnTime 44 | darkTime = localStorage.darkOnTime 45 | } 46 | const { msUntilLight, msUntilDark } = timeUntilSwitch(lightTime, darkTime) 47 | themeString = msUntilLight < msUntilDark ? 'dark' : 'light' 48 | msToSwitch = Math.min(msUntilDark, msUntilLight) 49 | } 50 | return { themeString, msToSwitch } 51 | } 52 | 53 | const { themeString } = getThemeFromLocalStorage() 54 | export const vuetify = createVuetify({ 55 | icons: { 56 | defaultSet: 'mdi', 57 | aliases, 58 | sets: { mdi } 59 | }, 60 | theme: { 61 | defaultTheme: themeString 62 | } 63 | }) 64 | -------------------------------------------------------------------------------- /src/renderer/src/store/UI/UIStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { useTheme } from 'vuetify' 3 | import { computed, ref, watch } from 'vue' 4 | import { deltaE, executeCached, hexToRgb, persistentRef } from '../../scripts/utils' 5 | import { getThemeFromLocalStorage } from '../../scripts/theme' 6 | 7 | export const useUIStore = defineStore('UI', () => { 8 | const theme = useTheme() 9 | 10 | const windowWidth = ref(window.innerWidth) 11 | const windowHeight = ref(window.innerHeight) 12 | const onWindowResize = () => { 13 | windowWidth.value = window.innerWidth 14 | windowHeight.value = window.innerHeight 15 | } 16 | window.addEventListener('resize', onWindowResize, false) 17 | 18 | const themeColorDark = persistentRef('themeColorDark', '#FFFFFF') 19 | const themeColorLight = persistentRef('themeColorLight', '#000000') 20 | 21 | const themeColor = computed(() => 22 | theme.global.name.value === 'dark' ? themeColorDark.value : themeColorLight.value 23 | ) 24 | const contrastToForeground = computed(() => { 25 | const themeRgb = hexToRgb(themeColor.value) 26 | const fgRgb = hexToRgb(theme.current.value.colors['on-background']) 27 | 28 | return deltaE(themeRgb, fgRgb) 29 | }) 30 | const themeTooSimilarToFg = computed(() => contrastToForeground.value < 17) 31 | const isDark = computed(() => theme.current.value.dark) 32 | 33 | const themeOptions = ['light', 'dark', 'system', 'schedule'] 34 | 35 | const themeString = persistentRef('theme', 'system') 36 | watch(themeString, () => applyThemeFromLocalStorage().then()) 37 | let scheduleTimeout = 0 38 | const lightOnTime = persistentRef('lightOnTime', '07:00') 39 | const darkOnTime = persistentRef('darkOnTime', '19:00') 40 | watch(lightOnTime, () => applyThemeFromLocalStorage().then()) 41 | watch(darkOnTime, () => applyThemeFromLocalStorage().then()) 42 | const sun = persistentRef( 43 | 'sunTimes', 44 | { 45 | rise: '05:00', 46 | set: '19:00' 47 | }, 48 | true 49 | ) 50 | const useSunSchedule = persistentRef('useSunSchedule', false) 51 | watch(useSunSchedule, () => applyThemeFromLocalStorage().then()) 52 | 53 | applyThemeFromLocalStorage().then() 54 | 55 | async function applyThemeFromLocalStorage() { 56 | if (useSunSchedule.value) { 57 | const sunriseTime = sun.value.rise 58 | updateSunStats().then(() => { 59 | // if update sun stats changed the sun times, reset the timeout 60 | if (sun.value.rise !== sunriseTime) applyThemeFromLocalStorage() 61 | }) 62 | } 63 | const { themeString, msToSwitch } = getThemeFromLocalStorage() 64 | theme.global.name.value = themeString 65 | if (msToSwitch !== -1) { 66 | clearTimeout(scheduleTimeout) 67 | // check theme calculations again after sunrise or sunset 68 | // @ts-ignore 69 | scheduleTimeout = setTimeout(() => applyThemeFromLocalStorage(), msToSwitch) 70 | } 71 | window.api.setTheme(themeString).then() 72 | } 73 | 74 | async function updateSunStats() { 75 | // get sunset, cache result 7 days 76 | const { sunset, sunrise } = await executeCached( 77 | getRawSunTimes, 78 | `sunTimes`, 79 | 1000 * 60 * 60 * 24 * 7 80 | ) 81 | // format the datetime to 16:27 82 | const sunsetTime = sunset.toLocaleString('nl-NL', { 83 | timeStyle: 'short', 84 | hour12: false 85 | }) 86 | const sunriseTime = sunrise.toLocaleString('nl-NL', { 87 | timeStyle: 'short', 88 | hour12: false 89 | }) 90 | 91 | sun.value = { 92 | set: sunsetTime, 93 | rise: sunriseTime 94 | } 95 | 96 | return [sunriseTime, sunsetTime] 97 | } 98 | 99 | async function getRawSunTimes() { 100 | const { ip } = await (await fetch('https://api.ipify.org?format=json')).json() 101 | const locationInfo = await (await fetch(`https://tools.keycdn.com/geo.json?host=${ip}`)).json() 102 | const lat = locationInfo.data.geo.latitude 103 | const lon = locationInfo.data.geo.longitude 104 | // Use the Sunrise Sunset API to get the data 105 | const url = `https://api.sunrise-sunset.org/json?lat=${lat}&lng=${lon}&formatted=0` 106 | const response = await fetch(url) 107 | if (!response.ok) throw new Error('NOT OK' + response.statusText) 108 | const data = await response.json() 109 | 110 | // Return an object with the sunrise and sunset times as Date objects 111 | return { 112 | sunrise: new Date(data.results.sunrise), 113 | sunset: new Date(data.results.sunset) 114 | } 115 | } 116 | 117 | return { 118 | themeColor, 119 | themeColorDark, 120 | themeColorLight, 121 | contrastToForeground, 122 | themeTooSimilarToFg, 123 | isDark, 124 | themeOptions, 125 | sun, 126 | useSunSchedule, 127 | lightOnTime, 128 | darkOnTime, 129 | windowWidth, 130 | windowHeight, 131 | themeString 132 | } 133 | }) 134 | -------------------------------------------------------------------------------- /src/renderer/src/store/UI/dialogStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { LikedTrack, Notification, TrackData, YouTubeSearchResult } from '../../scripts/types' 3 | import router from '../../scripts/router' 4 | import { Ref, ref } from 'vue' 5 | 6 | export const useDialogStore = defineStore('dialog', () => { 7 | const playlist = ref({ 8 | show: false, 9 | title: '', 10 | description: '', 11 | isPublic: true, 12 | isCollaborative: false, 13 | startTrack: null as SpotifyApi.TrackObjectFull | null 14 | }) 15 | 16 | const edit = ref({ 17 | show: false, 18 | loading: false, 19 | trackData: null as null | TrackData, 20 | likedTrack: null as null | LikedTrack, 21 | title: '', 22 | artists: [''], 23 | durationRange: [0, 1] 24 | }) 25 | 26 | const source = ref({ 27 | show: false, 28 | items: [] as YouTubeSearchResult[], 29 | loading: false, 30 | spotifyTrack: null as SpotifyApi.TrackObjectFull | null, 31 | trackData: null as TrackData | null 32 | }) 33 | 34 | const snackbars = ref([] as { open: boolean; text: string; timeout: number }[]) 35 | 36 | function addSnack(text: string, timeout = 4000) { 37 | const snack = { 38 | text, 39 | timeout, 40 | open: true 41 | } 42 | snackbars.value.push(snack) 43 | setTimeout(() => { 44 | snack.open = false 45 | snackbars.value.splice(snackbars.value.indexOf(snack), 1) 46 | }, timeout + 500) 47 | } 48 | 49 | const contextMenu = ref({ 50 | x: 0, 51 | y: 0, 52 | show: false, 53 | item: null as any 54 | }) 55 | 56 | const setContextMenuItem = (e: MouseEvent, item: any) => { 57 | contextMenu.value.item = item 58 | contextMenu.value.show = true 59 | contextMenu.value.x = e.pageX 60 | contextMenu.value.y = e.pageY 61 | } 62 | 63 | const notifications: Ref = ref([]) 64 | 65 | function checkWrapNotification() { 66 | const now = new Date() 67 | const lsKey = 'wrapped' + now.getFullYear() 68 | if (now.getMonth() !== 11) localStorage.removeItem(lsKey) 69 | if (now.getMonth() === 11 && localStorage.getItem(lsKey) === null) { 70 | addNotification({ 71 | title: 'Your Music Wrapped is ready!', 72 | description: 73 | 'View statistics about your listening behaviour, see your top artists, tracks, and more.', 74 | icon: 'mdi-music-box-multiple', 75 | dismissText: 'Later', 76 | viewText: 'View now', 77 | action: () => router.push('/wrapped') 78 | }) 79 | } 80 | } 81 | 82 | checkWrapNotification() 83 | 84 | function addNotification(options: Notification) { 85 | options.show = true 86 | notifications.value.push(options) 87 | } 88 | 89 | return { 90 | contextMenu, 91 | setContextMenuItem, 92 | addSnack, 93 | playlist, 94 | source, 95 | edit, 96 | notifications, 97 | snackbars 98 | } 99 | }) 100 | -------------------------------------------------------------------------------- /src/renderer/src/store/UI/update.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { ref } from 'vue' 3 | import log from 'electron-log/renderer' 4 | 5 | export const useUpdateStore = defineStore('update', () => { 6 | const updateState = ref({ 7 | checkingForUpdate: false, 8 | latest: true, 9 | downloaded: false, 10 | error: '', 11 | progress: { 12 | percent: 0, 13 | total: 1, 14 | transferred: 0 15 | }, 16 | updateVersion: '', 17 | releaseNotes: '' 18 | }) 19 | 20 | window.events.on('log', (...args: any[]) => { 21 | if (args[0] !== '[auto updater]' || args.length !== 3) return 22 | const [, type, data] = args 23 | log.info({ type, data }) 24 | switch (type) { 25 | case 'error': 26 | updateState.value.error = data.message 27 | break 28 | case 'checking-for-update': 29 | updateState.value.checkingForUpdate = true 30 | break 31 | case 'update-available': 32 | updateState.value.checkingForUpdate = false 33 | updateState.value.latest = false 34 | updateState.value.updateVersion = data.version 35 | updateState.value.releaseNotes = data.releaseNotes 36 | break 37 | case 'update-not-available': 38 | updateState.value.checkingForUpdate = false 39 | updateState.value.latest = true 40 | break 41 | case 'download-progress': 42 | updateState.value.progress.percent = data.percent 43 | updateState.value.progress.total = data.total 44 | updateState.value.progress.transferred = data.transferred 45 | break 46 | case 'update-downloaded': 47 | updateState.value.downloaded = true 48 | break 49 | } 50 | }) 51 | 52 | return { updateState } 53 | }) 54 | -------------------------------------------------------------------------------- /src/renderer/src/store/base.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { EventEmitter } from 'events' 3 | import { persistentRef } from '../scripts/utils' 4 | 5 | export const useBaseStore = defineStore('base', () => { 6 | const events = new EventEmitter() 7 | const isDev = !location.href.startsWith('file://') 8 | const offlineMode = persistentRef('offlineMode', false) 9 | 10 | const waitFor = (name: string) => new Promise((resolve) => events.once(name, resolve)) 11 | 12 | return { 13 | events, 14 | waitFor, 15 | isDev, 16 | offlineMode 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /src/renderer/src/store/ruurd-auth.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { computed } from 'vue' 3 | import { persistentRef } from '../scripts/utils' 4 | 5 | export const useRuurdAuthStore = defineStore('ruurd-auth', () => { 6 | const credentials = persistentRef( 7 | 'ruurdCredentials', 8 | { 9 | email: null as null | string, 10 | password: null as null | string, 11 | name: null as null | string 12 | }, 13 | true 14 | ) 15 | 16 | function logout() { 17 | credentials.value.email = null 18 | credentials.value.name = null 19 | credentials.value.password = null 20 | } 21 | 22 | const isLoggedIn = computed( 23 | () => 24 | credentials.value.email !== null && 25 | credentials.value.password !== null && 26 | credentials.value.name !== null 27 | ) 28 | 29 | return { 30 | credentials, 31 | isLoggedIn, 32 | logout 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /src/renderer/src/store/spotify-auth.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { useBaseStore } from './base' 3 | import { computed } from 'vue' 4 | import { usePlatformStore } from './electron' 5 | import { useLibraryStore } from './library' 6 | import { useSpotifyApiStore } from './spotify-api' 7 | import { useRouter } from 'vue-router' 8 | import { usePlayerStore } from './player/player' 9 | import { randomUser } from '../scripts/image-sources' 10 | import { baseDb } from '../scripts/database' 11 | import { persistentRef } from '../scripts/utils' 12 | import log from 'electron-log/renderer' 13 | 14 | export interface AuthToken { 15 | code: null | string 16 | access: null | string 17 | refresh: null | string 18 | expiryDate: null | number 19 | } 20 | 21 | export const useSpotifyAuthStore = defineStore('spotify-auth', () => { 22 | const platform = usePlatformStore() 23 | const library = useLibraryStore() 24 | const spotify = useSpotifyApiStore() 25 | const base = useBaseStore() 26 | const router = useRouter() 27 | const player = usePlayerStore() 28 | 29 | const secret = persistentRef('secret', '') 30 | const clientId = persistentRef('clientId', '') 31 | const tokens = persistentRef( 32 | 'tokens', 33 | { 34 | code: null, 35 | access: null, 36 | refresh: null, 37 | expiryDate: null 38 | }, 39 | true 40 | ) 41 | if (tokens.value.access !== null) checkAuth().then() 42 | const hasCredentials = computed(() => secret.value.length === 32 && clientId.value.length === 32) 43 | const isLoggedIn = computed( 44 | () => 45 | tokens.value.code !== null && 46 | tokens.value.access !== null && 47 | tokens.value.refresh !== null && 48 | tokens.value.expiryDate !== null 49 | ) 50 | 51 | // Spotify API Stuff 52 | const requestedScopes = 53 | 'ugc-image-upload user-read-email user-read-private playlist-read-collaborative playlist-modify-public playlist-read-private playlist-modify-private user-library-modify user-library-read user-top-read user-read-recently-played user-follow-read user-follow-modify' 54 | 55 | async function getAuthByRefreshToken(refreshToken: string): Promise { 56 | const result = await ( 57 | await fetch('https://accounts.spotify.com/api/token', { 58 | method: 'post', 59 | body: `grant_type=refresh_token&refresh_token=${refreshToken}&client_id=${clientId.value}&client_secret=${secret.value}`, 60 | headers: { 61 | 'Content-Type': 'application/x-www-form-urlencoded' 62 | } 63 | }) 64 | ).text() 65 | try { 66 | const parsed = JSON.parse(result) 67 | return { 68 | access: parsed.access_token, 69 | expiryDate: +new Date() + parsed.expires_in * 1000 70 | } as AuthToken 71 | } catch (e: any) { 72 | log.info('Error', e.message, 'result = ', result) 73 | } 74 | return {} as AuthToken 75 | } 76 | 77 | async function login() { 78 | tokens.value = await platform.firstLogin() 79 | await checkAuth() 80 | } 81 | 82 | async function loginByRefreshToken() { 83 | if (tokens.value.refresh === null || tokens.value.refresh === '') { 84 | log.warn("Couldn't get new token, refresh token isn't set", tokens) 85 | return 86 | } 87 | const { access, expiryDate } = await getAuthByRefreshToken(tokens.value.refresh) 88 | tokens.value.access = access 89 | tokens.value.expiryDate = expiryDate 90 | 91 | await checkAuth() 92 | } 93 | 94 | let tokenTimeout: number 95 | 96 | async function logout() { 97 | const db = await baseDb 98 | player.unload().then() 99 | 100 | tokens.value = { 101 | code: null, 102 | access: null, 103 | refresh: null, 104 | expiryDate: null 105 | } 106 | library.userInfo = { 107 | id: '', 108 | name: '', 109 | mail: '', 110 | country: '', 111 | followers: 0, 112 | avatar: randomUser() 113 | } 114 | await db.delete('spotify', 'library') 115 | await db.delete('spotify', 'view') 116 | await db.clear('cache') 117 | 118 | clearTimeout(tokenTimeout) 119 | platform.resetSpotifyLogin() 120 | await router.push('/login') 121 | } 122 | 123 | async function checkAuth() { 124 | await baseDb 125 | 126 | const now = Date.now() 127 | if (tokens.value.expiryDate !== null && tokens.value.expiryDate > now) { 128 | spotify.api.setAccessToken(tokens.value.access) 129 | base.events.emit('accessToken') 130 | 131 | const msUntilExpire = tokens.value.expiryDate - now 132 | clearTimeout(tokenTimeout) 133 | tokenTimeout = window.setTimeout( 134 | async () => { 135 | await loginByRefreshToken() 136 | }, 137 | msUntilExpire - 1000 * 60 * 5 138 | ) 139 | 140 | await library.initialize() 141 | } else { 142 | log.warn('Auth has expired, getting new token') 143 | //auth is expired 144 | await loginByRefreshToken() 145 | } 146 | } 147 | 148 | const awaitAuth = async () => { 149 | if (isLoggedIn.value && spotify.api.getAccessToken() !== null) return 150 | return await base.waitFor('accessToken') 151 | } 152 | 153 | return { 154 | isLoggedIn, 155 | requestedScopes, 156 | secret, 157 | clientId, 158 | hasCredentials, 159 | login, 160 | logout, 161 | awaitAuth 162 | } 163 | }) 164 | -------------------------------------------------------------------------------- /src/renderer/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 73 | 74 | 108 | -------------------------------------------------------------------------------- /src/renderer/src/views/Library.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 50 | 51 | 62 | -------------------------------------------------------------------------------- /src/renderer/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 26 | 27 | 38 | -------------------------------------------------------------------------------- /src/renderer/src/views/Search.vue: -------------------------------------------------------------------------------- 1 | 104 | 105 | 168 | 169 | 200 | -------------------------------------------------------------------------------- /src/renderer/src/views/browse/Browse.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 75 | 76 | 160 | -------------------------------------------------------------------------------- /src/renderer/src/views/browse/Category.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 34 | 47 | -------------------------------------------------------------------------------- /src/renderer/src/views/browse/Radio.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 122 | 123 | 143 | -------------------------------------------------------------------------------- /src/renderer/src/views/item/Album.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 101 | 102 | 149 | -------------------------------------------------------------------------------- /src/renderer/src/views/item/Artist.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 132 | 133 | 195 | -------------------------------------------------------------------------------- /src/renderer/src/views/item/Playlist.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/renderer/src/views/item/User.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 91 | 92 | 155 | -------------------------------------------------------------------------------- /src/renderer/src/views/library/Albums.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /src/renderer/src/views/library/Artists.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /src/renderer/src/views/library/Playlists.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /src/renderer/src/views/library/Tracks.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 74 | 75 | 100 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", 3 | "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["electron-vite/node"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", 3 | "include": [ 4 | "src/renderer/src/env.d.ts", 5 | "src/renderer/src/**/*", 6 | "src/renderer/src/**/*.vue", 7 | "src/preload/*.d.ts" 8 | ], 9 | "compilerOptions": { 10 | "composite": true, 11 | "baseUrl": ".", 12 | "paths": { 13 | "@renderer/*": [ 14 | "src/renderer/src/*" 15 | ] 16 | } 17 | } 18 | } 19 | --------------------------------------------------------------------------------