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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user