From 3a0f8210ddbfa4293b1e7733f00f3325d8322659 Mon Sep 17 00:00:00 2001 From: Janis Hutz Date: Tue, 1 Sep 2026 12:51:39 +0200 Subject: [PATCH] feat: add songs via Apple Music --- backend/src/app.ts | 5 +- web/index.html | 17 ++-- web/src/components/AddSong.vue | 2 + web/src/components/PlayerControls.vue | 2 +- web/src/components/ProgressBar.vue | 2 +- web/src/components/QueueViewer.vue | 65 +++++++------ web/src/components/SortableList.vue | 7 +- web/src/composables/DiskLoader.vue | 14 +++ web/src/composables/ImportTypePicker.vue | 40 ++++++++ web/src/composables/SearchView.vue | 79 ++++++++++++++++ web/src/composables/SongDiskLoader.vue | 0 web/src/composables/SongSearch.vue | 0 web/src/composables/diskLoader.ts | 16 ++++ web/src/composables/importTypePicker.ts | 20 +++- web/src/composables/searchManager.ts | 16 ++++ web/src/composables/songDiskLoader.ts | 0 web/src/composables/songSearch.ts | 0 web/src/scss/components/queue.scss | 92 +++++++++++++++++++ web/src/scss/components/search.scss | 12 +++ web/src/scss/components/sortablelist.scss | 5 + web/src/ts/dtype/global.d.ts | 7 ++ web/src/ts/dtype/playlist.d.ts | 2 +- web/src/ts/player/index.ts | 4 +- web/src/ts/player/plugins/interface.d.ts | 2 +- web/src/ts/player/plugins/musickit/index.ts | 13 +-- .../player/plugins/musickit/search/albums.ts | 10 -- .../player/plugins/musickit/search/dtype.d.ts | 26 ++++++ .../player/plugins/musickit/search/index.ts | 25 ++++- .../plugins/musickit/search/playlists.ts | 13 ++- .../player/plugins/musickit/search/songs.ts | 42 +++++++-- web/src/ts/player/state.ts | 22 +++++ web/todo.md | 1 + web/tsconfig.node.json | 50 +++++----- 33 files changed, 513 insertions(+), 98 deletions(-) create mode 100644 web/src/composables/DiskLoader.vue create mode 100644 web/src/composables/SearchView.vue delete mode 100644 web/src/composables/SongDiskLoader.vue delete mode 100644 web/src/composables/SongSearch.vue create mode 100644 web/src/composables/diskLoader.ts create mode 100644 web/src/composables/searchManager.ts delete mode 100644 web/src/composables/songDiskLoader.ts delete mode 100644 web/src/composables/songSearch.ts create mode 100644 web/src/scss/components/queue.scss create mode 100644 web/src/scss/components/search.scss create mode 100644 web/src/ts/dtype/global.d.ts delete mode 100644 web/src/ts/player/plugins/musickit/search/albums.ts create mode 100644 web/src/ts/player/plugins/musickit/search/dtype.d.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 8d4fd61..a0488e8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -29,9 +29,8 @@ const run = () => { __dirname, '/config/apple-music-api.config.secret.json' ) ).toString() ); - - app.get( '/dev-token', ( request: express.Request, response: express.Response ) => { - // sign dev token + app.get( '/dev-token', ( _request: express.Request, response: express.Response ) => { + // sign dev token const now = new Date().getTime(); const tomorrow = now + ( 24 * 3600 * 1000 ); const jwtToken = jwt.sign( { diff --git a/web/index.html b/web/index.html index 9e5fc8f..cdd27f9 100644 --- a/web/index.html +++ b/web/index.html @@ -1,13 +1,18 @@ - - + + + - Vite App - - + MusicPlayer + + + +
+ - + + diff --git a/web/src/components/AddSong.vue b/web/src/components/AddSong.vue index b4c1646..a734b70 100644 --- a/web/src/components/AddSong.vue +++ b/web/src/components/AddSong.vue @@ -1,4 +1,5 @@ + + diff --git a/web/src/composables/ImportTypePicker.vue b/web/src/composables/ImportTypePicker.vue index e69de29..c6029d7 100644 --- a/web/src/composables/ImportTypePicker.vue +++ b/web/src/composables/ImportTypePicker.vue @@ -0,0 +1,40 @@ + + + diff --git a/web/src/composables/SearchView.vue b/web/src/composables/SearchView.vue new file mode 100644 index 0000000..bd91978 --- /dev/null +++ b/web/src/composables/SearchView.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/web/src/composables/SongDiskLoader.vue b/web/src/composables/SongDiskLoader.vue deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/composables/SongSearch.vue b/web/src/composables/SongSearch.vue deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/composables/diskLoader.ts b/web/src/composables/diskLoader.ts new file mode 100644 index 0000000..c4473ee --- /dev/null +++ b/web/src/composables/diskLoader.ts @@ -0,0 +1,16 @@ +import { + type Ref, + ref +} from 'vue'; +import type { + FileImport +} from './importTypePicker'; + +export const isShowingDiskLoader = ref( false ); + +export const diskLoaderOpts: Ref = ref( null ); + +export const openDiskLoaderInterface = ( options: FileImport ) => { + isShowingDiskLoader.value = true; + diskLoaderOpts.value = options; +}; diff --git a/web/src/composables/importTypePicker.ts b/web/src/composables/importTypePicker.ts index ab5557e..4308c0b 100644 --- a/web/src/composables/importTypePicker.ts +++ b/web/src/composables/importTypePicker.ts @@ -1,3 +1,7 @@ +import { + type Ref, + ref +} from 'vue'; import type { Song } from '@/ts/dtype/playlist'; @@ -5,13 +9,25 @@ import type { export interface CloudImport { // TODO: Consider if should make selectable and display album / playlist contents to select specific songs 'name': string; - 'kind': 'list' | 'nested-list'; + 'type': 'cloud'; 'search': ( term: string ) => Promise; - 'addSelected': ( index: number ) => Promise; + 'addSelected': ( index: number ) => void; } // TODO: Need to add a way to access Apple Music API everywhere (maybe configure MusicKitJS globally and consume it in its plugin later?) export interface FileImport { 'name': string; + 'type': 'file'; 'process': ( files: File[] ) => Promise; } + +export type ImportTypes = CloudImport | FileImport; + +export const isShowingImportTypePicker = ref( false ); + +export const importers: Ref<( CloudImport | FileImport )[]> = ref( [] ); + +export const openImportTypePicker = ( options: ( CloudImport | FileImport )[] ) => { + isShowingImportTypePicker.value = true; + importers.value = options; +}; diff --git a/web/src/composables/searchManager.ts b/web/src/composables/searchManager.ts new file mode 100644 index 0000000..e02a828 --- /dev/null +++ b/web/src/composables/searchManager.ts @@ -0,0 +1,16 @@ +import { + type Ref, + ref +} from 'vue'; +import type { + CloudImport +} from './importTypePicker'; + +export const isShowingSearchView = ref( false ); + +export const searchOpts: Ref = ref( null ); + +export const openSearchInterface = ( options: CloudImport ) => { + searchOpts.value = options; + isShowingSearchView.value = true; +}; diff --git a/web/src/composables/songDiskLoader.ts b/web/src/composables/songDiskLoader.ts deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/composables/songSearch.ts b/web/src/composables/songSearch.ts deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/scss/components/queue.scss b/web/src/scss/components/queue.scss new file mode 100644 index 0000000..f49ab3b --- /dev/null +++ b/web/src/scss/components/queue.scss @@ -0,0 +1,92 @@ +.song-list-element { + display: flex; + width: 100%; + + >.song-cover-wrapper { + height: 10rem; + width: 10rem; + position: relative; + + .song-cover, + .fa-solid { + height: 10rem; + width: 10rem; + font-size: 7rem; + line-height: 100%; + display: flex; + justify-content: center; + align-items: center; + } + + >.play-overlay { + background-color: var(--overlay-color); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + &.hover { + display: none; + } + } + + &:hover { + >.play-overlay.hover { + display: block; + } + } + } +} + +.playing-symbols { + position: absolute; + left: 0; + right: 0; + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; + margin: 0; + padding-left: 20%; + padding-right: 20%; + width: 60%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + + .playing-bar { + height: 40%; + background-color: white; + width: 12%; + border-radius: 50px; + margin: auto; + } + + #bar-1 { + animation: music-playing 0.9s infinite ease-in-out; + } + + #bar-2 { + animation: music-playing 0.9s infinite ease-in-out; + animation-delay: 0.3s; + } + + #bar-3 { + animation: music-playing 0.9s infinite ease-in-out; + animation-delay: 0.6s; + } + + @keyframes music-playing { + 0% { + transform: scaleY(1); + } + + 50% { + transform: scaleY(0.5); + } + + 100% { + transform: scaleY(1); + } + } +} diff --git a/web/src/scss/components/search.scss b/web/src/scss/components/search.scss new file mode 100644 index 0000000..ab19504 --- /dev/null +++ b/web/src/scss/components/search.scss @@ -0,0 +1,12 @@ +.search-results-wrapper { + width: 70vw; + height: 60vh; + overflow-x: hidden; + overflow-y: scroll; + + >div { + >img { + width: 10%; + } + } +} diff --git a/web/src/scss/components/sortablelist.scss b/web/src/scss/components/sortablelist.scss index a3495a6..3d4047d 100644 --- a/web/src/scss/components/sortablelist.scss +++ b/web/src/scss/components/sortablelist.scss @@ -14,6 +14,11 @@ } >.movable { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + &.moving { position: fixed; } diff --git a/web/src/ts/dtype/global.d.ts b/web/src/ts/dtype/global.d.ts new file mode 100644 index 0000000..a630514 --- /dev/null +++ b/web/src/ts/dtype/global.d.ts @@ -0,0 +1,7 @@ +import type { + MusicKitObject +} from 'musickitjs-v3-types/MusicKit'; + +declare global { + var MusicKit: MusicKitObject; +} diff --git a/web/src/ts/dtype/playlist.d.ts b/web/src/ts/dtype/playlist.d.ts index 9ae86b2..ae734ff 100644 --- a/web/src/ts/dtype/playlist.d.ts +++ b/web/src/ts/dtype/playlist.d.ts @@ -40,7 +40,7 @@ export interface Song { /** * The cover image as a URL */ - 'cover': string; + 'artwork': string; /** * Song duration diff --git a/web/src/ts/player/index.ts b/web/src/ts/player/index.ts index eaf556c..fe82458 100644 --- a/web/src/ts/player/index.ts +++ b/web/src/ts/player/index.ts @@ -1,5 +1,6 @@ import { addSongList, + clearQueue, shuffleList } from './playlists/add'; import { @@ -117,7 +118,7 @@ const getSources = (): string[] => { }; const addSongFromSource = async ( source: string ) => { - addSongList( await sources[source]!.addSongsFromThisSource() ); + sources[source]!.addSongsFromThisSource( addSongList ); }; export default { @@ -133,6 +134,7 @@ export default { addSongFromSource, next, prev, + clearQueue, queue, queueIdx, duration, diff --git a/web/src/ts/player/plugins/interface.d.ts b/web/src/ts/player/plugins/interface.d.ts index a96b9d4..dd3fa88 100644 --- a/web/src/ts/player/plugins/interface.d.ts +++ b/web/src/ts/player/plugins/interface.d.ts @@ -94,5 +94,5 @@ export interface PlayerSourcePlugin { * Called when user adds another song to the playlist via this source. * You may use the provided interface elements (search bar and popups) to e.g. ask if user wants to use a playlist, album, etc */ - 'addSongsFromThisSource': () => Promise; + 'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => void; } diff --git a/web/src/ts/player/plugins/musickit/index.ts b/web/src/ts/player/plugins/musickit/index.ts index ccb9821..a89b9d1 100644 --- a/web/src/ts/player/plugins/musickit/index.ts +++ b/web/src/ts/player/plugins/musickit/index.ts @@ -1,8 +1,10 @@ -import 'musickitjs-v3-types'; import type { PlayerSourcePlugin, PlayerSourcePluginInitializer } from '../interface'; +import type { + Song +} from '@/ts/dtype/playlist'; import { addFromAppleMusic } from './search'; @@ -13,7 +15,7 @@ import { export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise => { return new Promise( ( resolve, reject ) => { const init = async ( storefront: string ): Promise => { - const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/', { + const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/dev-token', { 'credentials': 'include' } ); @@ -23,8 +25,8 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'developerToken': token, 'app': { 'name': 'MusicPlayer', - 'build': '4', - 'icon': 'https://music.janishutz.com/logo.jpg' + 'build': '4' + // 'icon': 'https://music.janishutz.com/logo.jpg' }, 'storefrontId': storefront.toUpperCase() } ); @@ -51,8 +53,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'name': 'Apple Music', 'seekTo': controls.seekTo, 'login': login, - // TODO: Implement - 'addSongsFromThisSource': async () => await addFromAppleMusic(), + 'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => addFromAppleMusic( instance, cb ), 'loading': { 'requiresLocalFiles': false } diff --git a/web/src/ts/player/plugins/musickit/search/albums.ts b/web/src/ts/player/plugins/musickit/search/albums.ts deleted file mode 100644 index b65a462..0000000 --- a/web/src/ts/player/plugins/musickit/search/albums.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { - MusicKitInstance -} from 'musickitjs-v3-types/MusicKitInstance'; - -export const searchAlbums = async ( instance: MusicKitInstance, term: string ) => { - const addSelected = ( idx: number ) => { - // TODO: Load album's songs and return them - Promise.resolve(); - }; -}; diff --git a/web/src/ts/player/plugins/musickit/search/dtype.d.ts b/web/src/ts/player/plugins/musickit/search/dtype.d.ts new file mode 100644 index 0000000..d0ec258 --- /dev/null +++ b/web/src/ts/player/plugins/musickit/search/dtype.d.ts @@ -0,0 +1,26 @@ +import type { + Artwork +} from 'musickitjs-v3-types/enums'; + +export interface AppleMusicApiSearchResult { + 'results': { + 'songs': { + 'data': AppleMusicSongData[], + 'href': string; + } + }; +} + +export interface AppleMusicSongData { + 'id': string, + 'type': string; + 'href': string; + 'attributes': { + 'albumName': string; + 'artistName': string; + 'artwork': Artwork, + 'name': string; + 'genreNames': string[]; + 'durationInMillis': number; + } +} diff --git a/web/src/ts/player/plugins/musickit/search/index.ts b/web/src/ts/player/plugins/musickit/search/index.ts index 6b24781..00eec1d 100644 --- a/web/src/ts/player/plugins/musickit/search/index.ts +++ b/web/src/ts/player/plugins/musickit/search/index.ts @@ -4,7 +4,28 @@ import type { import type { Song } from '@/ts/dtype/playlist'; +import { + openImportTypePicker +} from '@/composables/importTypePicker'; +import { + searchSongs +} from './songs'; -export const addFromAppleMusic = async ( instance: MusicKitInstance ): Promise => { - return []; +export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ): Promise => { + const songs = await searchSongs( instance, cb ); + + openImportTypePicker( [ + { + 'name': 'Songs', + 'type': 'cloud', + 'addSelected': songs.addSelected, + 'search': songs.search + }, + { + 'name': 'Playlists', + 'type': 'cloud', + 'addSelected': songs.addSelected, + 'search': songs.search + } + ] ); }; diff --git a/web/src/ts/player/plugins/musickit/search/playlists.ts b/web/src/ts/player/plugins/musickit/search/playlists.ts index 0e3c5bf..aa74732 100644 --- a/web/src/ts/player/plugins/musickit/search/playlists.ts +++ b/web/src/ts/player/plugins/musickit/search/playlists.ts @@ -1,10 +1,17 @@ +import type { + Song +} from '@/ts/dtype/playlist'; import type { MusicKitInstance } from 'musickitjs-v3-types/MusicKitInstance'; -export const searchPlaylists = async ( instance: MusicKitInstance, userPlaylists: boolean = true ) => { - const addSelected = ( idx: number ) => { +export const searchPlaylists = async ( instance: MusicKitInstance ) => { + // TODO: Get all playlists + + const search = async () => {}; + + const addSelected = async ( idx: number ): Promise => { // TODO: Load the playlist content and return songs - Promise.resolve(); + return []; }; }; diff --git a/web/src/ts/player/plugins/musickit/search/songs.ts b/web/src/ts/player/plugins/musickit/search/songs.ts index 02b5625..6d592ce 100644 --- a/web/src/ts/player/plugins/musickit/search/songs.ts +++ b/web/src/ts/player/plugins/musickit/search/songs.ts @@ -1,20 +1,46 @@ +import type { + AppleMusicApiSearchResult +} from './dtype'; import type { MusicKitInstance } from 'musickitjs-v3-types/MusicKitInstance'; +import type { + Song +} from '@/ts/dtype/playlist'; -export const seachSongs = async ( instance: MusicKitInstance ) => { - const search = async ( term: string ) => { +export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => { + let songs: Song[] = []; + + const search = async ( term: string ): Promise => { const params = { 'term': term, - 'types': [ 'songs' ], - 'l': 'en-us' + 'types': [ 'songs' ] }; + const results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data; - await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ); + songs = []; + + for ( const result of results ) { + songs.push( { + 'duration': Math.round( result.attributes.durationInMillis / 1000 ), + 'name': result.attributes.name, + 'additional-info': '', + 'artist': result.attributes.artistName, + 'artwork': window.MusicKit.formatArtworkURL( result.attributes.artwork, 1000, 1000 ), + 'identifier': result.id, + 'source': 'applemusic' + } ); + } + + return songs; }; - const addSelected = ( idx: number ) => { - // TODO: Need to only return the selected song - Promise.resolve(); + const addSelected = async ( idx: number ) => { + cb( [ songs[idx]! ] ); + }; + + return { + search, + addSelected }; }; diff --git a/web/src/ts/player/state.ts b/web/src/ts/player/state.ts index df288c2..cabc1d5 100644 --- a/web/src/ts/player/state.ts +++ b/web/src/ts/player/state.ts @@ -11,6 +11,12 @@ import type { import type { RepeatMode } from '../dtype/player'; +import { + useLocalPlayer +} from './plugins/local'; +import { + useMusicKit +} from './plugins/musickit'; export const sources: { [key: string]: PlayerSourcePlugin @@ -29,3 +35,19 @@ export const isPlaying = ref( false ); export const shuffle = ref( false ); export const repeat: Ref = ref( 'off' ); + +const initSources = async () => { + try { + sources['applemusic'] = await useMusicKit(); + } catch { + console.warn( 'MusicKitJS intialization failed' ); + } + + try { + sources['local'] = await useLocalPlayer(); + } catch { + console.warn( 'Local Player intialization failed' ); + } +}; + +initSources(); diff --git a/web/todo.md b/web/todo.md index bd7af45..d92dc15 100644 --- a/web/todo.md +++ b/web/todo.md @@ -13,6 +13,7 @@ - [ ] About page - [ ] Apple-Music signin - [ ] Loading existing playlists with local music and association of that +- [ ] Remote screens can use polling (once a minute or so) or SSE for updating (/fancy uses SSE by default, /share uses polling by default). Should reduce server load # Backend - [ ] Implement all endpoints diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json index c9b2bad..3dac890 100644 --- a/web/tsconfig.node.json +++ b/web/tsconfig.node.json @@ -1,27 +1,29 @@ // TSConfig for modules that run in Node.js environment via either transpilation or type-stripping. { - "extends": "@tsconfig/node24/tsconfig.json", - "include": [ - "vite.config.*", - "vitest.config.*", - "cypress.config.*", - "playwright.config.*", - "eslint.config.*" - ], - "compilerOptions": { - // Most tools use transpilation instead of Node.js's native type-stripping. - // Bundler mode provides a smoother developer experience. - "module": "preserve", - "moduleResolution": "bundler", - - // Include Node.js types and avoid accidentally including other `@types/*` packages. - "types": ["node"], - - // Disable emitting output during `vue-tsc --build`, which is used for type-checking only. - "noEmit": true, - - // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. - // Specified here to keep it out of the root directory. - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" - } + "extends": "@tsconfig/node24/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "playwright.config.*", + "eslint.config.*" + ], + "compilerOptions": { + // Most tools use transpilation instead of Node.js's native type-stripping. + // Bundler mode provides a smoother developer experience. + "module": "preserve", + "moduleResolution": "bundler", + // Include Node.js types and avoid accidentally including other `@types/*` packages. + "types": [ + "node" + ], + "typeRoots": [ + "./src/ts/dtype/global.d.ts" + ], + // Disable emitting output during `vue-tsc --build`, which is used for type-checking only. + "noEmit": true, + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. + // Specified here to keep it out of the root directory. + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + } }