feat: add looping, add media session controls, add basic search

This commit is contained in:
dusk 2023-04-30 05:27:40 +03:00
parent f2736c187c
commit 95ad71161d
Signed by: dusk
GPG Key ID: 1D8F8FAF2294D6EA
13 changed files with 298 additions and 83 deletions

View File

@ -8,7 +8,7 @@
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover" data-theme="vintage"> <body data-sveltekit-preload-data="hover" data-theme="crimson">
<div style="display: contents" class="h-full overflow-hidden">%sveltekit.body%</div> <div style="display: contents" class="h-full overflow-hidden">%sveltekit.body%</div>
</body> </body>

View File

@ -110,8 +110,9 @@ export class MetadataCommunicator {
if (resp) { if (resp) {
const data: any[] = resp.options.data; const data: any[] = resp.options.data;
resolve(data.map((t) => ({ resolve(data.map((t) => ({
id: t.id, id: t.external_id,
track: { track: {
id: t.id,
title: t.title, title: t.title,
track_num: t.track, track_num: t.track,
album_title: t.album, album_title: t.album,

View File

@ -10,7 +10,9 @@
<a <a
{href} {href}
{title} {title}
class="btn py-2 px-2.5 {isOnPage ? 'variant-ghost-primary' : 'hover:variant-soft-primary'}" class="btn py-2 px-2.5 {isOnPage
? `${title === 'search' ? 'rounded-r-none' : ''} variant-ghost-primary`
: 'hover:variant-soft-primary'}"
> >
<slot /> <slot />
</a> </a>

View File

@ -1,18 +1,14 @@
<script lang="ts"> <script lang="ts">
import Link from './a.svelte'; import Link from './a.svelte';
import IconTrack from '~icons/mdi/music-box'; import IconSearch from '~icons/mdi/search';
import IconSettings from '~icons/mdi/settings'; import IconSettings from '~icons/mdi/settings';
import IconArtist from '~icons/mdi/artist'; import IconQueue from '~icons/mdi/playlist-music';
import IconAlbum from '~icons/mdi/album'; import { page } from '$app/stores';
import IconPlaylist from '~icons/mdi/playlist-music'; import { search, searchText, setQueuePositionTo, tracksSorted } from '../stores';
import IconQueue from '~icons/mdi/format-list-bulleted';
let links: { id: string; href?: string; icon: any }[] = [ let links: { id: string; href?: string; icon: any }[] = [
{ id: 'search', href: '/', icon: IconSearch },
{ id: 'queue', icon: IconQueue }, { 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 } { id: 'settings', icon: IconSettings }
]; ];
</script> </script>
@ -22,5 +18,21 @@
<Link title={link.id} href={link.href ?? `/${link.id}`}> <Link title={link.id} href={link.href ?? `/${link.id}`}>
<svelte:component this={link.icon} class="w-7 h-7" /> <svelte:component this={link.icon} class="w-7 h-7" />
</Link> </Link>
{#if link.id === 'search' && $page.route.id === '/'}
<input
class="input rounded-none"
bind:value={$searchText}
on:input={(ev) => search(ev.currentTarget.value)}
on:keydown={(ev) => {
if (ev.key === 'Enter') {
const track_id = $tracksSorted.at(0) ?? null;
if (track_id !== null) {
setQueuePositionTo(track_id);
document.getElementById(`track-${track_id}`)?.focus();
}
}
}}
/>
{/if}
{/each} {/each}
</nav> </nav>

View File

@ -3,29 +3,26 @@
makeAudioUrl, makeAudioUrl,
makeThumbnailUrl, makeThumbnailUrl,
currentTrack, currentTrack,
volume,
muted,
paused,
nextQueuePosition,
getAudioElement,
loop,
queuePosition, queuePosition,
queue, prevQueuePosition
volume
} from '../stores'; } 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 IconMusic from '~icons/mdi/music';
import { RangeSlider } from '@skeletonlabs/skeleton'; import { RangeSlider } from '@skeletonlabs/skeleton';
import type { ResourceId } from '../types'; import { LoopKind } from '../types';
$: track = $currentTrack?.track; $: track = $currentTrack?.track;
$: track_id = $currentTrack?.id; $: track_id = $currentTrack?.id;
$: thumbUrl = track ? makeThumbnailUrl(track.thumbnail_id) : null; $: thumbUrl = track ? makeThumbnailUrl(track.thumbnail_id) : null;
$: audioUrl = track_id ? makeAudioUrl(track_id) : null; $: audioUrl = track_id ? makeAudioUrl(track_id) : null;
function getAudioElement(id: ResourceId | null) {
const elem = document.getElementById(`audio-source-${id}`);
if (elem === null) {
return null;
}
return elem as HTMLAudioElement;
}
function calculateMinuteSecond(seconds: number) { function calculateMinuteSecond(seconds: number) {
let secs = Math.floor(seconds); let secs = Math.floor(seconds);
let secsLeftover = secs % 60; let secsLeftover = secs % 60;
@ -37,7 +34,6 @@
return `${minutesFormatted}:${secondsFormatted}`; return `${minutesFormatted}:${secondsFormatted}`;
} }
let isPaused = false;
let currentTime = 0; let currentTime = 0;
let duration = 0; let duration = 0;
@ -48,31 +44,76 @@
<div class="flex gap-2 z-10"> <div class="flex gap-2 z-10">
{#if audioUrl !== null} {#if audioUrl !== null}
<audio <audio
id="audio-source-{track_id}" id="audio-source"
src={audioUrl} src={audioUrl}
autoplay autoplay
bind:paused={isPaused} bind:paused={$paused}
bind:currentTime bind:currentTime
bind:duration bind:duration
bind:muted={$muted}
bind:volume={$volume} bind:volume={$volume}
on:ended={(_) => { on:loadstart={(_) => {
const pos = $queuePosition; let mediaSession = navigator.mediaSession;
if (pos !== null) { let artwork = [];
const _newPos = pos + 1; if (thumbUrl !== null) {
const newPos = _newPos < $queue.length ? _newPos : null; artwork.push({ src: thumbUrl });
}
mediaSession.metadata = new MediaMetadata({
title: track?.title,
album: track?.album_title,
artist: track?.artist_name,
artwork
});
mediaSession.setPositionState({ position: currentTime, duration });
mediaSession.setActionHandler('nexttrack', nextQueuePosition);
mediaSession.setActionHandler('previoustrack', prevQueuePosition);
mediaSession.setActionHandler('seekto', (ev) => {
if (ev.seekTime !== undefined) {
currentTime = ev.seekTime;
}
});
mediaSession.setActionHandler('seekbackward', () => {
currentTime -= 5;
if (currentTime < 0) {
currentTime = 0;
}
});
mediaSession.setActionHandler('seekforward', () => {
currentTime += 5;
if (currentTime > duration) {
currentTime = duration;
}
});
}}
on:timeupdate={(_) => {
navigator.mediaSession.setPositionState({ position: currentTime, duration });
}}
on:ended={(ev) => {
duration = 0; duration = 0;
currentTime = 0; currentTime = 0;
queuePosition.set(newPos); switch ($loop) {
case LoopKind.Off:
nextQueuePosition();
break;
case LoopKind.Once:
const queuePos = nextQueuePosition();
if (queuePos === null) {
queuePosition.set(0);
}
break;
case LoopKind.Always:
ev.currentTarget.play();
break;
} }
}} }}
/> />
{/if} {/if}
<button <button
class="relative placeholder w-12 h-12" class="relative rounded-none 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(track_id ?? null); let elem = getAudioElement();
if (elem) { if (elem) {
elem.paused ? elem.play() : elem.pause(); elem.paused ? elem.play() : elem.pause();
} }
@ -88,8 +129,8 @@
on:load={() => (isError = false)} on:load={() => (isError = false)}
/> />
{/if} {/if}
<IconPlay class="child play-icon {showIcon && isPaused ? 'opacity-100' : 'opacity-0'}" /> <IconPlay class="child play-icon {showIcon && $paused ? 'opacity-100' : 'opacity-0'}" />
<IconPause class="child play-icon {showIcon && !isPaused ? 'opacity-100' : 'opacity-0'}" /> <IconPause class="child play-icon {showIcon && !$paused ? 'opacity-100' : 'opacity-0'}" />
</button> </button>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div <div

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { TrackWithId } from '../types'; import type { TrackWithId } from '../types';
import { queue, queuePosition, makeThumbnailUrl, currentTrack } from '../stores'; import { makeThumbnailUrl, currentTrack, setQueuePositionTo, getAudioElement } 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'; import IconMusic from '~icons/mdi/music';
@ -17,17 +17,13 @@
<div class="flex gap-2 w-fit max-w-full"> <div class="flex gap-2 w-fit max-w-full">
<button <button
id={`track-${track_id}`}
class="relative placeholder rounded min-w-[3rem] min-h-[3rem]" class="relative placeholder rounded min-w-[3rem] min-h-[3rem]"
on:click={(_) => { on:click={(_) => {
const position = $queue.indexOf(track_id); setQueuePositionTo(track_id);
if (position !== -1) { const elem = getAudioElement();
queuePosition.set(position); if (elem !== null) {
} else { elem.currentTime = 0;
queue.update((q) => {
q.push(track_id);
return q;
});
queuePosition.set($queue.length - 1);
} }
}} }}
on:pointerenter={(_) => (showPlayIcon = true)} on:pointerenter={(_) => (showPlayIcon = true)}
@ -52,12 +48,12 @@
</button> </button>
<div class="whitespace-nowrap overflow-ellipsis overflow-hidden"> <div class="whitespace-nowrap overflow-ellipsis overflow-hidden">
<span>#{track.track_num} - {track.title}</span> <span>#{track.track_num} - {track.title}</span>
<div class="text-sm whitespace-nowrap overflow-ellipsis overflow-hidden">
<span <span
class="badge variant-filled-primary py-0.5 {$currentTrack?.id == track_id class="badge variant-filled-primary py-0.5 {$currentTrack?.id == track_id
? 'visible' ? 'visible'
: 'hidden'}">playing</span : 'hidden'}">playing</span
> >
<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>

View File

@ -0,0 +1,38 @@
<script lang="ts">
import IconRepeat from '~icons/mdi/repeat';
import IconRepeatOff from '~icons/mdi/repeat-off';
import IconRepeatOnce from '~icons/mdi/repeat-once';
import { loop } from '../stores';
import { LoopKind } from '../types';
function getIcon(kind: LoopKind) {
switch (kind) {
case LoopKind.Always:
return IconRepeat;
case LoopKind.Off:
return IconRepeatOff;
case LoopKind.Once:
return IconRepeatOnce;
}
}
function changeLoop() {
switch ($loop) {
case LoopKind.Always:
loop.set(LoopKind.Off);
break;
case LoopKind.Off:
loop.set(LoopKind.Once);
break;
case LoopKind.Once:
loop.set(LoopKind.Always);
break;
}
}
$: icon = getIcon($loop);
</script>
<button on:click={changeLoop}>
<svelte:component this={icon} class="w-7 h-7" />
</button>

View File

@ -2,24 +2,26 @@
import IconVolumeHigh from '~icons/mdi/volume-high'; import IconVolumeHigh from '~icons/mdi/volume-high';
import IconVolumeMedium from '~icons/mdi/volume-medium'; import IconVolumeMedium from '~icons/mdi/volume-medium';
import IconVolumeLow from '~icons/mdi/volume-low'; import IconVolumeLow from '~icons/mdi/volume-low';
import IconVolumeMuted from '~icons/mdi/volume-off';
import { RangeSlider } from '@skeletonlabs/skeleton'; import { RangeSlider } from '@skeletonlabs/skeleton';
import type { ResourceId } from '../types'; import { volume, muted } from '../stores';
import { currentTrack, volume } from '../stores';
function getAudioElement(id: ResourceId | null) { $: icon = $muted
const elem = document.getElementById(`audio-source-${id}`); ? IconVolumeMuted
if (elem === null) { : $volume > 0.7
return null; ? IconVolumeHigh
} : $volume > 0.3
return elem as HTMLAudioElement; ? IconVolumeMedium
} : $volume > 0
? IconVolumeLow
$: audioElem = getAudioElement($currentTrack?.id ?? null); : IconVolumeMuted;
</script> </script>
<div class="flex items-center"> <div class="flex items-center">
<div class="w-24"> <div class="w-24">
<RangeSlider name="volume-slider" bind:value={$volume} step={0.01} max={1.0} /> <RangeSlider name="volume-slider" bind:value={$volume} step={0.01} max={1.0} />
</div> </div>
<IconVolumeHigh class="w-7 h-7" /> <button on:click={(_) => ($muted = !$muted)}
><svelte:component this={icon} class="w-7 h-7" /></button
>
</div> </div>

View File

@ -1,16 +1,25 @@
<script lang="ts"> <script lang="ts">
// Your selected Skeleton theme: // Your selected Skeleton theme:
import '@skeletonlabs/skeleton/themes/theme-vintage.css'; import '@skeletonlabs/skeleton/themes/theme-crimson.css';
// This contains the bulk of Skeletons required styles: // This contains the bulk of Skeletons required styles:
import '@skeletonlabs/skeleton/styles/all.css'; import '@skeletonlabs/skeleton/styles/all.css';
import '../app.css'; import '../app.css';
import { AppShell, Toast, toastStore } from '@skeletonlabs/skeleton'; import { AppShell, Toast, toastStore } from '@skeletonlabs/skeleton';
import { address, currentTrack, queuePosition, token, tracks, tracksSorted } from '../stores'; import {
address,
currentTrack,
paused,
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'; import VolumeSlider from '../components/volumeSlider.svelte';
import TrackControls from '../components/trackControls.svelte';
$: title = $currentTrack !== null ? `${$currentTrack.track.title} - musikspider` : `musikspider`; $: title = $currentTrack !== null ? `${$currentTrack.track.title} - musikspider` : `musikspider`;
@ -41,6 +50,10 @@
}); });
comm.connect($address, $token); comm.connect($address, $token);
comm.onConnect(async () => { comm.onConnect(async () => {
toastStore.trigger({
message: 'Fetching tracks',
background: 'variant-filled-tertiary'
});
const count = await comm.fetchTracksCount(); const count = await comm.fetchTracksCount();
let remaining = count; let remaining = count;
@ -52,14 +65,30 @@
return map; return map;
}); });
tracksSorted.update((map) => { tracksSorted.update((map) => {
ts.forEach((t, index) => map.set(index + offset, t.id)); ts.forEach((t) => map.push(t.id));
return map; return map;
}); });
remaining -= 500; remaining -= 500;
} }
toastStore.trigger({
message: `Fetched ${count} tracks`,
background: 'variant-filled-success'
});
}); });
</script> </script>
<svelte:window
on:keydown={(event) => {
const tagName = document.activeElement?.tagName ?? '';
if (tagName !== 'INPUT' && event.code === 'Space') {
event.preventDefault();
event.stopPropagation();
paused.set(!$paused);
}
}}
/>
<svelte:head> <svelte:head>
<title>{title}</title> <title>{title}</title>
</svelte:head> </svelte:head>
@ -71,7 +100,10 @@
</div> </div>
<div class="card rounded-none w-screen flex flex-grow items-center h-12"> <div class="card rounded-none w-screen flex flex-grow items-center h-12">
<PlayingNow /> <PlayingNow />
<div class="ml-auto"><VolumeSlider /></div> <div class="flex items-center ml-auto">
<TrackControls />
<VolumeSlider />
</div>
</div> </div>
</svelte:fragment> </svelte:fragment>
<slot /> <slot />

View File

@ -4,12 +4,12 @@
import TrackComponent from '../components/track.svelte'; import TrackComponent from '../components/track.svelte';
import type { Track, TrackWithId } from '../types'; import type { Track, TrackWithId } from '../types';
$: trackCount = $tracksSorted.size; $: trackCount = $tracksSorted.length;
let trackItemSize = 62; let trackItemSize = 62;
let listHeight = 0; let listHeight = 0;
function getTrack(index: number): TrackWithId { function getTrack(index: number): TrackWithId {
let id = $tracksSorted.get(index)!; let id = $tracksSorted.at(index)!;
let track = $tracks.get(id)!; let track = $tracks.get(id)!;
return { id, track }; return { id, track };
} }
@ -21,7 +21,7 @@
itemSize={trackItemSize} itemSize={trackItemSize}
itemCount={trackCount} itemCount={trackCount}
overscanCount={3} overscanCount={3}
getKey={(index) => $tracksSorted.get(index)} getKey={(index) => $tracksSorted.at(index)}
> >
<div slot="item" let:index let:style {style}> <div slot="item" let:index let:style {style}>
<div class="pt-2 pl-2 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>

View File

@ -30,7 +30,7 @@
<TrackComponent track_with_id={getTrack(index)} /> <TrackComponent track_with_id={getTrack(index)} />
<button <button
title="remove track" title="remove track"
class="mr-2 ml-auto btn px-3 hover:variant-soft-primary" class="btn w-7 h-7 mr-3 mt-2 ml-auto px-0 hover:variant-soft-primary"
on:click={(_) => { on:click={(_) => {
queue.update((q) => { queue.update((q) => {
q.splice(index, 1); q.splice(index, 1);

View File

@ -1,5 +1,5 @@
import { get, readable, writable, type Writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import type { ResourceId, Track, TrackWithId } from './types'; import { type ResourceId, type Track, type TrackId, type TrackWithId, LoopKind } from './types';
function writableStorage(key: string, defaultValue: string) { function writableStorage(key: string, defaultValue: string) {
const store = writable(localStorage.getItem(key) ?? defaultValue); const store = writable(localStorage.getItem(key) ?? defaultValue);
@ -14,13 +14,13 @@ export function makeThumbnailUrl(id: ResourceId) {
return `http://${get(address)}/thumbnail/${id}?token=${get(token)}`; return `http://${get(address)}/thumbnail/${id}?token=${get(token)}`;
} }
export function makeAudioUrl(id: ResourceId) { export function makeAudioUrl(id: TrackId) {
return `http://${get(address)}/audio/id/${id}?token=${get(token)}`; return `http://${get(address)}/audio/external_id/${id}?token=${get(token)}`;
} }
export const currentTrack = writable<TrackWithId | null>(null); export const currentTrack = writable<TrackWithId | null>(null);
export function getCurrentTrack(tracks: Map<ResourceId, Track>, queue: ResourceId[], position: number | null): TrackWithId | null { export function getCurrentTrack(tracks: Map<TrackId, Track>, queue: TrackId[], position: number | null): TrackWithId | null {
if (position === null) { if (position === null) {
return null; return null;
} }
@ -39,11 +39,94 @@ export function getCurrentTrack(tracks: Map<ResourceId, Track>, queue: ResourceI
} }
export const queuePosition = writable<number | null>(null); export const queuePosition = writable<number | null>(null);
export const queue = writable<ResourceId[]>([]); export const queue = writable<TrackId[]>([]);
export const tracks = writable<Map<ResourceId, Track>>(new Map()); export const tracks = writable<Map<TrackId, Track>>(new Map());
export const tracksSorted = writable<Map<number, ResourceId>>(new Map()); export const tracksSorted = writable<TrackId[]>([]);
queuePosition.subscribe((pos) => currentTrack.set(getCurrentTrack(get(tracks), get(queue), pos))); queuePosition.subscribe((pos) => currentTrack.set(getCurrentTrack(get(tracks), get(queue), pos)));
tracks.subscribe((newTracks) => currentTrack.set(getCurrentTrack(newTracks, get(queue), get(queuePosition)))); tracks.subscribe((newTracks) => currentTrack.set(getCurrentTrack(newTracks, get(queue), get(queuePosition))));
export function setQueuePositionTo(track_id: TrackId) {
let q = get(queue);
const position = q.indexOf(track_id);
if (position !== -1) {
queuePosition.set(position);
} else {
q.push(track_id);
queue.set(q);
queuePosition.set(q.length - 1);
}
}
export function prevQueuePosition() {
const pos = get(queuePosition);
if (pos !== null) {
const q = get(queue);
const _newPos = pos - 1;
const newPos = _newPos > -1 ? _newPos : q.length - 1;
queuePosition.set(newPos);
return newPos;
}
return null;
}
export function nextQueuePosition() {
const pos = get(queuePosition);
if (pos !== null) {
const q = get(queue);
const _newPos = pos + 1;
const newPos = _newPos < q.length ? _newPos : 0;
queuePosition.set(newPos);
return newPos;
}
return null;
}
export const paused = writable<boolean>(false);
export const volume = writable<number>(1.0); export const volume = writable<number>(1.0);
export const muted = writable<boolean>(false);
export const loop = writable<LoopKind>(LoopKind.Off);
export const searchText = writable<string>("");
export function search(q: string) {
const query = q.trim();
const t = get(tracks);
if (query.length === 0) {
let result: TrackId[] = [];
t.forEach((_, id) => (result.push(id)));
tracksSorted.set(result);
return;
}
const smartCase = query.toLowerCase() === query;
let result: TrackId[] = [];
t.forEach((track, id) => {
if (smartCase) {
const titleHas = track.title.toLowerCase().includes(query);
const albumHas = track.album_title.toLowerCase().includes(query);
const artistHas = track.artist_name.toLowerCase().includes(query);
if (titleHas || albumHas || artistHas) {
result.push(id);
}
} else {
const titleHas = track.title.includes(query);
const albumHas = track.album_title.includes(query);
const artistHas = track.artist_name.includes(query);
if (titleHas || albumHas || artistHas) {
result.push(id);
}
}
});
tracksSorted.set(result);
}
export function getAudioElement() {
const elem = document.getElementById('audio-source');
if (elem === null) {
return null;
}
return elem as HTMLAudioElement;
}

View File

@ -1,6 +1,8 @@
export type ResourceId = bigint; export type ResourceId = bigint;
export type TrackId = string;
export interface Track { export interface Track {
id: number,
title: string, title: string,
track_num: number, track_num: number,
album_title: string, album_title: string,
@ -11,7 +13,7 @@ export interface Track {
} }
export interface TrackWithId { export interface TrackWithId {
id: ResourceId, id: TrackId,
track: Track, track: Track,
} }
@ -23,3 +25,9 @@ export interface Album {
title: string, title: string,
artist_id: ResourceId, artist_id: ResourceId,
} }
export enum LoopKind {
Off,
Once,
Always,
}