feat: implement now playing, better layout
This commit is contained in:
parent
ef1af77dff
commit
e8e6ce3eba
@ -6,6 +6,9 @@
|
||||
$: isOnPage = href === $page.route.id;
|
||||
</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 />
|
||||
</a>
|
||||
|
99
src/components/playingnow.svelte
Normal file
99
src/components/playingnow.svelte
Normal 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>
|
@ -1,26 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Track } from '../types';
|
||||
import { address, token } from '../stores';
|
||||
import type { TrackWithId } from '../types';
|
||||
import { playingNow, makeThumbnailUrl, address, token } from '../stores';
|
||||
import Spinnny from '~icons/line-md/loading-loop';
|
||||
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 isError = false;
|
||||
let showPlayIcon = false;
|
||||
</script>
|
||||
|
||||
<div class="card flex gap-4 m-2 p-2 w-fit max-w-full">
|
||||
<button class="relative w-12 h-12 invisible hover:visible">
|
||||
<div class="visible rounded placeholder w-12 h-12" />
|
||||
<Spinnny class="absolute top-1 left-1 w-10 h-10 {showSpinner ? 'visible' : 'hidden'}" />
|
||||
<div class="card flex gap-2 m-2 p-1 w-fit max-w-full">
|
||||
<button
|
||||
class="relative placeholder rounded min-w-[3rem] min-h-[3rem]"
|
||||
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 -->
|
||||
<img
|
||||
src={url}
|
||||
class="absolute top-0 left-0 rounded w-12 h-12 {showSpinner || isError
|
||||
? 'hidden'
|
||||
: 'visible'}"
|
||||
class="top-0 left-0 w-12 h-12 rounded {showSpinner || isError ? 'hidden' : ''}"
|
||||
on:error={() => {
|
||||
isError = true;
|
||||
showSpinner = false;
|
||||
@ -29,7 +33,9 @@
|
||||
on:load={() => (showSpinner = false)}
|
||||
/>
|
||||
<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>
|
||||
<div class="whitespace-nowrap overflow-ellipsis overflow-hidden">
|
||||
|
@ -6,9 +6,10 @@
|
||||
import '../app.css';
|
||||
|
||||
import { AppShell, Toast, toastStore } from '@skeletonlabs/skeleton';
|
||||
import Navbar from '../components/navbar.svelte';
|
||||
import { address, token, tracks, tracksSorted } from '../stores';
|
||||
import { address, playingNow, token, tracks, tracksSorted } from '../stores';
|
||||
import { _metadataComm as comm } from './+layout';
|
||||
import Navbar from '../components/navbar.svelte';
|
||||
import PlayingNow from '../components/playingnow.svelte';
|
||||
|
||||
comm.setCallbacks({
|
||||
onConnect: () => {
|
||||
@ -24,6 +25,7 @@
|
||||
background: 'variant-filled-error',
|
||||
autohide: false
|
||||
});
|
||||
playingNow.set(null);
|
||||
},
|
||||
onIncompatible: (reason) => {
|
||||
toastStore.trigger({
|
||||
@ -52,25 +54,24 @@
|
||||
remaining -= 500;
|
||||
}
|
||||
});
|
||||
|
||||
$: title = $playingNow !== null ? `${$playingNow.track.title} - musikspider` : `musikspider`;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>musikspider</title>
|
||||
<title>{title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<AppShell>
|
||||
<svelte:fragment slot="footer">
|
||||
<div class="flex w-screen place-content-center">
|
||||
<div class="card m-2 sm:hidden z-1 fixed bottom-[48px]"><Navbar /></div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="card m-2 max-md:m-0 px-4 flex flex-1 items-center min-h-[48px]">
|
||||
now playing
|
||||
<div class="mx-auto max-sm:hidden"><Navbar /></div>
|
||||
<div class="max-sm:ml-auto">volume</div>
|
||||
<div class="w-screen">
|
||||
<div class="card fixed z-[1] mr-4 bottom-14 right-0"><Navbar /></div>
|
||||
<div class="card rounded-none fixed z-[1] w-full bottom-0 flex items-center h-12">
|
||||
<PlayingNow />
|
||||
<div class="ml-auto">volume</div>
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<slot />
|
||||
</AppShell>
|
||||
<Toast />
|
||||
<Toast position="tr" />
|
||||
|
@ -2,14 +2,16 @@
|
||||
import VirtualList from 'svelte-tiny-virtual-list';
|
||||
import { tracks, tracksSorted } from '../stores';
|
||||
import TrackComponent from '../components/track.svelte';
|
||||
import type { Track } from '../types';
|
||||
import type { Track, TrackWithId } from '../types';
|
||||
|
||||
$: trackCount = $tracksSorted.size;
|
||||
let trackItemSize = 72;
|
||||
let trackItemSize = 62;
|
||||
let listHeight = 0;
|
||||
|
||||
function getTrack(index: number): Track {
|
||||
return $tracks.get($tracksSorted.get(index)!)!;
|
||||
function getTrack(index: number): TrackWithId {
|
||||
let id = $tracksSorted.get(index)!;
|
||||
let track = $tracks.get(id)!;
|
||||
return { id, track };
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -21,7 +23,7 @@
|
||||
overscanCount={1}
|
||||
>
|
||||
<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>
|
||||
</VirtualList>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { ResourceId, Track } from './types';
|
||||
import type { ResourceId, Track, TrackWithId } from './types';
|
||||
|
||||
function writableStorage(key: string, defaultValue: string) {
|
||||
const store = writable(localStorage.getItem(key) ?? defaultValue);
|
||||
@ -7,8 +7,18 @@ function writableStorage(key: string, defaultValue: string) {
|
||||
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 token = writableStorage("token", "");
|
||||
|
||||
export const tracks = writable<Map<ResourceId, Track>>(new Map());
|
||||
export const tracksSorted = writable<Map<number, ResourceId>>(new Map());
|
||||
|
||||
export const playingNow = writable<TrackWithId | null>(null);
|
Loading…
Reference in New Issue
Block a user