fix(seekbar): pulsewave clean connection, retrotape rolling wheel at playhead

Pulsewave: draw flat played line only up to where the Gaussian envelope
starts (startX), eliminating overlap between the flat line and the wave.

Retrotape: replace dual fixed-position reels with a single spinning reel
that tracks the playhead position across the full seek bar width.

fix(i18n): remove literal 'N' from skip-star descriptions in all languages

Replace 'After N skips' with natural phrasing ('after several skips') in
EN, FR, NL, NB, ZH. Simplify ratingsSkipStarThresholdLabel to a single
word (Skips / Sauts / Overslagen / Hopp / 跳过次数). DE was already fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-08 12:46:58 +02:00
parent e64d151079
commit f0438cae5e
7 changed files with 80 additions and 84 deletions
+50 -54
View File
@@ -381,16 +381,6 @@ function drawPulseWave(
ctx.fillRect(0, cy - 1, buffered * w, 2); 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 // Animated pulse centered at playhead
const pulseR = Math.min(38, w * 0.13); const pulseR = Math.min(38, w * 0.13);
const amp = Math.min(h * 0.42, 5.5); const amp = Math.min(h * 0.42, 5.5);
@@ -398,6 +388,16 @@ function drawPulseWave(
const startX = Math.max(0, px - pulseR); const startX = Math.max(0, px - pulseR);
const endX = Math.min(w, px + pulseR); const endX = Math.min(w, px + pulseR);
// Flat played line up to where the wave envelope starts
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 3;
ctx.fillRect(0, cy - 1, startX, 2);
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.strokeStyle = played; ctx.strokeStyle = played;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
@@ -604,80 +604,76 @@ function drawLiquidFill(
function drawRetroTape( function drawRetroTape(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
progress: number, progress: number,
_buffered: number, buffered: number,
animState: AnimState, animState: AnimState,
) { ) {
const r = setupCanvas(canvas); const r = setupCanvas(canvas);
if (!r) return; if (!r) return;
const { ctx, w, h } = r; const { ctx, w, h } = r;
const { played, unplayed } = getColors(); const { played, buffered: buffCol, unplayed } = getColors();
const cy = h / 2; const cy = h / 2;
animState.angle += 0.042; animState.angle += 0.055;
const maxR = Math.floor(h / 2) - 1; const reelR = Math.min(h / 2 - 0.5, 9);
const minR = Math.max(2, maxR * 0.28); // Map progress to a center x that keeps the reel fully within the canvas
// supply reel (left): shrinks as progress increases const px = reelR + (w - 2 * reelR) * progress;
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 // Background track
const tapeLeft = lx + rL + 0.5; ctx.globalAlpha = 0.3;
const tapeRight = rx - rR - 0.5;
if (tapeRight > tapeLeft) {
ctx.globalAlpha = 0.45;
ctx.fillStyle = unplayed; ctx.fillStyle = unplayed;
ctx.fillRect(tapeLeft, cy - 1.5, tapeRight - tapeLeft, 3); ctx.fillRect(0, cy - 1, w, 2);
if (buffered > 0) {
ctx.globalAlpha = 0.5;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - 1, buffered * w, 2);
} }
const drawReel = ( // Played portion — up to the left edge of the reel
cx: number, if (progress > 0) {
radius: number, ctx.globalAlpha = 1;
angle: number, ctx.fillStyle = played;
isRight: boolean, ctx.shadowColor = played;
) => { ctx.shadowBlur = 4;
const color = isRight ? played : unplayed; ctx.fillRect(0, cy - 1, px - reelR, 2);
const alpha = isRight ? (progress > 0 ? 1 : 0.35) : (progress < 1 ? 0.55 : 0.35); ctx.shadowBlur = 0;
const glowing = isRight && progress > 0; }
ctx.globalAlpha = alpha; // Spinning reel at playhead
if (glowing) { ctx.shadowColor = played; ctx.shadowBlur = 6; } ctx.globalAlpha = 1;
ctx.strokeStyle = played;
ctx.lineWidth = 1;
ctx.shadowColor = played;
ctx.shadowBlur = 7;
// Outer ring // Outer ring
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.arc(px, cy, reelR, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// Hub // Hub
const hubR = Math.max(1.5, radius * 0.3); const hubR = Math.max(1.5, reelR * 0.28);
ctx.fillStyle = color; ctx.fillStyle = played;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, hubR, 0, Math.PI * 2); ctx.arc(px, cy, hubR, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// Spokes (only if reel is large enough) // Spokes
if (radius > hubR + 2.5) { if (reelR > hubR + 2) {
ctx.lineWidth = 0.9; ctx.lineWidth = 0.9;
ctx.strokeStyle = color; ctx.strokeStyle = played;
for (let s = 0; s < 3; s++) { for (let s = 0; s < 3; s++) {
const a = angle + (s * Math.PI * 2) / 3; const a = animState.angle + (s * Math.PI * 2) / 3;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5)); ctx.moveTo(px + 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.lineTo(px + Math.cos(a) * (reelR - 0.5), cy + Math.sin(a) * (reelR - 0.5));
ctx.stroke(); ctx.stroke();
} }
} }
};
drawReel(lx, rL, -animState.angle, false);
drawReel(rx, rR, animState.angle, true);
ctx.shadowBlur = 0;
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
+5 -5
View File
@@ -520,13 +520,13 @@ export const deTranslation = {
tabSystem: 'System', tabSystem: 'System',
tabGeneral: 'Allgemein', tabGeneral: 'Allgemein',
ratingsSectionTitle: 'Bewertungen', ratingsSectionTitle: 'Bewertungen',
ratingsSkipStarTitle: 'Überspringen für 1 Stern', ratingsSkipStarTitle: 'Skip → 1 Stern',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'Bei N Überspringen hintereinander: Titel auf 1 Stern setzen. Nur für zuvor unbewertete Titel.', 'Wird ein Titel mehrmals hintereinander übersprungen, bekommt er automatisch 1★. Nur für noch nicht bewertete Titel.',
ratingsSkipStarThresholdLabel: 'Überspringer bis 1★', ratingsSkipStarThresholdLabel: 'Skips',
ratingsMixFilterTitle: 'Filter nach Bewertung', ratingsMixFilterTitle: 'Nach Bewertung filtern',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Inhalte mit niedriger Bewertung in {{mix}} und {{albums}} filtern. Erneut auf den gewählten Stern klicken, um die Schwelle zu deaktivieren.', 'Gering bewertete Titel in {{mix}} und {{albums}} ausblenden. Nochmals auf den gewählten Stern klicken, um den Filter zu deaktivieren.',
ratingsMixMinSong: 'Titel', ratingsMixMinSong: 'Titel',
ratingsMixMinAlbum: 'Alben', ratingsMixMinAlbum: 'Alben',
ratingsMixMinArtist: 'Interpreten', ratingsMixMinArtist: 'Interpreten',
+2 -2
View File
@@ -507,8 +507,8 @@ export const enTranslation = {
ratingsSectionTitle: 'Ratings', ratingsSectionTitle: 'Ratings',
ratingsSkipStarTitle: 'Skip for 1 star', ratingsSkipStarTitle: 'Skip for 1 star',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'After N skips in a row, set the track to 1★. Only for tracks not rated before.', 'After several skips in a row, set the track to 1★. Only for tracks not yet rated.',
ratingsSkipStarThresholdLabel: 'Skips before 1★', ratingsSkipStarThresholdLabel: 'Skips',
ratingsMixFilterTitle: 'Filter by rating', ratingsMixFilterTitle: 'Filter by rating',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.', 'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.',
+3 -3
View File
@@ -520,11 +520,11 @@ export const frTranslation = {
ratingsSectionTitle: 'Notes', ratingsSectionTitle: 'Notes',
ratingsSkipStarTitle: 'Passer pour 1 étoile', ratingsSkipStarTitle: 'Passer pour 1 étoile',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'Après N sauts daffilée : mettre le morceau à 1 étoile. Uniquement sil n’était pas encore noté.', "Après plusieurs sauts daffilée, le morceau passe à 1 étoile. Uniquement sil n’était pas encore noté.",
ratingsSkipStarThresholdLabel: 'Sauts avant 1★', ratingsSkipStarThresholdLabel: 'Sauts',
ratingsMixFilterTitle: 'Filtrage par note', ratingsMixFilterTitle: 'Filtrage par note',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.', "Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.",
ratingsMixMinSong: 'Morceaux', ratingsMixMinSong: 'Morceaux',
ratingsMixMinAlbum: 'Albums', ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artistes', ratingsMixMinArtist: 'Artistes',
+2 -2
View File
@@ -503,8 +503,8 @@ export const nbTranslation = {
ratingsSectionTitle: 'Vurderinger', ratingsSectionTitle: 'Vurderinger',
ratingsSkipStarTitle: 'Hopp for 1 stjerne', ratingsSkipStarTitle: 'Hopp for 1 stjerne',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'Etter N hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.', 'Etter flere hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
ratingsSkipStarThresholdLabel: 'Hopp før 1★', ratingsSkipStarThresholdLabel: 'Hopp',
ratingsMixFilterTitle: 'Filtrering etter vurdering', ratingsMixFilterTitle: 'Filtrering etter vurdering',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.', 'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.',
+2 -2
View File
@@ -520,8 +520,8 @@ export const nlTranslation = {
ratingsSectionTitle: 'Beoordelingen', ratingsSectionTitle: 'Beoordelingen',
ratingsSkipStarTitle: 'Overslaan voor 1 ster', ratingsSkipStarTitle: 'Overslaan voor 1 ster',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'Na N overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.', 'Na meerdere overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.',
ratingsSkipStarThresholdLabel: 'Overslagen voor 1★', ratingsSkipStarThresholdLabel: 'Overslagen',
ratingsMixFilterTitle: 'Filter op beoordeling', ratingsMixFilterTitle: 'Filter op beoordeling',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.', 'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.',
+2 -2
View File
@@ -500,8 +500,8 @@ export const zhTranslation = {
ratingsSectionTitle: '评分', ratingsSectionTitle: '评分',
ratingsSkipStarTitle: '跳过以评 1 星', ratingsSkipStarTitle: '跳过以评 1 星',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'连续跳过 N 次后将曲目设为 1 星。仅适用于此前未评分的曲目。', '连续多次跳过后将曲目设为 1 星。仅适用于此前未评分的曲目。',
ratingsSkipStarThresholdLabel: '跳过次数(至 1★)', ratingsSkipStarThresholdLabel: '跳过次数',
ratingsMixFilterTitle: '按评分筛选', ratingsMixFilterTitle: '按评分筛选',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。', '在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。',