diff --git a/src/App.tsx b/src/App.tsx index 7898f83f..487922f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(() => { diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index e92f75a7..35bb5539 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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> { + const unique = [...new Set(ids.filter(Boolean))]; + const out = new Map(); + 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> { + const unique = [...new Set(ids.filter(Boolean))]; + const out = new Map(); + 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 { 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 { 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 { + 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 { try { await api('scrobble.view', { id, time, submission: true }); diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index f9159913..3cc60fec 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -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({ )} +
+ {t('entityRating.albumShort')} + +
{isMobile ? (
{/* Row 1 — Primary actions */} diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 3b43acb4..268fc109 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -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 ( -
- {[1, 2, 3, 4, 5].map(n => ( - - ))} -
- ); -} - // ── 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(['favorite', 'rating', 'duration']); // ── Props ───────────────────────────────────────────────────────────────────── @@ -76,6 +54,8 @@ interface AlbumTrackListProps { currentTrack: Track | null; isPlaying: boolean; ratings: Record; + /** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */ + userRatingOverrides: Record; starredSongs: Set; 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 ( -
-
- {label} +
+
+ {label}
{isResizable && (
startResize(e, colIndex, 1)} /> @@ -266,7 +255,7 @@ export default function AlbumTrackList({ return ( onRate(song.id, r)} /> ); diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 7d5ab9d1..521fd0aa 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -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([]); const [activeIdx, setActiveIdx] = useState(0); const timerRef = useRef | 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) => { diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 50d59410..90ef3849 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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 && (
{currentTrack.year}
)} - {renderStars(currentTrack.userRating)} + {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
diff --git a/src/components/StarRating.tsx b/src/components/StarRating.tsx new file mode 100644 index 00000000..baea128c --- /dev/null +++ b/src/components/StarRating.tsx @@ -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(null); + const [clearShrinkStar, setClearShrinkStar] = React.useState(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 ( +
+ {stars.map(n => { + const locked = n > selectCap; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 0f8d1cf9..26f69716 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -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(['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(null); - const heightsRef = useRef(null); - const progressRef = useRef(0); - const bufferedRef = useRef(0); - const isDragging = useRef(false); + const canvasRef = useRef(null); + const heightsRef = useRef(null); + const progressRef = useRef(0); + const bufferedRef = useRef(0); + const isDragging = useRef(false); + const animStateRef = useRef(makeAnimState()); const [hoverPct, setHoverPct] = useState(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(); diff --git a/src/locales/de.ts b/src/locales/de.ts index e26b9636..22bbc765 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 376fe83d..6e906866 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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", diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b0cc9f02..8f69d0cf 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index a7f8f789..55a1a348 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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", diff --git a/src/locales/nl.ts b/src/locales/nl.ts index f1a8d98a..8c14883b 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index f5ff9913..346be865 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -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: 'Что нового', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index fe48cc12..70d65077 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -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: '新功能', diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 28033ae0..f28355b7 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -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 && (
@@ -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), diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 5b852cce..11e91495 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -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 })}
+
+ {t('entityRating.artistShort')} + +
+
{(info?.lastFmUrl || artist.name) && (
diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index daf90436..40f01a91 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -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 (
-
- {label} +
+ {label}
{!isLastCol &&
startResize(e, colIndex, 1)} />}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 8ba36968..383ee1e8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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([]); @@ -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([]), - ]).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([]), + ]); + 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); diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index 6d2defbe..cdfd6ca1 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -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 && {currentTrack.suffix.toUpperCase()}} {currentTrack.bitRate && {currentTrack.bitRate} kbps} {currentTrack.duration && {formatTime(currentTrack.duration)}} - {renderStars(currentTrack.userRating)} + {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)} - ))} -
- ); -} - // ── 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
; return (
-
- {label} +
+ {label}
{!isLastCol && key !== 'delete' && (
startResize(e, colIndex, 1)} /> @@ -851,7 +846,7 @@ export default function PlaylistDetail() {
); - case 'rating': return handleRate(song.id, r)} />; + case 'rating': return handleRate(song.id, r)} />; case 'duration': return
{formatDuration(song.duration ?? 0)}
; case 'format': return (
@@ -917,7 +912,7 @@ export default function PlaylistDetail() { if (key === 'title') return
{label}
; if (key === 'delete') return
; if (key === 'favorite' || key === 'rating') return
; - return
{label}
; + return
{label}
; })}
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index 9404c444..281dfb9f 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -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 { 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([]); const [loading, setLoading] = useState(true); const [selectedGenres, setSelectedGenres] = useState([]); @@ -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]); diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 23e85088..09c35d04 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -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(null); const psyDrag = useDragDrop(); const [starredSongs, setStarredSongs] = useState>(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(null); const [addedArtist, setAddedArtist] = useState(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(); - 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); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 6cc91176..b1e432ca 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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() {
+ {/* Ratings (single block under Random Mix) */} +
+
+ +

{t('settings.ratingsSectionTitle')}

+
+
+
+
+
{t('settings.ratingsSkipStarTitle')}
+
{t('settings.ratingsSkipStarDesc')}
+
+
+ {auth.skipStarOnManualSkipsEnabled && ( + <> + + auth.setSkipStarManualSkipThreshold(Number(e.target.value))} + style={{ width: 72, padding: '6px 10px', fontSize: 13 }} + aria-label={t('settings.ratingsSkipStarThresholdLabel')} + /> + + )} + +
+
+ +
+ +
+
+
{t('settings.ratingsMixFilterTitle')}
+
+ {t('settings.ratingsMixFilterDesc', { + mix: t('sidebar.randomMix'), + albums: t('sidebar.randomAlbums'), + })} +
+
+ +
+ {auth.mixMinRatingFilterEnabled && ( + <> +
+
+ {([ + { 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 => ( +
+ {row.label} + +
+ ))} +
+ + )} +
+
+ )} @@ -1158,7 +1264,7 @@ export default function Settings() { {t('settings.seekbarStyleDesc')}
- {(['waveform', 'linedot', 'bar', 'thick', 'segmented'] as SeekbarStyle[]).map(style => ( + {(['waveform', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => ( /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; + /** 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; + 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 { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + const next: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + 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()( persist( (set, get) => ({ @@ -175,9 +239,17 @@ export const useAuthStore = create()( 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()( 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()( 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()( })); }, + setEntityRatingSupport: (serverId, level) => + set(s => ({ + entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level }, + })), + logout: () => set({ isLoggedIn: false, musicFolders: [] }), getBaseUrl: () => { @@ -307,6 +424,17 @@ export const useAuthStore = create()( 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, + ), + }); + }, } ) ); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 6c82fb1c..b647721e 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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; starredOverrides: Record; setStarredOverride: (id: string, starred: boolean) => void; + /** Optimistic track ratings (e.g. skip→1★ while UI lists still have stale `song.userRating`). */ + userRatingOverrides: Record; + 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