feat: redesign main page, get player working (mostly) with Apple Music source

This commit is contained in:
2026-09-01 18:16:11 +02:00
parent faa271edd6
commit 01526034ce
29 changed files with 425 additions and 219 deletions
+6 -4
View File
@@ -1,17 +1,19 @@
{ {
"dependencies": { "dependencies": {
"@types/cors": "^2.8.19",
"cors": "^2.8.6",
"express": "^5.2.1", "express": "^5.2.1",
"express-session": "^1.19.0", "express-session": "^1.19.0",
"jsonwebtoken": "^9.0.3" "jsonwebtoken": "^9.0.3"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^10.9.0",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "^26.2.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.2.0",
"eslint": "^10.9.0",
"eslint-plugin-vue": "^10.10.0", "eslint-plugin-vue": "^10.10.0",
"globals": "^17.11.0", "globals": "^17.11.0",
"typescript-eslint": "^8.67.0" "typescript-eslint": "^8.67.0"
+6 -1
View File
@@ -1,3 +1,4 @@
import cors from 'cors';
import express from 'express'; import express from 'express';
import expressSession from 'express-session'; import expressSession from 'express-session';
import fs from 'fs'; import fs from 'fs';
@@ -29,7 +30,11 @@ const run = () => {
__dirname, __dirname,
'/config/apple-music-api.config.secret.json' '/config/apple-music-api.config.secret.json'
) ).toString() ); ) ).toString() );
app.get( '/dev-token', ( _request: express.Request, response: express.Response ) => {
app.get( '/dev-token', cors( {
'origin': 'http://localhost:8081',
'credentials': true
} ), ( _request: express.Request, response: express.Response ) => {
// sign dev token // sign dev token
const now = new Date().getTime(); const now = new Date().getTime();
const tomorrow = now + ( 24 * 3600 * 1000 ); const tomorrow = now + ( 24 * 3600 * 1000 );
+1 -1
View File
@@ -30,7 +30,7 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint-plugin-vue": "^10.10.0", "eslint-plugin-vue": "^10.10.0",
"globals": "^17.11.0", "globals": "^17.11.0",
"musickitjs-v3-types": "^1.1.1", "musickitjs-v3-types": "^1.2.1",
"npm-run-all2": "^9.0.2", "npm-run-all2": "^9.0.2",
"sass-embedded": "^1.103.1", "sass-embedded": "^1.103.1",
"typescript": "~6.0.0", "typescript": "~6.0.0",
+11 -12
View File
@@ -6,38 +6,37 @@
ref ref
} from 'vue'; } from 'vue';
const theme = ref( '☼' ); const theme = ref( 'dark' );
const changeTheme = () => { const changeTheme = () => {
if ( theme.value === '☽' ) { if ( theme.value === 'moon' ) {
document.documentElement.classList.remove( 'dark' ); document.documentElement.classList.remove( 'dark' );
document.documentElement.classList.add( 'light' ); document.documentElement.classList.add( 'light' );
localStorage.setItem( 'theme', '☼' ); localStorage.setItem( 'theme', 'light' );
theme.value = '☼'; theme.value = 'sun';
} else if ( theme.value === '☼' ) { } else if ( theme.value === 'sun' ) {
document.documentElement.classList.remove( 'light' ); document.documentElement.classList.remove( 'light' );
document.documentElement.classList.add( 'dark' ); document.documentElement.classList.add( 'dark' );
localStorage.setItem( 'theme', '☽' ); localStorage.setItem( 'theme', 'dark' );
theme.value = '☽'; theme.value = 'moon';
} }
}; };
theme.value = localStorage.getItem( 'theme' ) ?? ''; theme.value = localStorage.getItem( 'theme' ) ?? '';
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === '☽' ) { if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === 'dark' ) {
document.documentElement.classList.add( 'dark' ); document.documentElement.classList.add( 'dark' );
theme.value = '☽'; theme.value = 'moon';
} else { } else {
document.documentElement.classList.add( 'light' ); document.documentElement.classList.add( 'light' );
theme.value = '☼'; theme.value = 'sun';
} }
</script> </script>
<template> <template>
<div> <div>
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();"> <button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
<!-- eslint-disable-next-line vue/no-v-html --> <i :class="['fa-solid', 'fa-' + theme]"></i>
<span class="material-symbols-outlined" v-html="theme"></span>
</button> </button>
<router-view id="main-view" v-slot="{ Component, route }"> <router-view id="main-view" v-slot="{ Component, route }">
<transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in"> <transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in">
+39 -9
View File
@@ -10,9 +10,10 @@
'required': true 'required': true
} ); } );
const addSong = ( source: string ) => { const addSong = async ( source: string ) => {
player.addSongFromSource( source ); if ( await player.addSongFromSource( source ) ) {
showPopup.value = false; showPopup.value = false;
}
}; };
</script> </script>
@@ -20,11 +21,19 @@
<div> <div>
<ImportTypePicker /> <ImportTypePicker />
<PopupElement v-model="showPopup" show-close> <PopupElement v-model="showPopup" show-close>
<h1>Add Song</h1> <div class="title">
<h1>Add Song(s)</h1>
<p>Pick the source of your songs</p>
</div>
<div class="song-sources"> <div class="song-sources">
<div v-for="(source, index) in sources" :key="index" @click="() => addSong( source.id )"> <div v-for="(source, index) in sources" :key="index" @click="() => addSong( source.id )">
<i v-if="source.icon" :class="['fa-solid', 'fa-' + source.icon]"></i> <div>
<p>{{ source.name }}</p> <i v-if="source.icon" :class="['fa-solid', 'fa-' + source.icon]"></i>
<p>{{ source.name }}</p>
</div>
<p v-if="!source.authorized.value" class="not-auth-notice">
You have not yet logged into this source. Clicking this will start login
</p>
</div> </div>
</div> </div>
</PopupElement> </PopupElement>
@@ -32,11 +41,20 @@
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.title {
>h1 {
margin-bottom: 5px;
}
>p {
margin: 0;
margin-bottom: 10px;
}
}
.song-sources { .song-sources {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
width: 50vw; width: 50vw;
height: 35vh; height: 40vh;
justify-content: center; justify-content: center;
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden; overflow-x: hidden;
@@ -51,9 +69,21 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
flex-direction: column;
>.fa-solid { >div {
font-size: 1.5rem; display: flex;
justify-content: center;
align-items: center;
>.fa-solid {
font-size: 1.5rem;
}
}
>.not-auth-notice {
font-size: 0.6rem;
margin: 0;
} }
} }
} }
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type {
Song
} from '@/ts/dtype/playlist';
const song = defineModel<Song>( {
'required': false
} );
</script>
<template>
<div class="current-song">
<div class="artwork">
<img
v-if="song?.artwork"
:src="song.artwork"
alt="Song cover"
class="song-cover"
>
<i v-else class="fa-solid fa-music song-cover"></i>
</div>
<div class="song-details">
<h1>
{{ song?.name ?? 'Not playing' }}
</h1>
<p>{{ song?.artist ?? 'No artist' }}</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.current-song {
width: 100%;
.artwork {
width: 100%;
>img, .fa-solid {
width: 60%;
max-height: 50%;
font-size: 15vw;
}
}
.song-details {
>* {
margin: 10px;
}
}
}
</style>
+14 -5
View File
@@ -3,10 +3,12 @@
import { import {
beautifyTime beautifyTime
} from '@/ts/util/time'; } from '@/ts/util/time';
import {
computed
} from 'vue';
import player from '@/ts/player'; import player from '@/ts/player';
const playbackPercentage = player.playbackPercentage; const playbackPercentage = player.playbackPercentage;
const duration = player.duration;
const repeatMode = player.repeat; const repeatMode = player.repeat;
const shuffleMode = player.shuffle; const shuffleMode = player.shuffle;
@@ -31,6 +33,14 @@
alert( 'Share menu not yet implemented' ); alert( 'Share menu not yet implemented' );
}; };
const current = computed( () => {
return beautifyTime( player.playbackPercentage.value * player.duration.value );
} );
const duration = computed( () => {
return beautifyTime( player.duration.value );
} );
// TODO: Button availability // TODO: Button availability
</script> </script>
@@ -39,7 +49,7 @@
<div class="controls"> <div class="controls">
<i class="fa-solid fa-backward-step" @click="player.prev"></i> <i class="fa-solid fa-backward-step" @click="player.prev"></i>
<i class="fa-solid fa-arrow-rotate-left quick-seek" @click="player.back10"></i> <i class="fa-solid fa-arrow-rotate-left quick-seek" @click="player.back10"></i>
<div :class="['play-pause', 'paused']"> <div :class="['play-pause', player.isPlaying.value ? undefined : 'paused']">
<i class="fa-solid fa-play" @click="player.play"></i> <i class="fa-solid fa-play" @click="player.play"></i>
<i class="fa-solid fa-pause" @click="player.pause"></i> <i class="fa-solid fa-pause" @click="player.pause"></i>
</div> </div>
@@ -48,12 +58,11 @@
</div> </div>
<div class="time"> <div class="time">
<!-- TODO: Probably needs to be a computed -->
<p class="current"> <p class="current">
{{ beautifyTime( playbackPercentage * duration ) }} {{ current }}
</p> </p>
<p class="duration"> <p class="duration">
{{ beautifyTime( duration ) }} {{ duration }}
</p> </p>
</div> </div>
<ProgressBar v-model="playbackPercentage" @move-end="seek" /> <ProgressBar v-model="playbackPercentage" @move-end="seek" />
+4 -3
View File
@@ -33,6 +33,7 @@
align-items: center; align-items: center;
z-index: 1000; z-index: 1000;
transition-delay: 0; transition-delay: 0;
overflow: hidden;
.popup-backdrop { .popup-backdrop {
width: 100vw; width: 100vw;
@@ -42,7 +43,7 @@
top: 0; top: 0;
left: 0; left: 0;
overflow: hidden; overflow: hidden;
transition: all 0.5s; transition: all 0.25s;
} }
.popup-main { .popup-main {
@@ -53,7 +54,7 @@
border-radius: 20px; border-radius: 20px;
transform: scale(1); transform: scale(1);
position: relative; position: relative;
overflow-y: scroll; overflow: hidden;
display: block; display: block;
transition: transform 0.25s ease, opacity 0.25s ease; transition: transform 0.25s ease, opacity 0.25s ease;
@@ -69,7 +70,7 @@
&.hidden { &.hidden {
top: -100vh; top: -100vh;
transition-delay: 0.5s; transition-delay: 0.25s;
.popup-main { .popup-main {
transform: scale(1.25); transform: scale(1.25);
+9 -4
View File
@@ -9,22 +9,27 @@
'default': 0.5 'default': 0.5
} ); } );
const offset = ref( -1 ); const offset = ref( -1 );
const isMoving = ref( false );
const bar = useTemplateRef( 'bar' ); const bar = useTemplateRef( 'bar' );
const start = ( ev: MouseEvent ) => { const start = ( ev: MouseEvent ) => {
offset.value = ev.x - bar.value!.offsetLeft; offset.value = bar.value!.getBoundingClientRect().x;
val.value = offset.value / bar.value!.clientWidth; isMoving.value = true;
val.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
emit( 'move-start' ); emit( 'move-start' );
}; };
const move = ( ev: MouseEvent ) => { const move = ( ev: MouseEvent ) => {
if ( offset.value > -1 ) { if ( isMoving.value ) {
val.value = Math.max( 0, Math.min( ev.x / bar.value!.clientWidth, 1 ) ); val.value = Math.max( 0, Math.min( ( ev.x - offset.value ) / bar.value!.clientWidth, 1 ) );
} }
}; };
const end = () => { const end = () => {
if ( !isMoving.value ) return;
offset.value = -1; offset.value = -1;
isMoving.value = false;
emit( 'move-end' ); emit( 'move-end' );
}; };
+22 -20
View File
@@ -15,16 +15,18 @@
beautifyTime beautifyTime
} from '@/ts/util/time'; } from '@/ts/util/time';
import player from '@/ts/player'; import player from '@/ts/player';
import {
queueIdx
} from '@/ts/player/state';
const queue: WritableComputedRef<Song[]> = computed( { const queue: WritableComputedRef<Song[]> = computed( {
get () { get () {
return player.queue.value.slice( player.queueIdx.value ); return player.queue.value.slice( player.queueIdx.value + 1 );
}, },
set ( val ) { set ( val ) {
player.queue.value = player.queue.value.slice( 0, player.queueIdx.value ).concat( val ); player.queue.value = player.queue.value.slice( 0, player.queueIdx.value + 1 ).concat( val );
} }
} ); } );
const isPlaying = player.isPlaying;
const showAddSong = ref( false ); const showAddSong = ref( false );
const showEditSong = ref( false ); const showEditSong = ref( false );
const editingSong: Ref<null | Song> = ref( null ); const editingSong: Ref<null | Song> = ref( null );
@@ -35,16 +37,16 @@
const editSong = ( idx: number ) => { const editSong = ( idx: number ) => {
showEditSong.value = true; showEditSong.value = true;
editingSong.value = player.queue.value[ idx ]!; editingSong.value = player.queue.value[ idx + queueIdx.value + 1 ]!;
}; };
const deleteSong = ( idx: number ) => { const deleteSong = ( idx: number ) => {
player.removeSong( idx ); player.removeSong( idx + queueIdx.value + 1 );
}; };
</script> </script>
<template> <template>
<div> <div class="queue-viewer">
<div> <div>
<button @click="addSong"> <button @click="addSong">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
@@ -54,6 +56,7 @@
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
Clear Clear
</button> </button>
<!-- TODO: On save, copy the current queue into rawQueue! -->
<button> <button>
<i class="fa-solid fa-save"></i> <i class="fa-solid fa-save"></i>
Save Save
@@ -66,7 +69,7 @@
</div> </div>
<SongEditor v-model="showEditSong" editing-song="" /> <SongEditor v-model="showEditSong" editing-song="" />
<AddSong v-model="showAddSong" /> <AddSong v-model="showAddSong" />
<div> <div v-if="queue.length > 0" class="queue-container">
<SortableList v-slot="{ item: song, index }" v-model="queue"> <SortableList v-slot="{ item: song, index }" v-model="queue">
<div class="song-list-element"> <div class="song-list-element">
<div class="song-cover-wrapper"> <div class="song-cover-wrapper">
@@ -77,19 +80,8 @@
class="song-cover" class="song-cover"
> >
<i v-else class="fa-solid fa-music song-cover"></i> <i v-else class="fa-solid fa-music song-cover"></i>
<div v-if="index === 0" class="play-overlay"> <div class="play-overlay hover">
<div v-if="isPlaying" class="playing-symbols"> <i class="fa-solid fa-play" @click="() => player.playIndex( index + queueIdx + 1 )"></i>
<div id="bar-1" class="playing-bar"></div>
<div id="bar-2" class="playing-bar"></div>
<div id="bar-3" class="playing-bar"></div>
</div>
<i
v-else
class="fa-solid fa-pause"
></i>
</div>
<div v-else class="play-overlay hover">
<i class="fa-solid fa-play" @click="() => player.playIndex( index )"></i>
</div> </div>
</div> </div>
<div class="song-details"> <div class="song-details">
@@ -105,6 +97,16 @@
</div> </div>
</SortableList> </SortableList>
</div> </div>
<div v-else class="queue-container">
<p>
No more songs in queue
</p>
<button @click="addSong">
<i class="fa-solid fa-plus"></i>
Add
</button>
</div>
</div> </div>
</template> </template>
+5 -2
View File
@@ -70,9 +70,9 @@
placeholder="Search..." placeholder="Search..."
@keypress="search" @keypress="search"
> >
<div v-if="!isSearching" class="search-results-wrapper"> <div v-if="!isSearching && results.length > 0" class="search-results-wrapper">
<div v-for="(result, index) in results" :key="index" @click="() => add(index)"> <div v-for="(result, index) in results" :key="index" @click="() => add(index)">
<img :src="result.artwork" :alt="'Album artwork of ' + result.name + ' by ' + result.artist"> <img v-if="result.artwork" :src="result.artwork" :alt="'Album artwork of ' + result.name + ' by ' + result.artist">
<div> <div>
<h4>{{ result.name }}</h4> <h4>{{ result.name }}</h4>
<p>{{ result.artist }}</p> <p>{{ result.artist }}</p>
@@ -80,6 +80,9 @@
<i v-if="addedIndex === index" class="fa-solid fa-check"></i> <i v-if="addedIndex === index" class="fa-solid fa-check"></i>
</div> </div>
</div> </div>
<div v-else-if="!isSearching && results.length === 0" class="search-results-wrapper placeholder">
No results found
</div>
<div v-else class="search-results-wrapper placeholder"> <div v-else class="search-results-wrapper placeholder">
Searching... Searching...
</div> </div>
+9 -8
View File
@@ -1,9 +1,10 @@
import {
createRouter,
createWebHistory
} from 'vue-router';
import { import {
useAuthStore useAuthStore
} from '@/stores/authstore'; } from '@/stores/authstore';
import {
createRouter, createWebHistory
} from 'vue-router';
const router = createRouter( { const router = createRouter( {
'history': createWebHistory( import.meta.env.BASE_URL ), 'history': createWebHistory( import.meta.env.BASE_URL ),
@@ -38,16 +39,16 @@ const router = createRouter( {
] ]
} ); } );
router.beforeEach( ( to, from ) => { router.beforeEach( to => {
const store = useAuthStore(); const store = useAuthStore();
if ( to.meta.authRequired && !store.isAuth ) { if ( to.meta.auth && !store.isAuth ) {
return { return {
'name': 'login' 'name': 'home'
}; };
} else if ( ( to.name === 'login' && store.isAuth ) || ( to.name === 'signup' && store.isAuth ) ) { } else if ( to.name === 'home' && store.isAuth ) {
return { return {
'name': 'app-home' 'name': 'app'
}; };
} }
} ); } );
+16 -1
View File
@@ -1,6 +1,5 @@
.mp-player { .mp-player {
width: 100%; width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -19,10 +18,15 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 3rem;
>.play-pause { >.play-pause {
cursor: pointer; cursor: pointer;
>.fa-play {
display: none;
}
&.paused { &.paused {
>.fa-pause { >.fa-pause {
display: none; display: none;
@@ -60,6 +64,11 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
>p {
margin-top: 0px;
margin-bottom: 5px;
}
>.duration { >.duration {
margin-left: auto; margin-left: auto;
} }
@@ -70,6 +79,12 @@
} }
>.bottom-bar { >.bottom-bar {
display: flex;
align-items: center;
justify-content: center;
height: 3rem;
margin-top: 15px;
>.fa-shuffle { >.fa-shuffle {
&.active { &.active {
background-color: var(--primary-color); background-color: var(--primary-color);
+79 -114
View File
@@ -1,127 +1,92 @@
.song-list-element { .queue-viewer {
display: flex; height: 100%;
width: 100%; width: 90%;
overflow: hidden;
>.song-cover-wrapper { >.queue-container {
height: 10rem; display: flex;
width: 10rem; width: 100%;
position: relative; height: 90%;
margin-right: 20px; flex-direction: column;
justify-content: center;
align-items: center;
.song-cover, .song-list-element {
.fa-solid {
height: 10rem;
width: 10rem;
font-size: 7rem;
line-height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
}
>.play-overlay {
background-color: var(--overlay-color);
position: absolute;
top: 0;
left: 0;
width: 100%; width: 100%;
height: 100%;
cursor: pointer;
&.hover { >.song-cover-wrapper {
display: none; height: 7rem;
width: 7rem;
position: relative;
margin-right: 20px;
.song-cover,
.fa-solid {
height: 100%;
width: 100%;
font-size: 6rem;
line-height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
>.play-overlay {
background-color: var(--overlay-color);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
&.hover {
display: none;
}
}
&:hover {
>.play-overlay.hover {
display: block;
}
}
} }
}
&:hover { >.song-details {
>.play-overlay.hover { display: flex;
display: block; flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
>* {
margin: 5px;
}
>h3 {
font-size: 1.2rem;
}
}
>.song-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-left: auto;
margin-right: 10px;
>* {
margin: 5px;
}
>.fa-solid {
font-size: 1.25rem;
cursor: pointer;
}
} }
} }
} }
>.song-details {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
>* {
margin: 5px;
}
>h3 {
font-size: 1.8rem;
}
}
>.song-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-left: auto;
margin-right: 10px;
>* {
margin: 5px;
}
>.fa-solid {
font-size: 1.25rem;
cursor: pointer;
}
}
}
.playing-symbols {
position: absolute;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
margin: 0;
padding-left: 20%;
padding-right: 20%;
width: 60%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
.playing-bar {
height: 40%;
background-color: white;
width: 12%;
border-radius: 50px;
margin: auto;
}
#bar-1 {
animation: music-playing 0.9s infinite ease-in-out;
}
#bar-2 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.3s;
}
#bar-3 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.6s;
}
@keyframes music-playing {
0% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.5);
}
100% {
transform: scaleY(1);
}
}
} }
@@ -2,6 +2,7 @@
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden; overflow-x: hidden;
height: 100%; height: 100%;
width: 100%;
position: relative; position: relative;
>.grip-target { >.grip-target {
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from 'vue'; } from 'vue';
export const useAuthStore = defineStore( 'authstore', () => { export const useAuthStore = defineStore( 'authstore', () => {
const isAuth = ref( false ); const isAuth = ref( true );
return { return {
isAuth isAuth
+22 -2
View File
@@ -23,6 +23,9 @@ import {
import type { import type {
RepeatMode RepeatMode
} from '../dtype/player'; } from '../dtype/player';
import type {
Song
} from '../dtype/playlist';
import { import {
playIndex playIndex
} from './playlists'; } from './playlists';
@@ -118,12 +121,29 @@ const getSources = (): string[] => {
return Object.keys( sources ); return Object.keys( sources );
}; };
const addToSongList = ( songs: Song[] ) => {
addSongList( songs );
if ( currentSource.value === '' ) {
playIndex( 0 );
}
};
/** /**
* Add songs to the playlist from given source * Add songs to the playlist from given source
* @param source - The ID of the source to add from * @param source - The ID of the source to add from
*/ */
const addSongFromSource = async ( source: string ) => { const addSongFromSource = async ( source: string ): Promise<boolean> => {
sources[source]!.addSongsFromThisSource( addSongList ); if ( sources[source]!.authorized.value ) {
sources[source]!.addSongsFromThisSource( addToSongList );
} else {
// TODO: Make this interact with user
sources[source]!.login!();
return false;
}
return true;
}; };
export default { export default {
+12 -1
View File
@@ -1,9 +1,16 @@
import { import {
currentSource,
isPlaying,
queue, queue,
queueIdx, queueIdx,
rawQueue, rawQueue,
shuffle shuffle,
sources
} from '../state'; } from '../state';
import {
duration,
playbackPercentage
} from '../status-tracking';
import type { import type {
Song Song
} from '@/ts/dtype/playlist'; } from '@/ts/dtype/playlist';
@@ -23,6 +30,10 @@ export const addSongList = ( songs: Song[], first: boolean = false ) => {
export const clearQueue = () => { export const clearQueue = () => {
queue.value = []; queue.value = [];
rawQueue.value = []; rawQueue.value = [];
sources[currentSource.value]?.stop();
duration.value = -1;
playbackPercentage.value = 1;
isPlaying.value = false;
}; };
/** /**
+11 -2
View File
@@ -1,5 +1,6 @@
import { import {
currentSource, currentSource,
isPlaying,
queue, queue,
queueIdx, queueIdx,
repeat, repeat,
@@ -21,9 +22,15 @@ export const playIndex = async ( idx: number ) => {
idx = repeat.value === 'all' ? queue.value.length - 1 : -1; idx = repeat.value === 'all' ? queue.value.length - 1 : -1;
} }
if ( idx < 0 ) return false;
sources[currentSource.value]?.stop(); sources[currentSource.value]?.stop();
if ( idx < 0 ) {
isPlaying.value = false;
currentSource.value = '';
return false;
}
stopTracking(); stopTracking();
const nextSong = queue.value[ idx ]!; const nextSong = queue.value[ idx ]!;
@@ -33,4 +40,6 @@ export const playIndex = async ( idx: number ) => {
sources[currentSource.value]?.playSong( nextSong.identifier ); sources[currentSource.value]?.playSong( nextSong.identifier );
startTracking(); startTracking();
isPlaying.value = true;
}; };
+5 -2
View File
@@ -1,3 +1,6 @@
import type {
Ref
} from 'vue';
import type { import type {
Song Song
} from '@/ts/dtype/playlist'; } from '@/ts/dtype/playlist';
@@ -91,9 +94,9 @@ export interface PlayerSourcePlugin {
'getDuration': () => number; 'getDuration': () => number;
/** /**
* Check if source is available for playback * Check if source is authorized
*/ */
'available': () => boolean; 'authorized': Ref<boolean>;
/** /**
* Called when user adds another song to the playlist via this source. * Called when user adds another song to the playlist via this source.
+4 -1
View File
@@ -2,6 +2,9 @@ import type {
PlayerSourcePlugin, PlayerSourcePlugin,
PlayerSourcePluginInitializer PlayerSourcePluginInitializer
} from '../interface'; } from '../interface';
import {
ref
} from 'vue';
export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<PlayerSourcePlugin> => { export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<PlayerSourcePlugin> => {
const player = document.createElement( 'audio' ); const player = document.createElement( 'audio' );
@@ -12,7 +15,7 @@ export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<P
}; };
return { return {
'available': () => true, 'authorized': ref( true ),
'id': 'local', 'id': 'local',
'name': 'Local Disk', 'name': 'Local Disk',
'play': player.play, 'play': player.play,
+1 -1
View File
@@ -43,7 +43,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
}; };
return { return {
'available': controls.getLoggedIn, 'authorized': controls.loggedIn,
'play': controls.play, 'play': controls.play,
'pause': controls.pause, 'pause': controls.pause,
'stop': controls.stop, 'stop': controls.stop,
+10 -5
View File
@@ -1,6 +1,9 @@
import type { import type {
MusicKitInstance MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance'; } from 'musickitjs-v3-types/MusicKitInstance';
import {
ref
} from 'vue';
export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => { export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
const play = () => { const play = () => {
@@ -27,16 +30,18 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
}; };
const getPlaybackPos = (): number => { const getPlaybackPos = (): number => {
return musickitInstance.currentPlaybackProgress; return musickitInstance.currentPlaybackTime / musickitInstance.currentPlaybackDuration;
}; };
const getDuration = (): number => { const getDuration = (): number => {
return musickitInstance.currentPlaybackDuration; return musickitInstance.currentPlaybackDuration;
}; };
const getLoggedIn = (): boolean => { const loggedIn = ref( musickitInstance.isAuthorized );
return musickitInstance.isAuthorized;
}; musickitInstance.addEventListener( 'authorizationStatusDidChange', () => {
loggedIn.value = musickitInstance.isAuthorized;
} );
return { return {
play, play,
@@ -46,6 +51,6 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
stop, stop,
getPlaybackPos, getPlaybackPos,
getDuration, getDuration,
getLoggedIn loggedIn
}; };
}; };
+11 -1
View File
@@ -18,9 +18,19 @@ export interface AppleMusicSongData {
'attributes': { 'attributes': {
'albumName': string; 'albumName': string;
'artistName': string; 'artistName': string;
'artwork': Artwork, 'artwork': Artwork;
'name': string; 'name': string;
'genreNames': string[]; 'genreNames': string[];
'durationInMillis': number; 'durationInMillis': number;
} }
} }
export interface AppleMusicPlaylistData {
'id': string;
'type': string;
'href': string;
'attributes': {
'artwork': Artwork;
'name': string;
}
}
@@ -1,3 +1,6 @@
import type {
AppleMusicPlaylistData
} from './dtype';
import type { import type {
MusicKitInstance MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance'; } from 'musickitjs-v3-types/MusicKitInstance';
@@ -6,10 +9,33 @@ import type {
} from '@/ts/dtype/playlist'; } from '@/ts/dtype/playlist';
export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => { export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
// TODO: Get all playlists let playlists: Song[] = [];
let filtered: Song[] = [];
const search = async ( term: string ): Promise<Song[]> => { const search = async ( term: string ): Promise<Song[]> => {
return []; if ( playlists.length === 0 ) {
playlists = ( ( await instance.api.music( '/v1/me/library/playlists', {
'limit': 100
} ) ).data as {
'data': AppleMusicPlaylistData[]
} ).data.map( val => {
return {
'artist': 'Playlist',
'artwork': val.attributes.artwork ? window.MusicKit.formatArtworkURL( val.attributes.artwork, 1000, 100 ) : '',
'duration': 0,
'identifier': val.id,
'additional-info': '',
'name': val.attributes.name,
'source': 'applemusic'
};
} );
}
filtered = playlists.filter( val => {
return val.name.includes( term );
} );
return filtered;
}; };
const addSelected = async ( idx: number ) => { const addSelected = async ( idx: number ) => {
@@ -1,5 +1,6 @@
import type { import type {
AppleMusicApiSearchResult AppleMusicApiSearchResult,
AppleMusicSongData
} from './dtype'; } from './dtype';
import type { import type {
MusicKitInstance MusicKitInstance
@@ -16,7 +17,16 @@ export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song
'term': term, 'term': term,
'types': [ 'songs' ] 'types': [ 'songs' ]
}; };
const results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
let results: AppleMusicSongData[] = [];
try {
results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
} catch {
console.debug( 'Failed results: got', results );
return [];
}
songs = []; songs = [];
+6 -2
View File
@@ -24,12 +24,15 @@ export const startTracking = () => {
} }
duration.value = sources[currentSource.value]?.getDuration() ?? -1; duration.value = sources[currentSource.value]?.getDuration() ?? -1;
setTimeout( () => {
duration.value = sources[currentSource.value]?.getDuration() ?? -1;
}, 2000 );
}; };
const tracker = () => { const tracker = () => {
playbackPercentage.value = sources[currentSource.value]?.getPlaybackPos() ?? -1; playbackPercentage.value = sources[currentSource.value]?.getPlaybackPos() ?? -1;
if ( playbackPercentage.value > duration.value ) { if ( playbackPercentage.value > 0.995 && duration.value > 0 ) {
if ( repeat.value === 'one' ) { if ( repeat.value === 'one' ) {
sources[currentSource.value]?.seekTo( 0 ); sources[currentSource.value]?.seekTo( 0 );
} else { } else {
@@ -42,8 +45,9 @@ const tracker = () => {
export const stopTracking = () => { export const stopTracking = () => {
try { try {
clearInterval( interval ); clearInterval( interval );
interval = -1;
} catch { } catch {
// empty // empty
} }
interval = -1;
}; };
+4 -4
View File
@@ -10,13 +10,13 @@ export const beautifyTime = ( time: number ): string => {
else if ( Math.floor( t / 60 ) > 0 ) else if ( Math.floor( t / 60 ) > 0 )
return `${ helper( Math.floor( t / 60 ), depth + 1 ) }:${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`; return `${ helper( Math.floor( t / 60 ), depth + 1 ) }:${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`;
else else
return `${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`; return `${ depth === 1 ? '00:' : '' }${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`;
}; };
if ( time < 0 ) { if ( time < 0 || isNaN( time ) ) {
return '-:--'; return '--:--';
} else if ( time == 0 ) { } else if ( time == 0 ) {
return '0:00'; return '00:00';
} }
return helper( Math.round( time ), 1 ); return helper( Math.round( time ), 1 );
+25 -9
View File
@@ -1,14 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import {
queue,
queueIdx
} from '@/ts/player/state';
import CurrentSong from '@/components/CurrentSong.vue';
import PlayerControls from '@/components/PlayerControls.vue'; import PlayerControls from '@/components/PlayerControls.vue';
import QueueViewer from '@/components/QueueViewer.vue'; import QueueViewer from '@/components/QueueViewer.vue';
</script> </script>
<template> <template>
<div class="main-app"> <div class="main-app">
<div class="player-controls"> <div
class="panel"
>
<div style="margin-bottom: 20px; width: 100%;">
<CurrentSong
v-model="queue[queueIdx]"
/>
</div>
<PlayerControls /> <PlayerControls />
</div> </div>
<div class="song-queue"> <div class="panel">
<QueueViewer /> <QueueViewer />
</div> </div>
</div> </div>
@@ -17,17 +29,21 @@
<style lang="scss" scoped> <style lang="scss" scoped>
.main-app { .main-app {
width: 100%; width: 100%;
height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: row;
.player-controls { .panel {
width: 60%; width: 45%;
} margin-left: 2.5%;
margin-right: 2.5%;
.song-queue { height: 100%;
width: 80%; display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
} }
} }
</style> </style>