music (web): Favorites hearts in the /music player

Wires the web player to the platform's per-user /api/music/favorites (same
endpoints the app uses). New useMusicFavorites hook (react-query, shared
optimistic cache) + a reusable MusicHeart toggle, placed at:
- album header (album) + each track row (track, reveals on row hover, filled
  favorites stay shown)
- album cards in the artist/grid views (album)
- artist discography header (artist)
- the now-playing player bar (current track)

Track key = homePath "Music/<rel>/<file>"; album/artist keys = music-rel.
No dedicated Favorites browsing view yet (hearts only), matching the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 11:58:02 +00:00
co-authored by Claude Opus 4.8
parent 6fc34363c8
commit 5af3119096
5 changed files with 142 additions and 16 deletions
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react';
import { useSeekBar, SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { MusicHeart } from '../apps/Music/MusicHeart';
import { trackHomePath } from '../apps/Music/shared';
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
// Mounted once in the persistent DashboardLayout (outside <Routes>), so it owns the single <audio>
@@ -153,6 +155,13 @@ export const MusicPlayerHost = () => {
/>
</div>
<MusicHeart
kind="track"
favKey={trackHomePath(current.albumRel, current.file)}
size={18}
className="shrink-0 p-1.5"
/>
<button type="button" onClick={close} className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground">
<X size={16} />
</button>
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, ChevronLeft } from 'lucide-react';
import { MusicHeart } from './MusicHeart';
import { useMusicPlayer } from '../../MusicPlayer';
import type { PlayerTrack } from '../../MusicPlayer';
import {
@@ -12,6 +13,7 @@ import {
isAudio,
sortTracks,
toRel,
trackHomePath,
type AlbumMeta,
type Discography,
type LsResult,
@@ -144,6 +146,9 @@ export const MusicDetail = () => {
<Play size={18} className="ml-0.5" />
</button>
)}
{playable && (
<MusicHeart kind="album" favKey={r} size={18} hoverReveal className="absolute right-2 top-2 rounded-full bg-black/40 p-1.5 text-white" />
)}
</div>
);
@@ -198,29 +203,37 @@ export const MusicDetail = () => {
<p className="mt-1 truncate text-sm text-muted-foreground">
{crumbs[crumbs.length - 2] ?? ''} · {album.length} songs
</p>
<button
type="button"
onClick={() => playCurrent(0)}
className="mt-3 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground hover:scale-105"
>
<Play size={18} className="ml-0.5" />
</button>
<div className="mt-3 flex items-center gap-3">
<button
type="button"
onClick={() => playCurrent(0)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground hover:scale-105"
>
<Play size={18} className="ml-0.5" />
</button>
<MusicHeart kind="album" favKey={rel} size={24} className="p-1" />
</div>
</div>
</div>
<div className="flex flex-col">
{album.map((t, i) => (
<button
<div
key={t.file}
type="button"
onClick={() => playCurrent(i)}
className={`flex items-center gap-4 rounded px-3 py-2 text-left hover:bg-muted ${
className={`group flex items-center gap-4 rounded px-3 py-2 hover:bg-muted ${
isCurrent(rel, t.file) ? 'text-primary' : 'text-foreground'
}`}
>
<span className="w-5 text-right text-sm tabular-nums text-muted-foreground">{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-sm">{t.title ?? t.file}</span>
{t.artist && <span className="hidden truncate text-xs text-muted-foreground sm:block">{t.artist}</span>}
</button>
<button
type="button"
onClick={() => playCurrent(i)}
className="flex min-w-0 flex-1 items-center gap-4 text-left"
>
<span className="w-5 text-right text-sm tabular-nums text-muted-foreground">{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-sm">{t.title ?? t.file}</span>
{t.artist && <span className="hidden truncate text-xs text-muted-foreground sm:block">{t.artist}</span>}
</button>
<MusicHeart kind="track" favKey={trackHomePath(rel, t.file)} size={16} hoverReveal className="shrink-0" />
</div>
))}
</div>
</div>
@@ -229,7 +242,10 @@ export const MusicDetail = () => {
{/* Artist — discography sections */}
{disco && !album && (
<div className="flex flex-col gap-6">
<h1 className="text-3xl font-bold text-foreground">{disco.artist}</h1>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-foreground">{disco.artist}</h1>
<MusicHeart kind="artist" favKey={rel} size={22} className="p-1" />
</div>
{TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => (
<section key={type}>
<h2 className="mb-2 text-lg font-semibold text-foreground">{type === 'Studio' ? 'Studio Albums' : type}</h2>
@@ -0,0 +1,43 @@
import { Heart } from 'lucide-react';
import type { FavoriteKind } from './shared';
import { useMusicFavorites } from './useMusicFavorites';
/**
* A heart toggle for a favoritable thing (track / album / artist). Reads and writes the shared
* favorites cache, so every heart for the same key stays in sync and flips optimistically. Stops click
* propagation so it works inside clickable rows/cards. Renders nothing without a key.
*/
export const MusicHeart = ({
kind,
favKey,
size = 18,
className = '',
hoverReveal = false,
}: {
kind: FavoriteKind;
favKey: string;
size?: number;
className?: string;
/** When set, a NOT-favorited heart is hidden until the enclosing `group` is hovered/focused; a
* favorited (filled) heart always stays visible. Keeps dense lists uncluttered. */
hoverReveal?: boolean;
}) => {
const { isFavorite, toggle } = useMusicFavorites();
if (!favKey) return null;
const on = isFavorite(kind, favKey);
const reveal = hoverReveal && !on ? 'opacity-0 group-hover:opacity-100 focus:opacity-100' : '';
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggle(kind, favKey);
}}
title={on ? 'Remove from favorites' : 'Add to favorites'}
aria-label={on ? 'Remove from favorites' : 'Add to favorites'}
className={`flex items-center justify-center transition-colors ${reveal} ${className}`}
>
<Heart size={size} className={on ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'} />
</button>
);
};
@@ -37,6 +37,12 @@ export const sortTracks = <T extends Track>(tracks: T[]): T[] => {
};
export type Discography = { artist: string; albums: Record<string, string> };
export type FavoriteKind = 'track' | 'album' | 'artist';
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
/** homePath ("Music/<rel>/<file>") for a track — its favorite key + /stream path. */
export const trackHomePath = (rel: string, file: string) => `${MUSIC_ROOT}/${rel}/${file}`;
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
export const isAudio = (n: string) => {
const d = n.lastIndexOf('.');
@@ -0,0 +1,52 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { FavoriteKind, GroupedFavorites } from './shared';
const KEY = ['music', 'favorites'] as const;
const EMPTY: GroupedFavorites = { tracks: [], albums: [], artists: [] };
const groupOf = (kind: FavoriteKind): keyof GroupedFavorites =>
kind === 'track' ? 'tracks' : kind === 'album' ? 'albums' : 'artists';
/**
* The user's music favorites (tracks / albums / artists) for the /music workspace, backed by the
* platform's `/api/music/favorites`. One shared react-query cache, so every heart reflects the same
* state; toggling is optimistic (flips instantly, rolls back on failure).
*/
export function useMusicFavorites() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const { data } = useQuery({
queryKey: KEY,
queryFn: () => get<GroupedFavorites>('/music/favorites'),
staleTime: 60_000,
});
const mutation = useMutation({
mutationFn: ({ on, kind, key }: { on: boolean; kind: FavoriteKind; key: string }) =>
on
? post('/music/favorites', { kind, key })
: del(`/music/favorites?kind=${encodeURIComponent(kind)}&key=${encodeURIComponent(key)}`),
onMutate: async ({ on, kind, key }) => {
await qc.cancelQueries({ queryKey: KEY });
const prev = qc.getQueryData<GroupedFavorites>(KEY) ?? EMPTY;
const g = groupOf(kind);
qc.setQueryData<GroupedFavorites>(KEY, {
...prev,
[g]: on ? [key, ...prev[g].filter((k) => k !== key)] : prev[g].filter((k) => k !== key),
});
return { prev };
},
onError: (_e, _v, ctx) => {
if (ctx?.prev) qc.setQueryData(KEY, ctx.prev);
},
});
const isFavorite = (kind: FavoriteKind, key: string) => (data ?? EMPTY)[groupOf(kind)].includes(key);
const toggle = (kind: FavoriteKind, key: string) => {
if (!key) return;
mutation.mutate({ on: !isFavorite(kind, key), kind, key });
};
return { isFavorite, toggle };
}