feat: implement now playing, better layout

This commit is contained in:
dusk 2023-04-25 21:58:18 +03:00
parent ef1af77dff
commit e8e6ce3eba
Signed by: dusk
GPG Key ID: 1D8F8FAF2294D6EA
6 changed files with 153 additions and 32 deletions

View File

@ -6,6 +6,9 @@
$: isOnPage = href === $page.route.id; $: isOnPage = href === $page.route.id;
</script> </script>
<a {href} class="btn p-2 px-3 {isOnPage ? 'variant-ghost-primary' : 'hover:variant-soft-primary'}"> <a
{href}
class="btn py-2 px-2.5 {isOnPage ? 'variant-ghost-primary' : 'hover:variant-soft-primary'}"
>
<slot /> <slot />
</a> </a>

View File

@ -0,0 +1,99 @@
<script lang="ts">
import { address, makeAudioUrl, makeThumbnailUrl, playingNow, token } from '../stores';
import IconPlay from '~icons/mdi/play';
import IconPause from '~icons/mdi/pause';
import { RangeSlider } from '@skeletonlabs/skeleton';
$: thumbUrl = $playingNow
? makeThumbnailUrl($address, $token, $playingNow.track.thumbnail_id)
: null;
$: audioUrl = $playingNow ? makeAudioUrl($address, $token, $playingNow.id) : null;
function getAudioElement() {
return document.getElementById('audio-source')! as HTMLAudioElement;
}
function calculateMinuteSecond(seconds: number) {
let secs = Math.floor(seconds);
let secsLeftover = secs % 60;
let minutes = (secs - secsLeftover) / 60;
let secondsFormatted = secsLeftover < 10 ? `0${secsLeftover}` : `${secsLeftover}`;
let minutesFormatted = minutes < 10 ? `0${minutes}` : `${minutes}`;
return `${minutesFormatted}:${secondsFormatted}`;
}
let isPaused = false;
let currentTime = 0;
let duration = 0;
let isError = false;
let showIcon = false;
</script>
{#if $playingNow !== null}
<div class="flex gap-2">
<audio
id="audio-source"
src={audioUrl}
bind:paused={isPaused}
bind:currentTime
bind:duration
autoplay
/>
<button
class="z-10 relative placeholder w-12 h-12"
on:pointerenter={(_) => (showIcon = true)}
on:pointerleave={(_) => (showIcon = false)}
on:click={(_) => {
let elem = getAudioElement();
if (isPaused) {
elem.play();
} else {
elem.pause();
}
}}
>
<!-- svelte-ignore a11y-missing-attribute -->
<img
src={thumbUrl}
class="child {isError ? 'hidden' : ''}"
on:error={() => (isError = true)}
on:load={() => (isError = false)}
/>
<IconPlay class="child play-icon {showIcon && isPaused ? 'visible' : 'hidden'}" />
<IconPause class="child play-icon {showIcon && !isPaused ? 'visible' : 'hidden'}" />
</button>
<div class="flex flex-col gap-1">
<div
title={$playingNow.track.title}
class="w-80 max-md:w-44 max-sm:w-32 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{$playingNow.track.title}
</div>
<div class="flex items-center gap-1">
<RangeSlider
name="progress"
bind:value={currentTime}
max={duration}
step={0.01}
class="w-72 max-md:w-36 max-sm:w-24"
/>
<div class="text-xs opacity-70 w-8">{calculateMinuteSecond(currentTime)}</div>
</div>
</div>
</div>
{/if}
<style lang="postcss">
:global(.play-icon) {
@apply variant-glass-surface backdrop-blur-sm;
}
:global(.child) {
@apply absolute top-0 left-0 w-12 h-12;
}
:global(.range-content) {
@apply p-0;
}
</style>

View File

@ -1,26 +1,30 @@
<script lang="ts"> <script lang="ts">
import type { Track } from '../types'; import type { TrackWithId } from '../types';
import { address, token } from '../stores'; import { playingNow, makeThumbnailUrl, address, token } 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';
export let track: Track; export let track_with_id: TrackWithId;
let track = track_with_id.track;
$: url = `http://${$address}/thumbnail/${track.thumbnail_id}?token=${$token}`; $: url = makeThumbnailUrl($address, $token, track.thumbnail_id);
let showSpinner = false; let showSpinner = false;
let isError = false; let isError = false;
let showPlayIcon = false;
</script> </script>
<div class="card flex gap-4 m-2 p-2 w-fit max-w-full"> <div class="card flex gap-2 m-2 p-1 w-fit max-w-full">
<button class="relative w-12 h-12 invisible hover:visible"> <button
<div class="visible rounded placeholder w-12 h-12" /> class="relative placeholder rounded min-w-[3rem] min-h-[3rem]"
<Spinnny class="absolute top-1 left-1 w-10 h-10 {showSpinner ? 'visible' : 'hidden'}" /> on:click={(_) => playingNow.set(track_with_id)}
on:pointerenter={(_) => (showPlayIcon = true)}
on:pointerleave={(_) => (showPlayIcon = false)}
>
<Spinnny class="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={url}
class="absolute top-0 left-0 rounded w-12 h-12 {showSpinner || isError class="top-0 left-0 w-12 h-12 rounded {showSpinner || isError ? 'hidden' : ''}"
? 'hidden'
: 'visible'}"
on:error={() => { on:error={() => {
isError = true; isError = true;
showSpinner = false; showSpinner = false;
@ -29,7 +33,9 @@
on:load={() => (showSpinner = false)} on:load={() => (showSpinner = false)}
/> />
<IconPlay <IconPlay
class="absolute top-0 left-0 w-12 h-12 rounded variant-glass-surface backdrop-blur-sm" 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">

View File

@ -6,9 +6,10 @@
import '../app.css'; import '../app.css';
import { AppShell, Toast, toastStore } from '@skeletonlabs/skeleton'; import { AppShell, Toast, toastStore } from '@skeletonlabs/skeleton';
import Navbar from '../components/navbar.svelte'; import { address, playingNow, token, tracks, tracksSorted } from '../stores';
import { address, token, tracks, tracksSorted } from '../stores';
import { _metadataComm as comm } from './+layout'; import { _metadataComm as comm } from './+layout';
import Navbar from '../components/navbar.svelte';
import PlayingNow from '../components/playingnow.svelte';
comm.setCallbacks({ comm.setCallbacks({
onConnect: () => { onConnect: () => {
@ -24,6 +25,7 @@
background: 'variant-filled-error', background: 'variant-filled-error',
autohide: false autohide: false
}); });
playingNow.set(null);
}, },
onIncompatible: (reason) => { onIncompatible: (reason) => {
toastStore.trigger({ toastStore.trigger({
@ -52,25 +54,24 @@
remaining -= 500; remaining -= 500;
} }
}); });
$: title = $playingNow !== null ? `${$playingNow.track.title} - musikspider` : `musikspider`;
</script> </script>
<svelte:head> <svelte:head>
<title>musikspider</title> <title>{title}</title>
</svelte:head> </svelte:head>
<AppShell> <AppShell>
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">
<div class="flex w-screen place-content-center"> <div class="w-screen">
<div class="card m-2 sm:hidden z-1 fixed bottom-[48px]"><Navbar /></div> <div class="card fixed z-[1] mr-4 bottom-14 right-0"><Navbar /></div>
</div> <div class="card rounded-none fixed z-[1] w-full bottom-0 flex items-center h-12">
<div class="flex flex-col"> <PlayingNow />
<div class="card m-2 max-md:m-0 px-4 flex flex-1 items-center min-h-[48px]"> <div class="ml-auto">volume</div>
now playing
<div class="mx-auto max-sm:hidden"><Navbar /></div>
<div class="max-sm:ml-auto">volume</div>
</div> </div>
</div> </div>
</svelte:fragment> </svelte:fragment>
<slot /> <slot />
</AppShell> </AppShell>
<Toast /> <Toast position="tr" />

View File

@ -2,14 +2,16 @@
import VirtualList from 'svelte-tiny-virtual-list'; import VirtualList from 'svelte-tiny-virtual-list';
import { tracks, tracksSorted } from '../stores'; import { tracks, tracksSorted } from '../stores';
import TrackComponent from '../components/track.svelte'; import TrackComponent from '../components/track.svelte';
import type { Track } from '../types'; import type { Track, TrackWithId } from '../types';
$: trackCount = $tracksSorted.size; $: trackCount = $tracksSorted.size;
let trackItemSize = 72; let trackItemSize = 62;
let listHeight = 0; let listHeight = 0;
function getTrack(index: number): Track { function getTrack(index: number): TrackWithId {
return $tracks.get($tracksSorted.get(index)!)!; let id = $tracksSorted.get(index)!;
let track = $tracks.get(id)!;
return { id, track };
} }
</script> </script>
@ -21,7 +23,7 @@
overscanCount={1} overscanCount={1}
> >
<div slot="item" let:index let:style {style}> <div slot="item" let:index let:style {style}>
<div class="pr-4 md:ml-32"><TrackComponent track={getTrack(index)} /></div> <div class="max-sm:pr-4"><TrackComponent track_with_id={getTrack(index)} /></div>
</div> </div>
</VirtualList> </VirtualList>
</div> </div>

View File

@ -1,5 +1,5 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { ResourceId, Track } from './types'; import type { ResourceId, Track, TrackWithId } 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);
@ -7,8 +7,18 @@ 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 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);