feat: basic player component

This commit is contained in:
2026-08-31 14:28:18 +02:00
parent 5b223875bd
commit 4d9064f5c2
11 changed files with 504 additions and 20 deletions
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import ProgressBar from './ProgressBar.vue';
import {
beautifyTime
} from '@/ts/util/time';
import player from '@/ts/player';
const playbackPercentage = player.playbackPercentage;
const duration = player.duration;
const repeatMode = player.repeat;
const shuffleMode = player.shuffle;
const seek = () => {
player.seekTo( playbackPercentage.value );
};
const toggleRepeatMode = () => {
switch ( repeatMode.value ) {
case 'one':
player.setRepeat( 'all' );
break;
case 'off':
player.setRepeat( 'one' );
break;
case 'all':
player.setRepeat( 'off' );
}
};
const openShareMenu = () => {
alert( 'Share menu not yet implemented' );
};
// TODO: Button availability
</script>
<template>
<div class="mp-player">
<div class="controls">
<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>
<div :class="['play-pause', 'paused']">
<i class="fa-solid fa-play" @click="player.play"></i>
<i class="fa-solid fa-pause" @click="player.pause"></i>
</div>
<i class="fa-solid fa-arrow-rotate-right quick-seek" @click="player.skip10"></i>
<i class="fa-solid fa-forward-step" @click="player.next"></i>
</div>
<div class="time">
<!-- TODO: Probably needs to be a computed -->
<p class="current">
{{ beautifyTime( playbackPercentage * duration ) }}
</p>
<p class="duration">
{{ beautifyTime( duration ) }}
</p>
</div>
<ProgressBar v-model="playbackPercentage" @move-end="seek" />
<div class="bottom-bar">
<!-- FA being a POS means need to make own mods to it -->
<i
:class="[
'fa-solid',
'fa-repeat',
'repeat',
repeatMode === 'one' ? 'once' : undefined,
repeatMode === 'all' ? 'all' : undefined
]"
@click="toggleRepeatMode"
></i>
<i class="fa-solid fa-share-from-square" @click="openShareMenu"></i>
<i
:class="['fa-solid', 'fa-shuffle', shuffleMode ? 'active' : undefined]"
@click="player.setShuffle( !shuffleMode )"
></i>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/scss/components/player.scss';
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import {
ref,
useTemplateRef
} from 'vue';
const val = defineModel<number>( {
'required': true,
'default': 0.5
} );
const offset = ref( -1 );
const bar = useTemplateRef( 'bar' );
const start = ( ev: MouseEvent ) => {
offset.value = ev.x - bar.value!.offsetLeft;
val.value = offset.value / bar.value!.clientWidth;
emit( 'move-start' );
};
const move = ( ev: MouseEvent ) => {
if ( offset.value > -1 ) {
val.value = Math.max( 0, Math.min( ev.x / bar.value!.clientWidth, 1 ) );
}
};
const end = () => {
offset.value = -1;
emit( 'move-end' );
};
const emit = defineEmits<{
( e: 'move-end' ): void;
( e: 'move-start' ): void;
}>();
</script>
<template>
<div class="progressbar">
<div ref="bar" class="back">
<div :style="`width: ${ val * 100 }%;`"></div>
</div>
<div
:class="['click-target', offset >= 0 ? 'active' : undefined]"
@mousedown="start"
@mousemove="move"
@mouseup="end"
@mouseleave="end"
></div>
</div>
</template>
<style lang="scss" scoped>
@import '@/scss/components/progressbar.scss';
</style>
+1
View File
@@ -1,3 +1,4 @@
import '@fortawesome/fontawesome-free/css/all.css';
import App from './App.vue';
import {
createApp
+110
View File
@@ -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%;
}
}
}
}
+37
View File
@@ -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;
}
}
}
+1 -1
View File
@@ -45,5 +45,5 @@ export interface Song {
/**
* Song duration
*/
'duration': string;
'duration': number;
}
+45 -5
View File
@@ -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
};
+100 -7
View File
@@ -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;
};
+2 -4
View File
@@ -18,6 +18,8 @@ export const sources: {
export const currentSource = ref( '' );
export const rawQueue: Ref<Playlist> = ref( [] );
export const queueIdx = ref( 0 );
export const queue: Ref<Playlist> = ref( [] );
@@ -27,7 +29,3 @@ export const isPlaying = ref( false );
export const shuffle = ref( false );
export const repeat: Ref<RepeatMode> = ref( 'off' );
export const playbackPercentage = ref( 0 );
export const duration = ref( 0 );
+48 -3
View File
@@ -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
}
};
+23
View File
@@ -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 );
};