mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
feat: basic player component
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import '@fortawesome/fontawesome-free/css/all.css';
|
||||
import App from './App.vue';
|
||||
import {
|
||||
createApp
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -45,5 +45,5 @@ export interface Song {
|
||||
/**
|
||||
* Song duration
|
||||
*/
|
||||
'duration': string;
|
||||
'duration': number;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 );
|
||||
};
|
||||
Reference in New Issue
Block a user