feat: improve sync

This commit is contained in:
2026-09-08 11:20:17 +02:00
parent 047a3a8b29
commit b11882c993
19 changed files with 160 additions and 55 deletions
+6 -3
View File
@@ -41,7 +41,8 @@ const create = ( name: string, uid: string ) => {
'index': 0, 'index': 0,
'lastUpdate': new Date().getTime(), 'lastUpdate': new Date().getTime(),
'playing': false, 'playing': false,
'start': new Date().getTime() 'start': new Date().getTime(),
'offset': 0
} }
}; };
@@ -112,9 +113,10 @@ const close = ( name: string, uid: string ) => {
* @param playing - Set to true if the player is playing currently * @param playing - Set to true if the player is playing currently
* @param index - The current playback index * @param index - The current playback index
* @param start - The timestamp where playback of the current song started * @param start - The timestamp where playback of the current song started
* @param offset - The playback offset from the start in seconds
* @returns true if update succeeded * @returns true if update succeeded
*/ */
const updateState = ( room: string, uid: string, playing: boolean, index: number, start: number ) => { const updateState = ( room: string, uid: string, playing: boolean, index: number, start: number, offset: number ) => {
if ( !rooms[room] || rooms[room].owner !== uid ) { if ( !rooms[room] || rooms[room].owner !== uid ) {
return false; return false;
} }
@@ -123,7 +125,8 @@ const updateState = ( room: string, uid: string, playing: boolean, index: number
'playing': playing, 'playing': playing,
'index': index, 'index': index,
'lastUpdate': new Date().getTime(), 'lastUpdate': new Date().getTime(),
'start': start 'start': start,
'offset': offset
}; };
return sendUpdate( room, 'state' ); return sendUpdate( room, 'state' );
+1
View File
@@ -20,6 +20,7 @@ export interface Room {
'start': number; 'start': number;
'playing': boolean; 'playing': boolean;
'lastUpdate': number; 'lastUpdate': number;
'offset': number;
}; };
'owner': string; 'owner': string;
'clients': Client[]; 'clients': Client[];
+2 -1
View File
@@ -79,7 +79,8 @@ const routes = ( app: express.Application, foss: boolean, config: Config ) => {
sdk.getUID( request )!, sdk.getUID( request )!,
request.body.playing ?? false, request.body.playing ?? false,
request.body.index ?? -1, request.body.index ?? -1,
request.body.start ?? new Date().getTime() request.body.start ?? new Date().getTime(),
request.body.offset ?? 0
) ) ) )
response.sendStatus( 200 ); response.sendStatus( 200 );
else else
@@ -8,6 +8,7 @@
import { import {
beautifyTime beautifyTime
} from '@/ts/util/time'; } from '@/ts/util/time';
import messages from '@/ts/messages';
import player from '@/ts/player'; import player from '@/ts/player';
const playbackPercentage = player.playbackPercentage; const playbackPercentage = player.playbackPercentage;
@@ -42,6 +43,8 @@
const duration = computed( () => { const duration = computed( () => {
return beautifyTime( player.duration.value ); return beautifyTime( player.duration.value );
} ); } );
messages.useRoomWatchers();
</script> </script>
<template> <template>
+6 -1
View File
@@ -12,8 +12,13 @@
const offset = ref( -1 ); const offset = ref( -1 );
const isMoving = ref( false ); const isMoving = ref( false );
const bar = useTemplateRef( 'bar' ); const bar = useTemplateRef( 'bar' );
const props = defineProps<{
'disallowMove'?: boolean
}>();
const start = ( ev: MouseEvent ) => { const start = ( ev: MouseEvent ) => {
if ( props.disallowMove ) return;
offset.value = bar.value!.getBoundingClientRect().x; offset.value = bar.value!.getBoundingClientRect().x;
isMoving.value = true; isMoving.value = true;
moveVal.value = ( ev.x - offset.value ) / bar.value!.clientWidth; moveVal.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
@@ -47,7 +52,7 @@
<div :style="`width: ${ isMoving ? moveVal * 100 : val * 100 }%;`"></div> <div :style="`width: ${ isMoving ? moveVal * 100 : val * 100 }%;`"></div>
</div> </div>
<div <div
:class="['click-target', offset >= 0 ? 'active' : undefined]" :class="['click-target', offset >= 0 ? 'active' : undefined, props.disallowMove ? 'disallowed' : undefined]"
@mousedown="start" @mousedown="start"
@mousemove="move" @mousemove="move"
@mouseup="end" @mouseup="end"
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
addPlaylist, addPlaylist,
removePlaylist removePlaylist,
selectPlaylist
} from '@/ts/userPlaylists'; } from '@/ts/userPlaylists';
import { import {
editingPlaylists, editingPlaylists,
playlistIdx as playlistIdx,
playlists playlists
} from '@/ts/userPlaylists/state'; } from '@/ts/userPlaylists/state';
import { import {
@@ -20,7 +20,6 @@
import { import {
UnownedError UnownedError
} from '@/ts/request'; } from '@/ts/request';
import player from '@/ts/player';
import router from '@/router'; import router from '@/router';
const checkingStatus = ref( true ); const checkingStatus = ref( true );
@@ -65,12 +64,6 @@
const togglePlaylistEditing = ( idx: number ) => { const togglePlaylistEditing = ( idx: number ) => {
editingPlaylists.value[ idx ] = !editingPlaylists.value[ idx ]; editingPlaylists.value[ idx ] = !editingPlaylists.value[ idx ];
}; };
const selectPlaylist = ( idx: number ) => {
playlistIdx.value = idx;
player.clearQueue();
player.loadPlaylist( playlists.value[ idx ]!.songs );
};
</script> </script>
<template> <template>
+27 -25
View File
@@ -1,16 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import {
computed,
ref
} from 'vue';
import { import {
currentQueue, currentQueue,
currentQueueIdx currentQueueIdx,
playbackTime,
showArtworks
} from '@/ts/shared/state'; } from '@/ts/shared/state';
import {
computed
} from 'vue';
const songs = computed( () => currentQueue.value?.slice( currentQueueIdx.value + 1 ) ?? [] ); const songs = computed( () => currentQueue.value?.slice( currentQueueIdx.value + 1 ) ?? [] );
// TODO: Move this out of this file
const showArtworks = ref( false );
const timeToPlay = computed( () => { const timeToPlay = computed( () => {
return ( idx: number ) => { return ( idx: number ) => {
let total = 0; let total = 0;
@@ -20,6 +19,7 @@
} }
total += currentQueue.value[ currentQueueIdx.value ]?.duration ?? 0; total += currentQueue.value[ currentQueueIdx.value ]?.duration ?? 0;
total -= playbackTime.value;
return Math.round( total / 60 ); return Math.round( total / 60 );
}; };
@@ -27,24 +27,26 @@
</script> </script>
<template> <template>
<div class="queue-container"> <div class="queue-viewer">
<div v-for="(song, index) in songs" :key="index" class="song-list-element"> <div class="queue-container">
<div class="song-cover-wrapper"> <div v-for="(song, index) in songs" :key="index" class="song-list-element">
<img <div class="song-cover-wrapper">
v-if="song.artwork && showArtworks" <img
:src="song.artwork" v-if="song.artwork && showArtworks"
alt="Song cover" :src="song.artwork"
class="song-cover" alt="Song cover"
> class="song-cover"
<i v-else class="fa-solid fa-music song-cover"></i> >
</div> <i v-else class="fa-solid fa-music song-cover"></i>
<div class="song-details"> </div>
<h3>{{ song.name }}</h3> <div class="song-details">
<p>{{ song.artist }}</p> <h3>{{ song.name }}</h3>
<p>{{ song['additional-info'] }}</p> <p>{{ song.artist }}</p>
</div> <p>{{ song['additional-info'] }}</p>
<div class="song-actions"> </div>
<p>In {{ timeToPlay( index ) }}min</p> <div class="song-actions">
<p>In {{ timeToPlay( index ) }}min</p>
</div>
</div> </div>
</div> </div>
</div> </div>
+4
View File
@@ -33,5 +33,9 @@
position: fixed; position: fixed;
z-index: 10000; z-index: 10000;
} }
&.disallowed {
cursor: default;
}
} }
} }
+5
View File
@@ -4,4 +4,9 @@ import type {
declare global { declare global {
var MusicKit: MusicKitObject; var MusicKit: MusicKitObject;
interface GlobalEventHandlersEventMap {
'musicplayer:playpause': CustomEvent<void>;
'musicplayer:playindex': CustomEvent<void>;
'musicplayer:seek': CustomEvent<void>;
}
} }
+23 -5
View File
@@ -4,6 +4,8 @@ import {
queueIdx queueIdx
} from '../player/state'; } from '../player/state';
import { import {
onMounted,
onUnmounted,
ref, ref,
watch watch
} from 'vue'; } from 'vue';
@@ -116,7 +118,8 @@ const sendStateData = () => {
request.post( `/room/${ room.value }/update/state`, JSON.stringify( { request.post( `/room/${ room.value }/update/state`, JSON.stringify( {
'playing': isPlaying.value, 'playing': isPlaying.value,
'index': queueIdx.value, 'index': queueIdx.value,
'start': new Date().getTime() - ( playbackPercentage.value * ( queue.value[ queueIdx.value ]?.duration ?? 0 ) ) - 100 'start': new Date().getTime() - 100,
'offset': playbackPercentage.value * ( queue.value[ queueIdx.value ]?.duration ?? 0 )
} ) ); } ) );
setTimeout( () => { setTimeout( () => {
@@ -128,10 +131,25 @@ const sendStateData = () => {
const useRoomWatchers = () => { const useRoomWatchers = () => {
watch( queue, sendPlaylistData ); watch( queue, sendPlaylistData );
watch( [ onMounted( () => {
isPlaying, document.addEventListener( 'musicplayer:playindex', sendStateData );
queueIdx document.addEventListener( 'musicplayer:seek', sendStateData );
], sendStateData ); document.addEventListener( 'musicplayer:playpause', sendStateData );
} );
onUnmounted( () => {
try {
document.addEventListener( 'musicplayer:play', sendStateData );
} catch { /* empty */ }
try {
document.addEventListener( 'musicplayer:seek', sendStateData );
} catch { /* empty */ }
try {
document.addEventListener( 'musicplayer:playpause', sendStateData );
} catch { /* empty */ }
} );
if ( room.value ) connect(); if ( room.value ) connect();
}; };
+3 -3
View File
@@ -29,7 +29,6 @@ import type {
import { import {
load load
} from './playlists/loader'; } from './playlists/loader';
import messages from '../messages';
import { import {
playIndex playIndex
} from './playlists'; } from './playlists';
@@ -52,6 +51,7 @@ const play = () => {
sources[currentSource.value]?.play(); sources[currentSource.value]?.play();
startTracking(); startTracking();
document.dispatchEvent( new CustomEvent( 'musicplayer:playpause' ) );
}; };
const pause = () => { const pause = () => {
@@ -61,6 +61,7 @@ const pause = () => {
sources[currentSource.value]?.pause(); sources[currentSource.value]?.pause();
stopTracking(); stopTracking();
document.dispatchEvent( new CustomEvent( 'musicplayer:playpause' ) );
}; };
/** /**
@@ -71,6 +72,7 @@ const seekTo = ( pos: number ) => {
if ( currentSource.value === '' ) return; if ( currentSource.value === '' ) return;
sources[currentSource.value]?.seekTo( pos ); sources[currentSource.value]?.seekTo( pos );
document.dispatchEvent( new CustomEvent( 'musicplayer:seek' ) );
}; };
const skip10 = () => { const skip10 = () => {
@@ -153,8 +155,6 @@ const addSongFromSource = async ( source: string, cb?: ( songs: Song[] ) => void
return true; return true;
}; };
messages.useRoomWatchers();
export default { export default {
play, play,
pause, pause,
+3 -3
View File
@@ -16,8 +16,8 @@ import type {
Song Song
} from '@/ts/dtype/playlist'; } from '@/ts/dtype/playlist';
import { import {
playlistIdx setPlaylistIdx
} from '@/ts/userPlaylists/state'; } from '@/ts/userPlaylists';
export const addSongList = ( songs: Song[] ) => { export const addSongList = ( songs: Song[] ) => {
rawQueue.value = rawQueue.value.concat( songs ); rawQueue.value = rawQueue.value.concat( songs );
@@ -25,7 +25,7 @@ export const addSongList = ( songs: Song[] ) => {
}; };
export const clearQueue = () => { export const clearQueue = () => {
playlistIdx.value = -1; setPlaylistIdx( 0 );
queue.value = []; queue.value = [];
rawQueue.value = []; rawQueue.value = [];
sources[currentSource.value]?.stop(); sources[currentSource.value]?.stop();
+4 -1
View File
@@ -15,7 +15,7 @@ import {
* Play a song at the given index of the queue. Wraps to 0 and end if index below 0 or above end * Play a song at the given index of the queue. Wraps to 0 and end if index below 0 or above end
* @param idx - The index in the queue to play at * @param idx - The index in the queue to play at
*/ */
export const playIndex = async ( idx: number ) => { export const playIndex = ( idx: number ) => {
if ( idx >= queue.value.length ) { if ( idx >= queue.value.length ) {
idx = repeat.value === 'all' ? 0 : -1; idx = repeat.value === 'all' ? 0 : -1;
} else if ( idx < 0 ) { } else if ( idx < 0 ) {
@@ -42,4 +42,7 @@ export const playIndex = async ( idx: number ) => {
startTracking(); startTracking();
isPlaying.value = true; isPlaying.value = true;
setTimeout( () => {
document.dispatchEvent( new CustomEvent( 'musicplayer:playindex', {} ) );
}, 500 );
}; };
+4
View File
@@ -6,6 +6,8 @@ import {
currentQueue, currentQueue,
currentQueueIdx, currentQueueIdx,
isPlaying, isPlaying,
playbackOffset,
playbackTime,
startTime startTime
} from './state'; } from './state';
import type { import type {
@@ -40,6 +42,8 @@ const connect = (): Promise<void> => {
currentQueueIdx.value = data.state.index; currentQueueIdx.value = data.state.index;
isPlaying.value = data.state.playing; isPlaying.value = data.state.playing;
startTime.value = data.state.start; startTime.value = data.state.start;
playbackTime.value = data.state.offset;
playbackOffset.value = data.state.offset;
resolve(); resolve();
}; };
+5
View File
@@ -2,6 +2,8 @@ import {
currentQueue, currentQueue,
currentQueueIdx, currentQueueIdx,
isPlaying, isPlaying,
playbackOffset,
playbackTime,
startTime startTime
} from './state'; } from './state';
import type { import type {
@@ -17,6 +19,7 @@ export interface StateUpdate {
'playing': boolean; 'playing': boolean;
'index': number; 'index': number;
'start': number; 'start': number;
'offset': number;
} }
export const messageHandler = ( msg: string ) => { export const messageHandler = ( msg: string ) => {
@@ -36,6 +39,8 @@ export const messageHandler = ( msg: string ) => {
currentQueueIdx.value = state.index; currentQueueIdx.value = state.index;
isPlaying.value = state.playing; isPlaying.value = state.playing;
startTime.value = state.start; startTime.value = state.start;
playbackTime.value = state.offset;
playbackOffset.value = state.offset;
} else { } else {
console.log( '[SSE] Received unknown data', data.type ); console.log( '[SSE] Received unknown data', data.type );
} }
+6
View File
@@ -13,3 +13,9 @@ export const isPlaying = ref( false );
export const currentQueueIdx = ref( -1 ); export const currentQueueIdx = ref( -1 );
export const startTime = ref( new Date().getTime() ); export const startTime = ref( new Date().getTime() );
export const showArtworks = ref( false );
export const playbackTime = ref( 0 );
export const playbackOffset = ref( 0 );
+13
View File
@@ -1,7 +1,10 @@
import { import {
editingPlaylists, editingPlaylists,
playlistIdx,
playlists playlists
} from './state'; } from './state';
import player from '../player';
export const addPlaylist = ( name: string ) => { export const addPlaylist = ( name: string ) => {
playlists.value.push( { playlists.value.push( {
@@ -17,3 +20,13 @@ export const removePlaylist = ( idx: number ) => {
editingPlaylists.value.splice( idx, 1 ); editingPlaylists.value.splice( idx, 1 );
} }
}; };
export const selectPlaylist = ( idx: number ) => {
playlistIdx.value = idx;
player.clearQueue();
player.loadPlaylist( playlists.value[ idx ]!.songs );
};
export const setPlaylistIdx = ( idx: number ) => {
playlistIdx.value = idx;
};
+4 -2
View File
@@ -16,15 +16,17 @@ import {
useNotification useNotification
} from '@kyvg/vue3-notification'; } from '@kyvg/vue3-notification';
export const savePlaylist = async () => { export const savePlaylist = () => {
rawQueue.value = queue.value; rawQueue.value = queue.value;
shuffle.value = false; shuffle.value = false;
if ( playlistIdx.value ) { if ( playlistIdx.value < 0 ) {
let name: null | string = ''; let name: null | string = '';
while ( !name || name.length === 0 ) { while ( !name || name.length === 0 ) {
name = prompt( 'You are trying to save a playlist that has not previously been created. Please enter a name for it' ); name = prompt( 'You are trying to save a playlist that has not previously been created. Please enter a name for it' );
if ( !name ) return;
} }
addPlaylist( name ); addPlaylist( name );
+39 -2
View File
@@ -1,21 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
type ComputedRef, type ComputedRef,
computed computed,
onMounted,
ref
} from 'vue'; } from 'vue';
import { import {
currentQueue, currentQueue,
currentQueueIdx currentQueueIdx,
isPlaying,
playbackOffset,
playbackTime,
startTime
} from '@/ts/shared/state'; } from '@/ts/shared/state';
import CurrentSong from '@/components/player/CurrentSong.vue'; import CurrentSong from '@/components/player/CurrentSong.vue';
import ProgressBar from '@/components/player/ProgressBar.vue';
import SharedQueue from '@/components/shared/SharedQueue.vue'; import SharedQueue from '@/components/shared/SharedQueue.vue';
import type { import type {
Song Song
} from '@/ts/dtype/playlist'; } from '@/ts/dtype/playlist';
import {
beautifyTime
} from '@/ts/util/time';
import shared from '@/ts/shared'; import shared from '@/ts/shared';
shared.connect(); shared.connect();
const playbackProgress = ref( 0 );
onMounted( () => {
setInterval( () => {
if ( !isPlaying.value ) return;
playbackTime.value = ( ( new Date().getTime() - startTime.value ) / 1000 ) + playbackOffset.value;
playbackProgress.value = playbackTime.value / song.value.duration;
if ( playbackTime.value > song.value.duration ) {
playbackOffset.value -= song.value.duration;
if ( currentQueueIdx.value < currentQueue.value.length - 1 )
currentQueueIdx.value++;
else
isPlaying.value = false;
}
}, 250 );
} );
const song: ComputedRef<Song> = computed( () => { const song: ComputedRef<Song> = computed( () => {
if ( currentQueueIdx.value >= 0 ) if ( currentQueueIdx.value >= 0 )
return currentQueue.value[currentQueueIdx.value]!; return currentQueue.value[currentQueueIdx.value]!;
@@ -37,6 +65,15 @@
<div> <div>
<div> <div>
<CurrentSong v-model="song" /> <CurrentSong v-model="song" />
<div class="time">
<p class="current">
{{ beautifyTime( playbackTime ) }}
</p>
<p class="duration">
{{ beautifyTime( song.duration ) }}
</p>
</div>
<ProgressBar v-model="playbackProgress" :disallow-move="true" />
</div> </div>
<div> <div>
<SharedQueue /> <SharedQueue />