mirror of
https://github.com/janishutz/MusicPlayerV2.git
synced 2025-11-25 04:54:23 +00:00
483 lines
17 KiB
TypeScript
483 lines
17 KiB
TypeScript
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<void> {
|
|
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<SearchResult> {
|
|
// 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; |