diff --git a/src/datasource/DataSource.ts b/src/datasource/DataSource.ts index 8c0e0a5c..2da0f1eb 100644 --- a/src/datasource/DataSource.ts +++ b/src/datasource/DataSource.ts @@ -11,6 +11,7 @@ import type { FeedNotification, LibrarySnapshot, Lyrics, + LyricsQuery, Playlist, ResolvedLink, RustAudioSource, @@ -170,5 +171,5 @@ export abstract class DataSource { /** Discovery shelves for a track: similar artists, related playlists, more from the album. */ getRelated?(track: Track): Promise; getBrowsePage?(target: BrowseTarget): Promise; - getLyrics?(track: Track): Promise; + getLyrics?(track: Track, query?: LyricsQuery): Promise; } diff --git a/src/datasource/types.ts b/src/datasource/types.ts index 1fac7cef..50ccf40a 100644 --- a/src/datasource/types.ts +++ b/src/datasource/types.ts @@ -50,6 +50,12 @@ export interface LyricsSourceAttempt { detail?: string; } +/** A listener-typed title and artist, replacing the track's own for one lookup. */ +export interface LyricsQuery { + title: string; + artist: string; +} + export interface Lyrics { lines: LyricLine[]; timing: "synced" | "estimated" | "none"; diff --git a/src/datasource/youtube/YouTubeMusicDataSource.ts b/src/datasource/youtube/YouTubeMusicDataSource.ts index 357311d8..a0d4a950 100644 --- a/src/datasource/youtube/YouTubeMusicDataSource.ts +++ b/src/datasource/youtube/YouTubeMusicDataSource.ts @@ -33,6 +33,7 @@ import type { FeedNotification, LibrarySnapshot, Lyrics, + LyricsQuery, LyricsSourceAttempt, Playlist, ResolvedLink, @@ -4613,7 +4614,7 @@ export class YouTubeMusicDataSource extends DataSource { } } - async getLyrics(track: Track): Promise { + async getLyrics(track: Track, query?: LyricsQuery): Promise { /* * v3: the cached shape now carries the per-source attempt log, and a v2 entry would * leave the lyrics screen unable to say where its words came from. @@ -4622,12 +4623,18 @@ export class YouTubeMusicDataSource extends DataSource { * one key would mean changing the setting appears to do nothing until the cache expires. */ const cacheKey = `lyrics:synced:v3:${getPreferredLyricsSourceId()}:${track.id}`; - const cached = await getCachedJson(cacheKey); - if (cached?.timing === "synced" && cached.lines.length > 0) return cached; + // A manual search (#89) skips the cache and overwrites it on a synced hit, so the fix sticks. + if (!query) { + const cached = await getCachedJson(cacheKey); + if (cached?.timing === "synced" && cached.lines.length > 0) return cached; + } + const searchTrack = query + ? { ...track, title: query.title, artist: query.artist, artists: undefined, album: undefined } + : track; - let refresh = this.lyricsRefreshPromises.get(track.id); + let refresh = query ? undefined : this.lyricsRefreshPromises.get(track.id); if (!refresh) { - refresh = this.fetchSyncedLyrics(track).finally(() => { + refresh = this.fetchSyncedLyrics(searchTrack).finally(() => { this.lyricsRefreshPromises.delete(track.id); }); this.lyricsRefreshPromises.set(track.id, refresh); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 5b1e730c..d384dcbf 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -1,5 +1,5 @@ import type { DataSource, StreamData } from "../datasource/DataSource"; -import type { Lyrics, Track } from "../datasource/types"; +import type { Lyrics, LyricsQuery, Track } from "../datasource/types"; import { logInternalDebug, logInternalError, logInternalInfo, logInternalWarn } from "../internal/logging"; import { isPrematureEnd } from "./prematureEnd"; import { AudioEngine, isEmbedRestrictedPlaybackError } from "./AudioEngine"; @@ -1920,7 +1920,7 @@ export class PlayerController { savePlaybackSettings(this.currentPlaybackSettings()); } - async getLyrics(track: Track): Promise { - return this.dataSource.getLyrics?.(track) ?? null; + async getLyrics(track: Track, query?: LyricsQuery): Promise { + return this.dataSource.getLyrics?.(track, query) ?? null; } } diff --git a/src/player/playerStore.ts b/src/player/playerStore.ts index f51060b6..fe2e4e7b 100644 --- a/src/player/playerStore.ts +++ b/src/player/playerStore.ts @@ -153,8 +153,8 @@ class ActivePlayerController implements PlayerControllerActions { tabManager.getActivePlayer().setSleepTimer(minutes); getSleepTimerRemainingMs = () => tabManager.getActivePlayer().getSleepTimerRemainingMs(); - getLyrics = (track: Parameters[0]) => - tabManager.getActivePlayer().getLyrics(track); + getLyrics = (...args: Parameters) => + tabManager.getActivePlayer().getLyrics(...args); getPlayerSession = () => tabManager.getActivePlayer().exportSession(); removeFromQueueAt = (index: number) => tabManager.getActivePlayer().removeFromQueueAt(index); playQueueTrackAt = (index: number) => tabManager.getActivePlayer().playQueueTrackAt(index); diff --git a/src/ui/pages/LyricsView.tsx b/src/ui/pages/LyricsView.tsx index a6ee6846..aa573adb 100644 --- a/src/ui/pages/LyricsView.tsx +++ b/src/ui/pages/LyricsView.tsx @@ -1,4 +1,5 @@ import { + type FormEvent, type KeyboardEvent, memo, useCallback, @@ -10,7 +11,13 @@ import { import { useReduceMotion } from "../settings/renderEffects"; import { cn } from "@/lib/utils"; import { CloseIcon, FullScreenIcon, LyricsIcon, QuitFullScreenIcon, RefreshIcon } from "@/ui/icons"; -import type { Lyrics, LyricsSourceAttempt, LyricsSourceStatus } from "../../datasource/types"; +import type { + Lyrics, + LyricsQuery, + LyricsSourceAttempt, + LyricsSourceStatus, + Track, +} from "../../datasource/types"; import { LYRICS_SOURCES } from "../../datasource/youtube/lyricsSources"; import { FloatingPanel } from "../components/FloatingPanel"; import { logInternalWarn } from "../../internal/logging"; @@ -31,19 +38,18 @@ const AUTO_SCROLL_RESUME_MS = 4500; const PAUSED_SAMPLE_MS = 250; /** - * Depth by distance from the active line: opacity, then blur. + * Depth by distance from the active line, as opacity only. * - * The blur is what makes the column read as a focal plane rather than a dimmed list, but it - * is a GPU filter and every blurred node is its own layer — so it stops after four lines - * either side. Past that the opacity alone is low enough that nobody can tell. + * No per-line blur: every line re-filtering at once on open, song change and play start + * spiked the webview by ~4 GB for seconds (#135, #90). */ const DEPTH = [ - { opacity: 1, blur: 0 }, - { opacity: 0.55, blur: 0.7 }, - { opacity: 0.36, blur: 1.5 }, - { opacity: 0.24, blur: 2.4 }, - { opacity: 0.16, blur: 3.2 }, - { opacity: 0.12, blur: 0 }, + { opacity: 1 }, + { opacity: 0.55 }, + { opacity: 0.36 }, + { opacity: 0.24 }, + { opacity: 0.16 }, + { opacity: 0.12 }, ]; /* @@ -88,6 +94,8 @@ export function LyricsView({ onClose }: LyricsViewProps) { const [isLoading, setIsLoading] = useState(false); const [failed, setFailed] = useState(false); const [reloadToken, setReloadToken] = useState(0); + // Tagged with its track so skipping to another song drops back to the automatic lookup. + const [manualQuery, setManualQuery] = useState<(LyricsQuery & { trackId: string }) | null>(null); const [activeIndex, setActiveIndex] = useState(-1); const [isFollowPaused, setIsFollowPaused] = useState(false); const [focusIndex, setFocusIndex] = useState(null); @@ -144,7 +152,8 @@ export function LyricsView({ onClose }: LyricsViewProps) { if (!track) return; setIsLoading(true); - void playerController.getLyrics(track) + const query = manualQuery?.trackId === track.id ? manualQuery : undefined; + void playerController.getLyrics(track, query) .then((result) => { if (!cancelled) setLyrics(result); }) @@ -162,7 +171,7 @@ export function LyricsView({ onClose }: LyricsViewProps) { return () => { cancelled = true; }; - }, [track?.id, reloadToken]); + }, [track?.id, reloadToken, manualQuery]); /* * Every provider is a network call, so a song opened offline has nothing to show. Retrying @@ -642,7 +651,16 @@ export function LyricsView({ onClose }: LyricsViewProps) { sourceLabel && via {sourceLabel} )} - {isSynced && track && } + {track && ( + + setManualQuery({ ...query, trackId: track.id })} + /> + {isSynced && } + + )} ); @@ -732,7 +750,7 @@ const SyncedLine = memo(function SyncedLine({ // Using (text-start) instead of (text-left) className={cn( "group relative origin-left text-pretty text-start font-bold leading-[1.16] tracking-[-0.035em]", - "transition-[opacity,filter,color] duration-500 ease-out", + "transition-[opacity,color] duration-500 ease-out", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", /* * The sweep paints its own colour through background-clip, so the active line must @@ -743,10 +761,7 @@ const SyncedLine = memo(function SyncedLine({ isActive && !reduce ? "lyric-sweep" : "text-foreground", !isActive && "hover:opacity-100", )} - style={{ - opacity: depth.opacity, - filter: depth.blur ? `blur(${depth.blur}px)` : undefined, - }} + style={{ opacity: depth.opacity }} onClick={() => onSeek(index)} > {/* The one piece of brand colour on the screen, and the only thing marking which line @@ -858,6 +873,79 @@ function LyricsSourcePanel({ ); } +const SEARCH_INPUT = + "w-full min-w-0 rounded-lg bg-background px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"; + +/** + * Searches the lyric sources with a typed title and artist instead of the video's own. + * + * Uploads titled "Song / Artist // Letra" on a fan's channel otherwise search for the wrong + * song entirely (#89). Pre-filled with the track so a small correction is a small edit. + */ +function LyricsSearchPanel({ + track, + onSearch, +}: { + track: Track; + onSearch: (query: LyricsQuery) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const [title, setTitle] = useState(track.title); + const [artist, setArtist] = useState(track.artist); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!title.trim()) return; + onSearch({ title: title.trim(), artist: artist.trim() }); + setIsOpen(false); + }; + + return ( + setIsOpen((open) => !open)} + aria-expanded={isOpen} + aria-haspopup="dialog" + > + Search lyrics + + } + > +
+ setTitle(event.target.value)} + placeholder="Song title" + aria-label="Song title" + className={SEARCH_INPUT} + /> + setArtist(event.target.value)} + placeholder="Artist" + aria-label="Artist" + className={SEARCH_INPUT} + /> + +
+
+ ); +} + function formatOffset(offset: number): string { if (offset === 0) return "In sync"; const magnitude = Math.abs(offset).toFixed(2).replace(/\.?0+$/, ""); diff --git a/src/ui/settings/lyricsFontScale.check.ts b/src/ui/settings/lyricsFontScale.check.ts index 32c17766..265d0e91 100644 --- a/src/ui/settings/lyricsFontScale.check.ts +++ b/src/ui/settings/lyricsFontScale.check.ts @@ -45,7 +45,7 @@ for (const option of LYRICS_FONT_SCALES) { } equal(normalizeFontScale(1.19), 1.2, "a near miss snaps to the closest step"); -equal(normalizeFontScale(0.1), 0.85, "an absurdly small value clamps to the smallest step"); +equal(normalizeFontScale(0.1), 0.7, "an absurdly small value clamps to the smallest step"); equal(normalizeFontScale(99), 1.45, "an absurdly large one clamps to the largest"); equal(normalizeFontScale(Number.NaN), DEFAULT_LYRICS_FONT_SCALE, "NaN falls back to default"); equal( diff --git a/src/ui/settings/lyricsFontScale.ts b/src/ui/settings/lyricsFontScale.ts index 3644766c..bcf7920b 100644 --- a/src/ui/settings/lyricsFontScale.ts +++ b/src/ui/settings/lyricsFontScale.ts @@ -13,6 +13,7 @@ const CHANGE_EVENT = "lyrics-font-scale-change"; export const DEFAULT_LYRICS_FONT_SCALE = 1; export const LYRICS_FONT_SCALES = [ + { value: 0.7, label: "Extra small" }, { value: 0.85, label: "Small" }, { value: 1, label: "Default" }, { value: 1.2, label: "Large" }, diff --git a/src/ui/settings/renderEffects.ts b/src/ui/settings/renderEffects.ts index 60d3dce8..8e3873d4 100644 --- a/src/ui/settings/renderEffects.ts +++ b/src/ui/settings/renderEffects.ts @@ -49,7 +49,7 @@ export const RENDER_EFFECTS = [ { id: "filters", label: "Blur and colour filters", - description: "Blurred artwork washes and the lyrics depth-of-field.", + description: "Blurred artwork washes.", }, { id: "shadows",