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 { useClient } from 'hooks/useClient';
|
||||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react';
|
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react';
|
||||||
import { useSeekBar, SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
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';
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
|
|
||||||
// Mounted once in the persistent DashboardLayout (outside <Routes>), so it owns the single <audio>
|
// Mounted once in the persistent DashboardLayout (outside <Routes>), so it owns the single <audio>
|
||||||
@@ -153,6 +155,13 @@ export const MusicPlayerHost = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<button type="button" onClick={close} className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Play, ChevronLeft } from 'lucide-react';
|
import { Play, ChevronLeft } from 'lucide-react';
|
||||||
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { useMusicPlayer } from '../../MusicPlayer';
|
import { useMusicPlayer } from '../../MusicPlayer';
|
||||||
import type { PlayerTrack } from '../../MusicPlayer';
|
import type { PlayerTrack } from '../../MusicPlayer';
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
isAudio,
|
isAudio,
|
||||||
sortTracks,
|
sortTracks,
|
||||||
toRel,
|
toRel,
|
||||||
|
trackHomePath,
|
||||||
type AlbumMeta,
|
type AlbumMeta,
|
||||||
type Discography,
|
type Discography,
|
||||||
type LsResult,
|
type LsResult,
|
||||||
@@ -144,6 +146,9 @@ export const MusicDetail = () => {
|
|||||||
<Play size={18} className="ml-0.5" />
|
<Play size={18} className="ml-0.5" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -198,29 +203,37 @@ export const MusicDetail = () => {
|
|||||||
<p className="mt-1 truncate text-sm text-muted-foreground">
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
{crumbs[crumbs.length - 2] ?? ''} · {album.length} songs
|
{crumbs[crumbs.length - 2] ?? ''} · {album.length} songs
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div className="mt-3 flex items-center gap-3">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => playCurrent(0)}
|
type="button"
|
||||||
className="mt-3 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground hover:scale-105"
|
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>
|
<Play size={18} className="ml-0.5" />
|
||||||
|
</button>
|
||||||
|
<MusicHeart kind="album" favKey={rel} size={24} className="p-1" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{album.map((t, i) => (
|
{album.map((t, i) => (
|
||||||
<button
|
<div
|
||||||
key={t.file}
|
key={t.file}
|
||||||
type="button"
|
className={`group flex items-center gap-4 rounded px-3 py-2 hover:bg-muted ${
|
||||||
onClick={() => playCurrent(i)}
|
|
||||||
className={`flex items-center gap-4 rounded px-3 py-2 text-left hover:bg-muted ${
|
|
||||||
isCurrent(rel, t.file) ? 'text-primary' : 'text-foreground'
|
isCurrent(rel, t.file) ? 'text-primary' : 'text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="w-5 text-right text-sm tabular-nums text-muted-foreground">{i + 1}</span>
|
<button
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{t.title ?? t.file}</span>
|
type="button"
|
||||||
{t.artist && <span className="hidden truncate text-xs text-muted-foreground sm:block">{t.artist}</span>}
|
onClick={() => playCurrent(i)}
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +242,10 @@ export const MusicDetail = () => {
|
|||||||
{/* Artist — discography sections */}
|
{/* Artist — discography sections */}
|
||||||
{disco && !album && (
|
{disco && !album && (
|
||||||
<div className="flex flex-col gap-6">
|
<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) => (
|
{TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => (
|
||||||
<section key={type}>
|
<section key={type}>
|
||||||
<h2 className="mb-2 text-lg font-semibold text-foreground">{type === 'Studio' ? 'Studio Albums' : type}</h2>
|
<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 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']);
|
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
|
||||||
export const isAudio = (n: string) => {
|
export const isAudio = (n: string) => {
|
||||||
const d = n.lastIndexOf('.');
|
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