diff --git a/web/src/components/PlayerControls.vue b/web/src/components/PlayerControls.vue index e69de29..8ecfd52 100644 --- a/web/src/components/PlayerControls.vue +++ b/web/src/components/PlayerControls.vue @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + {{ beautifyTime( playbackPercentage * duration ) }} + + + {{ beautifyTime( duration ) }} + + + + + + + + + + + + + diff --git a/web/src/components/ProgressBar.vue b/web/src/components/ProgressBar.vue new file mode 100644 index 0000000..5097a97 --- /dev/null +++ b/web/src/components/ProgressBar.vue @@ -0,0 +1,54 @@ + + + + + + + + + + + + diff --git a/web/src/main.ts b/web/src/main.ts index 0540e36..1c95866 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,3 +1,4 @@ +import '@fortawesome/fontawesome-free/css/all.css'; import App from './App.vue'; import { createApp diff --git a/web/src/scss/components/player.scss b/web/src/scss/components/player.scss new file mode 100644 index 0000000..2fd0a7f --- /dev/null +++ b/web/src/scss/components/player.scss @@ -0,0 +1,110 @@ +.mp-player { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + + .fa-solid { + font-size: 2.5rem; + cursor: pointer; + transition: font-size ease 0.5s; + user-select: none; + + &:hover { + font-size: 2.7rem; + } + } + + >.controls { + display: flex; + align-items: center; + justify-content: center; + + >.play-pause { + cursor: pointer; + + &.paused { + >.fa-pause { + display: none; + } + + >.fa-play { + display: unset; + } + } + } + + >.quick-seek { + position: relative; + + &::after { + content: "10"; + font-family: sans-serif; + font-weight: bold; + font-size: 40%; + position: absolute; + left: 0; + top: 5%; + height: 95%; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + } + } + } + + >.time { + display: flex; + width: 85%; + margin-left: auto; + margin-right: auto; + + >.duration { + margin-left: auto; + } + } + + .progress { + width: 95%; + } + + >.bottom-bar { + >.fa-shuffle { + &.active { + background-color: var(--primary-color); + color: var(--secondary-color); + border-radius: 10px; + } + } + + >.repeat { + position: relative; + padding: 5px; + + &.once, + &.all { + background-color: var(--primary-color); + color: var(--secondary-color); + border-radius: 10px; + } + + &.once::after { + position: absolute; + content: "1"; + font-size: 40%; + font-family: sans-serif; + top: 5px; + right: 5px; + background-color: var(--accent-color); + width: 1.5em; + height: 1.5em; + display: flex; + align-items: center; + justify-content: center; + border-radius: 1em; + line-height: 100%; + } + } + } +} diff --git a/web/src/scss/components/progressbar.scss b/web/src/scss/components/progressbar.scss new file mode 100644 index 0000000..0778834 --- /dev/null +++ b/web/src/scss/components/progressbar.scss @@ -0,0 +1,37 @@ +.progressbar { + position: relative; + width: 100%; + cursor: pointer; + user-select: none; + + >.back { + width: 100%; + background-color: var(--accent-background); + height: 10px; + border-radius: 10px; + overflow: hidden; + + >div { + background-color: var(--accent-color); + height: 10px; + } + } + + >.click-target { + width: 100%; + height: 10px; + position: absolute; + top: 0; + left: 0; + z-index: 10; + + &.active { + cursor: grabbing; + width: 100vw; + height: 100vh; + transition: none; + position: fixed; + z-index: 10000; + } + } +} diff --git a/web/src/ts/dtype/playlist.d.ts b/web/src/ts/dtype/playlist.d.ts index 3cbf36f..9ae86b2 100644 --- a/web/src/ts/dtype/playlist.d.ts +++ b/web/src/ts/dtype/playlist.d.ts @@ -45,5 +45,5 @@ export interface Song { /** * Song duration */ - 'duration': string; + 'duration': number; } diff --git a/web/src/ts/player/index.ts b/web/src/ts/player/index.ts index ffa36d3..eaf556c 100644 --- a/web/src/ts/player/index.ts +++ b/web/src/ts/player/index.ts @@ -1,20 +1,26 @@ +import { + addSongList, + shuffleList +} from './playlists/add'; import { currentSource, + isPlaying, + queue, queueIdx, + rawQueue, repeat, shuffle, sources } from './state'; import { + duration, + playbackPercentage, startTracking, stopTracking } from './status-tracking'; import type { RepeatMode } from '../dtype/player'; -import { - addSongList -} from './playlists/add'; import { playIndex } from './playlists'; @@ -31,6 +37,8 @@ const prev = () => { const play = () => { if ( currentSource.value === '' ) return; + isPlaying.value = true; + sources[currentSource.value]?.play(); startTracking(); }; @@ -38,6 +46,8 @@ const play = () => { const pause = () => { if ( currentSource.value === '' ) return; + isPlaying.value = false; + sources[currentSource.value]?.pause(); stopTracking(); }; @@ -70,11 +80,34 @@ const back10 = () => { seekTo( ( ( ( source?.getPlaybackPos() ?? 0 ) * duration ) - 10 ) / duration ); }; +/** + * Turn on or off shuffle + * @param enabled - Whether to enable or disable shuffle + */ const setShuffle = ( enabled: boolean ) => { - // TODO: Shuffle the order shuffle.value = enabled; + + if ( enabled ) { + shuffleList(); + queueIdx.value = 0; + } else { + const curr = queue.value[queueIdx.value]; + + queue.value = []; + + for ( let i = 0; i < rawQueue.value.length; i++ ) { + if ( rawQueue.value[i] === curr ) + queueIdx.value = i; + + queue.value.push( rawQueue.value[i]! ); + } + } }; +/** + * Change the repeat mode + * @param mode - The repeat mode to switch into + */ const setRepeat = ( mode: RepeatMode ) => { repeat.value = mode; }; @@ -99,5 +132,12 @@ export default { getSources, addSongFromSource, next, - prev + prev, + queue, + queueIdx, + duration, + playbackPercentage, + repeat, + shuffle, + isPlaying }; diff --git a/web/src/ts/player/playlists/add.ts b/web/src/ts/player/playlists/add.ts index 2c60f73..d6a4fee 100644 --- a/web/src/ts/player/playlists/add.ts +++ b/web/src/ts/player/playlists/add.ts @@ -1,14 +1,107 @@ +import { + queue, + queueIdx, + rawQueue, + shuffle +} from '../state'; import type { Song } from '@/ts/dtype/playlist'; -import { - queue -} from '../state'; -export const addSongList = ( songs: Song[] ) => { - for ( const song of songs ) { - queue.value.push( song ); +export const addSongList = ( songs: Song[], first: boolean = false ) => { + if ( first ) { + // FIXME: Needs to insert at current index in queue, but where for rawQueue? + // Probably at end if shuffled, else insert into queue, then copy + rawQueue.value = songs.concat( rawQueue.value ); + queue.value = songs.concat( queue.value ); + } else { + rawQueue.value = rawQueue.value.concat( songs ); + queue.value = queue.value.concat( songs ); } }; -export const shuffleList = () => {}; +export const clearQueue = () => { + queue.value = []; + rawQueue.value = []; +}; + +/** + * Remove a song from the given queue index + * @param idx - The index to remove at + * @returns True if successful, False if not + */ +export const removeSong = ( idx: number ) => { + if ( idx <= queueIdx.value ) return false; + + const removed = queue.value.splice( idx, 1 )[0]; + + // TODO: Check that this works + for ( let i = 0; i < rawQueue.value.length; i++ ) { + if ( rawQueue.value[ i ] === removed ) { + rawQueue.value.splice( i, 1 ); + } + } + + return true; +}; + +/** + * Move a song in the queue + * @param idx - The index to move + * @param newIdx - The index to move it to + * @returns True if successful, false if constraints were violated + */ +export const moveSong = ( idx: number, newIdx: number ) => { + if ( idx === newIdx ) return true; + + if ( idx <= queueIdx.value || newIdx <= queueIdx.value || idx > queue.value.length || newIdx > queue.value.length ) + return false; + + // Affects only the queue if shuffled, else copy the queue into rawQueue + const movedEl = queue.value[ idx ]!; + + if ( idx < newIdx ) + for ( let i = idx + 1; i < Math.min( queue.value.length, newIdx + 1 ); i++ ) { + queue.value[ i - 1 ] = queue.value[ i ]!; + } + else if ( idx > newIdx ) + for ( let i = idx + 1; i > Math.max( 0, newIdx + 1 ); i-- ) { + queue.value[ i ] = queue.value[ i - 1 ]!; + } + + queue.value[ newIdx ] = movedEl; + + if ( !shuffle.value ) { + // If we don't shuffle, the rawQueue should also be updated + rawQueue.value = []; + + for ( const song of queue.value ) { + rawQueue.value.push( song ); + } + } + + return true; +}; + +/** Shuffle the song list */ +export const shuffleList = () => { + const shuffled = rawQueue.value + .map( ( val, idx ) => { + return { + val, + idx, + 'sort': Math.random() + }; + } ).sort( ( a, b ) => a.sort - b.sort ); + const q: Song[] = []; + + // Make current song the first in the list + q.push( rawQueue.value[queueIdx.value]! ); + + for ( const el of shuffled ) { + if ( el.idx !== queueIdx.value ) + q.push( el.val ); + } + + queue.value = q; +}; diff --git a/web/src/ts/player/state.ts b/web/src/ts/player/state.ts index 25396dc..df288c2 100644 --- a/web/src/ts/player/state.ts +++ b/web/src/ts/player/state.ts @@ -18,6 +18,8 @@ export const sources: { export const currentSource = ref( '' ); +export const rawQueue: Ref = ref( [] ); + export const queueIdx = ref( 0 ); export const queue: Ref = ref( [] ); @@ -27,7 +29,3 @@ export const isPlaying = ref( false ); export const shuffle = ref( false ); export const repeat: Ref = ref( 'off' ); - -export const playbackPercentage = ref( 0 ); - -export const duration = ref( 0 ); diff --git a/web/src/ts/player/status-tracking.ts b/web/src/ts/player/status-tracking.ts index eb0f4fa..d9773d6 100644 --- a/web/src/ts/player/status-tracking.ts +++ b/web/src/ts/player/status-tracking.ts @@ -1,4 +1,49 @@ -// Tracking of time and player status -export const startTracking = () => {}; +import { + currentSource, + queueIdx, + repeat, + sources +} from './state'; +import { + playIndex +} from './playlists'; +import { + ref +} from 'vue'; -export const stopTracking = () => {}; +// Tracking of time and player status +let interval = -1; + +export const playbackPercentage = ref( 0 ); + +export const duration = ref( 0 ); + +export const startTracking = () => { + if ( interval === -1 ) { + interval = setInterval( tracker, 250 ); + } + + duration.value = sources[currentSource.value]?.getDuration() ?? -1; +}; + +const tracker = () => { + playbackPercentage.value = sources[currentSource.value]?.getPlaybackPos() ?? -1; + + if ( playbackPercentage.value > duration.value ) { + if ( repeat.value === 'one' ) { + sources[currentSource.value]?.seekTo( 0 ); + } else { + stopTracking(); + playIndex( queueIdx.value + 1 ); + } + } +}; + +export const stopTracking = () => { + try { + clearInterval( interval ); + interval = -1; + } catch { + // empty + } +}; diff --git a/web/src/ts/util/time.ts b/web/src/ts/util/time.ts new file mode 100644 index 0000000..68f207e --- /dev/null +++ b/web/src/ts/util/time.ts @@ -0,0 +1,23 @@ +/** + * Get a human-readable representation of time + * @param time - The time to convert, in seconds + * @returns The time, in human-readable format + */ +export const beautifyTime = ( time: number ): string => { + const helper = ( t: number, depth: number ): string => { + if ( depth > 2 ) + return `${ Math.floor( t / 60 ) }:${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`; + else if ( Math.floor( t / 60 ) > 0 ) + return `${ helper( Math.floor( t / 60 ), depth + 1 ) }:${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`; + else + return `${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`; + }; + + if ( time < 0 ) { + return '-:--'; + } else if ( time == 0 ) { + return '0:00'; + } + + return helper( Math.round( time ), 1 ); +};
+ {{ beautifyTime( playbackPercentage * duration ) }} +
+ {{ beautifyTime( duration ) }} +