mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user