feat: add looping, add media session controls, add basic search
This commit is contained in:
parent
f2736c187c
commit
95ad71161d
@ -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>
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 });
|
||||||
duration = 0;
|
}
|
||||||
currentTime = 0;
|
mediaSession.metadata = new MediaMetadata({
|
||||||
queuePosition.set(newPos);
|
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;
|
||||||
|
currentTime = 0;
|
||||||
|
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
|
||||||
|
@ -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>
|
||||||
<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="badge variant-filled-primary py-0.5 {$currentTrack?.id == track_id
|
||||||
|
? 'visible'
|
||||||
|
: 'hidden'}">playing</span
|
||||||
|
>
|
||||||
<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>
|
||||||
|
38
src/components/trackControls.svelte
Normal file
38
src/components/trackControls.svelte
Normal 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>
|
@ -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>
|
||||||
|
@ -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 />
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
101
src/stores.ts
101
src/stores.ts
@ -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 const volume = writable<number>(1.0);
|
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 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;
|
||||||
|
}
|
10
src/types.ts
10
src/types.ts
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,4 +24,10 @@ export interface Artist {
|
|||||||
export interface Album {
|
export interface Album {
|
||||||
title: string,
|
title: string,
|
||||||
artist_id: ResourceId,
|
artist_id: ResourceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LoopKind {
|
||||||
|
Off,
|
||||||
|
Once,
|
||||||
|
Always,
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user