import type { SearchResult, Song, SongMove } from "./song"; interface Config { devToken: string; userToken: string; } type ControlAction = 'play' | 'pause' | 'next' | 'previous' | 'skip-10' | 'back-10'; type RepeatMode = 'off' | 'once' | 'all'; class MusicKitJSWrapper { playingSongID: number; playlist: Song[]; queue: number[]; config: Config; musicKit: any; isLoggedIn: boolean; isPreparedToPlay: boolean; repeatMode: RepeatMode; isShuffleEnabled: boolean; hasEncounteredAuthError: boolean; queuePos: number; audioPlayer: HTMLAudioElement; constructor () { this.playingSongID = 0; this.playlist = []; this.queue = []; this.config = { devToken: '', userToken: '', }; this.isShuffleEnabled = false; this.repeatMode = 'off'; this.isPreparedToPlay = false; this.isLoggedIn = false; this.hasEncounteredAuthError = false; this.queuePos = 0; this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement; const self = this; if ( !window.MusicKit ) { document.addEventListener( 'musickitloaded', () => { self.init(); } ); } else { this.init(); } } /** * Log a user into Apple Music. Will automatically initialize MusicKitJS, once user is logged in * @returns {void} */ logIn (): void { if ( !this.musicKit.isAuthorized ) { this.musicKit.authorize().then( () => { this.isLoggedIn = true; this.init(); } ).catch( () => { this.hasEncounteredAuthError = true; } ); } else { this.musicKit.authorize().then( () => { this.isLoggedIn = true; this.init(); } ).catch( () => { this.hasEncounteredAuthError = true; } ); } } /** * Initialize MusicKitJS. Should not be called. Use logIn instead, which first tries to log the user in, then calls this method. * @returns {void} */ init (): void { fetch( localStorage.getItem( 'url' ) + '/getAppleMusicDevToken', { credentials: 'include' } ).then( res => { if ( res.status === 200 ) { res.text().then( token => { this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement; // MusicKit global is now defined MusicKit.configure( { developerToken: token, app: { name: 'MusicPlayer', build: '3' }, storefrontId: 'CH', } ).then( () => { this.config.devToken = token; this.musicKit = MusicKit.getInstance(); if ( this.musicKit.isAuthorized ) { this.isLoggedIn = true; this.config.userToken = this.musicKit.musicUserToken; } this.musicKit.shuffleMode = MusicKit.PlayerShuffleMode.off; } ); } ); } } ); } /** * Get the authentication status of the user * @returns {boolean[]} Returns an array, where the first element indicates login status, the second one, if an error was encountered */ getAuth (): boolean[] { return [ this.isLoggedIn, this.hasEncounteredAuthError ]; } /** * Request data from the Apple Music API * @param {string} url The URL at the Apple Music API to call (including protocol and url) * @param {( data: object ) => void} callback A callback function that takes the data and returns nothing * @returns {void} */ apiGetRequest ( url: string, callback: ( data: object ) => void ): void { if ( this.config.devToken != '' && this.config.userToken != '' ) { fetch( url, { method: 'GET', headers: { 'Authorization': `Bearer ${ this.config.devToken }`, 'Music-User-Token': this.config.userToken } } ).then( res => { if ( res.status === 200 ) { res.json().then( json => { try { callback( { 'status': 'ok', 'data': json } ); } catch( err ) { /* empty */} } ); } else { try { callback( { 'status': 'error', 'error': res.status } ); } catch( err ) { /* empty */} } } ); } else return; } /** * Set the playlist to play * @param {Song[]} playlist The playlist as an array of songs * @returns {void} */ setPlaylist ( playlist: Song[] ): void { this.playlist = playlist; this.setShuffle( this.isShuffleEnabled ); } setPlaylistByID ( id: string ): Promise { return new Promise( ( resolve, reject ) => { this.musicKit.setQueue( { playlist: id } ).then( () => { const pl = this.musicKit.queue.items; const songs: Song[] = []; for ( const item in pl ) { let url = pl[ item ].attributes.artwork.url; url = url.replace( '{w}', pl[ item ].attributes.artwork.width ); url = url.replace( '{h}', pl[ item ].attributes.artwork.height ); const song: Song = { artist: pl[ item ].attributes.artistName, cover: url, duration: pl[ item ].attributes.durationInMillis / 1000, id: pl[ item ].id, origin: 'apple-music', title: pl[ item ].attributes.name, genres: pl[ item ].attributes.genreNames } songs.push( song ); } this.playlist = songs; this.setShuffle( this.isShuffleEnabled ); this.queuePos = 0; this.playingSongID = this.queue[ 0 ]; this.prepare( this.playingSongID ); resolve(); } ).catch( err => { console.error( err ); reject( err ); } ); } ); } /** * Prepare a specific song in the queue for playing and start playing * @param {number} playlistID The ID of the song in the playlist to prepare to play * @returns {boolean} Returns true, if successful, false, if playlist is missing / empty. Set that first */ prepare ( playlistID: number ): boolean { if ( this.playlist.length > 0 ) { this.playingSongID = playlistID; this.isPreparedToPlay = true; for ( const el in this.queue ) { if ( this.queue[ el ] === playlistID ) { this.queuePos = parseInt( el ); break; } } if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) { this.musicKit.setQueue( { 'song': this.playlist[ this.playingSongID ].id } ).then( () => { setTimeout( () => { this.control( 'play' ); }, 500 ); } ).catch( ( err ) => { console.log( err ); } ); } else { this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement; this.audioPlayer.src = this.playlist[ this.playingSongID ].id; setTimeout( () => { this.control( 'play' ); }, 500 ); } return true; } else { return false; } } /** * Control the player * @param {ControlAction} action Action to take on the player * @returns {boolean} returns a boolean indicating if there was a change in song. */ control ( action: ControlAction ): boolean { switch ( action ) { case "play": if ( this.isPreparedToPlay ) { this.control( 'pause' ); if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) { this.musicKit.play(); return false; } else { this.audioPlayer.play(); return false; } } else { return false; } case "pause": if ( this.isPreparedToPlay ) { if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) { this.musicKit.pause(); return false; } else { this.audioPlayer.pause(); return false; } } else { return false; } case "back-10": if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) { this.musicKit.seekToTime( this.musicKit.currentPlaybackTime > 10 ? this.musicKit.currentPlaybackTime - 10 : 0 ); return false; } else { this.audioPlayer.currentTime = this.audioPlayer.currentTime > 10 ? this.audioPlayer.currentTime - 10 : 0; return false; } case "skip-10": if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) { if ( this.musicKit.currentPlaybackTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) { this.musicKit.seekToTime( this.musicKit.currentPlaybackTime + 10 ); return false; } else { if ( this.repeatMode !== 'once' ) { this.control( 'next' ); return true; } else { this.musicKit.seekToTime( 0 ); return false; } } } else { if ( this.audioPlayer.currentTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) { this.audioPlayer.currentTime = this.audioPlayer.currentTime + 10; } else { if ( this.repeatMode !== 'once' ) { this.control( 'next' ); } else { this.audioPlayer.currentTime = 0; } } return false; } case "next": this.control( 'pause' ); if ( this.queuePos < this.queue.length - 1 ) { this.queuePos += 1; this.prepare( this.queue[ this.queuePos ] ); return true; } else { this.queuePos = 0; if ( this.repeatMode !== 'all' ) { this.control( 'pause' ); } else { this.playingSongID = this.queue[ this.queuePos ]; this.prepare( this.queue[ this.queuePos ] ); } return true; } case "previous": this.control( 'pause' ); if ( this.queuePos > 0 ) { this.queuePos -= 1; this.prepare( this.queue[ this.queuePos ] ); return true; } else { this.queuePos = this.queue.length - 1; return true; } } } setShuffle ( enabled: boolean ) { this.isShuffleEnabled = enabled; this.queue = []; if ( enabled ) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const d = []; for ( const el in this.playlist ) { d.push( parseInt( el ) ); } this.queue = d.map( value => ( { value, sort: Math.random() } ) ) .sort( ( a, b ) => a.sort - b.sort ) .map( ( { value } ) => value ); this.queue.splice( this.queue.indexOf( this.playingSongID ), 1 ); this.queue.push( this.playingSongID ); this.queue.reverse(); } else { for ( const song in this.playlist ) { this.queue.push( parseInt( song ) ); } } // Find current song ID in queue for ( const el in this.queue ) { if ( this.queue[ el ] === this.playingSongID ) { this.queuePos = parseInt( el ); break; } } } setRepeatMode ( mode: RepeatMode ) { this.repeatMode = mode; } goToPos ( pos: number ) { if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) { this.musicKit.seekToTime( pos ); } else { this.audioPlayer.currentTime = pos; } } moveSong ( move: SongMove ) { const newQueue = []; const finishedQueue = []; let songID = 0; for ( const song in this.playlist ) { if ( this.playlist[ song ].id === move.songID ) { songID = parseInt( song ); break; } } for ( const el in this.queue ) { if ( this.queue[ el ] !== songID ) { newQueue.push( this.queue[ el ] ); } } let hasBeenAdded = false; for ( const el in newQueue ) { if ( parseInt( el ) === move.newPos ) { finishedQueue.push( songID ); hasBeenAdded = true; } finishedQueue.push( newQueue[ el ] ); } if ( !hasBeenAdded ) { finishedQueue.push( songID ); } this.queue = finishedQueue; } /** * Get the current position of the play heed. Will return in ms since start of the song * @returns {number} */ getPlaybackPos (): number { if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) { return this.musicKit.currentPlaybackTime; } else { return this.audioPlayer.currentTime; } } /** * Get details on the currently playing song * @returns {Song} */ getPlayingSong (): Song { return this.playlist[ this.playingSongID ]; } /** * Get the playlist index of the currently playing song * @returns {number} */ getPlayingSongID (): number { return this.playingSongID; } /** * Get the queue index of the currently playing song * @returns {number} */ getQueueID (): number { return this.queuePos; } /** * Get the full playlist, as it is set currently, not ordered by queue settings, but as passed in originally * @returns {Song[]} */ getPlaylist (): Song[] { return this.playlist; } /** * Same as getPlaylist, but returns a ordered playlist, by how it will play according to the queue. * @returns {Song[]} */ getQueue (): Song[] { const data = []; for ( const el in this.queue ) { data.push( this.playlist[ this.queue[ el ] ] ); } return data; } /** * Get all playlists the authenticated user has on Apple Music. Only available once the user has authenticated! * @param {( data: object ) => void} cb The callback function called with the results from the API * @returns {boolean} Returns true, if user is authenticated and request was started, false if not. */ getUserPlaylists ( cb: ( data: object ) => void ): boolean { if ( this.isLoggedIn ) { this.apiGetRequest( 'https://api.music.apple.com/v1/me/library/playlists', cb ); return true; } else { return false; } } getPlaying ( ): boolean { if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) { return this.musicKit.isPlaying; } else { return !this.audioPlayer.paused; } } findSongOnAppleMusic ( searchTerm: string ): Promise { // TODO: Make storefront adjustable return new Promise( ( resolve, reject ) => { const queryParameters = { term: ( searchTerm ), types: [ 'songs' ], }; this.musicKit.api.music( `v1/catalog/ch/search`, queryParameters ).then( results => { resolve( results ); } ).catch( e => { console.error( e ); reject( e ); } ); } ); } } export default MusicKitJSWrapper;