feat: add basic queue management, add volume slider

This commit is contained in:
dusk 2023-04-26 04:31:41 +03:00
parent 14cdb06e15
commit f2736c187c
Signed by: dusk
GPG Key ID: 1D8F8FAF2294D6EA
8 changed files with 260 additions and 92 deletions

View File

@ -1,15 +1,26 @@
<script> <script lang="ts">
import Link from './a.svelte'; import Link from './a.svelte';
import IconMusic from '~icons/mdi/music'; import IconTrack from '~icons/mdi/music-box';
import IconSettings from '~icons/mdi/settings'; import IconSettings from '~icons/mdi/settings';
import IconArtist from '~icons/mdi/artist'; import IconArtist from '~icons/mdi/artist';
import IconAlbum from '~icons/mdi/album'; import IconAlbum from '~icons/mdi/album';
import IconPlaylist from '~icons/mdi/playlist-music'; import IconPlaylist from '~icons/mdi/playlist-music';
import IconQueue from '~icons/mdi/format-list-bulleted';
let links: { id: string; href?: string; icon: any }[] = [
{ id: 'queue', icon: IconQueue },
{ id: 'tracks', href: '/', icon: IconTrack },
{ id: 'albums', icon: IconAlbum },
{ id: 'artists', icon: IconArtist },
{ id: 'playlists', icon: IconPlaylist },
{ id: 'settings', icon: IconSettings }
];
</script> </script>
<nav class="flex"> <nav class="flex">
<Link title="tracks" href="/"><IconMusic class="w-7 h-7" /></Link> {#each links as link}
<Link title="albums" href="/albums"><IconAlbum class="w-7 h-7" /></Link> <Link title={link.id} href={link.href ?? `/${link.id}`}>
<Link title="artists" href="/artists"><IconArtist class="w-7 h-7" /></Link> <svelte:component this={link.icon} class="w-7 h-7" />
<Link title="settings" href="/settings"><IconSettings class="w-7 h-7" /></Link> </Link>
{/each}
</nav> </nav>

View File

@ -1,16 +1,29 @@
<script lang="ts"> <script lang="ts">
import { address, makeAudioUrl, makeThumbnailUrl, playingNow, token } from '../stores'; import {
makeAudioUrl,
makeThumbnailUrl,
currentTrack,
queuePosition,
queue,
volume
} from '../stores';
import IconPlay from '~icons/mdi/play'; import IconPlay from '~icons/mdi/play';
import IconPause from '~icons/mdi/pause'; import IconPause from '~icons/mdi/pause';
import IconMusic from '~icons/mdi/music';
import { RangeSlider } from '@skeletonlabs/skeleton'; import { RangeSlider } from '@skeletonlabs/skeleton';
import type { ResourceId } from '../types';
$: thumbUrl = $playingNow $: track = $currentTrack?.track;
? makeThumbnailUrl($address, $token, $playingNow.track.thumbnail_id) $: track_id = $currentTrack?.id;
: null; $: thumbUrl = track ? makeThumbnailUrl(track.thumbnail_id) : null;
$: audioUrl = $playingNow ? makeAudioUrl($address, $token, $playingNow.id) : null; $: audioUrl = track_id ? makeAudioUrl(track_id) : null;
function getAudioElement() { function getAudioElement(id: ResourceId | null) {
return document.getElementById('audio-source')! as HTMLAudioElement; const elem = document.getElementById(`audio-source-${id}`);
if (elem === null) {
return null;
}
return elem as HTMLAudioElement;
} }
function calculateMinuteSecond(seconds: number) { function calculateMinuteSecond(seconds: number) {
@ -32,45 +45,58 @@
let showIcon = false; let showIcon = false;
</script> </script>
{#if $playingNow !== null} <div class="flex gap-2 z-10">
<div class="flex gap-2 z-10"> {#if audioUrl !== null}
<audio <audio
id="audio-source" id="audio-source-{track_id}"
src={audioUrl} src={audioUrl}
autoplay
bind:paused={isPaused} bind:paused={isPaused}
bind:currentTime bind:currentTime
bind:duration bind:duration
autoplay bind:volume={$volume}
on:ended={(_) => {
const pos = $queuePosition;
if (pos !== null) {
const _newPos = pos + 1;
const newPos = _newPos < $queue.length ? _newPos : null;
duration = 0;
currentTime = 0;
queuePosition.set(newPos);
}
}}
/> />
{/if}
<button <button
class="relative placeholder w-12 h-12" class="relative placeholder w-12 h-12"
on:pointerenter={(_) => (showIcon = true)} on:pointerenter={(_) => (showIcon = true)}
on:pointerleave={(_) => (showIcon = false)} on:pointerleave={(_) => (showIcon = false)}
on:click={(_) => { on:click={(_) => {
let elem = getAudioElement(); let elem = getAudioElement(track_id ?? null);
if (isPaused) { if (elem) {
elem.play(); elem.paused ? elem.play() : elem.pause();
} else {
elem.pause();
} }
}} }}
> >
<IconMusic class="absolute top-1 left-1 w-10 h-10" />
<!-- svelte-ignore a11y-missing-attribute --> <!-- svelte-ignore a11y-missing-attribute -->
{#if thumbUrl !== null}
<img <img
src={thumbUrl} src={thumbUrl}
class="child {isError ? 'hidden' : ''}" class="child {isError ? 'hidden' : ''}"
on:error={() => (isError = true)} on:error={() => (isError = true)}
on:load={() => (isError = false)} on:load={() => (isError = false)}
/> />
<IconPlay class="child play-icon {showIcon && isPaused ? 'visible' : 'hidden'}" /> {/if}
<IconPause class="child play-icon {showIcon && !isPaused ? 'visible' : 'hidden'}" /> <IconPlay class="child play-icon {showIcon && isPaused ? 'opacity-100' : 'opacity-0'}" />
<IconPause class="child play-icon {showIcon && !isPaused ? 'opacity-100' : 'opacity-0'}" />
</button> </button>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div <div
title={$playingNow.track.title} title={track?.title ?? 'Not playing'}
class="w-80 max-md:w-44 max-sm:w-32 whitespace-nowrap overflow-ellipsis overflow-hidden" class="w-80 max-md:w-44 max-sm:w-32 whitespace-nowrap overflow-ellipsis overflow-hidden"
> >
{$playingNow.track.title} {track?.title ?? 'Not playing'}
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<RangeSlider <RangeSlider
@ -83,17 +109,16 @@
<div class="text-xs opacity-70 w-8">{calculateMinuteSecond(currentTime)}</div> <div class="text-xs opacity-70 w-8">{calculateMinuteSecond(currentTime)}</div>
</div> </div>
</div> </div>
</div> </div>
{/if}
<style lang="postcss"> <style lang="postcss">
:global(.play-icon) { button :global(.play-icon) {
@apply variant-glass-surface backdrop-blur-sm; @apply transition-opacity variant-glass-surface backdrop-blur-sm;
} }
:global(.child) { button :global(.child) {
@apply absolute top-0 left-0 w-12 h-12; @apply absolute top-0 left-0 w-12 h-12;
} }
:global(.range-content) { div :global(.range-content) {
@apply p-0; @apply p-0;
} }
</style> </style>

View File

@ -1,30 +1,46 @@
<script lang="ts"> <script lang="ts">
import type { TrackWithId } from '../types'; import type { TrackWithId } from '../types';
import { playingNow, makeThumbnailUrl, address, token } from '../stores'; import { queue, queuePosition, makeThumbnailUrl, currentTrack } from '../stores';
import Spinnny from '~icons/line-md/loading-loop'; import Spinnny from '~icons/line-md/loading-loop';
import IconPlay from '~icons/mdi/play'; import IconPlay from '~icons/mdi/play';
import IconMusic from '~icons/mdi/music';
export let track_with_id: TrackWithId; export let track_with_id: TrackWithId;
let track = track_with_id.track; let track = track_with_id.track;
let track_id = track_with_id.id;
$: url = makeThumbnailUrl($address, $token, track.thumbnail_id); $: thumbUrl = makeThumbnailUrl(track.thumbnail_id);
let showSpinner = false; let showSpinner = false;
let isError = false; let isError = false;
let showPlayIcon = false; let showPlayIcon = false;
</script> </script>
<div class="card flex gap-2 m-2 p-1 w-fit max-w-full"> <div class="flex gap-2 w-fit max-w-full">
<button <button
class="relative placeholder rounded min-w-[3rem] min-h-[3rem]" class="relative placeholder rounded min-w-[3rem] min-h-[3rem]"
on:click={(_) => playingNow.set(track_with_id)} on:click={(_) => {
const position = $queue.indexOf(track_id);
if (position !== -1) {
queuePosition.set(position);
} else {
queue.update((q) => {
q.push(track_id);
return q;
});
queuePosition.set($queue.length - 1);
}
}}
on:pointerenter={(_) => (showPlayIcon = true)} on:pointerenter={(_) => (showPlayIcon = true)}
on:pointerleave={(_) => (showPlayIcon = false)} on:pointerleave={(_) => (showPlayIcon = false)}
> >
<Spinnny class="top-1 left-1 w-10 h-10 {showSpinner ? 'visible' : 'hidden'}" /> <IconMusic class="absolute top-1 left-1 w-10 h-10" />
<Spinnny
class="absolute play-icon top-1 left-1 w-10 h-10 {showSpinner ? 'visible' : 'hidden'}"
/>
<!-- svelte-ignore a11y-missing-attribute --> <!-- svelte-ignore a11y-missing-attribute -->
<img <img
src={url} src={thumbUrl}
class="top-0 left-0 w-12 h-12 rounded {showSpinner || isError ? 'hidden' : ''}" class="child {showSpinner || isError ? 'hidden' : ''}"
on:error={() => { on:error={() => {
isError = true; isError = true;
showSpinner = false; showSpinner = false;
@ -32,17 +48,27 @@
on:loadstart={() => (showSpinner = true)} on:loadstart={() => (showSpinner = true)}
on:load={() => (showSpinner = false)} on:load={() => (showSpinner = false)}
/> />
<IconPlay <IconPlay class="child play-icon {showPlayIcon ? 'opacity-100' : 'opacity-0'}" />
class="absolute top-0 left-0 w-12 h-12 rounded variant-glass-surface backdrop-blur-sm {showPlayIcon
? 'visible'
: 'hidden'}"
/>
</button> </button>
<div class="whitespace-nowrap overflow-ellipsis overflow-hidden"> <div class="whitespace-nowrap overflow-ellipsis overflow-hidden">
#{track.track_num} - {track.title} <span>#{track.track_num} - {track.title}</span>
<span
class="badge variant-filled-primary py-0.5 {$currentTrack?.id == track_id
? 'visible'
: 'hidden'}">playing</span
>
<div class="text-sm whitespace-nowrap overflow-ellipsis overflow-hidden"> <div class="text-sm whitespace-nowrap overflow-ellipsis overflow-hidden">
<span class="opacity-70">{track.album_title ? `from ${track.album_title}` : ''}</span> <span class="opacity-70">{track.album_title ? `from ${track.album_title}` : ''}</span>
<span class="opacity-40">{track.artist_name ? `by ${track.artist_name}` : ''}</span> <span class="opacity-40">{track.artist_name ? `by ${track.artist_name}` : ''}</span>
</div> </div>
</div> </div>
</div> </div>
<style lang="postcss">
button :global(.play-icon) {
@apply transition-opacity variant-glass-surface backdrop-blur-sm;
}
button :global(.child) {
@apply rounded absolute top-0 left-0 w-12 h-12;
}
</style>

View File

@ -0,0 +1,25 @@
<script lang="ts">
import IconVolumeHigh from '~icons/mdi/volume-high';
import IconVolumeMedium from '~icons/mdi/volume-medium';
import IconVolumeLow from '~icons/mdi/volume-low';
import { RangeSlider } from '@skeletonlabs/skeleton';
import type { ResourceId } from '../types';
import { currentTrack, volume } from '../stores';
function getAudioElement(id: ResourceId | null) {
const elem = document.getElementById(`audio-source-${id}`);
if (elem === null) {
return null;
}
return elem as HTMLAudioElement;
}
$: audioElem = getAudioElement($currentTrack?.id ?? null);
</script>
<div class="flex items-center">
<div class="w-24">
<RangeSlider name="volume-slider" bind:value={$volume} step={0.01} max={1.0} />
</div>
<IconVolumeHigh class="w-7 h-7" />
</div>

View File

@ -1,4 +1,4 @@
<script> <script lang="ts">
// Your selected Skeleton theme: // Your selected Skeleton theme:
import '@skeletonlabs/skeleton/themes/theme-vintage.css'; import '@skeletonlabs/skeleton/themes/theme-vintage.css';
// This contains the bulk of Skeletons required styles: // This contains the bulk of Skeletons required styles:
@ -6,13 +6,17 @@
import '../app.css'; import '../app.css';
import { AppShell, Toast, toastStore } from '@skeletonlabs/skeleton'; import { AppShell, Toast, toastStore } from '@skeletonlabs/skeleton';
import { address, playingNow, token, tracks, tracksSorted } from '../stores'; import { address, currentTrack, queuePosition, token, tracks, tracksSorted } from '../stores';
import { _metadataComm as comm } from './+layout'; import { _metadataComm as comm } from './+layout';
import Navbar from '../components/navbar.svelte'; import Navbar from '../components/navbar.svelte';
import PlayingNow from '../components/playingnow.svelte'; import PlayingNow from '../components/playingnow.svelte';
import VolumeSlider from '../components/volumeSlider.svelte';
$: title = $currentTrack !== null ? `${$currentTrack.track.title} - musikspider` : `musikspider`;
comm.setCallbacks({ comm.setCallbacks({
onConnect: () => { onConnect: () => {
toastStore.clear();
toastStore.trigger({ toastStore.trigger({
message: 'Successfully connected to the server', message: 'Successfully connected to the server',
background: 'variant-filled-success' background: 'variant-filled-success'
@ -25,7 +29,7 @@
background: 'variant-filled-error', background: 'variant-filled-error',
autohide: false autohide: false
}); });
playingNow.set(null); queuePosition.set(null);
}, },
onIncompatible: (reason) => { onIncompatible: (reason) => {
toastStore.trigger({ toastStore.trigger({
@ -54,8 +58,6 @@
remaining -= 500; remaining -= 500;
} }
}); });
$: title = $playingNow !== null ? `${$playingNow.track.title} - musikspider` : `musikspider`;
</script> </script>
<svelte:head> <svelte:head>
@ -64,12 +66,12 @@
<AppShell> <AppShell>
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">
<div class="w-screen"> <div class="flex w-screen place-content-end max-sm:place-content-center">
<div class="card fixed mr-4 bottom-14 right-0"><Navbar /></div> <div class="card fixed bottom-14 sm:mr-4"><Navbar /></div>
<div class="card rounded-none fixed w-full bottom-0 flex items-center h-12">
<PlayingNow />
<div class="ml-auto">volume</div>
</div> </div>
<div class="card rounded-none w-screen flex flex-grow items-center h-12">
<PlayingNow />
<div class="ml-auto"><VolumeSlider /></div>
</div> </div>
</svelte:fragment> </svelte:fragment>
<slot /> <slot />

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import VirtualList from 'svelte-tiny-virtual-list'; import VirtualList from 'svelte-tiny-virtual-list';
import { tracks, tracksSorted } from '../stores'; import { queue, tracks, tracksSorted } from '../stores';
import TrackComponent from '../components/track.svelte'; import TrackComponent from '../components/track.svelte';
import type { Track, TrackWithId } from '../types'; import type { Track, TrackWithId } from '../types';
@ -20,10 +20,12 @@
height={listHeight} height={listHeight}
itemSize={trackItemSize} itemSize={trackItemSize}
itemCount={trackCount} itemCount={trackCount}
overscanCount={1} overscanCount={3}
getKey={(index) => $tracksSorted.get(index)}
> >
<div slot="item" let:index let:style {style}> <div slot="item" let:index let:style {style}>
<div class="max-sm:pr-4"><TrackComponent track_with_id={getTrack(index)} /></div> <div class="pt-2 pl-2 max-sm:pr-4"><TrackComponent track_with_id={getTrack(index)} /></div>
<hr class="w-full mt-1.5" />
</div> </div>
</VirtualList> </VirtualList>
</div> </div>

View File

@ -0,0 +1,52 @@
<script lang="ts">
import VirtualList from 'svelte-tiny-virtual-list';
import { currentTrack, queue, queuePosition, tracks } from '../../stores';
import TrackComponent from '../../components/track.svelte';
import type { TrackWithId } from '../../types';
import IconRemove from '~icons/mdi/minus-thick';
import { get } from 'svelte/store';
$: trackCount = $queue.length;
let trackItemSize = 62;
let listHeight = 0;
$: getTrack = (index: number): TrackWithId => {
let id = $queue.at(index)!;
let track = $tracks.get(id)!;
return { id, track };
};
</script>
<div class="h-full" bind:offsetHeight={listHeight}>
<VirtualList
height={listHeight}
itemSize={trackItemSize}
itemCount={trackCount}
overscanCount={3}
getKey={(index) => $queue.at(index)}
>
<div slot="item" let:index let:style {style}>
<div class="flex pt-2 pl-2 max-sm:pr-4">
<TrackComponent track_with_id={getTrack(index)} />
<button
title="remove track"
class="mr-2 ml-auto btn px-3 hover:variant-soft-primary"
on:click={(_) => {
queue.update((q) => {
q.splice(index, 1);
return q;
});
const curTrack = $currentTrack;
if (curTrack !== null) {
const newPos = $queue.indexOf(curTrack.id);
queuePosition.set(newPos === -1 ? null : newPos);
}
}}
>
<IconRemove class="w-7 h-7" />
</button>
</div>
<hr class="w-full mt-1.5" />
</div>
</VirtualList>
</div>

View File

@ -1,4 +1,4 @@
import { writable } from 'svelte/store'; import { get, readable, writable, type Writable } from 'svelte/store';
import type { ResourceId, Track, TrackWithId } from './types'; import type { ResourceId, Track, TrackWithId } from './types';
function writableStorage(key: string, defaultValue: string) { function writableStorage(key: string, defaultValue: string) {
@ -7,18 +7,43 @@ function writableStorage(key: string, defaultValue: string) {
return store; return store;
} }
export function makeThumbnailUrl(address: string, token: string, id: ResourceId) {
return `http://${address}/thumbnail/${id}?token=${token}`;
}
export function makeAudioUrl(address: string, token: string, id: ResourceId) {
return `http://${address}/audio/id/${id}?token=${token}`;
}
export const address = writableStorage("address", "127.0.0.1:5505"); export const address = writableStorage("address", "127.0.0.1:5505");
export const token = writableStorage("token", ""); export const token = writableStorage("token", "");
export function makeThumbnailUrl(id: ResourceId) {
return `http://${get(address)}/thumbnail/${id}?token=${get(token)}`;
}
export function makeAudioUrl(id: ResourceId) {
return `http://${get(address)}/audio/id/${id}?token=${get(token)}`;
}
export const currentTrack = writable<TrackWithId | null>(null);
export function getCurrentTrack(tracks: Map<ResourceId, Track>, queue: ResourceId[], position: number | null): TrackWithId | null {
if (position === null) {
return null;
}
const id = queue.at(position);
if (id === undefined) {
return null;
}
const track = tracks.get(id);
if (track === undefined) {
return null;
}
return {
track,
id,
};
}
export const queuePosition = writable<number | null>(null);
export const queue = writable<ResourceId[]>([]);
export const tracks = writable<Map<ResourceId, Track>>(new Map()); export const tracks = writable<Map<ResourceId, Track>>(new Map());
export const tracksSorted = writable<Map<number, ResourceId>>(new Map()); export const tracksSorted = writable<Map<number, ResourceId>>(new Map());
export const playingNow = writable<TrackWithId | null>(null); queuePosition.subscribe((pos) => currentTrack.set(getCurrentTrack(get(tracks), get(queue), pos)));
tracks.subscribe((newTracks) => currentTrack.set(getCurrentTrack(newTracks, get(queue), get(queuePosition))));
export const volume = writable<number>(1.0);