Files
jellytau/src/lib/components/FavoriteButton.svelte
T
dtourolle 62873cab3d feat(search): answer search from a local index; tier downloads by lifetime
Search's instant leg read only downloaded items, so with no downloads it
returned nothing and every keystroke fell through to a full Recursive=true
server query. It now reads the whole synced catalog through the same
availability CTE get_items uses, gated on the same include_catalog_browse
flag so search and browse cannot diverge. (UR-065, DR-108)

Also fixes three defects found while confirming that:

- items_fts grew by a full duplicate index every catalog pass. INSERT OR
  REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the
  old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement
  took a fresh rowid and inserted a second entry. Now a real upsert, with
  migration 021 rebuilding existing indexes. (DR-110)
- DELETE FROM items existed nowhere, so server-side deletions never
  propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types,
  skipping downloaded items, and refusing to run after a partial crawl
  because items.parent_id cascades. (DR-110)
- The index omitted MusicArtist, Playlist and People, which search groups
  results by. Adds them plus people_fts (migration 022). (DR-111)

Re-indexing moves from a frontend startup call to a Rust background task
with a 6h TTL, so a long session no longer searches a stale catalog and a
restart no longer forces a crawl regardless of freshness. (DR-109, IR-030)

Downloads gain a lifetime tier. Eviction selected every completed row by
age with no download_source filter, so hitting the storage limit deleted
the oldest download -- typically one saved deliberately for offline -- to
make room for a precached track. It now reclaims only 'auto' rows, and
expired ones are reclaimed first, before live cache is evicted.
(DR-126, DR-127)

Downloaded video and audio-only handoffs now play from disk instead of
streaming; the video path had never consulted downloads at all. No
transcode is involved: MPV runs video=no and ExoPlayer has no surface for
an Audio item. (DR-123 in part, DR-128)

FTS queries are built as quoted phrases so apostrophes, hyphens and
slashes are data rather than operator syntax, and the item-type filter is
bound rather than interpolated.

Specs: docs/specs/catalog-index-search.md,
docs/specs/read-through-media-cache.md

Includes concurrently-developed favourites browsing and background-audio
stream-end handling; the two workstreams share offline.rs, lib.rs and
online.rs, so no subset of files builds independently.
2026-08-04 17:35:17 +02:00

175 lines
4.9 KiB
Svelte

<!-- TRACES: UR-017, UR-068 | DR-021, DR-119 -->
<script lang="ts">
import { toggleFavorite } from "$lib/services/favorites";
import { haptics } from "$lib/utils/haptics";
import { toast } from "$lib/stores/toast";
import { favoriteOverrides } from "$lib/stores/favorites";
interface Props {
itemId: string;
isFavorite?: boolean;
size?: "sm" | "md" | "lg";
className?: string;
/**
* "button" (default) is the standalone control used in header/hero rows;
* "overlay" is the artwork corner variant used on cards, which needs its
* own scrim to stay legible over any poster.
*/
variant?: "button" | "overlay";
/** Stop the click reaching a parent card/row that would navigate or play. */
stopPropagation?: boolean;
}
let {
itemId,
isFavorite = $bindable(false),
size = "md",
className = "",
variant = "button",
stopPropagation = false,
}: Props = $props();
let isLoading = $state(false);
let isAnimating = $state(false);
// A toggle from any other surface (or the backend's `favorites-changed`
// refresh) wins over the prop we were mounted with — otherwise a heart tapped
// on a card would still read empty on the detail page behind it.
$effect(() => {
const override = $favoriteOverrides.get(itemId);
if (override !== undefined && override !== isFavorite) {
isFavorite = override;
}
});
const sizeClasses = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
};
async function handleToggle(event: MouseEvent) {
// On a card the heart sits inside a clickable tile; without this, hearting
// an item would also open (or play) it.
if (stopPropagation) {
event.stopPropagation();
event.preventDefault();
}
if (isLoading) return;
isLoading = true;
isAnimating = true;
try {
const newValue = await toggleFavorite(itemId, isFavorite);
isFavorite = newValue;
// Haptic feedback
if (newValue) {
haptics.success();
toast.show("Added to favorites", "success", 1500);
} else {
haptics.tap();
toast.show("Removed from favorites", "info", 1500);
}
// Reset animation after it completes
setTimeout(() => {
isAnimating = false;
}, 600);
} catch (error) {
console.error("Failed to toggle favorite:", error);
toast.show("Failed to update favorites", "error");
isAnimating = false;
} finally {
isLoading = false;
}
}
// Compute button classes
const buttonClass = $derived.by(() => {
const baseClasses =
variant === "overlay"
? "p-1.5 rounded-full transition-all bg-black/50 backdrop-blur-sm hover:bg-black/70"
: "p-2 rounded-full transition-all";
const colorClasses = isFavorite
? "text-red-500 hover:text-red-400"
: variant === "overlay"
? "text-white/80 hover:text-white"
: "text-gray-400 hover:text-white";
const loadingClasses = isLoading ? "opacity-50 cursor-wait" : "";
return `${baseClasses} ${colorClasses} ${loadingClasses} ${className}`.trim();
});
// Compute SVG classes
const svgClass = $derived.by(() => {
const sizeClass = sizeClasses[size];
return sizeClass;
});
// Inline animation styles
const buttonStyle = $derived(isAnimating ? "animation: bounce-once 0.6s ease-in-out;" : "");
const svgStyle = $derived(isAnimating && isFavorite ? "animation: heart-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);" : "");
</script>
<button
onclick={handleToggle}
disabled={isLoading}
class={buttonClass}
style={buttonStyle}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
{#if isFavorite}
<!-- Filled heart with scale animation -->
<svg
class={svgClass}
style={svgStyle}
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{:else}
<!-- Outline heart -->
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{/if}
</button>
<style>
@keyframes heart-pop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
@keyframes bounce-once {
0%, 100% {
transform: translateY(0);
}
25% {
transform: translateY(-8px);
}
50% {
transform: translateY(0);
}
75% {
transform: translateY(-4px);
}
}
</style>