feat(tracks): search + lazy load images
This commit is contained in:
@@ -36,6 +36,7 @@
|
|||||||
"@protobuf-ts/grpcweb-transport": "^2.9.4",
|
"@protobuf-ts/grpcweb-transport": "^2.9.4",
|
||||||
"@protobuf-ts/plugin": "^2.9.4",
|
"@protobuf-ts/plugin": "^2.9.4",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"fzf": "^0.5.2",
|
||||||
"mode-watcher": "^0.5.0"
|
"mode-watcher": "^0.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,9 +68,8 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
use:enhance={submitPlayerAction}
|
use:enhance={submitPlayerAction}
|
||||||
>
|
>
|
||||||
{#each player.queue as track, i (track.hash)}
|
{#each player.queue as track, i}
|
||||||
<button
|
<button
|
||||||
animate:flip={{ duration: 100 }}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
class="flex flex-row items-center gap-2 rounded-lg px-3 py-2 transition-all hover:bg-secondary"
|
class="flex flex-row items-center gap-2 rounded-lg px-3 py-2 transition-all hover:bg-secondary"
|
||||||
formaction="/player?/skip-to-queue-index&index={i}"
|
formaction="/player?/skip-to-queue-index&index={i}"
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
import type { SubmitFunction } from '../../../routes/tracks/[hash]/$types';
|
import type { SubmitFunction } from '../../../routes/tracks/[hash]/$types';
|
||||||
import type { Track } from '$lib/proto/library';
|
import type { Track } from '$lib/proto/library';
|
||||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
track: Track;
|
track: Track;
|
||||||
|
hideCover?: boolean;
|
||||||
|
action: Action;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { track }: Props = $props();
|
let { track, action, hideCover = false }: Props = $props();
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
|
|
||||||
@@ -58,15 +61,22 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="/tracks/{track.hash}?/play"
|
action="/tracks/{track.hash}?/play"
|
||||||
use:enhance={submitPlayTrack}
|
use:enhance={submitPlayTrack}
|
||||||
|
use:action
|
||||||
|
data-track-hash={track.hash}
|
||||||
>
|
>
|
||||||
<ContextMenu.Root bind:open={isOpen}>
|
<ContextMenu.Root bind:open={isOpen}>
|
||||||
<ContextMenu.Trigger>
|
<ContextMenu.Trigger>
|
||||||
<div class="relative">
|
<div class="relative aspect-square w-full">
|
||||||
<button type="submit" class="relative overflow-hidden rounded-lg">
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="relative aspect-square h-full w-full overflow-hidden rounded-lg"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
class="aspect-square w-auto transition-all duration-150 hover:scale-105 hover:saturate-150"
|
class="aspect-square h-full w-full transform-gpu transition-all duration-150 hover:scale-105 hover:saturate-150"
|
||||||
src={getCoverUrl(track.hash)}
|
src={hideCover ? undefined : getCoverUrl(track.hash)}
|
||||||
alt={track.name}
|
alt={track.name}
|
||||||
|
width="640"
|
||||||
|
height="640"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
25
src/lib/search.svelte.ts
Normal file
25
src/lib/search.svelte.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Fzf } from 'fzf';
|
||||||
|
import type { Track } from './proto/library';
|
||||||
|
import { getContext, setContext } from 'svelte';
|
||||||
|
|
||||||
|
class SearchState {
|
||||||
|
input = $state('');
|
||||||
|
|
||||||
|
filterTracks(tracks: Track[]) {
|
||||||
|
const fzf = new Fzf(tracks, {
|
||||||
|
selector: (t) => `${t.name} ${t.artistName}`,
|
||||||
|
sort: true,
|
||||||
|
});
|
||||||
|
return fzf.find(this.input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_KEY = Symbol('SEARCH');
|
||||||
|
|
||||||
|
export function setSearchState() {
|
||||||
|
return setContext(SEARCH_KEY, new SearchState());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSearchState() {
|
||||||
|
return getContext<ReturnType<typeof setSearchState>>(SEARCH_KEY);
|
||||||
|
}
|
||||||
@@ -7,11 +7,17 @@
|
|||||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||||
import { getPlayerState, setPlayerState } from '$lib/player.svelte';
|
import { getPlayerState, setPlayerState } from '$lib/player.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Search } from 'lucide-svelte';
|
||||||
|
import { setSearchState, getSearchState } from '$lib/search.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
setPlayerState();
|
setPlayerState();
|
||||||
|
setSearchState();
|
||||||
const player = getPlayerState();
|
const player = getPlayerState();
|
||||||
|
const search = getSearchState();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
return () => player.abort();
|
return () => player.abort();
|
||||||
@@ -24,8 +30,17 @@
|
|||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|
||||||
<div class="flex max-h-screen flex-auto grow flex-col overflow-hidden">
|
<div class="flex max-h-screen flex-auto grow flex-col overflow-hidden">
|
||||||
<main class="grow overflow-hidden">
|
<header class="flex shrink-0 items-center gap-2 border-b p-2">
|
||||||
<Sidebar.Trigger />
|
<Sidebar.Trigger />
|
||||||
|
<Separator orientation="vertical" class="mr-2 h-4" />
|
||||||
|
<div class="relative w-fit">
|
||||||
|
<Input class="pl-8" bind:value={search.input} />
|
||||||
|
<Search
|
||||||
|
class="pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="grow overflow-hidden">
|
||||||
<ScrollArea class="flex h-full flex-col overflow-auto">
|
<ScrollArea class="flex h-full flex-col overflow-auto">
|
||||||
<div class="m-4 flex flex-col">
|
<div class="m-4 flex flex-col">
|
||||||
<!-- <Sidebar.Trigger /> -->
|
<!-- <Sidebar.Trigger /> -->
|
||||||
|
|||||||
@@ -1,18 +1,61 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import TrackListing from '$lib/components/groove/TrackListing.svelte';
|
import TrackListing from '$lib/components/groove/TrackListing.svelte';
|
||||||
import type { PageServerData } from './$types';
|
import type { PageServerData } from './$types';
|
||||||
|
import { getSearchState } from '$lib/search.svelte';
|
||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
import { SvelteSet as Set } from 'svelte/reactivity';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageServerData;
|
data: PageServerData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const search = getSearchState();
|
||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let visibleTracks = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const options: IntersectionObserverInit = {
|
||||||
|
// @ts-ignore
|
||||||
|
root: container,
|
||||||
|
rootMargin: "400px 0px 400px 0px",
|
||||||
|
threshold: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const callback: IntersectionObserverCallback = (entries, _observer) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
const hash = entry.target.getAttribute('data-track-hash')!;
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
if (!visibleTracks.has(hash)) {
|
||||||
|
visibleTracks.add(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (visibleTracks.has(hash)) {
|
||||||
|
visibleTracks.delete(hash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(callback, options);
|
||||||
|
|
||||||
|
const observe: Action = (node) => {
|
||||||
|
observer.observe(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const firstFewTracks = search.filterTracks(data.tracks).slice(0,40);
|
||||||
|
firstFewTracks.forEach(e => {
|
||||||
|
visibleTracks.add(e.item.hash);
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-2 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8"
|
class="grid grid-cols-2 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8"
|
||||||
|
bind:this={container}
|
||||||
>
|
>
|
||||||
{#each data.tracks as track}
|
{#each search.input.length > 0 ? (search.filterTracks(data.tracks).map(r => r.item) ?? []) : data.tracks as track (track.hash)}
|
||||||
<TrackListing {track} />
|
<TrackListing {track} action={observe} hideCover={!visibleTracks.has(track.hash)}/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user