some progress on player + playlist loading

This commit is contained in:
2024-06-25 11:45:11 +02:00
parent 56a714ab9e
commit 1ffdc873a7
10 changed files with 468 additions and 58 deletions

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200">
<!-- TODO: Update URL --> <!-- TODO: Update URL -->
<script src="/musickit.js"></script> <script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
<title>MusicPlayer</title> <title>MusicPlayer</title>
</head> </head>
<body> <body>

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +1,18 @@
<template> <template>
<div> <div>
<h1>Library</h1> <h1>Library</h1>
<playlistsView></playlistsView> <playlistsView :playlists="$props.playlists"></playlistsView>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import playlistsView from '@/components/playlistsView.vue'; import playlistsView from '@/components/playlistsView.vue';
defineProps( {
'playlists': {
'default': [],
'type': Array<any>,
'required': true,
}
} );
</script> </script>

View File

@@ -29,7 +29,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import playlistView from '@/components/playlistView.vue'; import playlistView from '@/components/playlistView.vue';
import MusicKitJSWrapper from '@/scripts/player'; import MusicKitJSWrapper from '@/scripts/music-player';
const isPlaying = ref( false ); const isPlaying = ref( false );
const repeatMode = ref( '' ); const repeatMode = ref( '' );
@@ -46,9 +46,9 @@
isPlaying.value = !isPlaying.value; isPlaying.value = !isPlaying.value;
// TODO: Execute function on player // TODO: Execute function on player
if ( isPlaying.value ) { if ( isPlaying.value ) {
player.play(); player.control( 'play' );
} else { } else {
player.pause(); player.control( 'pause' );
} }
} }
@@ -85,8 +85,28 @@
} }
} }
const getPlaylists = ( cb: ( data: object ) => void ) => {
player.getUserPlaylists( cb );
}
const logIntoAppleMusic = () => {
player.logIn();
}
const getAuth = (): boolean[] => {
return player.getAuth();
}
const skipLogin = () => {
player.init();
}
defineExpose( { defineExpose( {
logIntoAppleMusic,
getPlaylists,
controlUI, controlUI,
getAuth,
skipLogin,
} ); } );
</script> </script>

View File

@@ -1,5 +1,18 @@
<template> <template>
<div> <div>
<h3>Your playlists</h3> <h3>Your playlists</h3>
<div v-for="pl in $props.playlists" v-bind:key="pl.id">
{{ pl.attributes.name }}
</div>
</div> </div>
</template> </template>
<script setup lang="ts">
const props = defineProps( {
'playlists': {
'default': [],
'type': Array<any>,
'required': true,
}
} )
</script>

View File

@@ -0,0 +1,361 @@
type Origin = 'apple-music' | 'disk';
interface Song {
/**
* The ID. Either the apple music ID, or if from local disk, an ID starting in local_
*/
id: string;
/**
* Origin of the song
*/
origin: Origin;
/**
* The cover image as a URL
*/
cover: string;
/**
* The artist of the song
*/
artist: string;
/**
* The name of the song
*/
title: string;
/**
* Duration of the song in milliseconds
*/
duration: number;
/**
* (OPTIONAL) The genres this song belongs to. Can be displayed on the showcase screen, but requires settings there
*/
genres?: string[];
/**
* (OPTIONAL) This will be displayed in brackets on the showcase screens
*/
additionalInfo?: string;
}
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;
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;
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 => {
// 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 );
}
/**
* 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;
return true;
} else {
return false;
}
}
/**
* Control the player
* @param {ControlAction} action Action to take on the player
* @returns {void}
*/
control ( action: ControlAction ): void {
switch ( action ) {
case "play":
if ( this.isPreparedToPlay ) {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.play();
} else {
// TODO: Implement
}
} else {
return;
}
break;
case "pause":
if ( this.isPreparedToPlay ) {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.pause();
} else {
// TODO: Implement
}
} else {
return;
}
break;
case "back-10":
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.seekToTime( this.musicKit.currentPlaybackTime > 10 ? this.musicKit.currentPlaybackTime - 10 : 0 );
} else {
// TODO: Implement
}
break;
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 );
} else {
if ( this.repeatMode !== 'once' ) {
this.control( 'next' );
} else {
this.musicKit.seekToTime( 0 );
}
}
} else {
// TODO: Finish
// if ( this.audioPlayer.currentTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) {
// this.audioPlayer.currentTime = this.audioPlayer.currentTime + 10;
// this.pos = this.audioPlayer.currentTime;
// this.sendUpdate( 'pos' );
// } else {
// if ( this.repeatMode !== 'one' ) {
// this.control( 'next' );
// } else {
// this.audioPlayer.currentTime = 0;
// this.pos = this.audioPlayer.currentTime;
// this.sendUpdate( 'pos' );
// }
// }
}
break;
case "next":
//
break;
case "previous":
}
}
setShuffle ( enabled: boolean ) {
this.isShuffleEnabled = enabled;
// TODO: Shuffle playlist
}
setRepeatMode ( mode: RepeatMode ) {
this.repeatMode = mode;
}
goToPos ( pos: number ) {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.seekToTime( pos );
} else {
// TODO: Implement
}
}
/**
* 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 0;
// TODO: Implement
}
}
/**
* 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 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;
}
}
// findSongOnAppleMusic ( searchTerm: string ): Song => {
// TODO: Implement
// }
}
export default MusicKitJSWrapper;

View File

@@ -1,3 +1,6 @@
// IMPORTANT: Old, unfinished version that doesn't ship! See ./music-player.ts for the actual code!
type Origin = 'apple-music' | 'disk'; type Origin = 'apple-music' | 'disk';
interface Song { interface Song {

View File

@@ -1,13 +1,15 @@
<template> <template>
<div class="app-view"> <div class="app-view">
<div class="home-view" v-if="isLoggedIntoAppleMusic"> <div class="home-view" v-if="isLoggedIntoAppleMusic">
<libraryView class="library-view"></libraryView> <libraryView class="library-view" :playlists="playlists"></libraryView>
<playerView :class="'player-view' + ( isShowingFullScreenPlayer ? ' full-screen-player' : '' )" @player-state-change="( state ) => { handlePlayerStateChange( state ) }"></playerView>
</div> </div>
<div v-else class="login-view"> <div v-else class="login-view">
<img src="@/assets/appleMusicIcon.svg" alt="Apple Music Icon"> <img src="@/assets/appleMusicIcon.svg" alt="Apple Music Icon">
<button class="fancy-button" style="margin-top: 20px;">Log into Apple Music</button> <button class="fancy-button" style="margin-top: 20px;" @click="logIntoAppleMusic()">Log into Apple Music</button>
<button class="fancy-button" title="This allows you to use local playlists only. Cover images for your songs will be fetched from the apple music api as good as possible" @click="skipLogin()">Continue without logging in</button>
</div> </div>
<playerView :class="'player-view' + ( isLoggedIntoAppleMusic ? ( isShowingFullScreenPlayer ? ' full-screen-player' : '' ) : ' player-hidden' )" @player-state-change="( state ) => { handlePlayerStateChange( state ) }"
ref="player"></playerView>
</div> </div>
</template> </template>
@@ -16,8 +18,10 @@
import libraryView from '@/components/libraryView.vue'; import libraryView from '@/components/libraryView.vue';
import { ref } from 'vue'; import { ref } from 'vue';
const isLoggedIntoAppleMusic = ref( true ); const isLoggedIntoAppleMusic = ref( false );
const isShowingFullScreenPlayer = ref( false ); const isShowingFullScreenPlayer = ref( false );
const player = ref( playerView );
const playlists = ref( [] );
const handlePlayerStateChange = ( newState: string ) => { const handlePlayerStateChange = ( newState: string ) => {
if ( newState === 'hide' ) { if ( newState === 'hide' ) {
@@ -26,21 +30,44 @@
isShowingFullScreenPlayer.value = true; isShowingFullScreenPlayer.value = true;
} }
} }
let loginChecker = 0;
const logIntoAppleMusic = () => {
loginChecker = setInterval( () => {
if ( player.value.getAuth()[ 0 ] ) {
isLoggedIntoAppleMusic.value = true;
player.value.getPlaylists( ( data ) => {
console.log( data.data.data );
playlists.value = data.data.data;
} );
clearInterval( loginChecker );
} else if ( player.value.getAuth()[ 1 ] ) {
clearInterval( loginChecker );
alert( 'An error occurred when logging you in. Please try again!' );
}
}, 500 );
}
const skipLogin = () => {
isLoggedIntoAppleMusic.value = true;
player.value.skipLogin();
}
</script> </script>
<style scoped> <style scoped>
.library-view { .library-view {
height: calc( 90vh - 10px ); height: calc( 90vh - 10px );
width: 100vw; width: 100%;
} }
.app-view { .app-view {
height: 100vh; height: 100%;
width: 100vw; width: 100%;
} }
.home-view { .home-view {
height: 100vh; height: 100%;
} }
.login-view { .login-view {
@@ -71,4 +98,8 @@
left: 0; left: 0;
bottom: 0; bottom: 0;
} }
.player-hidden {
display: none;
}
</style> </style>

17
backend/dist/app.js vendored
View File

@@ -24,15 +24,16 @@ const run = () => {
// sign dev token // sign dev token
const privateKey = fs_1.default.readFileSync(path_1.default.join(__dirname + '/config/apple_private_key.p8')).toString(); const privateKey = fs_1.default.readFileSync(path_1.default.join(__dirname + '/config/apple_private_key.p8')).toString();
// TODO: Remove secret // TODO: Remove secret
const config = JSON.parse('' + fs_1.default.readFileSync(path_1.default.join(__dirname + '/config/apple-music-api.config.json'))); const config = JSON.parse('' + fs_1.default.readFileSync(path_1.default.join(__dirname + '/config/apple-music-api.config.secret.json')));
const jwtToken = jsonwebtoken_1.default.sign({}, privateKey, { const now = new Date().getTime();
const tomorrow = now + 24 * 3600 * 1000;
const jwtToken = jsonwebtoken_1.default.sign({
'iss': config.teamID,
'iat': Math.floor(now / 1000),
'exp': Math.floor(tomorrow / 1000),
}, privateKey, {
algorithm: "ES256", algorithm: "ES256",
expiresIn: "180d", keyid: config.keyID
issuer: config.teamID,
header: {
alg: "ES256",
kid: config.keyID
}
}); });
res.send(jwtToken); res.send(jwtToken);
}); });

View File

@@ -26,15 +26,16 @@ const run = () => {
// sign dev token // sign dev token
const privateKey = fs.readFileSync( path.join( __dirname + '/config/apple_private_key.p8' ) ).toString(); const privateKey = fs.readFileSync( path.join( __dirname + '/config/apple_private_key.p8' ) ).toString();
// TODO: Remove secret // TODO: Remove secret
const config = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/apple-music-api.config.json' ) ) ); const config = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/apple-music-api.config.secret.json' ) ) );
const jwtToken = jwt.sign( {}, privateKey, { const now = new Date().getTime();
const tomorrow = now + 24 * 3600 * 1000;
const jwtToken = jwt.sign( {
'iss': config.teamID,
'iat': Math.floor( now / 1000 ),
'exp': Math.floor( tomorrow / 1000 ),
}, privateKey, {
algorithm: "ES256", algorithm: "ES256",
expiresIn: "180d", keyid: config.keyID
issuer: config.teamID,
header: {
alg: "ES256",
kid: config.keyID
}
} ); } );
res.send( jwtToken ); res.send( jwtToken );
} ); } );