feat: implement now playing, better layout
This commit is contained in:
parent
ef1af77dff
commit
e8e6ce3eba
@ -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>
|
||||||
|
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">
|
<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">
|
||||||
|
@ -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" />
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
Loading…
Reference in New Issue
Block a user