mostly complete base spec player

This commit is contained in:
2024-06-26 17:44:07 +02:00
parent 4ecf93d31b
commit 76f543eb2f
20 changed files with 2226 additions and 154 deletions

View File

@@ -1,24 +1,35 @@
<template>
<div>
<h1>Library</h1>
<playlistsView :playlists="$props.playlists" @selected-playlist="( id ) => selectPlaylist( id )"></playlistsView>
<playlistsView :playlists="$props.playlists" @selected-playlist="( id ) => selectPlaylist( id )" :is-logged-in="$props.isLoggedIn"
@custom-playlist="( pl ) => selectCustomPlaylist( pl )"></playlistsView>
</div>
</template>
<script setup lang="ts">
import playlistsView from '@/components/playlistsView.vue';
import type { ReadFile } from '@/scripts/song';
const emits = defineEmits( [ 'selected-playlist' ] );
const emits = defineEmits( [ 'selected-playlist', 'custom-playlist' ] );
const selectPlaylist = ( id: string ) => {
emits( 'selected-playlist', id );
}
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
emits( 'custom-playlist', playlist );
}
defineProps( {
'playlists': {
'default': [],
'type': Array<any>,
'required': true,
}
},
'isLoggedIn': {
'default': false,
'type': Boolean,
'required': true,
}
} );
</script>

View File

@@ -0,0 +1,383 @@
<!-- eslint-disable no-undef -->
<template>
<div id="notifications">
<div class="message-box" :class="[ location, size ]">
<div class="message-container" :class="messageType">
<button @click="handleNotifications();" class="close-notification"><span class="material-symbols-outlined close-notification-icon">close</span></button>
<span class="material-symbols-outlined types hide" v-if="messageType == 'hide'">question_mark</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'ok'" style="background-color: green;">done</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'error'" style="background-color: red;">close</span>
<span class="material-symbols-outlined types progress-spinner" v-else-if="messageType == 'progress'" style="background-color: blue;">progress_activity</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'info'" style="background-color: lightblue;">info</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'warning'" style="background-color: orangered;">warning</span>
<p class="message" @click="notificationAction()">{{ notifications[ currentDID ] ? notifications[ currentDID ].message : '' }}</p>
<div :class="'countdown countdown-' + messageType" :style="'width: ' + ( 100 - ( currentTime - notificationDisplayStartTime ) / ( notifications[ currentDID ] ? notifications[ currentDID ].showDuration : 1 ) / 10 ) + '%'"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import router from '@/router';
import { onUnmounted, ref, type Ref } from 'vue';
defineProps( {
location: {
type: String,
'default': 'topleft',
},
size: {
type: String,
'default': 'default',
}
// Size options: small, default (default option), big, bigger, huge
} );
interface Notification {
message: string;
showDuration: number;
messageType: string;
priority: string;
id: number;
redirect?: string;
}
interface NotificationList {
[ key: string ]: Notification
}
const notifications: Ref<NotificationList> = ref( {} );
const queue: Ref<number[]> = ref( [] );
const currentDID: Ref<number> = ref( 0 );
const messageType: Ref<string> = ref( 'hide' );
const currentID = ref( { 'critical': 0, 'medium': 1000, 'low': 10000 } );
const notificationDisplayStartTime: Ref<number> = ref( 0 );
const currentTime: Ref<number> = ref( 0 );
let progressBar = 0;
let notificationTimeout = 0;
const notificationAction = () => {
if ( notifications.value[ currentDID.value ] ) {
if ( notifications.value[ currentDID.value ].redirect ) {
router.push( notifications.value[ currentDID.value ].redirect ?? '' );
}
}
};
/**
* Create a notification that will be displayed using the internal notification scheduler
* @param {string} message The message to show. Can only be plain text (no HTML)
* @param {number} showDuration The duration in seconds for which to show the notification
* @param {string} messageType Type of notification to show. Will dictate how it looks: 'ok', 'error', 'info', 'warn', 'progress'
* @param {string} priority The priority of the message: 'low', 'normal', 'critical'
* @returns {number}
*/
const createNotification = ( message: string, showDuration: number, messageType: string, priority: string, redirect?: string ): number => {
/*
Takes a notification options array that contains: message, showDuration (in seconds), messageType (ok, error, progress, info) and priority (low, normal, critical).
Returns a notification ID which can be used to cancel the notification. The component will throttle notifications and display
one at a time and prioritize messages with higher priority. Use vue refs to access these methods.
*/
let id = 0;
if ( priority === 'critical' ) {
currentID.value[ 'critical' ] += 1;
id = currentID.value[ 'critical' ];
} else if ( priority === 'normal' ) {
currentID.value[ 'medium' ] += 1;
id = currentID.value[ 'medium' ];
} else if ( priority === 'low' ) {
currentID.value[ 'low' ] += 1;
id = currentID.value[ 'low' ];
}
notifications.value[ id ] = { 'message': message, 'showDuration': showDuration, 'messageType': messageType, 'priority': priority, 'id': id, redirect: redirect };
queue.value.push( id );
console.log( 'scheduled notification: ' + id + ' (' + message + ')' );
if ( ( new Date().getTime() - notificationDisplayStartTime.value ) / 1000 >= ( notifications.value[ currentDID.value ] ? notifications.value[ currentDID.value ].showDuration : 0 ) ) {
handleNotifications();
}
return id;
}
/**
* Update a notification's message after creating it
* @param {number} id The notification ID returned by createNotification
* @param {string} message The new message
* @returns {void}
*/
const updateNotification = ( id: number, message: string ): void => {
if ( notifications.value[ id ] ) {
notifications.value[ id ].message = message;
}
}
/**
* Delete a previously created notification
* @param {string} id The notification ID returned by createNotification
* @returns {undefined}
*/
const cancelNotification = ( id: number ): undefined => {
try {
delete notifications.value[ id ];
} catch ( error ) {
console.log( 'notification to be deleted is nonexistent or currently being displayed' );
}
try {
queue.value.splice( queue.value.indexOf( id ), 1 );
} catch {
console.debug( 'queue empty' );
}
if ( currentDID.value == id ) {
try {
clearTimeout( notificationTimeout );
} catch (err) { /* empty */ }
handleNotifications();
}
}
const handleNotifications = () => {
notificationDisplayStartTime.value = new Date().getTime();
queue.value.sort();
if ( queue.value.length > 0 ) {
if ( currentDID.value !== 0 ) {
delete notifications.value[ currentDID.value ];
}
currentDID.value = notifications.value[ queue.value[ 0 ] ][ 'id' ];
messageType.value = notifications.value[ queue.value[ 0 ] ].messageType;
queue.value.reverse();
queue.value.pop();
progressBar = setInterval( progressBarHandler, 25 );
notificationTimeout = setTimeout( () => {
handleNotifications();
}, notifications.value[ currentDID.value ].showDuration * 1000 );
// eslint-disable-next-line no-undef
$( '.message-box' ).css( 'z-index', 1000 );
} else {
try {
clearInterval( progressBar );
} catch (err) { /* empty */ }
messageType.value = 'hide';
// eslint-disable-next-line no-undef
$( '.message-box' ).css( 'z-index', -1 );
}
}
const progressBarHandler = () => {
currentTime.value = new Date().getTime();
}
onUnmounted( () => {
try {
clearInterval( progressBar );
} catch (err) { /* empty */ }
try {
clearInterval( notificationTimeout );
} catch (err) { /* empty */ }
} );
defineExpose( {
createNotification,
cancelNotification,
updateNotification
} );
</script>
<style scoped>
.message-box {
position: fixed;
z-index: -1;
color: white;
transition: all 0.5s;
width: 95vw;
right: 2.5vw;
top: 1vh;
height: 10vh;
}
.close-notification {
position: absolute;
top: 5px;
right: 5px;
background: none;
color: white;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
}
.close-notification-icon {
font-size: 1.75rem;
}
.countdown {
position: absolute;
bottom: 0;
left: 0;
height: 5px;
}
.message-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
opacity: 1;
transition: all 0.5s;
cursor: default;
}
.types {
color: white;
border-radius: 100%;
margin-right: auto;
margin-left: 5%;
padding: 1.5%;
font-size: 200%;
}
.message {
margin-right: calc( 5% + 30px );
text-align: end;
height: 90%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.ok {
background-color: rgb(1, 71, 1);
}
.countdown-ok {
background-color: green;
}
.error {
background-color: rgb(114, 1, 1);
}
.countdown-error {
background-color: red;
}
.info {
background-color: rgb(44, 112, 151);
}
.countdown-info {
background-color: blue;
}
.warning {
background-color: orange;
}
.countdown-warning {
background-color: orangered;
}
.hide {
opacity: 0;
}
.progress {
z-index: 100;
background-color: rgb(0, 0, 99);
}
.countdown-ok {
background: none;
}
.progress-spinner {
animation: spin 2s infinite linear;
}
@keyframes spin {
from {
transform: rotate( 0deg );
}
to {
transform: rotate( 720deg );
}
}
@media only screen and (min-width: 750px) {
.default {
height: 10vh;
width: 32vw;
}
.small {
height: 7vh;
width: 27vw;
}
.big {
height: 12vh;
width: 38vw;
}
.bigger {
height: 15vh;
width: 43vw;
}
.huge {
height: 20vh;
width: 50vw;
}
.topleft {
top: 3vh;
left: 0.5vw;
}
.topright {
top: 3vh;
right: 0.5vw;
}
.bottomright {
bottom: 3vh;
right: 0.5vw;
}
.bottomleft {
bottom: 3vh;
right: 0.5vw;
}
}
@media only screen and (min-width: 1500px) {
.default {
height: 10vh;
width: 15vw;
}
.small {
height: 7vh;
width: 11vw;
}
.big {
height: 12vh;
width: 17vw;
}
.bigger {
height: 15vh;
width: 20vw;
}
.huge {
height: 20vh;
width: 25vw;
}
}
</style>

View File

@@ -1,40 +1,54 @@
<template>
<div>
<div :class="'player' + ( isShowingFullScreenPlayer ? '' : ' player-hidden' )">
<div class="main-player">
<!-- TODO: Make cover art of song or otherwise MusicPlayer Logo -->
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo-player" @click="controlUI( 'show' )" v-if="coverArt === ''">
<img :src="coverArt" alt="MusicPlayer Logo" class="logo-player" @click="controlUI( 'show' )" v-else>
<div class="song-name-wrapper">
<p class="song-name" @click="controlUI( 'show' )">{{ currentlyPlayingSongName }} <i v-if="currentlyPlayingSongArtist">by {{ currentlyPlayingSongArtist }}</i></p>
<div :class="'playback' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )">
<sliderView :position="pos" :active="true" :duration="duration" name="main" @pos="( pos ) => player.goToPos( pos )"
v-if="isShowingFullScreenPlayer"></sliderView>
<div :class="'playback-pos-wrapper' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )">
<p class="playback-pos">{{ nicePlaybackPos }}</p>
<p v-if="!isShowingFullScreenPlayer"> / </p>
<p class="playback-duration">{{ niceDuration }}</p>
<div class="player">
<div :class="'main-player' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )">
<div :class="'song-name-wrapper' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )" @click="controlUI( 'show' )">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo-player" v-if="coverArt === ''">
<img :src="coverArt" alt="MusicPlayer Logo" class="logo-player" v-else>
<div class="name-time">
<p class="song-name">{{ currentlyPlayingSongName }} <i v-if="currentlyPlayingSongArtist">by {{ currentlyPlayingSongArtist }}</i></p>
<div class="playback" v-if="!isShowingFullScreenPlayer">
<div class="playback-pos-wrapper">
<p class="playback-pos">{{ nicePlaybackPos }}</p>
<p> / </p>
<p class="playback-duration">{{ niceDuration }}</p>
</div>
</div>
</div>
</div>
<div class="controls-wrapper">
<span class="material-symbols-outlined controls next-previous" @click="control( 'previous' )" id="previous">skip_previous</span>
<span class="material-symbols-outlined controls forward-back" @click="control( 'back' )" :style="'rotate: -' + 360 * clickCountBack + 'deg;'">replay_10</span>
<span class="material-symbols-outlined controls" v-if="isPlaying" @click="playPause()" id="play-pause">pause</span>
<span class="material-symbols-outlined controls" v-else @click="playPause()" id="play-pause">play_arrow</span>
<span class="material-symbols-outlined controls forward-back" @click="control( 'forward' )" :style="'rotate: ' + 360 * clickCountForward + 'deg;'">forward_10</span>
<span class="material-symbols-outlined controls next-previous" @click="control( 'next' )" id="next">skip_next</span>
<div :class="'controls-wrapper' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )">
<div class="main-controls">
<span class="material-symbols-outlined controls next-previous" @click="control( 'previous' )" id="previous" v-if="isShowingFullScreenPlayer">skip_previous</span>
<span class="material-symbols-outlined controls forward-back" @click="control( 'back' )" :style="'rotate: -' + 360 * clickCountBack + 'deg;'" v-if="isShowingFullScreenPlayer">replay_10</span>
<span class="material-symbols-outlined controls" v-if="isPlaying" @click="playPause()" id="play-pause">pause</span>
<span class="material-symbols-outlined controls" v-else @click="playPause()" id="play-pause">play_arrow</span>
<span class="material-symbols-outlined controls forward-back" @click="control( 'forward' )" :style="'rotate: ' + 360 * clickCountForward + 'deg;'" v-if="isShowingFullScreenPlayer">forward_10</span>
<span class="material-symbols-outlined controls next-previous" @click="control( 'next' )" id="next">skip_next</span>
</div>
<span class="material-symbols-outlined controls" @click="control( 'repeat' )" style="margin-left: 20px;">repeat{{ repeatMode }}</span>
<span class="material-symbols-outlined controls" @click="control( 'shuffle' )">shuffle{{ shuffleMode }}</span>
<div class="slider-wrapper" v-if="isShowingFullScreenPlayer">
<div class="slider-pb-pos">
<p class="playback-pos">{{ nicePlaybackPos }}</p>
<p class="playback-duration" @click="toggleRemaining()">{{ niceDuration }}</p>
</div>
<sliderView :position="pos" :active="true" :duration="duration" name="main" @pos="( pos ) => player.goToPos( pos )"></sliderView>
</div>
<div class="shuffle-repeat" v-if="isShowingFullScreenPlayer">
<span class="material-symbols-outlined controls" @click="control( 'repeat' )" style="margin-right: auto;">repeat{{ repeatMode }}</span>
<span class="material-symbols-outlined controls" @click="control( 'shuffle' )">shuffle{{ shuffleMode }}</span>
</div>
</div>
</div>
</div>
<div :class="'playlist-view' + ( isShowingFullScreenPlayer ? '' : ' hidden' )">
<span class="material-symbols-outlined close-fullscreen" @click="controlUI( 'hide' )">close</span>
<playlistView :playlist="playlist" class="pl-wrapper" :currently-playing="currentlyPlayingSongIndex" :is-playing="isPlaying"
@control="( action ) => { control( action ) }" @play-song="( song ) => { playSong( song ) }"></playlistView>
<playlistView :playlist="playlist" class="pl-wrapper" :currently-playing="currentlyPlayingSongIndex" :is-playing="isPlaying" :pos="pos"
@control="( action ) => { control( action ) }" @play-song="( song ) => { playSong( song ) }"
@add-new-songs="( songs ) => addNewSongs( songs )" @playlist-reorder="( move ) => moveSong( move )"></playlistView>
</div>
<notificationsModule ref="notifications" location="bottomleft" size="bigger"></notificationsModule>
<audio src="" id="local-audio" controls="false"></audio>
</div>
</template>
@@ -46,7 +60,9 @@
import playlistView from '@/components/playlistView.vue';
import MusicKitJSWrapper from '@/scripts/music-player';
import sliderView from './sliderView.vue';
import type { Song } from '@/scripts/song';
import type { ReadFile, Song, SongMove } from '@/scripts/song';
import { parseBlob } from 'music-metadata-browser';
import notificationsModule from './notificationsModule.vue';
const isPlaying = ref( false );
const repeatMode = ref( '' );
@@ -65,6 +81,7 @@
const currentlyPlayingSongArtist = ref( '' );
const pos = ref( 0 );
const duration = ref( 0 );
const notifications = ref( notificationsModule );
const emits = defineEmits( [ 'playerStateChange' ] );
@@ -79,6 +96,10 @@
}
}
const toggleRemaining = () => {
isShowingRemainingTime.value = !isShowingRemainingTime.value;
}
const control = ( action: string ) => {
if ( action === 'pause' ) {
isPlaying.value = false;
@@ -103,9 +124,11 @@
if ( shuffleMode.value === '' ) {
shuffleMode.value = '_on';
player.setShuffle( true );
getDetails();
} else {
shuffleMode.value = '';
player.setShuffle( false );
getDetails();
}
getDetails();
} else if ( action === 'forward' ) {
@@ -177,13 +200,71 @@
} );
}
const selectCustomPlaylist = async ( pl: ReadFile[] ) => {
let n = notifications.value.createNotification( 'Analyzing playlist', 200, 'progress', 'normal' );
playlist.value = [];
let plLoad: Song[] = [];
for ( let element in pl ) {
try {
plLoad.push( await fetchSongData( pl[ element ] ) );
} catch ( e ) {
console.error( e );
}
notifications.value.updateNotification( n, `Analyzing playlist (${element}/${pl.length})` );
}
playlist.value = plLoad;
player.setPlaylist( playlist.value );
player.prepare( 0 );
isPlaying.value = true;
setTimeout( () => {
startProgressTracker();
getDetails();
}, 2000 );
notifications.value.cancelNotification( n );
notifications.value.createNotification( 'Playlist loaded', 10, 'ok', 'normal' );
}
const fetchSongData = ( songDetails: ReadFile ): Promise<Song> => {
return new Promise( ( resolve, reject ) => {
fetch( songDetails.url ).then( res => {
if ( res.status === 200 ) {
res.blob().then( blob => {
parseBlob( blob ).then( data => {
player.findSongOnAppleMusic( data.common.title ?? songDetails.filename.split( '.' )[ 0 ] ).then( d => {
let url = d.data.results.songs.data[ 0 ].attributes.artwork.url;
url = url.replace( '{w}', String( d.data.results.songs.data[ 0 ].attributes.artwork.width ) );
url = url.replace( '{h}', String( d.data.results.songs.data[ 0 ].attributes.artwork.height ) );
const song: Song = {
artist: data.common.artist ?? d.data.results.songs.data[ 0 ].attributes.artistName,
title: data.common.title ?? d.data.results.songs.data[ 0 ].attributes.name,
duration: data.format.duration ?? ( d.data.results.songs.data[ 0 ].attributes.durationInMillis / 1000 ),
id: songDetails.url,
origin: 'disk',
cover: url
}
resolve( song );
} ).catch( e => {
reject( e );
} );
} ).catch( e => {
reject( e );
} );
} ).catch( e => {
reject( e );
} );
}
} ).catch( e => {
reject( e );
} );
} );
}
const getDetails = () => {
const details = player.getPlayingSong();
currentlyPlayingSongName.value = details.title;
coverArt.value = details.cover;
currentlyPlayingSongIndex.value = player.getPlayingSongID();
currentlyPlayingSongIndex.value = player.getQueueID();
playlist.value = player.getQueue();
console.log( playlist.value );
currentlyPlayingSongArtist.value = details.artist;
}
@@ -207,7 +288,10 @@
let progressTracker = 0;
let hasReachedEnd = false;
let hasStarted = false;
const startProgressTracker = () => {
hasReachedEnd = false;
isPlaying.value = true;
const playingSong = player.getPlayingSong();
duration.value = playingSong.duration;
@@ -224,9 +308,18 @@
}
progressTracker = setInterval( () => {
pos.value = player.getPlaybackPos();
if ( pos.value > playingSong.duration - 1 ) {
// TODO: repeat
control( 'next' );
if ( pos.value > playingSong.duration - 1 && !hasReachedEnd ) {
stopProgressTracker();
hasReachedEnd = true;
if ( repeatMode.value === '_one_on' ) {
player.goToPos( 0 );
} else {
control( 'next' );
}
}
if ( pos.value > 0 && !hasStarted ) {
hasStarted = true;
}
const minuteCount = Math.floor( pos.value / 60 );
@@ -264,6 +357,49 @@
isPlaying.value = false;
}
const moveSong = ( move: SongMove ) => {
player.moveSong( move );
getDetails();
}
const addNewSongs = async ( songs: ReadFile[] ) => {
let n = notifications.value.createNotification( 'Analyzing new songs', 200, 'progress', 'normal' );
playlist.value = player.getPlaylist();
for ( let element in songs ) {
try {
playlist.value.push( await fetchSongData( songs[ element ] ) );
} catch ( e ) {
console.error( e );
}
notifications.value.updateNotification( n, `Analyzing new songs (${element}/${songs.length})` );
}
player.setPlaylist( playlist.value );
player.prepare( 0 );
isPlaying.value = true;
setTimeout( () => {
startProgressTracker();
getDetails();
}, 2000 );
notifications.value.cancelNotification( n );
notifications.value.createNotification( 'New songs added', 10, 'ok', 'normal' );
}
emits( 'playerStateChange', isShowingFullScreenPlayer.value ? 'show' : 'hide' );
document.addEventListener( 'keydown', ( e ) => {
if ( e.key === ' ' ) {
// TODO: fix
e.preventDefault();
playPause();
} else if ( e.key === 'ArrowRight' ) {
e.preventDefault();
control( 'next' );
} else if ( e.key === 'ArrowLeft' ) {
e.preventDefault();
control( 'previous' );
}
} );
defineExpose( {
logIntoAppleMusic,
getPlaylists,
@@ -271,12 +407,12 @@
getAuth,
skipLogin,
selectPlaylist,
selectCustomPlaylist,
} );
</script>
<style scoped>
.player {
height: 15%;
width: 100%;
display: flex;
justify-content: center;
@@ -296,7 +432,14 @@
position: relative
}
.main-player.full-screen {
flex-direction: column;
height: 30vh;
min-height: 250px;
}
.song-name-wrapper {
margin-top: 10px;
cursor: pointer;
margin-left: 10px;
width: 100%;
@@ -305,22 +448,66 @@
font-weight: bold;
font-size: 1.25rem;
display: flex;
flex-direction: column;
flex-direction: row;
justify-content: center;
align-items: center;
flex-grow: 0;
}
.song-name-wrapper.full-screen {
flex-direction: row;
max-height: 50%;
align-items: center;
}
.name-time {
margin-right: auto;
margin-left: 10px;
}
.song-name {
margin: 0;
height: fit-content;
}
.slider-wrapper {
position: relative;
width: 90%;
margin-bottom: 5px;
}
.shuffle-repeat {
margin-top: 5px;
display: flex;
width: 80%;
position: relative;
z-index: 5;
}
.slider-pb-pos {
display: flex;
justify-content: center;
align-items: center;
}
.slider-pb-pos .playback-duration {
margin-top: 5px;
margin-left: auto;
user-select: none;
cursor: pointer;
}
.slider-pb-pos .playback-pos {
margin-top: 5px;
user-select: none;
user-select: none;
}
.logo-player {
cursor: pointer;
height: 80%;
margin-left: 30px;
}
.player-hidden {
height: 100%;
width: auto;
margin-left: 10px;
}
.hidden {
@@ -342,6 +529,17 @@
flex-wrap: nowrap;
}
.controls-wrapper.full-screen {
flex-direction: column;
width: 80%;
}
.main-controls {
display: flex;
justify-content: center;
align-items: center;
}
#play-pause {
font-size: 2.5rem;
}
@@ -390,7 +588,7 @@
}
.pl-wrapper {
height: 80vh;
height: 70vh;
}
.playback {
@@ -412,6 +610,7 @@
display: flex;
justify-content: center;
align-items: center;
user-select: none;
}
.playback-pos-wrapper p {
@@ -425,4 +624,27 @@
.playback-pos-wrapper.full-screen .playback-duration {
margin-left: auto;
}
@media only screen and (min-width: 800px) {
.slider-wrapper {
width: 40%;
}
.shuffle-repeat {
width: 35%;
}
.main-controls .controls {
font-size: 2rem;
}
#play-pause {
font-size: 3rem;
}
}
#local-audio {
position: fixed;
bottom: -50%;
}
</style>

View File

@@ -1,7 +1,12 @@
<template>
<div>
<h1>Playlist</h1>
<input type="file" multiple accept="audio/*" id="more-songs">
<button @click="addNewSongs()">Load local songs</button>
<p v-if="!hasSelectedSongs">Please select at least one song to proceed</p>
<div class="playlist-box" id="pl-box">
<!-- TODO: Allow sorting -->
<!-- TODO: Allow adding more songs with search on Apple Music or loading from local disk -->
<div class="song" v-for="song in computedPlaylist" v-bind:key="song.id"
:class="( song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) && isPlaying ? 'playing' : ' not-playing' )
+ ( ( !isPlaying && ( song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) ) ) ? ' active-song' : '' )">
@@ -16,16 +21,18 @@
<span class="material-symbols-outlined play-icon" @click="control( 'play' )" v-if="song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' )">play_arrow</span>
<span class="material-symbols-outlined play-icon" @click="play( song.id )" v-else>play_arrow</span>
<span class="material-symbols-outlined pause-icon" @click="control( 'pause' )">pause</span>
<span class="material-symbols-outlined move-icon" @click="moveSong( song.id, 'up' )" title="Move song up" v-if="canBeMoved( 'up', song.id )">arrow_upward</span>
<span class="material-symbols-outlined move-icon" @click="moveSong( song.id, 'down' )" title="Move song down" v-if="canBeMoved( 'down', song.id )">arrow_downward</span>
<h3 class="song-title">{{ song.title }}</h3>
<p class="playing-in">playing in</p>
<p class="playing-in">{{ getTimeUntil( song ) }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Song } from '@/scripts/song';
import { computed } from 'vue';
import type { ReadFile, Song } from '@/scripts/song';
import { computed, ref } from 'vue';
const props = defineProps( {
'playlist': {
@@ -42,8 +49,14 @@
default: true,
required: true,
type: Boolean,
},
'pos': {
default: 0,
required: false,
type: Number,
}
} );
const hasSelectedSongs = ref( true );
const computedPlaylist = computed( () => {
let pl: Song[] = [];
@@ -54,31 +67,53 @@
return pl;
} );
// TODO: Implement
// const getTimeUntil = computed( () => {
// return ( song ) => {
// let timeRemaining = 0;
// for ( let i = this.queuePos; i < Object.keys( this.songs ).length; i++ ) {
// if ( this.songs[ i ] == song ) {
// break;
// }
// timeRemaining += parseInt( this.songs[ i ].duration );
// }
// if ( this.isPlaying ) {
// if ( timeRemaining === 0 ) {
// return 'Currently playing';
// } else {
// return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - this.pos / 60 ) + 'min';
// }
// } else {
// if ( timeRemaining === 0 ) {
// return 'Plays next';
// } else {
// return 'Playing less than ' + Math.ceil( timeRemaining / 60 - this.pos / 60 ) + 'min after starting to play';
// }
// }
// }
// } );
const canBeMoved = computed( () => {
return ( direction: movementDirection, songID: string ): boolean => {
let id = 0;
for ( let song in props.playlist ) {
if ( props.playlist[ song ].id === songID ) {
id = parseInt( song );
break;
}
}
if ( direction === 'up' ) {
if ( props.currentlyPlaying + 1 === id || props.currentlyPlaying === id ) {
return false;
}
return true;
} else {
if ( id === props.playlist.length - 1 || props.currentlyPlaying === id ) {
return false;
}
return true;
}
}
} )
const getTimeUntil = computed( () => {
return ( song: Song ) => {
let timeRemaining = 0;
for ( let i = props.currentlyPlaying; i < Object.keys( props.playlist ).length; i++ ) {
if ( props.playlist[ i ] == song ) {
break;
}
timeRemaining += props.playlist[ i ].duration;
}
if ( props.isPlaying ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - props.pos / 60 ) + 'min';
}
} else {
if ( timeRemaining === 0 ) {
return 'Plays next';
} else {
return 'Playing less than ' + Math.ceil( timeRemaining / 60 - props.pos / 60 ) + 'min after starting to play';
}
}
}
} );
const control = ( action: string ) => {
@@ -89,12 +124,44 @@
emits( 'play-song', song );
}
const emits = defineEmits( [ 'play-song', 'control' ] );
const addNewSongs = () => {
// TODO: Also allow loading Apple Music songs
const fileURLList: ReadFile[] = [];
const allFiles = ( document.getElementById( 'pl-loader' ) as HTMLInputElement ).files ?? [];
if ( allFiles.length > 0 ) {
hasSelectedSongs.value = true;
for ( let file = 0; file < allFiles.length; file++ ) {
fileURLList.push( { 'url': URL.createObjectURL( allFiles[ file ] ), 'filename': allFiles[ file ].name } );
}
emits( 'add-new-songs', fileURLList );
} else {
hasSelectedSongs.value = false;
}
}
type movementDirection = 'up' | 'down';
const moveSong = ( songID: string, direction: movementDirection ) => {
let newSongPos = 0;
let hasFoundSongToMove = false;
for ( let el in props.playlist ) {
if ( props.playlist[ el ].id === songID ) {
const currPos = parseInt( el );
newSongPos = currPos + ( direction === 'up' ? -1 : 1 );
hasFoundSongToMove = true;
break;
}
}
if ( hasFoundSongToMove ) {
emits( 'playlist-reorder', { 'songID': songID, 'newPos': newSongPos } );
}
}
const emits = defineEmits( [ 'play-song', 'control', 'playlist-reorder', 'add-new-songs' ] );
</script>
<style scoped>
.playlist-box {
height: 80vh !important;
height: calc( 100% - 100px );
width: 100%;
overflow: scroll;
display: flex;
@@ -222,4 +289,9 @@
display: none;
}
.move-icon {
font-size: 1.5rem;
cursor: pointer;
user-select: none;
}
</style>

View File

@@ -1,10 +1,16 @@
<template>
<div class="playlists">
<h3 style="width: fit-content;">Your playlists</h3>
<div v-if="$props.playlists ? $props.playlists.length < 1 : true">
loading...
<div v-if="( $props.playlists ? $props.playlists.length < 1 : true ) && $props.isLoggedIn">
Loading...
<!-- TODO: Make prettier -->
</div>
<div v-else-if="!$props.isLoggedIn">
<p>You are not logged into Apple Music.</p>
<input class="fancy-button" type="file" multiple="true" accept="audio/*" id="pl-loader"><br>
<button @click="loadPlaylistFromDisk()" class="fancy-button">Load custom playlist from disk</button>
<p v-if="!hasSelectedSongs">Please select at least one song to proceed!</p>
</div>
<div class="playlist-wrapper">
<div v-for="pl in $props.playlists" v-bind:key="pl.id" class="playlist" @click="selectPlaylist( pl.id )">
{{ pl.attributes.name }}
@@ -14,15 +20,38 @@
</template>
<script setup lang="ts">
import type { ReadFile } from '@/scripts/song';
import { ref } from 'vue';
const hasSelectedSongs = ref( true );
defineProps( {
'playlists': {
'default': [],
'type': Array<any>,
'required': true,
},
'isLoggedIn': {
'default': false,
'type': Boolean,
'required': true,
}
} );
const emits = defineEmits( [ 'selected-playlist' ] );
const loadPlaylistFromDisk = () => {
const fileURLList: ReadFile[] = [];
const allFiles = ( document.getElementById( 'pl-loader' ) as HTMLInputElement ).files ?? [];
if ( allFiles.length > 0 ) {
hasSelectedSongs.value = true;
for ( let file = 0; file < allFiles.length; file++ ) {
fileURLList.push( { 'url': URL.createObjectURL( allFiles[ file ] ), 'filename': allFiles[ file ].name } );
}
emits( 'custom-playlist', fileURLList );
} else {
hasSelectedSongs.value = false;
}
}
const emits = defineEmits( [ 'selected-playlist', 'custom-playlist' ] );
const selectPlaylist = ( id: string ) => {
emits( 'selected-playlist', id );

View File

@@ -0,0 +1,481 @@
<!-- eslint-disable no-undef -->
<template>
<div id="popup-backdrop">
<div class="popup-container">
<div class="popup" :class="size">
<div class="close-wrapper"><span class="material-symbols-outlined close-button" @click="closePopup( 'cancel' );" title="Close this popup">close</span></div>
<div class="message-container">
<div v-if="contentType === 'string'" class="options">
<h3>{{ data.message }}</h3>
<div class="button-wrapper">
<button @click="closePopup( 'ok' )" title="Close popup" class="buttons fancy-button">Ok</button>
</div>
</div>
<div v-else-if="contentType === 'html'" v-html="data.message" class="options"></div>
<div v-else-if="contentType === 'code'" class="options">
<h3>{{ data.message }}</h3>
<button @click="copy()" id="code-copy" class="buttons fancy-button">Copy</button>
<pre>
<code>
{{ data.options.code }}
</code>
</pre>
</div>
<div v-else-if="contentType === 'long-text'" class="options">
<h3>{{ data.message }}</h3>
<p>{{ data.options.note }}</p>
<textarea cols="80" rows="10" v-model="data.selected" id="text-input"></textarea>
<div class="button-wrapper">
<button @click="closePopup( 'ok' )" title="Save changes" class="buttons fancy-button">{{ data.options.display.save ?? 'Save' }}</button>
<button @click="closePopup( 'cancel' )" title="Cancel changes" class="buttons fancy-button">{{ data.options.display.cancel ?? 'Cancel' }}</button>
</div>
</div>
<div v-else-if="contentType === 'text'" class="options">
<h3>{{ data.message }}</h3>
<input type="text" v-model="data.selected">
<p>{{ info }}</p>
<div class="button-wrapper">
<button @click="closePopup( 'ok' )" title="Save changes" class="buttons fancy-button">{{ data.options.display.save ?? 'Save' }}</button>
<button @click="closePopup( 'cancel' )" title="Cancel changes" class="buttons fancy-button">{{ data.options.display.cancel ?? 'Cancel' }}</button>
</div>
</div>
<div v-else-if="contentType === 'number'" class="options">
<h3>{{ data.message }}</h3>
<input type="number" v-model="data.selected">
<div class="button-wrapper">
<button @click="closePopup( 'ok' )" title="Save changes" class="buttons fancy-button">{{ data.options.display.save ?? 'Save' }}</button>
<button @click="closePopup( 'cancel' )" title="Cancel changes" class="buttons fancy-button">{{ data.options.display.cancel ?? 'Cancel' }}</button>
</div>
</div>
<div v-else-if="contentType === 'settings'" class="options">
<h3>{{ data.message }}</h3>
<settings v-model:settings="data.options"></settings>
<div class="button-wrapper">
<button @click="submitSettings( 'ok' )" title="Save changes" class="buttons fancy-button">Save</button>
<button @click="closePopup( 'cancel' )" title="Cancel changes" class="buttons fancy-button">Cancel</button>
</div>
</div>
<div v-else-if="contentType === 'confirm'" class="confirm options">
<h3>{{ data.message }}</h3>
<div class="button-wrapper">
<button @click="closePopup( 'ok' )" title="Save changes" class="buttons fancy-button">{{ data.options.ok ?? 'Ok' }}</button>
<button @click="closePopup( 'cancel' )" title="Cancel changes" class="buttons fancy-button">{{ data.options.display.cancel ?? 'Cancel' }}</button>
</div>
</div>
<div v-else-if="contentType === 'dropdown'" class="options">
<h3>{{ data.message }}</h3>
<select id="select" v-model="data.selected">
<option v-for="selectOption in data.options" :key="selectOption.value" :value="selectOption.value">{{ selectOption.displayName }}</option>
</select>
<div class="button-wrapper">
<button @click="closePopup( 'ok' )" title="Save changes" class="buttons fancy-button">{{ data.options.display.save ?? 'Save' }}</button>
<button @click="closePopup( 'cancel' )" title="Cancel changes" class="buttons fancy-button">{{ data.options.display.cancel ?? 'Cancel' }}</button>
</div>
</div>
<div v-else-if="contentType === 'selection'" class="options selection">
<h3>{{ data.message }}</h3>
<div v-for="selectOption in data.options.selections" :key="selectOption.value" class="select-button-wrapper">
<button class="select-button" @click="closePopupAdvanced( 'ok', selectOption.value )">{{ selectOption.displayName }}</button>
</div>
</div>
<div v-else-if="contentType === 'iframe'" class="options iframe-wrapper">
<iframe :src="data.options.link" frameborder="0" class="popup-iframe"></iframe>
</div>
<div v-else-if="contentType === 'editor'" class="options">
<!-- Create the toolbar container -->
<h3>{{ data.message }}</h3>
<p v-if="data.options.note" v-html="data.options.note"></p>
<!-- Optional toggles (added via options object) -->
<table class="editor-options">
<tr v-for="element in data.options.settings" :key="element.id">
<td>
{{ element.displayName }}
</td>
<td>
<input type="text" v-if="element.type === 'text'" v-model="data.selected[ element.id ]">
<input type="number" v-else-if="element.type === 'number'" v-model="data.selected[ element.id ]">
<input type="email" v-else-if="element.type === 'email'" v-model="data.selected[ element.id ]">
<select v-else-if="element.type === 'dropdown'" v-model="data.selected[ element.id ]">
<option v-for="el in element.options" :key="el.value" :value="el.value">{{ el.displayName }}</option>
</select>
</td>
</tr>
</table>
<div id="quill-toolbar">
<span class="ql-formats">
<select class="ql-font" title="Fonts">
<option selected="" title="Default"></option>
<option value="serif" title="Serif"></option>
<option value="monospace" title="Monospace"></option>
</select>
<select class="ql-size" title="Font size">
<option value="small" title="Small"></option>
<option selected="" title="Default"></option>
<option value="large" title="Large"></option>
<option value="huge" title="Huge"></option>
</select>
</span>
<span class="ql-formats">
<button class="ql-bold" title="Bold"></button>
<button class="ql-italic" title="Italic"></button>
<button class="ql-underline" title="Underlined"></button>
<button class="ql-strike" title="Strikethrough"></button>
</span>
<span class="ql-formats">
<select class="ql-color" title="Text colour"></select>
<select class="ql-background" title="Background colour"></select>
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" title="Ordered list"></button>
<button class="ql-list" value="bullet" title="Bullet points"></button>
<select class="ql-align" title="Alignment">
<option selected="" title="left"></option>
<option value="center" title="center"></option>
<option value="right" title="right"></option>
<option value="justify" title="block"></option>
</select>
</span>
<span class="ql-formats">
<button class="ql-link" title="Insert link"></button>
<button class="ql-image" title="Insert image"></button>
</span>
</div>
<!-- Create the editor container -->
<div id="quill-editor">
</div>
<div class="message-iframe" v-if="data.selected.oldMsg" style="height: 60vh;">
<p>Attached message: </p>
<iframe :srcdoc="data.selected.oldMsg" frameborder="0" class="message-iframe"></iframe>
</div>
<div class="button-wrapper">
<button @click="closePopupEditor()" :title="data.options.saveButtonHint" class="buttons fancy-button">{{ data.options.saveButtonDisplay }}</button>
<button @click="closePopup( 'cancel' )" title="Cancel changes" class="buttons fancy-button">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Options to be passed in: html, settings (for settings component), strings, confirm, dropdowns, selection -->
<script>
import settings from '@/components/settingsOptions.vue';
import hljs from 'highlight.js';
import beautify from 'json-beautify';
import Quill from 'quill';
import( 'quill/dist/quill.snow.css' );
export default {
name: 'popupsHandler',
components: {
settings,
},
props: {
size: {
type: String,
'default': 'normal',
},
},
data () {
return {
contentType: 'dropdown',
data: {
options: {
display: '',
},
},
info: '',
editor: null,
};
},
methods: {
closePopup( message ) {
if ( this.data.options.disallowedCharacters ) {
for ( let letter in this.data.selected ) {
if ( this.data.options.disallowedCharacters.includes( this.data.selected[ letter ] ) ) {
this.info = `Illegal character "${ this.data.selected[ letter ] }"`;
return false;
}
}
}
// eslint-disable-next-line no-undef
$( '#popup-backdrop' ).fadeOut( 300 );
if ( message ) {
this.$emit( 'data', { 'data': this.data.selected, 'status': message } );
}
},
closePopupEditor () {
this.data.selected;
this.data.selected.mail = document.getElementsByClassName( 'ql-editor' )[ 0 ].innerHTML + ( this.data.selected.oldMsg ?? '' );
this.closePopup( 'editor' );
},
selectTicket ( option ) {
let total = 0;
for ( let i in this.data.options.count ) {
total += this.data.options.count[ i ];
}
if ( total < this.data.options.max ) {
this.data.options.count[ option ] += 1;
}
},
deselectTicket ( option ) {
if ( this.data.options.count[ option ] > 0 ) {
this.data.options.count[ option ] -= 1;
}
},
submitTicket () {
// eslint-disable-next-line no-undef
$( '#popup-backdrop' ).fadeOut( 300 );
this.$emit( 'ticket', { 'data': this.data.options.count, 'component': this.data.options.id } );
},
closePopupAdvanced ( message, data ) {
this.data[ 'selected' ] = data;
this.closePopup( message );
},
submitSettings () {
// eslint-disable-next-line no-undef
$( '#popup-backdrop' ).fadeOut( 300 );
const dat = this.data.options;
let ret = {};
for ( let setting in dat ) {
if ( dat[ setting ][ 'type' ] !== 'link' ) {
ret[ setting ] = dat[ setting ][ 'value' ];
}
}
this.$emit( 'data', { 'data': ret, 'status': 'settings' } );
},
openPopup ( message, options, dataType, selected ) {
this.data = {
'message': message ?? 'No message defined on method call!!',
'options': options ?? { '1': { 'value': 'undefined', 'displayName': 'No options specified in call' } },
'selected': selected ?? ''
};
this.contentType = dataType ?? 'string';
// eslint-disable-next-line no-undef
$( '#popup-backdrop' ).fadeIn( 300 );
if ( dataType === 'code' ) {
if ( options.lang === 'json' ) {
this.data.options.code = beautify( options.code, null, 2, 50 );
}
setTimeout( () => {
hljs.highlightAll();
}, 200 );
} else if ( dataType === 'editor' ) {
setTimeout( () => {
if ( !document.getElementById( 'quill-editor' ).classList.contains( 'ql-container' ) ) {
this.editor = new Quill( '#quill-editor', {
modules: { toolbar: '#quill-toolbar' },
theme: 'snow',
} );
if ( this.data.selected === '' ) {
this.data.selected = {};
}
setTimeout( () => {
if ( selected.message ) {
console.log( selected.message );
document.getElementsByClassName( 'ql-editor' )[ 0 ].innerHTML = selected.message;
}
}, 500 );
}
}, 200 );
}
},
copy() {
const codeCopy = document.getElementById( 'code-copy' )
codeCopy.innerHTML = 'Copied!';
navigator.clipboard.writeText( this.data.options.code );
setTimeout( () => {
codeCopy.innerHTML = 'Copy';
}, 2000 );
}
},
};
</script>
<style scoped>
.message-iframe {
width: 100%;
height: 50vh;
margin-top: 1%;
}
pre {
width: 100%;
}
code {
text-align: left;
}
#popup-backdrop {
position: fixed;
top: 0;
left: 0;
z-index: 10;
width: 100vw;
height: 100vh;
background-color: rgba( 0, 0, 0, 0.6 );
display: none;
}
.popup-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
#text-input {
width: 90%;
resize: vertical;
}
.button-wrapper {
width: 100%;
margin-top: 3%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.close-wrapper {
width: 100%;
height: 5%;
display: flex;
justify-content: center;
align-items: flex-end;
flex-direction: column;
}
.close-button {
margin-right: 1vw;
margin-top: 2vw;
font-size: 200%;
cursor: pointer;
}
.popup {
border: none;
border-radius: 20px;
background-color: white;
width: 90vw;
height: 80vh;
}
.popup-iframe {
width: 100%;
height: 100%;
}
.iframe-wrapper {
height: 100%;
}
.message-container {
height: 90%;
width: 90%;
margin-left: 5%;
overflow: scroll;
}
.options {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
overflow: visible;
min-height: 100%;
width: 100%;
}
.options .buttons {
padding: 1% 2%;
margin: 5px;
display: inline-block;
}
.options .buttons:hover {
background-color: darkgreen;
}
.select-button-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.select-button {
background-color: rgba( 0, 0, 0, 0 ) !important;
color: black !important;
padding: 3vh 2vw !important;
border: solid black 1px;
border-radius: 5px;
transition: all 0.5s ease-in-out;
margin-bottom: 1vh;
width: 90%;
cursor: pointer;
}
.select-button:hover {
background-color: gray !important;
}
.controls {
user-select: none;
cursor: pointer;
font-size: 100%;
font-weight: bold;
border: solid var( --primary-color ) 1px;
border-radius: 100%;
}
@media only screen and (min-width: 999px) {
.small {
width: 40%;
height: 40%;
}
.normal {
width: 50%;
height: 50%;
}
.big {
width: 60%;
height: 60%;
}
.bigger {
width: 70%;
height: 70%;
}
.huge {
width: 80%;
height: 80%;
}
}
#quill-editor, #quill-toolbar {
width: 90%;
}
#quill-editor {
min-height: 20vh;
}
.editor-options {
width: 80%;
}
</style>

View File

@@ -1,4 +1,4 @@
import type { Song } from "./song";
import type { SearchResult, Song, SongMove } from "./song";
interface Config {
devToken: string;
@@ -20,6 +20,7 @@ class MusicKitJSWrapper {
isShuffleEnabled: boolean;
hasEncounteredAuthError: boolean;
queuePos: number;
audioPlayer: HTMLAudioElement;
constructor () {
this.playingSongID = 0;
@@ -35,6 +36,7 @@ class MusicKitJSWrapper {
this.isLoggedIn = false;
this.hasEncounteredAuthError = false;
this.queuePos = 0;
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
const self = this;
@@ -77,6 +79,7 @@ class MusicKitJSWrapper {
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,
@@ -204,7 +207,11 @@ class MusicKitJSWrapper {
console.log( err );
} );
} else {
// TODO: Implement
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
this.audioPlayer.src = this.playlist[ this.playingSongID ].id;
setTimeout( () => {
this.control( 'play' );
}, 500 );
}
return true;
} else {
@@ -225,8 +232,8 @@ class MusicKitJSWrapper {
this.musicKit.play();
return false;
} else {
this.audioPlayer.play();
return false;
// TODO: Implement
}
} else {
return false;
@@ -237,8 +244,8 @@ class MusicKitJSWrapper {
this.musicKit.pause();
return false;
} else {
this.audioPlayer.pause();
return false;
// TODO: Implement
}
} else {
return false;
@@ -248,8 +255,8 @@ class MusicKitJSWrapper {
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;
// TODO: Implement
}
case "skip-10":
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
@@ -266,30 +273,30 @@ class MusicKitJSWrapper {
}
}
} 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' );
// }
// }
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":
if ( this.queuePos < this.queue.length ) {
if ( this.queuePos < this.queue.length - 1 ) {
this.queuePos += 1;
this.prepare( this.queue[ this.queuePos ] );
return true;
} else {
this.queuePos = 0;
this.control( 'pause' );
if ( this.repeatMode !== 'all' ) {
this.control( 'pause' );
} else {
this.playingSongID = this.queue[ this.queuePos ];
this.prepare( this.queue[ this.queuePos ] );
}
return true;
}
case "previous":
@@ -324,6 +331,13 @@ class MusicKitJSWrapper {
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 ) {
@@ -334,10 +348,40 @@ class MusicKitJSWrapper {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.seekToTime( pos );
} else {
// TODO: Implement
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}
@@ -346,8 +390,7 @@ class MusicKitJSWrapper {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
return this.musicKit.currentPlaybackTime;
} else {
return 0;
// TODO: Implement
return this.audioPlayer.currentTime;
}
}
@@ -367,6 +410,14 @@ class MusicKitJSWrapper {
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[]}
@@ -405,14 +456,25 @@ class MusicKitJSWrapper {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
return this.musicKit.isPlaying;
} else {
// TODO: Implement
return false;
return !this.audioPlayer.paused;
}
}
// findSongOnAppleMusic ( searchTerm: string ): Song => {
// TODO: Implement
// }
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;

View File

@@ -1,7 +1,44 @@
const subscribe = ( handler: ( data: any ) => {} ): string => {
return '';
/*
* MusicPlayerV2 - notificationHandler.ts
*
* Created by Janis Hutz 06/26/2024, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
// These functions handle connections to the backend with socket.io
import { io, type Socket } from "socket.io-client"
class NotificationHandler {
socket: Socket;
constructor () {
this.socket = io();
}
/**
* Description
* @param {string} event The event to emit
* @param {any} data
* @returns {void}
*/
emit ( event: string, data: any ): void {
this.socket.emit( event, data );
}
registerListener ( event: string, cb: ( data: any ) => void ): void {
this.socket.on( event, cb );
}
disconnect (): void {
this.socket.disconnect();
}
joinRoom ( roomName: string ): void {
// this.socket.
}
}
const unsubscribe = ( id: string ) => {
}
export default NotificationHandler;

View File

@@ -40,4 +40,44 @@ export interface Song {
* (OPTIONAL) This will be displayed in brackets on the showcase screens
*/
additionalInfo?: string;
}
export interface ReadFile {
url: string;
filename: string;
}
export interface SearchResult {
data: {
results: {
songs: {
data: AppleMusicSongData[],
href: string;
}
};
}
}
export interface AppleMusicSongData {
id: string,
type: string;
href: string;
attributes: {
albumName: string;
artistName: string;
artwork: {
width: number,
height: number,
url: string
},
name: string;
genreNames: string[];
durationInMillis: number;
}
}
export interface SongMove {
songID: string;
newPos: number;
}

View File

@@ -1,14 +1,15 @@
<template>
<div class="app-view">
<div class="home-view" v-if="isLoggedIntoAppleMusic">
<libraryView class="library-view" :playlists="playlists" @selected-playlist="( id ) => { selectPlaylist( id ) }"></libraryView>
<div class="home-view" v-if="isReady">
<libraryView class="library-view" :playlists="playlists" @selected-playlist="( id ) => { selectPlaylist( id ) }"
:is-logged-in="isLoggedIntoAppleMusic" @custom-playlist="( pl ) => selectCustomPlaylist( pl )"></libraryView>
</div>
<div v-else class="login-view">
<img src="@/assets/appleMusicIcon.svg" alt="Apple Music Icon">
<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>
<playerView :class="'player-view' + ( isLoggedIntoAppleMusic ? ( isShowingFullScreenPlayer ? ' full-screen-player' : '' ) : ' player-hidden' )" @player-state-change="( state ) => { handlePlayerStateChange( state ) }"
<playerView :class="'player-view' + ( isReady ? ( isShowingFullScreenPlayer ? ' full-screen-player' : '' ) : ' player-hidden' )" @player-state-change="( state ) => { handlePlayerStateChange( state ) }"
ref="player"></playerView>
</div>
</template>
@@ -17,8 +18,10 @@
import playerView from '@/components/playerView.vue';
import libraryView from '@/components/libraryView.vue';
import { ref } from 'vue';
import type { ReadFile } from '@/scripts/song';
const isLoggedIntoAppleMusic = ref( false );
const isReady = ref( false );
const isShowingFullScreenPlayer = ref( false );
const player = ref( playerView );
const playlists = ref( [] );
@@ -37,6 +40,7 @@
loginChecker = setInterval( () => {
if ( player.value.getAuth()[ 0 ] ) {
isLoggedIntoAppleMusic.value = true;
isReady.value = true;
player.value.getPlaylists( ( data ) => {
playlists.value = data.data.data;
} );
@@ -49,13 +53,18 @@
}
const skipLogin = () => {
isLoggedIntoAppleMusic.value = true;
isReady.value = true;
isLoggedIntoAppleMusic.value = false;
player.value.skipLogin();
}
const selectPlaylist = ( id: string ) => {
player.value.selectPlaylist( id );
}
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
player.value.selectCustomPlaylist( playlist );
}
</script>
<style scoped>

View File

@@ -1,8 +1,8 @@
<template>
<div class="home-view">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
<h1>MusicPlayer</h1>
<button @click="login()" class="fancy-button">Log in</button>
<button @click="login()" class="fancy-button">Log in / Sign up</button>
<p>MusicPlayer is a browser based Music Player, that allows you to connect other devices, simply with another web-browser, where you can see the current playlist with sleek animations. You can log in using your Apple Music account or load a playlist from your local disk, simply by selecting the songs using a file picker.</p>
</div>
</template>
@@ -28,5 +28,6 @@ import router from '@/router';
.logo {
height: 50vh;
border-radius: 50px;
}
</style>