mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(seekbar+ratings): 5 new seekbar styles and entity/mix rating system (PR #130)
Seekbar styles (5 new, by Psychotoxical): - Neon Glow: transparent track, played section as multi-layer glowing neon tube - Pulse Wave: flat line with animated gaussian sine pulse at playhead (rAF) - Particle Trail: particles spawn at playhead and drift with glow (rAF) - Liquid Fill: glass tube fills with liquid and animated wave surface (rAF) - Retro Tape: two spinning reels connected by tape, shrink/grow with progress (rAF) Ratings system (PR #130 by cucadmuh): - Shared StarRating component with pulse/clear animations, disabled/locked states - Entity ratings for albums and artists via OpenSubsonic probe (setEntityRatingSupport) - Skip→1★: after N consecutive manual skips, set unrated track to 1★ (persisted per server) - Mix rating filter: exclude low-rated songs/albums/artists from RandomMix, RandomAlbums, Hero, Home - Batch refill in fetchRandomMixSongsUntilFull to fill list despite strict filters - Prefetch artist/album ratings via getArtist/getAlbum for incomplete list payloads - Settings: new Ratings section for skip threshold and per-axis mix filter stars - i18n: all 7 languages (en, de, fr, nl, zh, nb, ru) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+13
-4
@@ -56,7 +56,7 @@ import { IS_LINUX } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { getMusicFolders } from './api/subsonic';
|
||||
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
@@ -101,6 +101,7 @@ function AppShell() {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
|
||||
@@ -112,19 +113,27 @@ function AppShell() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
const serverAtStart = activeServerId;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart;
|
||||
try {
|
||||
const folders = await getMusicFolders();
|
||||
if (!cancelled) setMusicFolders(folders);
|
||||
if (stillThisServer()) setMusicFolders(folders);
|
||||
} catch {
|
||||
if (!cancelled) setMusicFolders([]);
|
||||
if (stillThisServer()) setMusicFolders([]);
|
||||
}
|
||||
try {
|
||||
const level = await probeEntityRatingSupport();
|
||||
if (stillThisServer()) setEntityRatingSupport(serverAtStart, level);
|
||||
} catch {
|
||||
if (stillThisServer()) setEntityRatingSupport(serverAtStart, 'track_only');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoggedIn, activeServerId, setMusicFolders]);
|
||||
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
|
||||
|
||||
// Reset scroll position on route change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -64,6 +64,15 @@ export interface SubsonicAlbum {
|
||||
starred?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||
export interface SubsonicOpenArtistRef {
|
||||
id?: string;
|
||||
name?: string;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicSong {
|
||||
@@ -79,6 +88,11 @@ export interface SubsonicSong {
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
userRating?: number;
|
||||
/** Some OpenSubsonic responses attach parent ratings on child songs. */
|
||||
albumUserRating?: number;
|
||||
artistUserRating?: number;
|
||||
artists?: SubsonicOpenArtistRef[];
|
||||
albumArtists?: SubsonicOpenArtistRef[];
|
||||
// Audio technical info
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
@@ -141,6 +155,8 @@ export interface SubsonicArtist {
|
||||
albumCount?: number;
|
||||
coverArt?: string;
|
||||
starred?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicGenre {
|
||||
@@ -253,6 +269,71 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
|
||||
return { album, songs: song ?? [] };
|
||||
}
|
||||
|
||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||
|
||||
function parseEntityUserRating(v: unknown): number | undefined {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
|
||||
export async function prefetchArtistUserRatings(
|
||||
ids: string[],
|
||||
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||
): Promise<Map<string, number>> {
|
||||
const unique = [...new Set(ids.filter(Boolean))];
|
||||
const out = new Map<string, number>();
|
||||
if (!unique.length) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= unique.length) return;
|
||||
const id = unique[i];
|
||||
try {
|
||||
const { artist } = await getArtist(id);
|
||||
const r = parseEntityUserRating(artist.userRating);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const nWorkers = Math.min(concurrency, unique.length);
|
||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
|
||||
export async function prefetchAlbumUserRatings(
|
||||
ids: string[],
|
||||
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||
): Promise<Map<string, number>> {
|
||||
const unique = [...new Set(ids.filter(Boolean))];
|
||||
const out = new Map<string, number>();
|
||||
if (!unique.length) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= unique.length) return;
|
||||
const id = unique[i];
|
||||
try {
|
||||
const { album } = await getAlbum(id);
|
||||
const r = parseEntityUserRating(album.userRating);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const nWorkers = Math.min(concurrency, unique.length);
|
||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
@@ -374,6 +455,28 @@ export async function setRating(id: string, rating: number): Promise<void> {
|
||||
await api('setRating.view', { id, rating });
|
||||
}
|
||||
|
||||
/** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */
|
||||
export type EntityRatingSupportLevel = 'track_only' | 'full';
|
||||
|
||||
/**
|
||||
* Probe server for OpenSubsonic extensions. When `openSubsonic: true`, we treat album/artist
|
||||
* rating as supported (same `setRating.view` + entity id); otherwise track-only.
|
||||
*/
|
||||
export async function probeEntityRatingSupport(): Promise<EntityRatingSupportLevel> {
|
||||
try {
|
||||
const data = await api<{ openSubsonic?: boolean; openSubsonicExtensions?: unknown[] }>(
|
||||
'getOpenSubsonicExtensions.view',
|
||||
{},
|
||||
8000,
|
||||
);
|
||||
if (data.openSubsonic === true) return 'full';
|
||||
if (Array.isArray(data.openSubsonicExtensions)) return 'full';
|
||||
return 'track_only';
|
||||
} catch {
|
||||
return 'track_only';
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrobbleSong(id: string, time: number): Promise<void> {
|
||||
try {
|
||||
await api('scrobble.view', { id, time, submission: true });
|
||||
|
||||
@@ -6,6 +6,8 @@ import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -85,6 +87,10 @@ interface AlbumHeaderProps {
|
||||
onEnqueueAll: () => void;
|
||||
onBio: () => void;
|
||||
onCloseBio: () => void;
|
||||
entityRatingValue: number;
|
||||
onEntityRatingChange: (rating: number) => void;
|
||||
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
|
||||
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
@@ -107,6 +113,9 @@ export default function AlbumHeader({
|
||||
onEnqueueAll,
|
||||
onBio,
|
||||
onCloseBio,
|
||||
entityRatingValue,
|
||||
onEntityRatingChange,
|
||||
entityRatingSupport,
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -186,6 +195,15 @@ export default function AlbumHeader({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-entity-rating">
|
||||
<span className="album-detail-entity-rating-label">{t('entityRating.albumShort')}</span>
|
||||
<StarRating
|
||||
value={entityRatingValue}
|
||||
onChange={onEntityRatingChange}
|
||||
disabled={entityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="album-detail-actions-mobile">
|
||||
{/* Row 1 — Primary actions */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -24,29 +25,6 @@ function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = React.useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
// 'num' → always 60 px fixed, no resize handle
|
||||
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
|
||||
@@ -58,14 +36,14 @@ const COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
|
||||
];
|
||||
|
||||
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
||||
|
||||
// Columns where cell content should be centred (both header and rows)
|
||||
// Columns where header label is centred in the cell (matches row controls below)
|
||||
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
@@ -76,6 +54,8 @@ interface AlbumTrackListProps {
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
ratings: Record<string, number>;
|
||||
/** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */
|
||||
userRatingOverrides: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
@@ -89,6 +69,7 @@ export default function AlbumTrackList({
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
ratings,
|
||||
userRatingOverrides,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
onRate,
|
||||
@@ -193,13 +174,21 @@ export default function AlbumTrackList({
|
||||
);
|
||||
}
|
||||
|
||||
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
|
||||
// direction=1: drag right → this column grows, title (1fr) shrinks
|
||||
// px-width columns: centred (compact controls) or left-aligned label + right-edge divider
|
||||
const isResizable = !isLastCol;
|
||||
return (
|
||||
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
</div>
|
||||
{isResizable && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
@@ -266,7 +255,7 @@ export default function AlbumTrackList({
|
||||
return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
|
||||
+25
-2
@@ -8,8 +8,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
const HERO_ALBUM_COUNT = 8;
|
||||
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
|
||||
const HERO_RANDOM_POOL = 32;
|
||||
|
||||
// Crossfading background — same layer pattern as FullscreenPlayer
|
||||
function HeroBg({ url }: { url: string }) {
|
||||
@@ -54,14 +58,33 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
|
||||
}, [albumsProp, musicLibraryFilterVersion]);
|
||||
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
|
||||
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
|
||||
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
|
||||
getRandomAlbums(pool)
|
||||
.then(async raw => {
|
||||
const list = albumMix
|
||||
? (await filterAlbumsByMixRatings(raw, cfg)).slice(0, HERO_ALBUM_COUNT)
|
||||
: raw;
|
||||
setAlbums(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [
|
||||
albumsProp,
|
||||
musicLibraryFilterVersion,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
// Start / restart auto-advance timer
|
||||
const startTimer = useCallback((len: number) => {
|
||||
|
||||
@@ -214,6 +214,7 @@ export default function QueuePanel() {
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const currentCoverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
||||
[currentTrack?.coverArt]
|
||||
@@ -447,7 +448,7 @@ export default function QueuePanel() {
|
||||
{currentTrack.year && (
|
||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||
)}
|
||||
{renderStars(currentTrack.userRating)}
|
||||
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function StarRating({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
maxStars = 5,
|
||||
maxSelectable: maxSelectableProp,
|
||||
labelKey = 'albumDetail.ratingLabel',
|
||||
ariaLabel,
|
||||
className = '',
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (rating: number) => void;
|
||||
disabled?: boolean;
|
||||
/** Number of star buttons (1…maxStars). Default 5. */
|
||||
maxStars?: number;
|
||||
/** Highest selectable star (inclusive); higher stars are shown but disabled. */
|
||||
maxSelectable?: number;
|
||||
labelKey?: string;
|
||||
/** Overrides `t(labelKey)` for the radiogroup `aria-label` when set. */
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stars = React.useMemo(
|
||||
() => Array.from({ length: Math.max(1, Math.min(5, maxStars)) }, (_, i) => i + 1),
|
||||
[maxStars]
|
||||
);
|
||||
const selectCap = Math.min(maxSelectableProp ?? stars.length, stars.length);
|
||||
const [hover, setHover] = React.useState(0);
|
||||
const [pulseStar, setPulseStar] = React.useState<number | null>(null);
|
||||
const [clearShrinkStar, setClearShrinkStar] = React.useState<number | null>(null);
|
||||
/** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */
|
||||
const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false);
|
||||
|
||||
const cappedValue = Math.min(Math.max(0, value), selectCap);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value > 0) setSuppressHoverPreview(false);
|
||||
}, [value]);
|
||||
|
||||
const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap);
|
||||
const filled = (n: number) => (effectiveHover || cappedValue) >= n;
|
||||
|
||||
const handleStarClick = (n: number) => {
|
||||
if (disabled || n > selectCap) return;
|
||||
setSuppressHoverPreview(false);
|
||||
|
||||
const next = cappedValue === n ? 0 : n;
|
||||
onChange(next);
|
||||
setHover(0);
|
||||
|
||||
setPulseStar(null);
|
||||
setClearShrinkStar(null);
|
||||
|
||||
if (next === 0) {
|
||||
setSuppressHoverPreview(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setClearShrinkStar(n));
|
||||
});
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setPulseStar(n));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerLeave = () => {
|
||||
setHover(0);
|
||||
setSuppressHoverPreview(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`star-rating${disabled ? ' star-rating--disabled' : ''}${suppressHoverPreview ? ' star-rating--suppress-hover' : ''} ${className}`.trim()}
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel ?? t(labelKey)}
|
||||
aria-disabled={disabled}
|
||||
onMouseLeave={disabled ? undefined : handleContainerLeave}
|
||||
>
|
||||
{stars.map(n => {
|
||||
const locked = n > selectCap;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}${locked ? ' star--locked' : ''}`}
|
||||
onMouseEnter={() =>
|
||||
!disabled && !suppressHoverPreview && !locked && setHover(n)
|
||||
}
|
||||
onClick={() => handleStarClick(n)}
|
||||
onAnimationEnd={e => {
|
||||
if (e.currentTarget !== e.target) return;
|
||||
const name = e.animationName;
|
||||
if (name === 'star-rating-star-pulse') {
|
||||
setPulseStar(s => (s === n ? null : s));
|
||||
}
|
||||
if (name === 'star-rating-star-clear-shrink') {
|
||||
setClearShrinkStar(s => (s === n ? null : s));
|
||||
}
|
||||
}}
|
||||
disabled={disabled || locked}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={filled(n)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+456
-14
@@ -10,6 +10,28 @@ function fmt(s: number): string {
|
||||
const BAR_COUNT = 500;
|
||||
const SEG_COUNT = 60;
|
||||
|
||||
// ── animation state ───────────────────────────────────────────────────────────
|
||||
|
||||
type Particle = {
|
||||
x: number; y: number;
|
||||
vx: number; vy: number;
|
||||
life: number; maxLife: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type AnimState = {
|
||||
particles: Particle[];
|
||||
time: number;
|
||||
lastProgress: number;
|
||||
angle: number;
|
||||
};
|
||||
|
||||
export function makeAnimState(): AnimState {
|
||||
return { particles: [], time: 0, lastProgress: 0, angle: 0 };
|
||||
}
|
||||
|
||||
const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'liquidfill', 'retrotape']);
|
||||
|
||||
// ── color helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getColors() {
|
||||
@@ -275,6 +297,390 @@ function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: nu
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── new styles ────────────────────────────────────────────────────────────────
|
||||
|
||||
function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
|
||||
// Ghost track — barely visible
|
||||
ctx.globalAlpha = 0.07;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.12;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
if (progress <= 0) return;
|
||||
|
||||
const px = progress * w;
|
||||
|
||||
// Wide outer glow
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 22;
|
||||
ctx.fillRect(0, cy - 5, px, 10);
|
||||
|
||||
// Mid glow
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.fillRect(0, cy - 2.5, px, 5);
|
||||
|
||||
// Inner glow
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillRect(0, cy - 1.5, px, 3);
|
||||
|
||||
// Bright white core
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillRect(0, cy - 0.75, px, 1.5);
|
||||
|
||||
// End-cap flare
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawPulseWave(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const px = progress * w;
|
||||
const t = animState.time;
|
||||
|
||||
// Base line
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Played flat line
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.fillRect(0, cy - 1, px, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Animated pulse centered at playhead
|
||||
const pulseR = Math.min(38, w * 0.13);
|
||||
const amp = Math.min(h * 0.42, 5.5);
|
||||
const sigma = pulseR * 0.42;
|
||||
const startX = Math.max(0, px - pulseR);
|
||||
const endX = Math.min(w, px + pulseR);
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, cy);
|
||||
for (let x = startX; x <= endX; x += 0.75) {
|
||||
const dx = x - px;
|
||||
const env = Math.exp(-(dx * dx) / (2 * sigma * sigma));
|
||||
const wave = env * amp * Math.sin(dx * 0.28 - t * 18);
|
||||
ctx.lineTo(x, cy - wave);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawParticleTrail(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const px = progress * w;
|
||||
|
||||
// Spawn particles at playhead based on movement
|
||||
const prevPx = animState.lastProgress * w;
|
||||
const moved = Math.abs(px - prevPx);
|
||||
const spawnN = Math.min(5, 1 + Math.floor(moved * 1.5));
|
||||
for (let i = 0; i < spawnN; i++) {
|
||||
animState.particles.push({
|
||||
x: px + (Math.random() - 0.5) * 3,
|
||||
y: cy + (Math.random() - 0.5) * (h * 0.55),
|
||||
vx: -(Math.random() * 1.0 + 0.3),
|
||||
vy: (Math.random() - 0.5) * 0.6,
|
||||
life: 1,
|
||||
maxLife: 25 + Math.random() * 35,
|
||||
size: Math.random() * 1.8 + 0.8,
|
||||
});
|
||||
}
|
||||
animState.lastProgress = progress;
|
||||
|
||||
// Update + cull
|
||||
for (const p of animState.particles) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy *= 0.97;
|
||||
p.life -= 1 / p.maxLife;
|
||||
}
|
||||
animState.particles = animState.particles.filter(p => p.life > 0);
|
||||
if (animState.particles.length > 180) {
|
||||
animState.particles = animState.particles.slice(-180);
|
||||
}
|
||||
|
||||
// Background line
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Played line
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillRect(0, cy - 1, px, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Particles
|
||||
ctx.shadowColor = played;
|
||||
for (const p of animState.particles) {
|
||||
ctx.globalAlpha = p.life * 0.85;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillStyle = played;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Playhead dot
|
||||
if (progress > 0) {
|
||||
const dx = Math.max(5, Math.min(w - 5, px));
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawLiquidFill(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const t = animState.time;
|
||||
|
||||
const tubeH = Math.min(13, Math.max(6, h * 0.62));
|
||||
const tubeR = tubeH / 2;
|
||||
const y0 = (h - tubeH) / 2;
|
||||
const y1 = y0 + tubeH;
|
||||
|
||||
// Glass tube background
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.strokeStyle = unplayed;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.stroke();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.clip();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, y0, buffered * w, tubeH);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
const px = progress * w;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.clip();
|
||||
|
||||
// Liquid body with animated wave on top surface
|
||||
const surfaceY = y0 + tubeH * 0.22; // liquid surface ~78% full
|
||||
const waveAmp = Math.min(2.0, tubeH * 0.14);
|
||||
const waveFreq = 0.09;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-1, y1 + 1);
|
||||
ctx.lineTo(-1, surfaceY);
|
||||
|
||||
for (let x = 0; x <= px + 1; x += 1) {
|
||||
const wave = waveAmp * Math.sin(x * waveFreq + t * 2.2);
|
||||
ctx.lineTo(x, surfaceY + wave);
|
||||
}
|
||||
ctx.lineTo(px + 1, y1 + 1);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 9;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Glass highlight on top
|
||||
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
|
||||
hl.addColorStop(0, 'rgba(255,255,255,0.28)');
|
||||
hl.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.fillStyle = hl;
|
||||
ctx.fillRect(0, y0, px, tubeH * 0.45);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Tube outline (on top)
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.strokeStyle = unplayed;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawRetroTape(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
_buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
|
||||
animState.angle += 0.042;
|
||||
|
||||
const maxR = Math.floor(h / 2) - 1;
|
||||
const minR = Math.max(2, maxR * 0.28);
|
||||
// supply reel (left): shrinks as progress increases
|
||||
const rL = minR + (maxR - minR) * (1 - progress);
|
||||
// takeup reel (right): grows as progress increases
|
||||
const rR = minR + (maxR - minR) * progress;
|
||||
const lx = maxR + 1;
|
||||
const rx = w - maxR - 1;
|
||||
|
||||
// Tape between reels
|
||||
const tapeLeft = lx + rL + 0.5;
|
||||
const tapeRight = rx - rR - 0.5;
|
||||
if (tapeRight > tapeLeft) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(tapeLeft, cy - 1.5, tapeRight - tapeLeft, 3);
|
||||
}
|
||||
|
||||
const drawReel = (
|
||||
cx: number,
|
||||
radius: number,
|
||||
angle: number,
|
||||
isRight: boolean,
|
||||
) => {
|
||||
const color = isRight ? played : unplayed;
|
||||
const alpha = isRight ? (progress > 0 ? 1 : 0.35) : (progress < 1 ? 0.55 : 0.35);
|
||||
const glowing = isRight && progress > 0;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
if (glowing) { ctx.shadowColor = played; ctx.shadowBlur = 6; }
|
||||
|
||||
// Outer ring
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Hub
|
||||
const hubR = Math.max(1.5, radius * 0.3);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, hubR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Spokes (only if reel is large enough)
|
||||
if (radius > hubR + 2.5) {
|
||||
ctx.lineWidth = 0.9;
|
||||
ctx.strokeStyle = color;
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const a = angle + (s * Math.PI * 2) / 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5));
|
||||
ctx.lineTo(cx + Math.cos(a) * (radius - 0.5), cy + Math.sin(a) * (radius - 0.5));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drawReel(lx, rL, -animState.angle, false);
|
||||
drawReel(rx, rR, animState.angle, true);
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── dispatcher ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function drawSeekbar(
|
||||
@@ -283,13 +689,20 @@ export function drawSeekbar(
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState?: AnimState,
|
||||
) {
|
||||
const anim = animState ?? makeAnimState();
|
||||
switch (style) {
|
||||
case 'waveform': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
case 'linedot': drawLineDot(canvas, progress, buffered); break;
|
||||
case 'bar': drawBar(canvas, progress, buffered); break;
|
||||
case 'thick': drawThick(canvas, progress, buffered); break;
|
||||
case 'segmented': drawSegmented(canvas, progress, buffered); break;
|
||||
case 'waveform': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
case 'linedot': drawLineDot(canvas, progress, buffered); break;
|
||||
case 'bar': drawBar(canvas, progress, buffered); break;
|
||||
case 'thick': drawThick(canvas, progress, buffered); break;
|
||||
case 'segmented': drawSegmented(canvas, progress, buffered); break;
|
||||
case 'neon': drawNeon(canvas, progress, buffered); break;
|
||||
case 'pulsewave': drawPulseWave(canvas, progress, buffered, anim); break;
|
||||
case 'particletrail': drawParticleTrail(canvas, progress, buffered, anim); break;
|
||||
case 'liquidfill': drawLiquidFill(canvas, progress, buffered, anim); break;
|
||||
case 'retrotape': drawRetroTape(canvas, progress, buffered, anim); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,13 +725,15 @@ export function SeekbarPreview({
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const heights = style === 'waveform' ? makeHeights('seekbar-preview-demo') : null;
|
||||
const heights = style === 'waveform' ? makeHeights('seekbar-preview-demo') : null;
|
||||
const animState = makeAnimState();
|
||||
let t = 0;
|
||||
const tick = () => {
|
||||
t += 0.012;
|
||||
t += 0.016;
|
||||
animState.time = t;
|
||||
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
|
||||
const buffered = Math.min(1, progress + 0.18);
|
||||
drawSeekbar(canvas, style, heights, progress, buffered);
|
||||
drawSeekbar(canvas, style, heights, progress, buffered, animState);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
@@ -367,11 +782,12 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function WaveformSeek({ trackId }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(0);
|
||||
const bufferedRef = useRef(0);
|
||||
const isDragging = useRef(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(0);
|
||||
const bufferedRef = useRef(0);
|
||||
const isDragging = useRef(false);
|
||||
const animStateRef = useRef<AnimState>(makeAnimState());
|
||||
|
||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||
|
||||
@@ -388,17 +804,43 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
||||
}, [trackId]);
|
||||
|
||||
// Static styles: redraw on progress / buffered / track changes
|
||||
useEffect(() => {
|
||||
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
if (canvasRef.current) {
|
||||
drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered);
|
||||
}
|
||||
}, [progress, buffered, trackId, seekbarStyle]);
|
||||
|
||||
// Animated styles: rAF loop
|
||||
useEffect(() => {
|
||||
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
animStateRef.current = makeAnimState();
|
||||
let rafId: number;
|
||||
const tick = () => {
|
||||
animStateRef.current.time += 0.016;
|
||||
drawSeekbar(
|
||||
canvas,
|
||||
seekbarStyle,
|
||||
heightsRef.current,
|
||||
progressRef.current,
|
||||
bufferedRef.current,
|
||||
animStateRef.current,
|
||||
);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [seekbarStyle]);
|
||||
|
||||
// Resize observer
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
|
||||
@@ -145,6 +145,13 @@ export const deTranslation = {
|
||||
ratingLabel: 'Bewertung',
|
||||
enlargeCover: 'Vergrößern',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Albumbewertung',
|
||||
artistShort: 'Künstlerbewertung',
|
||||
albumAriaLabel: 'Albumbewertung',
|
||||
artistAriaLabel: 'Künstlerbewertung',
|
||||
saveFailed: 'Bewertung konnte nicht gespeichert werden.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Zurück',
|
||||
albums: 'Alben',
|
||||
@@ -512,6 +519,18 @@ export const deTranslation = {
|
||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Allgemein',
|
||||
ratingsSectionTitle: 'Bewertungen',
|
||||
ratingsSkipStarTitle: 'Überspringen für 1 Stern',
|
||||
ratingsSkipStarDesc:
|
||||
'Bei N Überspringen hintereinander: Titel auf 1 Stern setzen. Nur für zuvor unbewertete Titel.',
|
||||
ratingsSkipStarThresholdLabel: 'Überspringer bis 1★',
|
||||
ratingsMixFilterTitle: 'Filter nach Bewertung',
|
||||
ratingsMixFilterDesc:
|
||||
'Inhalte mit niedriger Bewertung in {{mix}} und {{albums}} filtern. Erneut auf den gewählten Stern klicken, um die Schwelle zu deaktivieren.',
|
||||
ratingsMixMinSong: 'Titel',
|
||||
ratingsMixMinAlbum: 'Alben',
|
||||
ratingsMixMinArtist: 'Interpreten',
|
||||
ratingsMixMinThresholdAria: 'Mindest-Sterne: {{label}}',
|
||||
backupTitle: 'Backup & Wiederherstellung',
|
||||
backupExport: 'Einstellungen exportieren',
|
||||
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
|
||||
@@ -550,6 +569,11 @@ export const deTranslation = {
|
||||
seekbarBar: 'Balken',
|
||||
seekbarThick: 'Dicker Balken',
|
||||
seekbarSegmented: 'Segmentiert',
|
||||
seekbarNeon: 'Neonröhre',
|
||||
seekbarPulsewave: 'Pulswelle',
|
||||
seekbarParticletrail: 'Partikel-Spur',
|
||||
seekbarLiquidfill: 'Flüssigkeit',
|
||||
seekbarRetrotape: 'Retro-Band',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Was ist neu',
|
||||
|
||||
@@ -146,6 +146,13 @@ export const enTranslation = {
|
||||
ratingLabel: 'Rating',
|
||||
enlargeCover: 'Enlarge',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Album rating',
|
||||
artistShort: 'Artist rating',
|
||||
albumAriaLabel: 'Album rating',
|
||||
artistAriaLabel: 'Artist rating',
|
||||
saveFailed: 'Could not save rating.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Back',
|
||||
albums: 'Albums',
|
||||
@@ -497,6 +504,18 @@ export const enTranslation = {
|
||||
tabServer: 'Server',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'General',
|
||||
ratingsSectionTitle: 'Ratings',
|
||||
ratingsSkipStarTitle: 'Skip for 1 star',
|
||||
ratingsSkipStarDesc:
|
||||
'After N skips in a row, set the track to 1★. Only for tracks not rated before.',
|
||||
ratingsSkipStarThresholdLabel: 'Skips before 1★',
|
||||
ratingsMixFilterTitle: 'Filter by rating',
|
||||
ratingsMixFilterDesc:
|
||||
'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.',
|
||||
ratingsMixMinSong: 'Songs',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artists',
|
||||
ratingsMixMinThresholdAria: 'Minimum stars: {{label}}',
|
||||
backupTitle: 'Backup & Restore',
|
||||
backupExport: 'Export settings',
|
||||
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
|
||||
@@ -551,6 +570,11 @@ export const enTranslation = {
|
||||
seekbarBar: 'Bar',
|
||||
seekbarThick: 'Thick Bar',
|
||||
seekbarSegmented: 'Segmented',
|
||||
seekbarNeon: 'Neon Glow',
|
||||
seekbarPulsewave: 'Pulse Wave',
|
||||
seekbarParticletrail: 'Particle Trail',
|
||||
seekbarLiquidfill: 'Liquid Fill',
|
||||
seekbarRetrotape: 'Retro Tape',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "What's New",
|
||||
|
||||
@@ -145,6 +145,13 @@ export const frTranslation = {
|
||||
ratingLabel: 'Note',
|
||||
enlargeCover: 'Agrandir',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Note de l’album',
|
||||
artistShort: 'Note de l’artiste',
|
||||
albumAriaLabel: 'Note de l’album',
|
||||
artistAriaLabel: 'Note de l’artiste',
|
||||
saveFailed: 'Impossible d’enregistrer la note.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Retour',
|
||||
albums: 'Albums',
|
||||
@@ -510,6 +517,18 @@ export const frTranslation = {
|
||||
shortcutNativeFullscreen: 'Plein écran natif',
|
||||
tabSystem: 'Système',
|
||||
tabGeneral: 'Général',
|
||||
ratingsSectionTitle: 'Notes',
|
||||
ratingsSkipStarTitle: 'Passer pour 1 étoile',
|
||||
ratingsSkipStarDesc:
|
||||
'Après N sauts d’affilée : mettre le morceau à 1 étoile. Uniquement s’il n’était pas encore noté.',
|
||||
ratingsSkipStarThresholdLabel: 'Sauts avant 1★',
|
||||
ratingsMixFilterTitle: 'Filtrage par note',
|
||||
ratingsMixFilterDesc:
|
||||
'Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.',
|
||||
ratingsMixMinSong: 'Morceaux',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artistes',
|
||||
ratingsMixMinThresholdAria: 'Étoiles minimum : {{label}}',
|
||||
backupTitle: 'Sauvegarde & Restauration',
|
||||
backupExport: 'Exporter les paramètres',
|
||||
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
|
||||
@@ -548,6 +567,11 @@ export const frTranslation = {
|
||||
seekbarBar: 'Barre',
|
||||
seekbarThick: 'Barre épaisse',
|
||||
seekbarSegmented: 'Segmentée',
|
||||
seekbarNeon: 'Néon',
|
||||
seekbarPulsewave: 'Onde pulsée',
|
||||
seekbarParticletrail: 'Traînée de particules',
|
||||
seekbarLiquidfill: 'Tube liquide',
|
||||
seekbarRetrotape: 'Bande rétro',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Quoi de neuf',
|
||||
|
||||
@@ -145,6 +145,13 @@ export const nbTranslation = {
|
||||
ratingLabel: 'Vurdering',
|
||||
enlargeCover: 'Forstørr',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Albumvurdering',
|
||||
artistShort: 'Artistvurdering',
|
||||
albumAriaLabel: 'Albumvurdering',
|
||||
artistAriaLabel: 'Artistvurdering',
|
||||
saveFailed: 'Kunne ikke lagre vurderingen.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Tilbake',
|
||||
albums: 'Album',
|
||||
@@ -493,6 +500,18 @@ export const nbTranslation = {
|
||||
tabServer: 'Tjener',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Generelt',
|
||||
ratingsSectionTitle: 'Vurderinger',
|
||||
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
|
||||
ratingsSkipStarDesc:
|
||||
'Etter N hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
|
||||
ratingsSkipStarThresholdLabel: 'Hopp før 1★',
|
||||
ratingsMixFilterTitle: 'Filtrering etter vurdering',
|
||||
ratingsMixFilterDesc:
|
||||
'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.',
|
||||
ratingsMixMinSong: 'Spor',
|
||||
ratingsMixMinAlbum: 'Album',
|
||||
ratingsMixMinArtist: 'Artister',
|
||||
ratingsMixMinThresholdAria: 'Minimum stjerner: {{label}}',
|
||||
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
|
||||
backupExport: 'Eksporter innstillinger',
|
||||
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst – hold filen sikker.',
|
||||
@@ -547,6 +566,11 @@ export const nbTranslation = {
|
||||
seekbarBar: 'Linje',
|
||||
seekbarThick: 'Tykk linje',
|
||||
seekbarSegmented: 'Segmentert',
|
||||
seekbarNeon: 'Neon',
|
||||
seekbarPulsewave: 'Pulsbølge',
|
||||
seekbarParticletrail: 'Partikkelspor',
|
||||
seekbarLiquidfill: 'Væskerør',
|
||||
seekbarRetrotape: 'Retrotape',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "Nyheter",
|
||||
|
||||
@@ -145,6 +145,13 @@ export const nlTranslation = {
|
||||
ratingLabel: 'Beoordeling',
|
||||
enlargeCover: 'Vergroten',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Albumbeoordeling',
|
||||
artistShort: 'Artiestbeoordeling',
|
||||
albumAriaLabel: 'Albumbeoordeling',
|
||||
artistAriaLabel: 'Artiestbeoordeling',
|
||||
saveFailed: 'Beoordeling opslaan mislukt.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Terug',
|
||||
albums: 'Albums',
|
||||
@@ -510,6 +517,18 @@ export const nlTranslation = {
|
||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||
tabSystem: 'Systeem',
|
||||
tabGeneral: 'Algemeen',
|
||||
ratingsSectionTitle: 'Beoordelingen',
|
||||
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
|
||||
ratingsSkipStarDesc:
|
||||
'Na N overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.',
|
||||
ratingsSkipStarThresholdLabel: 'Overslagen voor 1★',
|
||||
ratingsMixFilterTitle: 'Filter op beoordeling',
|
||||
ratingsMixFilterDesc:
|
||||
'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.',
|
||||
ratingsMixMinSong: 'Nummers',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artiesten',
|
||||
ratingsMixMinThresholdAria: 'Minimum sterren: {{label}}',
|
||||
backupTitle: 'Back-up & Herstel',
|
||||
backupExport: 'Instellingen exporteren',
|
||||
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
|
||||
@@ -548,6 +567,11 @@ export const nlTranslation = {
|
||||
seekbarBar: 'Balk',
|
||||
seekbarThick: 'Dikke balk',
|
||||
seekbarSegmented: 'Gesegmenteerd',
|
||||
seekbarNeon: 'Neon',
|
||||
seekbarPulsewave: 'Pulsgolf',
|
||||
seekbarParticletrail: 'Deeltjesspoor',
|
||||
seekbarLiquidfill: 'Vloeistofbuis',
|
||||
seekbarRetrotape: 'Retrotape',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Wat is nieuw',
|
||||
|
||||
@@ -147,6 +147,13 @@ export const ruTranslation = {
|
||||
ratingLabel: 'Оценка',
|
||||
enlargeCover: 'Увеличить обложку',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Оценка альбома',
|
||||
artistShort: 'Оценка исполнителя',
|
||||
albumAriaLabel: 'Оценка альбома',
|
||||
artistAriaLabel: 'Оценка исполнителя',
|
||||
saveFailed: 'Не удалось сохранить оценку.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Назад',
|
||||
albums: 'Альбомы',
|
||||
@@ -464,6 +471,9 @@ export const ruTranslation = {
|
||||
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
||||
minimizeToTray: 'Сворачивать в трей',
|
||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||
useCustomTitlebar: 'Своя строка заголовка',
|
||||
useCustomTitlebarDesc:
|
||||
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK.',
|
||||
discordRichPresence: 'Статус в Discord',
|
||||
discordRichPresenceDesc:
|
||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||
@@ -515,6 +525,18 @@ export const ruTranslation = {
|
||||
tabServer: 'Сервер',
|
||||
tabSystem: 'Система',
|
||||
tabGeneral: 'Общие',
|
||||
ratingsSectionTitle: 'Рейтинги',
|
||||
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
|
||||
ratingsSkipStarDesc:
|
||||
'При N скипов подряд ставить 1★ треку. Только для не оцененных ранее.',
|
||||
ratingsSkipStarThresholdLabel: 'Скипов',
|
||||
ratingsMixFilterTitle: 'Фильтрация по рейтингу',
|
||||
ratingsMixFilterDesc:
|
||||
'Фильтровать с низким рейтингом в «{{mix}}» и «{{albums}}». Повторный клик по выбранной звезде отключает порог.',
|
||||
ratingsMixMinSong: 'Песни',
|
||||
ratingsMixMinAlbum: 'Альбомы',
|
||||
ratingsMixMinArtist: 'Исполнители',
|
||||
ratingsMixMinThresholdAria: 'Минимум звёзд: {{label}}',
|
||||
backupTitle: 'Резервная копия',
|
||||
backupExport: 'Экспорт настроек',
|
||||
backupExportDesc:
|
||||
@@ -571,6 +593,11 @@ export const ruTranslation = {
|
||||
seekbarBar: 'Полоса',
|
||||
seekbarThick: 'Толстая полоса',
|
||||
seekbarSegmented: 'Сегменты',
|
||||
seekbarNeon: 'Неон',
|
||||
seekbarPulsewave: 'Пульс-волна',
|
||||
seekbarParticletrail: 'Частицы',
|
||||
seekbarLiquidfill: 'Жидкость',
|
||||
seekbarRetrotape: 'Ретро-лента',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Что нового',
|
||||
|
||||
@@ -145,6 +145,13 @@ export const zhTranslation = {
|
||||
ratingLabel: '评分',
|
||||
enlargeCover: '放大',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: '专辑评分',
|
||||
artistShort: '艺人评分',
|
||||
albumAriaLabel: '专辑评分',
|
||||
artistAriaLabel: '艺人评分',
|
||||
saveFailed: '无法保存评分。',
|
||||
},
|
||||
artistDetail: {
|
||||
back: '返回',
|
||||
albums: '专辑',
|
||||
@@ -490,6 +497,18 @@ export const zhTranslation = {
|
||||
tabServer: '服务器',
|
||||
tabSystem: '系统',
|
||||
tabGeneral: '通用',
|
||||
ratingsSectionTitle: '评分',
|
||||
ratingsSkipStarTitle: '跳过以评 1 星',
|
||||
ratingsSkipStarDesc:
|
||||
'连续跳过 N 次后将曲目设为 1 星。仅适用于此前未评分的曲目。',
|
||||
ratingsSkipStarThresholdLabel: '跳过次数(至 1★)',
|
||||
ratingsMixFilterTitle: '按评分筛选',
|
||||
ratingsMixFilterDesc:
|
||||
'在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。',
|
||||
ratingsMixMinSong: '歌曲',
|
||||
ratingsMixMinAlbum: '专辑',
|
||||
ratingsMixMinArtist: '艺人',
|
||||
ratingsMixMinThresholdAria: '最低星数:{{label}}',
|
||||
backupTitle: '备份与恢复',
|
||||
backupExport: '导出设置',
|
||||
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
||||
@@ -544,6 +563,11 @@ export const zhTranslation = {
|
||||
seekbarBar: '条形',
|
||||
seekbarThick: '粗条形',
|
||||
seekbarSegmented: '分段式',
|
||||
seekbarNeon: '霓虹',
|
||||
seekbarPulsewave: '脉冲波',
|
||||
seekbarParticletrail: '粒子轨迹',
|
||||
seekbarLiquidfill: '液体填充',
|
||||
seekbarRetrotape: '复古磁带',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: '新功能',
|
||||
|
||||
@@ -13,6 +13,7 @@ import AlbumHeader from '../components/AlbumHeader';
|
||||
import AlbumTrackList from '../components/AlbumTrackList';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
@@ -33,6 +34,7 @@ export default function AlbumDetail() {
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
@@ -52,6 +54,11 @@ export default function AlbumDetail() {
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const offlineJobs = useOfflineStore(s => s.jobs);
|
||||
const serverId = auth.activeServerId ?? '';
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
|
||||
|
||||
const [albumEntityRating, setAlbumEntityRating] = useState(0);
|
||||
|
||||
const offlineStatus: 'none' | 'downloading' | 'cached' = (() => {
|
||||
if (!album) return 'none';
|
||||
@@ -90,6 +97,11 @@ export default function AlbumDetail() {
|
||||
}).catch(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0);
|
||||
}, [id, album?.album.id, album?.album.userRating]);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
@@ -126,9 +138,37 @@ const handleEnqueueAll = () => {
|
||||
|
||||
const handleRate = async (songId: string, rating: number) => {
|
||||
setRatings(r => ({ ...r, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
await setRating(songId, rating);
|
||||
};
|
||||
|
||||
const handleAlbumEntityRating = async (rating: number) => {
|
||||
if (!album || album.album.id !== id) return;
|
||||
const albumId = album.album.id;
|
||||
const ratingAtStart = album.album.userRating ?? 0;
|
||||
|
||||
setAlbumEntityRating(rating);
|
||||
|
||||
if (albumEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(albumId, rating);
|
||||
setAlbum(cur =>
|
||||
cur && cur.album.id === albumId
|
||||
? { ...cur, album: { ...cur.album, userRating: rating } }
|
||||
: cur,
|
||||
);
|
||||
} catch (err) {
|
||||
setAlbumEntityRating(ratingAtStart);
|
||||
setEntityRatingSupport(serverId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBio = async () => {
|
||||
if (!album) return;
|
||||
if (bio) { setBioOpen(true); return; }
|
||||
@@ -270,6 +310,9 @@ const handleEnqueueAll = () => {
|
||||
offlineProgress={offlineProgress}
|
||||
onCacheOffline={handleCacheOffline}
|
||||
onRemoveOffline={handleRemoveOffline}
|
||||
entityRatingValue={albumEntityRating}
|
||||
onEntityRatingChange={handleAlbumEntityRating}
|
||||
entityRatingSupport={albumEntityRatingSupport}
|
||||
/>
|
||||
{offlineStorageFull && (
|
||||
<div className="offline-storage-full-banner" role="alert">
|
||||
@@ -290,6 +333,7 @@ const handleEnqueueAll = () => {
|
||||
currentTrack={currentTrack}
|
||||
isPlaying={isPlaying}
|
||||
ratings={ratings}
|
||||
userRatingOverrides={userRatingOverrides}
|
||||
starredSongs={new Set([
|
||||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
@@ -14,6 +14,7 @@ import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import { invalidateCoverArt } from '../utils/imageCache';
|
||||
import { showToast } from '../utils/toast';
|
||||
import StarRating from '../components/StarRating';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -71,6 +72,11 @@ export default function ArtistDetail() {
|
||||
const { downloadArtist, bulkProgress } = useOfflineStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
|
||||
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -94,6 +100,34 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
||||
}, [id, artist?.id, artist?.userRating]);
|
||||
|
||||
const handleArtistEntityRating = async (rating: number) => {
|
||||
if (!artist || artist.id !== id) return;
|
||||
const artistId = artist.id;
|
||||
const ratingAtStart = artist.userRating ?? 0;
|
||||
|
||||
setArtistEntityRating(rating);
|
||||
|
||||
if (artistEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(artistId, rating);
|
||||
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
|
||||
} catch (err) {
|
||||
setArtistEntityRating(ratingAtStart);
|
||||
setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// "Also Featured On" — loaded in background after main content renders
|
||||
useEffect(() => {
|
||||
if (!id || !artist) return;
|
||||
@@ -351,6 +385,16 @@ export default function ArtistDetail() {
|
||||
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-entity-rating">
|
||||
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
|
||||
<StarRating
|
||||
value={artistEntityRating}
|
||||
onChange={handleArtistEntityRating}
|
||||
disabled={artistEntityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
|
||||
+12
-3
@@ -20,7 +20,7 @@ const FAV_COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
|
||||
@@ -182,8 +182,17 @@ export default function Favorites() {
|
||||
const isCentered = key === 'duration';
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
</div>
|
||||
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
|
||||
</div>
|
||||
|
||||
+56
-24
@@ -7,10 +7,19 @@ import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useHomeStore } from '../store/homeStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
|
||||
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
|
||||
const HOME_RANDOM_FETCH = 100;
|
||||
const HOME_HERO_COUNT = 8;
|
||||
const HOME_DISCOVER_SLICE = 20;
|
||||
|
||||
export default function Home() {
|
||||
const homeSections = useHomeStore(s => s.sections);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
||||
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -23,30 +32,50 @@ export default function Home() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
]).then(([s, n, r, f, rp, artists]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, 8));
|
||||
setRandom(r.slice(8));
|
||||
setMostPlayed(f);
|
||||
setRecentlyPlayed(rp);
|
||||
// Pick 16 random artists via Fisher-Yates shuffle
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMix =
|
||||
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
|
||||
const [s, n, rRaw, f, rp, artists] = await Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', randomSize).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const r = await filterAlbumsByMixRatings(rRaw, mixCfg);
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
|
||||
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
|
||||
setMostPlayed(f);
|
||||
setRecentlyPlayed(rp);
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
setRandomArtists(shuffled.slice(0, 16));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
setRandomArtists(shuffled.slice(0, 16));
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [musicLibraryFilterVersion, homeSections]);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
musicLibraryFilterVersion,
|
||||
homeSections,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||
@@ -55,7 +84,10 @@ export default function Home() {
|
||||
) => {
|
||||
try {
|
||||
const more = await getAlbumList(type, 12, currentList.length);
|
||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const batch =
|
||||
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
|
||||
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
|
||||
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
||||
} catch (e) {
|
||||
console.error('Failed to load more', e);
|
||||
|
||||
@@ -211,6 +211,7 @@ export default function NowPlaying() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
@@ -292,7 +293,7 @@ export default function NowPlaying() {
|
||||
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
|
||||
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
|
||||
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
|
||||
{renderStars(currentTrack.userRating)}
|
||||
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
|
||||
<button onClick={toggleStar} className="np-star-btn"
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
import StarRating from '../components/StarRating';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
@@ -50,23 +51,6 @@ function codecLabel(song: SubsonicSong): string {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const [hover, setHover] = React.useState(0);
|
||||
return (
|
||||
<div className="star-rating">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
>★</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
const PL_COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||
@@ -74,7 +58,7 @@ const PL_COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
@@ -85,7 +69,7 @@ export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore(
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
playTrack: s.playTrack,
|
||||
enqueue: s.enqueue,
|
||||
@@ -94,6 +78,7 @@ export default function PlaylistDetail() {
|
||||
isPlaying: s.isPlaying,
|
||||
starredOverrides: s.starredOverrides,
|
||||
setStarredOverride: s.setStarredOverride,
|
||||
userRatingOverrides: s.userRatingOverrides,
|
||||
}))
|
||||
);
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
@@ -370,6 +355,7 @@ export default function PlaylistDetail() {
|
||||
// ── Rating / Star ─────────────────────────────────────────────
|
||||
const handleRate = (songId: string, rating: number) => {
|
||||
setRatings(prev => ({ ...prev, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
setRating(songId, rating).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -750,8 +736,17 @@ export default function PlaylistDetail() {
|
||||
if (key === 'delete') return <div key="delete" />;
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
</div>
|
||||
{!isLastCol && key !== 'delete' && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
@@ -851,7 +846,7 @@ export default function PlaylistDetail() {
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
|
||||
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
|
||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||
case 'format': return (
|
||||
<div key="format" className="track-meta">
|
||||
@@ -917,7 +912,7 @@ export default function PlaylistDetail() {
|
||||
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
|
||||
if (key === 'delete') return <div key="delete" />;
|
||||
if (key === 'favorite' || key === 'rating') return <div key={key} />;
|
||||
return <div key={key} className={isCentered ? 'col-center' : ''}>{label}</div>;
|
||||
return <div key={key} className={isCentered ? 'col-center' : ''} style={!isCentered ? { paddingLeft: 12 } : undefined}>{label}</div>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
@@ -12,6 +13,10 @@ import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
const ALBUM_COUNT = 30;
|
||||
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
|
||||
const ALBUM_FETCH_OVERSHOOT = 100;
|
||||
/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */
|
||||
const GENRE_UNION_PREFILTER_CAP = 250;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||
@@ -25,17 +30,21 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[union[i], union[j]] = [union[j], union[i]];
|
||||
}
|
||||
return union.slice(0, ALBUM_COUNT);
|
||||
const pool = union.slice(0, GENRE_UNION_PREFILTER_CAP);
|
||||
const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth());
|
||||
return filtered.slice(0, ALBUM_COUNT);
|
||||
}
|
||||
|
||||
export default function RandomAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const auth = useAuthStore();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
|
||||
const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
|
||||
const mixMinRatingAlbum = auth.mixMinRatingAlbum;
|
||||
const mixMinRatingArtist = auth.mixMinRatingArtist;
|
||||
const serverId = auth.activeServerId ?? '';
|
||||
const { downloadAlbum } = useOfflineStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
@@ -94,7 +103,13 @@ export default function RandomAlbums() {
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = genres.length > 0 ? await fetchByGenres(genres) : await getAlbumList('random', ALBUM_COUNT);
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMixActive =
|
||||
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
|
||||
const data = genres.length > 0
|
||||
? await fetchByGenres(genres)
|
||||
: (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
|
||||
setAlbums(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -102,7 +117,12 @@ export default function RandomAlbums() {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [musicLibraryFilterVersion]);
|
||||
}, [
|
||||
musicLibraryFilterVersion,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
|
||||
|
||||
|
||||
+37
-9
@@ -1,10 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import {
|
||||
fetchRandomMixSongsUntilFull,
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
passesMixMinRatings,
|
||||
} from '../utils/mixRatingFilter';
|
||||
|
||||
const AUDIOBOOK_GENRES = [
|
||||
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
||||
@@ -35,7 +40,26 @@ export default function RandomMix() {
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const psyDrag = useDragDrop();
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
||||
const {
|
||||
excludeAudiobooks,
|
||||
setExcludeAudiobooks,
|
||||
customGenreBlacklist,
|
||||
setCustomGenreBlacklist,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingSong,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
} = useAuthStore();
|
||||
|
||||
const mixRatingCfg = useMemo(
|
||||
() => ({
|
||||
enabled: mixMinRatingFilterEnabled,
|
||||
minSong: mixMinRatingSong,
|
||||
minAlbum: mixMinRatingAlbum,
|
||||
minArtist: mixMinRatingArtist,
|
||||
}),
|
||||
[mixMinRatingFilterEnabled, mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist]
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||
const [addedArtist, setAddedArtist] = useState<string | null>(null);
|
||||
@@ -56,11 +80,11 @@ export default function RandomMix() {
|
||||
const fetchSongs = () => {
|
||||
setLoading(true);
|
||||
setSongs([]);
|
||||
getRandomSongs(50)
|
||||
.then(fetched => {
|
||||
setSongs(fetched);
|
||||
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth())
|
||||
.then(list => {
|
||||
setSongs(list);
|
||||
const st = new Set<string>();
|
||||
fetched.forEach(s => { if (s.starred) st.add(s.id); });
|
||||
list.forEach(s => { if (s.starred) st.add(s.id); });
|
||||
setStarredSongs(st);
|
||||
setLoading(false);
|
||||
})
|
||||
@@ -97,6 +121,7 @@ export default function RandomMix() {
|
||||
if (song.title && checkText(song.title)) return false;
|
||||
if (song.album && checkText(song.album)) return false;
|
||||
if (song.artist && checkText(song.artist)) return false;
|
||||
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -132,8 +157,11 @@ export default function RandomMix() {
|
||||
setGenreMixComplete(false);
|
||||
setGenreMixSongs([]);
|
||||
try {
|
||||
const fetched = await getRandomSongs(50, genre, 45000);
|
||||
setGenreMixSongs(fetched);
|
||||
const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), {
|
||||
genre,
|
||||
timeout: 45000,
|
||||
});
|
||||
setGenreMixSongs(list);
|
||||
} catch {}
|
||||
setGenreMixLoading(false);
|
||||
setGenreMixComplete(true);
|
||||
|
||||
+109
-3
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star
|
||||
} from 'lucide-react';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
import { showToast } from '../utils/toast';
|
||||
@@ -18,7 +18,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import ThemePicker from '../components/ThemePicker';
|
||||
import { useAuthStore, ServerProfile, type SeekbarStyle } from '../store/authStore';
|
||||
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } from '../store/authStore';
|
||||
import { SeekbarPreview } from '../components/WaveformSeek';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
@@ -33,6 +33,7 @@ import { pingWithCredentials } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Equalizer from '../components/Equalizer';
|
||||
import StarRating from '../components/StarRating';
|
||||
|
||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||
|
||||
@@ -778,6 +779,111 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ratings (single block under Random Mix) */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Star size={18} />
|
||||
<h2>{t('settings.ratingsSectionTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsSkipStarDesc')}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{auth.skipStarOnManualSkipsEnabled && (
|
||||
<>
|
||||
<label htmlFor="settings-skip-star-threshold" style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
|
||||
{t('settings.ratingsSkipStarThresholdLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="settings-skip-star-threshold"
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
value={auth.skipStarManualSkipThreshold}
|
||||
onChange={e => auth.setSkipStarManualSkipThreshold(Number(e.target.value))}
|
||||
style={{ width: 72, padding: '6px 10px', fontSize: 13 }}
|
||||
aria-label={t('settings.ratingsSkipStarThresholdLabel')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label className="toggle-switch" aria-label={t('settings.ratingsSkipStarTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.skipStarOnManualSkipsEnabled}
|
||||
onChange={e => auth.setSkipStarOnManualSkipsEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.ratingsMixFilterDesc', {
|
||||
mix: t('sidebar.randomMix'),
|
||||
albums: t('sidebar.randomAlbums'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.mixMinRatingFilterEnabled}
|
||||
onChange={e => auth.setMixMinRatingFilterEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.mixMinRatingFilterEnabled && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: '1rem 0.75rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{([
|
||||
{ key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong },
|
||||
{ key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum },
|
||||
{ key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist },
|
||||
] as const).map(row => (
|
||||
<div
|
||||
key={row.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>{row.label}</span>
|
||||
<StarRating
|
||||
maxSelectable={MIX_MIN_RATING_FILTER_MAX_STARS}
|
||||
value={row.value}
|
||||
onChange={row.set}
|
||||
ariaLabel={t('settings.ratingsMixMinThresholdAria', { label: row.label })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomeCustomizer />
|
||||
</>
|
||||
)}
|
||||
@@ -1158,7 +1264,7 @@ export default function Settings() {
|
||||
{t('settings.seekbarStyleDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{(['waveform', 'linedot', 'bar', 'thick', 'segmented'] as SeekbarStyle[]).map(style => (
|
||||
{(['waveform', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => (
|
||||
<SeekbarPreview
|
||||
key={style}
|
||||
style={style}
|
||||
|
||||
+129
-1
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
|
||||
export interface ServerProfile {
|
||||
@@ -11,7 +12,7 @@ export interface ServerProfile {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented';
|
||||
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
||||
|
||||
interface AuthState {
|
||||
// Multi-server
|
||||
@@ -63,6 +64,29 @@ interface AuthState {
|
||||
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
|
||||
hotCacheDownloadDir: string;
|
||||
|
||||
/** After this many manual skips of the same track, set track rating to 1 if still unrated (below 1 star). */
|
||||
skipStarOnManualSkipsEnabled: boolean;
|
||||
/** Manual skips per track before applying rating 1 (when enabled). */
|
||||
skipStarManualSkipThreshold: number;
|
||||
/**
|
||||
* Manual Next-count per track for skip→1★. Key = `${serverId}\\u001f${trackId}`
|
||||
* (empty serverId when none). Persisted; cleared when the track finishes naturally or when threshold is reached.
|
||||
*/
|
||||
skipStarManualSkipCountsByKey: Record<string, number>;
|
||||
/** Increment skip count for current server + track; clears stored count when threshold reached. */
|
||||
recordSkipStarManualAdvance: (trackId: string) => { crossedThreshold: boolean } | null;
|
||||
/** Drop persisted skip count for this track on the active server (e.g. natural playback end). */
|
||||
clearSkipStarManualCountForTrack: (trackId: string) => void;
|
||||
|
||||
/** Random mixes, random albums, home hero: drop non‑zero ratings at or below per‑axis thresholds (0 = unrated, kept). */
|
||||
mixMinRatingFilterEnabled: boolean;
|
||||
/** 0 = ignore; 1–3 = cutoff (UI); exclude track rating r when 0 < r ≤ cutoff. */
|
||||
mixMinRatingSong: number;
|
||||
/** 0 = ignore; album entity rating from payload or `getAlbum` when missing. */
|
||||
mixMinRatingAlbum: number;
|
||||
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
|
||||
mixMinRatingArtist: number;
|
||||
|
||||
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
|
||||
musicFolders: Array<{ id: string; name: string }>;
|
||||
/**
|
||||
@@ -73,6 +97,13 @@ interface AuthState {
|
||||
/** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */
|
||||
musicLibraryFilterVersion: number;
|
||||
|
||||
/**
|
||||
* Per server: whether `setRating` is assumed to work for album/artist ids (OpenSubsonic-style).
|
||||
* Absent key = not probed yet (`unknown` in UI).
|
||||
*/
|
||||
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
|
||||
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
|
||||
|
||||
// Status
|
||||
isLoggedIn: boolean;
|
||||
isConnecting: boolean;
|
||||
@@ -122,6 +153,12 @@ interface AuthState {
|
||||
setHotCacheMaxMb: (v: number) => void;
|
||||
setHotCacheDebounceSec: (v: number) => void;
|
||||
setHotCacheDownloadDir: (v: string) => void;
|
||||
setSkipStarOnManualSkipsEnabled: (v: boolean) => void;
|
||||
setSkipStarManualSkipThreshold: (v: number) => void;
|
||||
setMixMinRatingFilterEnabled: (v: boolean) => void;
|
||||
setMixMinRatingSong: (v: number) => void;
|
||||
setMixMinRatingAlbum: (v: number) => void;
|
||||
setMixMinRatingArtist: (v: number) => void;
|
||||
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
|
||||
setMusicLibraryFilter: (folderId: 'all' | string) => void;
|
||||
logout: () => void;
|
||||
@@ -135,6 +172,33 @@ function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
/** Upper bound for mix min-rating thresholds (UI shows five stars, only 1…this many are selectable). */
|
||||
export const MIX_MIN_RATING_FILTER_MAX_STARS = 3;
|
||||
|
||||
function clampMixFilterMinStars(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v)));
|
||||
}
|
||||
|
||||
function clampSkipStarThreshold(v: number): number {
|
||||
if (!Number.isFinite(v)) return 3;
|
||||
return Math.max(1, Math.min(99, Math.round(v)));
|
||||
}
|
||||
|
||||
function skipStarCountStorageKey(serverId: string | null | undefined, trackId: string): string {
|
||||
return `${serverId ?? ''}\u001f${trackId}`;
|
||||
}
|
||||
|
||||
function sanitizeSkipStarCounts(raw: unknown): Record<string, number> {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
const next: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > 0) next[k] = Math.min(Math.floor(n), 1_000_000);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -175,9 +239,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
hotCacheMaxMb: 256,
|
||||
hotCacheDebounceSec: 30,
|
||||
hotCacheDownloadDir: '',
|
||||
skipStarOnManualSkipsEnabled: false,
|
||||
skipStarManualSkipThreshold: 3,
|
||||
skipStarManualSkipCountsByKey: {},
|
||||
mixMinRatingFilterEnabled: false,
|
||||
mixMinRatingSong: 0,
|
||||
mixMinRatingAlbum: 0,
|
||||
mixMinRatingArtist: 0,
|
||||
musicFolders: [],
|
||||
musicLibraryFilterByServer: {},
|
||||
musicLibraryFilterVersion: 0,
|
||||
entityRatingSupportByServer: {},
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
@@ -199,10 +271,12 @@ export const useAuthStore = create<AuthState>()(
|
||||
set(s => {
|
||||
const newServers = s.servers.filter(srv => srv.id !== id);
|
||||
const switchedAway = s.activeServerId === id;
|
||||
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
|
||||
return {
|
||||
servers: newServers,
|
||||
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
|
||||
isLoggedIn: switchedAway ? false : s.isLoggedIn,
|
||||
entityRatingSupportByServer: entityRatingRest,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -263,6 +337,44 @@ export const useAuthStore = create<AuthState>()(
|
||||
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
|
||||
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
|
||||
|
||||
setSkipStarOnManualSkipsEnabled: (v) =>
|
||||
set({
|
||||
skipStarOnManualSkipsEnabled: v,
|
||||
...(v ? {} : { skipStarManualSkipCountsByKey: {} }),
|
||||
}),
|
||||
setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }),
|
||||
|
||||
recordSkipStarManualAdvance: (trackId: string) => {
|
||||
const s = get();
|
||||
if (!s.skipStarOnManualSkipsEnabled || s.skipStarManualSkipThreshold < 1) return null;
|
||||
const key = skipStarCountStorageKey(s.activeServerId, trackId);
|
||||
const prev = s.skipStarManualSkipCountsByKey[key] ?? 0;
|
||||
const threshold = s.skipStarManualSkipThreshold;
|
||||
const next = prev + 1;
|
||||
if (next >= threshold) {
|
||||
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
|
||||
set({ skipStarManualSkipCountsByKey: rest });
|
||||
return { crossedThreshold: true };
|
||||
}
|
||||
set({
|
||||
skipStarManualSkipCountsByKey: { ...s.skipStarManualSkipCountsByKey, [key]: next },
|
||||
});
|
||||
return { crossedThreshold: false };
|
||||
},
|
||||
|
||||
clearSkipStarManualCountForTrack: (trackId: string) => {
|
||||
const s = get();
|
||||
const key = skipStarCountStorageKey(s.activeServerId, trackId);
|
||||
if (s.skipStarManualSkipCountsByKey[key] === undefined) return;
|
||||
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
|
||||
set({ skipStarManualSkipCountsByKey: rest });
|
||||
},
|
||||
|
||||
setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }),
|
||||
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
|
||||
|
||||
setMusicFolders: (folders) => {
|
||||
const sid = get().activeServerId;
|
||||
set(s => {
|
||||
@@ -286,6 +398,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
}));
|
||||
},
|
||||
|
||||
setEntityRatingSupport: (serverId, level) =>
|
||||
set(s => ({
|
||||
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
|
||||
})),
|
||||
|
||||
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
|
||||
|
||||
getBaseUrl: () => {
|
||||
@@ -307,6 +424,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
|
||||
return rest;
|
||||
},
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error || !state) return;
|
||||
useAuthStore.setState({
|
||||
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
|
||||
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
||||
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
|
||||
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
||||
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
||||
),
|
||||
});
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
||||
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
@@ -76,6 +76,9 @@ interface PlayerState {
|
||||
lastfmLovedCache: Record<string, boolean>;
|
||||
starredOverrides: Record<string, boolean>;
|
||||
setStarredOverride: (id: string, starred: boolean) => void;
|
||||
/** Optimistic track ratings (e.g. skip→1★ while UI lists still have stale `song.userRating`). */
|
||||
userRatingOverrides: Record<string, number>;
|
||||
setUserRatingOverride: (id: string, rating: number) => void;
|
||||
|
||||
playRadio: (station: InternetRadioStation) => void;
|
||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
||||
@@ -161,6 +164,35 @@ let seekTarget: number | null = null;
|
||||
// to the Rust backend before it has finished the previous one.
|
||||
let togglePlayLock = false;
|
||||
|
||||
/**
|
||||
* Skip → 1★: counts in `authStore.skipStarManualSkipCountsByKey` (persisted).
|
||||
* Only user-initiated `next()` increments. Natural track end (incl. gapless) clears the count;
|
||||
* threshold reached clears count and sets 1★ if still unrated.
|
||||
*/
|
||||
function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void {
|
||||
if (!manual || !skippedTrack) return;
|
||||
const id = skippedTrack.id;
|
||||
const adv = useAuthStore.getState().recordSkipStarManualAdvance(id);
|
||||
if (!adv?.crossedThreshold) return;
|
||||
const live = usePlayerStore.getState();
|
||||
const fromQueue = live.queue.find(t => t.id === id);
|
||||
const cur =
|
||||
live.userRatingOverrides[id] ??
|
||||
fromQueue?.userRating ??
|
||||
skippedTrack.userRating ??
|
||||
0;
|
||||
if (cur >= 1) return;
|
||||
setRating(id, 1)
|
||||
.then(() => {
|
||||
usePlayerStore.setState(s => ({
|
||||
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)),
|
||||
currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack,
|
||||
userRatingOverrides: { ...s.userRatingOverrides, [id]: 1 },
|
||||
}));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
||||
// Internet radio streams are played via a native <audio> element instead of
|
||||
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
|
||||
@@ -357,6 +389,9 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
isAudioPaused = false;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
if (store.currentTrack?.id) {
|
||||
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
||||
}
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
let nextTrack: Track | null = null;
|
||||
@@ -576,6 +611,19 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
lastfmLovedCache: {},
|
||||
starredOverrides: {},
|
||||
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
|
||||
userRatingOverrides: {},
|
||||
setUserRatingOverride: (id, rating) =>
|
||||
set(s => {
|
||||
const nextOverrides = { ...s.userRatingOverrides };
|
||||
if (rating === 0) delete nextOverrides[id];
|
||||
else nextOverrides[id] = rating;
|
||||
return {
|
||||
userRatingOverrides: nextOverrides,
|
||||
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: rating } : t)),
|
||||
currentTrack:
|
||||
s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack,
|
||||
};
|
||||
}),
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
repeatMode: 'off',
|
||||
@@ -887,6 +935,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
// ── next / previous ──────────────────────────────────────────────────────
|
||||
next: (manual = true) => {
|
||||
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
||||
applySkipStarOnManualNext(currentTrack, manual);
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
get().playTrack(queue[nextIdx], queue, manual);
|
||||
|
||||
@@ -1067,7 +1067,50 @@
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
margin: var(--space-2) 0 var(--space-2);
|
||||
}
|
||||
|
||||
.album-detail-entity-rating {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Inline with label: do not stretch to full row width */
|
||||
.album-detail-entity-rating .star-rating {
|
||||
width: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.album-detail-entity-rating-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.artist-detail-entity-rating {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.artist-detail-entity-rating .star-rating {
|
||||
width: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.artist-detail-entity-rating-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.album-detail-actions {
|
||||
|
||||
@@ -3800,6 +3800,84 @@ select.input.input:focus {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* While clearing: no yellow hover until pointer leaves or next click */
|
||||
.star-rating--suppress-hover .star:hover {
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.star-rating--disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.star-rating--disabled .star:hover {
|
||||
color: inherit;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.star-rating .star {
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.star-rating .star.star--locked {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.star-rating .star.star--locked:hover {
|
||||
color: var(--ctp-overlay1);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@keyframes star-rating-star-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.star-rating .star.star--pulse {
|
||||
animation: star-rating-star-pulse 0.38s ease-out;
|
||||
}
|
||||
|
||||
@keyframes star-rating-star-clear-shrink {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
55% {
|
||||
transform: scale(0.58);
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.star-rating .star.star--clear-shrink {
|
||||
animation: star-rating-star-clear-shrink 0.44s ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.star-rating .star.star--pulse,
|
||||
.star-rating .star.star--clear-shrink {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Animations ─── */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
getRandomSongs,
|
||||
prefetchAlbumUserRatings,
|
||||
prefetchArtistUserRatings,
|
||||
type SubsonicAlbum,
|
||||
type SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
/** Target list size for Random Mix after rating filter. */
|
||||
export const RANDOM_MIX_TARGET_SIZE = 50;
|
||||
const RANDOM_MIX_BATCH_SIZE = 50;
|
||||
/** Upper bound on `getRandomSongs` calls (avoids infinite loop if the library is tiny or the filter is extreme). */
|
||||
const RANDOM_MIX_MAX_BATCHES = 40;
|
||||
/** Stop if several batches in a row bring no new track ids (server keeps repeating the same set). */
|
||||
const RANDOM_MIX_MAX_DUP_STREAK = 6;
|
||||
|
||||
export interface MixMinRatingsConfig {
|
||||
enabled: boolean;
|
||||
minSong: number;
|
||||
minAlbum: number;
|
||||
minArtist: number;
|
||||
}
|
||||
|
||||
export function getMixMinRatingsConfigFromAuth(): MixMinRatingsConfig {
|
||||
const s = useAuthStore.getState();
|
||||
return {
|
||||
enabled: s.mixMinRatingFilterEnabled,
|
||||
minSong: s.mixMinRatingSong,
|
||||
minAlbum: s.mixMinRatingAlbum,
|
||||
minArtist: s.mixMinRatingArtist,
|
||||
};
|
||||
}
|
||||
|
||||
function numRating(v: unknown): number | undefined {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
function ratingFromArtistRefs(
|
||||
list: Array<{ id?: string; userRating?: unknown }> | undefined,
|
||||
preferId?: string,
|
||||
): number | undefined {
|
||||
if (!list?.length) return undefined;
|
||||
if (preferId) {
|
||||
const m = list.find(a => a.id === preferId);
|
||||
const r = numRating(m?.userRating);
|
||||
if (r !== undefined) return r;
|
||||
}
|
||||
for (const a of list) {
|
||||
const r = numRating(a.userRating);
|
||||
if (r !== undefined) return r;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
|
||||
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
|
||||
const d = numRating(song.artistUserRating);
|
||||
if (d !== undefined) return d;
|
||||
const fromArtists = ratingFromArtistRefs(song.artists, song.artistId);
|
||||
if (fromArtists !== undefined) return fromArtists;
|
||||
return ratingFromArtistRefs(song.albumArtists, song.artistId);
|
||||
}
|
||||
|
||||
/** Song-level album (parent) rating when the server puts it on the child payload. */
|
||||
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
|
||||
return numRating(song.albumUserRating);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random mixes: when enabled, drop items with a **non-zero** rating that is **at or below** the
|
||||
* chosen threshold (inclusive). `0` / missing = unrated, never excluded.
|
||||
*/
|
||||
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
|
||||
if (!c.enabled) return true;
|
||||
if (c.minSong > 0) {
|
||||
const r = numRating(song.userRating);
|
||||
if (r !== undefined && r > 0 && r <= c.minSong) return false;
|
||||
}
|
||||
if (c.minAlbum > 0) {
|
||||
const r = effectiveAlbumRatingOnSong(song);
|
||||
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
|
||||
}
|
||||
if (c.minArtist > 0) {
|
||||
const r = effectiveArtistRatingForFilter(song);
|
||||
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface MixAlbumFilterExtra {
|
||||
/** From `getArtist` when list payloads omit artist rating. */
|
||||
artistUserRating?: number;
|
||||
/** From `getAlbum` when list payloads omit album `userRating`. */
|
||||
albumUserRating?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Random album lists: album `userRating` when present; optional extra from entity fetches.
|
||||
* Song axis is not on this payload. `0` / missing = unrated, keep.
|
||||
*/
|
||||
export function passesMixMinRatingsForAlbum(
|
||||
album: SubsonicAlbum,
|
||||
c: MixMinRatingsConfig,
|
||||
extra?: MixAlbumFilterExtra,
|
||||
): boolean {
|
||||
if (!c.enabled) return true;
|
||||
if (c.minAlbum > 0) {
|
||||
const r = numRating(album.userRating ?? extra?.albumUserRating);
|
||||
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
|
||||
}
|
||||
if (c.minArtist > 0) {
|
||||
const r = numRating(extra?.artistUserRating);
|
||||
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches missing entity ratings (bounded concurrency) then filters. Used for random album grids / hero.
|
||||
*/
|
||||
export async function filterAlbumsByMixRatings(
|
||||
albums: SubsonicAlbum[],
|
||||
c: MixMinRatingsConfig,
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
if (!c.enabled) return albums;
|
||||
if (c.minAlbum <= 0 && c.minArtist <= 0) return albums;
|
||||
const needArtist = c.minArtist > 0;
|
||||
const needAlbum = c.minAlbum > 0;
|
||||
let byArtist = new Map<string, number>();
|
||||
let byAlbum = new Map<string, number>();
|
||||
if (needArtist) {
|
||||
const ids = [...new Set(albums.map(a => a.artistId).filter(Boolean))] as string[];
|
||||
byArtist = await prefetchArtistUserRatings(ids);
|
||||
}
|
||||
if (needAlbum) {
|
||||
const ids = [...new Set(albums.filter(a => a.userRating === undefined).map(a => a.id))];
|
||||
if (ids.length) byAlbum = await prefetchAlbumUserRatings(ids);
|
||||
}
|
||||
return albums.filter(a =>
|
||||
passesMixMinRatingsForAlbum(a, c, {
|
||||
artistUserRating: a.artistId ? byArtist.get(a.artistId) : undefined,
|
||||
albumUserRating: byAlbum.get(a.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `getArtist` / `getAlbum` ratings into songs before `passesMixMinRatings` when list payloads omit them.
|
||||
*/
|
||||
export async function enrichSongsForMixRatingFilter(
|
||||
songs: SubsonicSong[],
|
||||
c: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
|
||||
const artistIds =
|
||||
c.minArtist > 0
|
||||
? [...new Set(songs.filter(s => s.artistUserRating === undefined && effectiveArtistRatingForFilter(s) === undefined && s.artistId).map(s => s.artistId!))]
|
||||
: [];
|
||||
const albumIds =
|
||||
c.minAlbum > 0
|
||||
? [...new Set(songs.filter(s => s.albumUserRating === undefined && s.albumId).map(s => s.albumId!))]
|
||||
: [];
|
||||
const [byArtist, byAlbum] = await Promise.all([
|
||||
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
|
||||
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
|
||||
]);
|
||||
if (!byArtist.size && !byAlbum.size) return songs;
|
||||
return songs.map(s => ({
|
||||
...s,
|
||||
...(s.artistUserRating === undefined &&
|
||||
s.artistId &&
|
||||
byArtist.has(s.artistId) && { artistUserRating: byArtist.get(s.artistId)! }),
|
||||
...(s.albumUserRating === undefined &&
|
||||
s.albumId &&
|
||||
byAlbum.has(s.albumId) && { albumUserRating: byAlbum.get(s.albumId)! }),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads random songs in batches until `RANDOM_MIX_TARGET_SIZE` pass `passesMixMinRatings` (after enrich),
|
||||
* or limits are hit. When the mix rating filter is off, a single batch is used.
|
||||
*/
|
||||
export async function fetchRandomMixSongsUntilFull(
|
||||
c: MixMinRatingsConfig,
|
||||
opts?: { genre?: string; timeout?: number },
|
||||
): Promise<SubsonicSong[]> {
|
||||
const timeout = opts?.timeout ?? 15000;
|
||||
const genre = opts?.genre;
|
||||
|
||||
if (!c.enabled) {
|
||||
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
|
||||
return raw.slice(0, RANDOM_MIX_TARGET_SIZE);
|
||||
}
|
||||
|
||||
const out: SubsonicSong[] = [];
|
||||
const outIds = new Set<string>();
|
||||
const seenFromApi = new Set<string>();
|
||||
let dupStreak = 0;
|
||||
|
||||
for (let b = 0; b < RANDOM_MIX_MAX_BATCHES && out.length < RANDOM_MIX_TARGET_SIZE; b++) {
|
||||
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
|
||||
if (!raw.length) break;
|
||||
|
||||
const novel = raw.filter(s => !seenFromApi.has(s.id));
|
||||
for (const s of raw) seenFromApi.add(s.id);
|
||||
|
||||
if (!novel.length) {
|
||||
if (++dupStreak >= RANDOM_MIX_MAX_DUP_STREAK) break;
|
||||
continue;
|
||||
}
|
||||
dupStreak = 0;
|
||||
|
||||
const enriched = await enrichSongsForMixRatingFilter(novel, c);
|
||||
for (const s of enriched) {
|
||||
if (!passesMixMinRatings(s, c) || outIds.has(s.id)) continue;
|
||||
outIds.add(s.id);
|
||||
out.push(s);
|
||||
if (out.length >= RANDOM_MIX_TARGET_SIZE) break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -24,8 +24,13 @@ function loadPrefs(
|
||||
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
|
||||
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
|
||||
columns.filter(c => c.required).forEach(c => visible.add(c.key));
|
||||
const widths = { ...defaultWidths, ...(parsed.widths ?? {}) };
|
||||
const durationCol = columns.find(c => c.key === 'duration');
|
||||
if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) {
|
||||
widths.duration = defaultWidths.duration;
|
||||
}
|
||||
return {
|
||||
widths: { ...defaultWidths, ...(parsed.widths ?? {}) },
|
||||
widths,
|
||||
visible,
|
||||
};
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user