Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/datasource/DataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
FeedNotification,
LibrarySnapshot,
Lyrics,
LyricsQuery,
Playlist,
ResolvedLink,
RustAudioSource,
Expand Down Expand Up @@ -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<BrowseShelf[]>;
getBrowsePage?(target: BrowseTarget): Promise<BrowsePage>;
getLyrics?(track: Track): Promise<Lyrics | null>;
getLyrics?(track: Track, query?: LyricsQuery): Promise<Lyrics | null>;
}
6 changes: 6 additions & 0 deletions src/datasource/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
17 changes: 12 additions & 5 deletions src/datasource/youtube/YouTubeMusicDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
FeedNotification,
LibrarySnapshot,
Lyrics,
LyricsQuery,
LyricsSourceAttempt,
Playlist,
ResolvedLink,
Expand Down Expand Up @@ -4613,7 +4614,7 @@ export class YouTubeMusicDataSource extends DataSource {
}
}

async getLyrics(track: Track): Promise<Lyrics> {
async getLyrics(track: Track, query?: LyricsQuery): Promise<Lyrics> {
/*
* 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.
Expand All @@ -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<Lyrics>(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<Lyrics>(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);
Expand Down
6 changes: 3 additions & 3 deletions src/player/PlayerController.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -1920,7 +1920,7 @@ export class PlayerController {
savePlaybackSettings(this.currentPlaybackSettings());
}

async getLyrics(track: Track): Promise<Lyrics | null> {
return this.dataSource.getLyrics?.(track) ?? null;
async getLyrics(track: Track, query?: LyricsQuery): Promise<Lyrics | null> {
return this.dataSource.getLyrics?.(track, query) ?? null;
}
}
4 changes: 2 additions & 2 deletions src/player/playerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ class ActivePlayerController implements PlayerControllerActions {
tabManager.getActivePlayer().setSleepTimer(minutes);
getSleepTimerRemainingMs = () =>
tabManager.getActivePlayer().getSleepTimerRemainingMs();
getLyrics = (track: Parameters<PlayerController["getLyrics"]>[0]) =>
tabManager.getActivePlayer().getLyrics(track);
getLyrics = (...args: Parameters<PlayerController["getLyrics"]>) =>
tabManager.getActivePlayer().getLyrics(...args);
getPlayerSession = () => tabManager.getActivePlayer().exportSession();
removeFromQueueAt = (index: number) => tabManager.getActivePlayer().removeFromQueueAt(index);
playQueueTrackAt = (index: number) => tabManager.getActivePlayer().playQueueTrackAt(index);
Expand Down
126 changes: 107 additions & 19 deletions src/ui/pages/LyricsView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type FormEvent,
type KeyboardEvent,
memo,
useCallback,
Expand All @@ -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";
Expand All @@ -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 },
];

/*
Expand Down Expand Up @@ -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<number | null>(null);
Expand Down Expand Up @@ -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);
})
Expand All @@ -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
Expand Down Expand Up @@ -642,7 +651,16 @@ export function LyricsView({ onClose }: LyricsViewProps) {
sourceLabel && <span className="truncate">via {sourceLabel}</span>
)}
</span>
{isSynced && track && <LyricsOffsetControl trackId={track.id} offset={offset} />}
{track && (
<span className="flex shrink-0 items-center gap-2">
<LyricsSearchPanel
key={track.id}
track={track}
onSearch={(query) => setManualQuery({ ...query, trackId: track.id })}
/>
{isSynced && <LyricsOffsetControl trackId={track.id} offset={offset} />}
</span>
)}
</footer>
</section>
);
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 (
<FloatingPanel
open={isOpen}
onOpenChange={setIsOpen}
side="top"
className="w-[18rem]"
trigger={
<button
type="button"
className="rounded-full px-1.5 py-0.5 transition-colors hover:bg-card hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setIsOpen((open) => !open)}
aria-expanded={isOpen}
aria-haspopup="dialog"
>
Search lyrics
</button>
}
>
<form onSubmit={submit} className="flex flex-col gap-2 p-1">
<input
autoFocus
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Song title"
aria-label="Song title"
className={SEARCH_INPUT}
/>
<input
value={artist}
onChange={(event) => setArtist(event.target.value)}
placeholder="Artist"
aria-label="Artist"
className={SEARCH_INPUT}
/>
<button
type="submit"
disabled={!title.trim()}
className="self-end rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Search
</button>
</form>
</FloatingPanel>
);
}

function formatOffset(offset: number): string {
if (offset === 0) return "In sync";
const magnitude = Math.abs(offset).toFixed(2).replace(/\.?0+$/, "");
Expand Down
2 changes: 1 addition & 1 deletion src/ui/settings/lyricsFontScale.check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/ui/settings/lyricsFontScale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
2 changes: 1 addition & 1 deletion src/ui/settings/renderEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading