From 170d666702e6a50ee6d047067ad0d1b251212de0 Mon Sep 17 00:00:00 2001 From: Mihai M <695577+mihaimetal@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:11:20 +0300 Subject: [PATCH 1/2] feat(player): add Go to album to the track menu Show the item when the current track has an album browse id, and keep that id on queued tracks so the player overflow menu can open /album/$id. --- src/components/layout/app-shell.tsx | 23 +++++++++++++------- src/components/layout/player-bar.tsx | 6 ++--- src/components/layout/player-cover-menu.tsx | 15 ++++++++++++- src/components/layout/player-more-menu.tsx | 19 ++++++++++++++-- src/components/shared/track-context-menu.tsx | 23 ++++++++++---------- src/lib/innertube/album.ts | 10 ++++++++- src/lib/store/playback.test.ts | 20 +++++++++++++++++ src/lib/store/playback.ts | 3 +++ 8 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index d6fc7bd..f236a22 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -190,16 +190,23 @@ export function AppShell({ children }: { children: ReactNode }) { const navigate = useNavigate(); useEffect(() => { let cancelled = false; - let dispose: (() => void) | undefined; - void listen<{ id: string }>("nav:artist", (e) => { - void navigate({ to: "/artist/$id", params: { id: e.payload.id } }); - }).then((un) => { - if (cancelled) un(); - else dispose = un; - }); + const disposers: (() => void)[] = []; + const watch = ( + event: "nav:artist" | "nav:album", + to: "/artist/$id" | "/album/$id", + ) => { + void listen<{ id: string }>(event, (e) => { + void navigate({ to, params: { id: e.payload.id } }); + }).then((un) => { + if (cancelled) un(); + else disposers.push(un); + }); + }; + watch("nav:artist", "/artist/$id"); + watch("nav:album", "/album/$id"); return () => { cancelled = true; - dispose?.(); + for (const un of disposers) un(); }; }, [navigate]); diff --git a/src/components/layout/player-bar.tsx b/src/components/layout/player-bar.tsx index 269b73c..4da1c8b 100644 --- a/src/components/layout/player-bar.tsx +++ b/src/components/layout/player-bar.tsx @@ -695,9 +695,9 @@ export function PlayerBar({ {/* Bottom row: lyrics-source + queue + volume on the left, song/video toggle + more menu on the right. `PlayerMoreMenu` handles the floating-window case internally — its - `onGoToArtist` callback emits a Tauri nav event there - instead of calling `useNavigate` (which would throw without - a router). */} + `onGoToArtist` / `onGoToAlbum` callbacks emit a Tauri nav + event there instead of calling `useNavigate` (which would + throw without a router). */}
diff --git a/src/components/layout/player-cover-menu.tsx b/src/components/layout/player-cover-menu.tsx index 15cbf99..428db4b 100644 --- a/src/components/layout/player-cover-menu.tsx +++ b/src/components/layout/player-cover-menu.tsx @@ -56,6 +56,7 @@ function PlayerCoverMenuMain(props: Props) { navigate({ to: "/artist/$id", params: { id } })} + onGoToAlbum={(id) => navigate({ to: "/album/$id", params: { id } })} /> ); } @@ -70,6 +71,12 @@ function PlayerCoverMenuFloating(props: Props) { /* command might not be registered in older builds */ }); }} + onGoToAlbum={(id) => { + void emit("nav:album", { id }); + void invoke("focus_main_window").catch(() => { + /* command might not be registered in older builds */ + }); + }} /> ); } @@ -78,7 +85,11 @@ function PlayerCoverMenuInner({ track, children, onGoToArtist, -}: Props & { onGoToArtist: (artistId: string) => void }) { + onGoToAlbum, +}: Props & { + onGoToArtist: (artistId: string) => void; + onGoToAlbum: (albumId: string) => void; +}) { // Same stub-item dance as `PlayerMoreMenu`: the controller owns React // Query hooks that can't be skipped when nothing is playing. const item: ShelfItem = track @@ -89,6 +100,7 @@ function PlayerCoverMenuInner({ thumbnails: track.thumbnails, artists: track.artists, album: track.album, + albumId: track.albumId, duration: track.duration, } : { kind: "song", id: "", title: "", thumbnails: [] }; @@ -133,6 +145,7 @@ function PlayerCoverMenuInner({ controller={controller} primitives={ctxPrimitives} onGoToArtist={onGoToArtist} + onGoToAlbum={onGoToAlbum} /> navigate({ to: "/artist/$id", params: { id } }) } + onGoToAlbum={(id) => + navigate({ to: "/album/$id", params: { id } }) + } /> ); } @@ -98,6 +101,12 @@ function PlayerMoreMenuFloating(props: Props) { /* command might not be registered in older builds */ }); }} + onGoToAlbum={(id) => { + void emit("nav:album", { id }); + void invoke("focus_main_window").catch(() => { + /* command might not be registered in older builds */ + }); + }} /> ); } @@ -114,7 +123,11 @@ function PlayerMoreMenuInner({ align = "end", side = "top", onGoToArtist, -}: Props & { onGoToArtist: (artistId: string) => void }) { + onGoToAlbum, +}: Props & { + onGoToArtist: (artistId: string) => void; + onGoToAlbum: (albumId: string) => void; +}) { const item: ShelfItem = track ? { kind: "song", @@ -123,6 +136,7 @@ function PlayerMoreMenuInner({ thumbnails: track.thumbnails, artists: track.artists, album: track.album, + albumId: track.albumId, duration: track.duration, } : { kind: "song", id: "", title: "", thumbnails: [] }; @@ -162,6 +176,7 @@ function PlayerMoreMenuInner({ controller={controller} primitives={dropPrimitives} onGoToArtist={onGoToArtist} + onGoToAlbum={onGoToAlbum} /> ) : null} diff --git a/src/components/shared/track-context-menu.tsx b/src/components/shared/track-context-menu.tsx index 9bf5f53..f510e7d 100644 --- a/src/components/shared/track-context-menu.tsx +++ b/src/components/shared/track-context-menu.tsx @@ -248,6 +248,7 @@ export function TrackMenuItems({ primitives, removal, onGoToArtist, + onGoToAlbum, }: { item: ShelfItem; context?: TrackContext; @@ -262,6 +263,8 @@ export function TrackMenuItems({ * forward to `useNavigate()`. */ onGoToArtist?: (artistId: string) => void; + /** Same cross-window split as `onGoToArtist`, for `/album/$id`. */ + onGoToAlbum?: (albumId: string) => void; }) { const store = usePlaybackStore.getState; const { Item, Separator, Sub, SubTrigger, SubContent } = primitives; @@ -278,7 +281,7 @@ export function TrackMenuItems({ } = controller; const artist = item.artists?.find((a) => !!a.id); - const albumBrowseId = undefined; + const albumBrowseId = item.albumId; return ( <> @@ -384,16 +387,8 @@ export function TrackMenuItems({ Go to artist )} - {albumBrowseId && ( - { - // Album navigation isn't wired yet — `albumBrowseId` is - // currently always undefined so this branch never runs. - // Left as a placeholder for when album browse IDs start - // flowing through. - void albumBrowseId; - }} - > + {albumBrowseId && onGoToAlbum && ( + onGoToAlbum(albumBrowseId)}> Go to album @@ -449,6 +444,9 @@ export function TrackContextMenu({ item, children, context, removal }: Props) { onGoToArtist={(id) => navigate({ to: "/artist/$id", params: { id } }) } + onGoToAlbum={(id) => + navigate({ to: "/album/$id", params: { id } }) + } /> @@ -512,6 +510,9 @@ export function TrackMoreMenu({ onGoToArtist={(id) => navigate({ to: "/artist/$id", params: { id } }) } + onGoToAlbum={(id) => + navigate({ to: "/album/$id", params: { id } }) + } /> diff --git a/src/lib/innertube/album.ts b/src/lib/innertube/album.ts index 74b0bb9..3df86fb 100644 --- a/src/lib/innertube/album.ts +++ b/src/lib/innertube/album.ts @@ -77,7 +77,15 @@ export async function fetchAlbum(id: string): Promise { const mapped = mapResponsiveListItem(row); if (mapped && mapped.kind === "song" && !seenIds.has(mapped.id)) { seenIds.add(mapped.id); - tracks.push(mapped); + // Album-page rows rarely carry their own album browse link (the + // user is already on the album), so stamp the page id/title onto + // each track. That lets "Go to album" and Last.fm scrobbles work + // after the user queues from this page. + tracks.push({ + ...mapped, + album: mapped.album ?? title, + albumId: mapped.albumId ?? id, + }); } } diff --git a/src/lib/store/playback.test.ts b/src/lib/store/playback.test.ts index 5963b86..250120d 100644 --- a/src/lib/store/playback.test.ts +++ b/src/lib/store/playback.test.ts @@ -166,3 +166,23 @@ describe("album 'Play next' ordering", () => { ]); }); }); + +describe("playback shelfItemToTrack albumId", () => { + beforeEach(() => setup({})); + + it("carries albumId from a shelf item into the queue", () => { + usePlaybackStore.getState().playNow({ + kind: "song", + id: "vid", + title: "Song", + thumbnails: [], + album: "The Album", + albumId: "MPREb_x", + }); + expect(usePlaybackStore.getState().queue[0]).toMatchObject({ + videoId: "vid", + album: "The Album", + albumId: "MPREb_x", + }); + }); +}); diff --git a/src/lib/store/playback.ts b/src/lib/store/playback.ts index 546e476..309d8b0 100644 --- a/src/lib/store/playback.ts +++ b/src/lib/store/playback.ts @@ -15,6 +15,8 @@ export type QueueTrack = { subtitle?: string; artists?: { id?: string; name: string }[]; album?: string; + /** Browse id for the album this track belongs to (e.g. "MPREb_…"). */ + albumId?: string; thumbnails: Thumbnail[]; /** Original duration from browse responses, may be undefined until /player resolves. */ duration?: number; @@ -104,6 +106,7 @@ function shelfItemToTrack(item: ShelfItem | QueueTrack): QueueTrack | null { subtitle: item.subtitle, artists: item.artists, album: item.album, + albumId: item.albumId, thumbnails: item.thumbnails, duration: item.duration, }; From 6a680fcf4b18781c5279cfd4851f7beb61a3c5a2 Mon Sep 17 00:00:00 2001 From: Mihai M <695577+mihaimetal@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:40:38 +0300 Subject: [PATCH 2/2] fix(player): show Go to album when the queue item lacked albumId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most play sources never put an album browse id on the queued track, so the player ⋮ item stayed hidden even for album songs. Read MPREb_ ids and the row's own Go to album menu, and look the current track up via /next when the queue still has no id. --- src/components/layout/app-shell.tsx | 2 + src/components/shared/track-context-menu.tsx | 6 +- src/lib/innertube/album.test.ts | 94 ++++++++++++++ src/lib/innertube/album.ts | 49 ++++++++ src/lib/innertube/shared.test.ts | 124 ++++++++++++++++++- src/lib/innertube/shared.ts | 74 +++++++++-- src/lib/store/playback.test.ts | 13 ++ src/lib/store/playback.ts | 20 +++ src/lib/use-track-album.ts | 42 +++++++ 9 files changed, 411 insertions(+), 13 deletions(-) create mode 100644 src/lib/innertube/album.test.ts create mode 100644 src/lib/use-track-album.ts diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index f236a22..6c3cc07 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -25,6 +25,7 @@ import { WhatsNewDialog } from "@/components/layout/whats-new-dialog"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { useAudioEngine } from "@/lib/audio-engine"; +import { useResolveCurrentAlbum } from "@/lib/use-track-album"; import { useCacheAutoClean } from "@/lib/cache-cleanup"; import { usePlaybackNotifications } from "@/lib/playback-notifications"; import { useLastfmScrobbler } from "@/lib/lastfm-scrobbler"; @@ -91,6 +92,7 @@ function useGlobalShortcuts() { export function AppShell({ children }: { children: ReactNode }) { useAudioEngine(); + useResolveCurrentAlbum(); useYtdlpSetup(); useUpdateStartupCheck(); useWhatsNewOnUpdate(); diff --git a/src/components/shared/track-context-menu.tsx b/src/components/shared/track-context-menu.tsx index f510e7d..b6a43fa 100644 --- a/src/components/shared/track-context-menu.tsx +++ b/src/components/shared/track-context-menu.tsx @@ -70,6 +70,7 @@ import { } from "@/lib/innertube/mutations"; import { toggleLiked } from "@/lib/like-actions"; import { usePlaybackStore } from "@/lib/store/playback"; +import { useTrackAlbumId } from "@/lib/use-track-album"; import type { ShelfItem } from "@/lib/innertube/types"; import { syncLastfmLove } from "@/lib/lastfm"; @@ -281,7 +282,10 @@ export function TrackMenuItems({ } = controller; const artist = item.artists?.find((a) => !!a.id); - const albumBrowseId = item.albumId; + const albumBrowseId = useTrackAlbumId( + item.kind === "song" || item.kind === "video" ? item.id : undefined, + item.albumId, + ); return ( <> diff --git a/src/lib/innertube/album.test.ts b/src/lib/innertube/album.test.ts new file mode 100644 index 0000000..37c9aa4 --- /dev/null +++ b/src/lib/innertube/album.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { albumIdFromWatchNext } from "./album"; +import type { YtNode } from "./shared"; + +function watchNext(rows: YtNode[]): YtNode { + return { + contents: { + singleColumnMusicWatchNextResultsRenderer: { + tabbedRenderer: { + watchNextTabbedResultsRenderer: { + tabs: [ + { + tabRenderer: { + content: { + musicQueueRenderer: { + content: { + playlistPanelRenderer: { + contents: rows, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + }, + }; +} + +describe("albumIdFromWatchNext", () => { + it("returns the matching row's album browse id", () => { + const json = watchNext([ + { + playlistPanelVideoRenderer: { + title: { runs: [{ text: "Song" }] }, + navigationEndpoint: { watchEndpoint: { videoId: "vid1" } }, + longBylineText: { + runs: [ + { + text: "Album", + navigationEndpoint: { browseEndpoint: { browseId: "MPREb_one" } }, + }, + ], + }, + }, + }, + { + playlistPanelVideoRenderer: { + title: { runs: [{ text: "Other" }] }, + navigationEndpoint: { watchEndpoint: { videoId: "vid2" } }, + longBylineText: { + runs: [ + { + text: "Other Album", + navigationEndpoint: { browseEndpoint: { browseId: "MPREb_two" } }, + }, + ], + }, + }, + }, + ]); + expect(albumIdFromWatchNext(json, "vid2")).toBe("MPREb_two"); + }); + + it("does not use a neighbor row's album", () => { + const json = watchNext([ + { + playlistPanelVideoRenderer: { + title: { runs: [{ text: "Single" }] }, + navigationEndpoint: { watchEndpoint: { videoId: "vid1" } }, + longBylineText: { runs: [{ text: "Artist" }] }, + }, + }, + { + playlistPanelVideoRenderer: { + title: { runs: [{ text: "Album track" }] }, + navigationEndpoint: { watchEndpoint: { videoId: "vid2" } }, + longBylineText: { + runs: [ + { + text: "Album", + navigationEndpoint: { browseEndpoint: { browseId: "MPREb_x" } }, + }, + ], + }, + }, + }, + ]); + expect(albumIdFromWatchNext(json, "vid1")).toBeUndefined(); + }); +}); diff --git a/src/lib/innertube/album.ts b/src/lib/innertube/album.ts index 3df86fb..c4722d1 100644 --- a/src/lib/innertube/album.ts +++ b/src/lib/innertube/album.ts @@ -2,13 +2,62 @@ import type { AlbumPage, MinimalArtist, ShelfItem } from "./types"; import { parseTrackCount } from "./parse-count"; import { collectResponsiveRows, + mapPlaylistPanelVideo, mapResponsiveListItem, rawBrowse, + rawNext, readRuns, readThumbnails, type YtNode, } from "./shared"; +/** Pull the watch-next playlist panel out of a `/next` response. */ +function watchNextPanel(json: YtNode): YtNode | undefined { + return ( + json?.contents?.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer + ?.watchNextTabbedResultsRenderer?.tabs?.[0]?.tabRenderer?.content + ?.musicQueueRenderer?.content?.playlistPanelRenderer ?? + json?.continuationContents?.playlistPanelContinuation + ); +} + +/** + * Album browse id for `videoId` from a `/next` payload. Matches the + * current-track row first; does not fall back to a neighbor's album. + */ +export function albumIdFromWatchNext( + json: YtNode, + videoId: string, +): string | undefined { + const panel = watchNextPanel(json); + for (const c of (panel?.contents as YtNode[] | undefined) ?? []) { + const row = + c.playlistPanelVideoRenderer ?? + c.playlistPanelVideoWrapperRenderer?.primaryRenderer + ?.playlistPanelVideoRenderer; + if (!row) continue; + const mapped = mapPlaylistPanelVideo(row); + if (mapped?.id === videoId && mapped.albumId) return mapped.albumId; + } + return undefined; +} + +/** + * Look up the album browse id for a playing video. Used when the queue + * item was built from a row that didn't carry an album link (radio, + * home, persisted queues). + */ +export async function fetchAlbumIdForVideo( + videoId: string, +): Promise { + const json = await rawNext({ + videoId, + isAudioOnly: true, + enablePersistentPlaylistPanel: true, + }); + return albumIdFromWatchNext(json, videoId); +} + function extractAlbumHeader(json: YtNode): YtNode { return ( json?.header?.musicDetailHeaderRenderer ?? diff --git a/src/lib/innertube/shared.test.ts b/src/lib/innertube/shared.test.ts index cc2baaf..9cccec4 100644 --- a/src/lib/innertube/shared.test.ts +++ b/src/lib/innertube/shared.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { mapShelfWrapper, splitSetCookieHeader, type YtNode } from "./shared"; +import { + albumBrowseIdFromEndpoint, + mapPlaylistPanelVideo, + mapResponsiveListItem, + mapShelfWrapper, + splitSetCookieHeader, + type YtNode, +} from "./shared"; // Fallback splitter for runtimes without Headers.getSetCookie. The // tricky part is NOT splitting on the comma inside an Expires date. @@ -124,3 +131,118 @@ describe("mapShelfWrapper more endpoint", () => { expect(mapShelfWrapper(wrapper, 0).more).toBeUndefined(); }); }); + +describe("album browse id extraction", () => { + it("treats MPREb_ as an album even without pageType", () => { + expect( + albumBrowseIdFromEndpoint({ + browseEndpoint: { browseId: "MPREb_abc" }, + }), + ).toBe("MPREb_abc"); + }); + + it("ignores artist channels", () => { + expect( + albumBrowseIdFromEndpoint({ + browseEndpoint: { + browseId: "UCxyz", + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: "MUSIC_PAGE_TYPE_ARTIST", + }, + }, + }, + }), + ).toBeUndefined(); + }); + + it("maps a panel video album from a byline without pageType", () => { + const item = mapPlaylistPanelVideo({ + title: { runs: [{ text: "Song" }] }, + navigationEndpoint: { watchEndpoint: { videoId: "vid1" } }, + longBylineText: { + runs: [ + { text: "Artist" }, + { text: " • " }, + { + text: "The Album", + navigationEndpoint: { browseEndpoint: { browseId: "MPREb_x" } }, + }, + ], + }, + }); + expect(item).toMatchObject({ + id: "vid1", + album: "The Album", + albumId: "MPREb_x", + }); + }); + + it("maps a panel video album from the overflow menu", () => { + const item = mapPlaylistPanelVideo({ + title: { runs: [{ text: "Song" }] }, + navigationEndpoint: { watchEndpoint: { videoId: "vid1" } }, + longBylineText: { runs: [{ text: "Artist" }] }, + menu: { + menuRenderer: { + items: [ + { + menuNavigationItemRenderer: { + text: { runs: [{ text: "Go to album" }] }, + navigationEndpoint: { + browseEndpoint: { browseId: "MPREb_from_menu" }, + }, + }, + }, + ], + }, + }, + }); + expect(item?.albumId).toBe("MPREb_from_menu"); + }); + + it("maps a list-row album from the overflow menu", () => { + const item = mapResponsiveListItem({ + flexColumns: [ + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: "Song", + navigationEndpoint: { watchEndpoint: { videoId: "vid1" } }, + }, + ], + }, + }, + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { runs: [{ text: "Artist" }] }, + }, + }, + ], + menu: { + menuRenderer: { + items: [ + { + menuNavigationItemRenderer: { + navigationEndpoint: { + browseEndpoint: { + browseId: "MPREb_row", + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: "MUSIC_PAGE_TYPE_ALBUM", + }, + }, + }, + }, + }, + }, + ], + }, + }, + }); + expect(item).toMatchObject({ id: "vid1", albumId: "MPREb_row" }); + }); +}); diff --git a/src/lib/innertube/shared.ts b/src/lib/innertube/shared.ts index 9a3df6b..b1de665 100644 --- a/src/lib/innertube/shared.ts +++ b/src/lib/innertube/shared.ts @@ -475,6 +475,56 @@ export function findContinuationToken(root: unknown): string | undefined { return result; } +/** True for a browse id that opens an album page (`/album/$id`). */ +export function isAlbumBrowseId(id: string | undefined): id is string { + return typeof id === "string" && id.startsWith("MPREb_"); +} + +/** + * Album browse id on a navigation endpoint. YTM sometimes omits + * `pageType` and only ships `MPREb_…`; treat that as an album too. + */ +export function albumBrowseIdFromEndpoint( + ep: YtNode | undefined, +): string | undefined { + const browse = ep?.browseEndpoint; + const id = browse?.browseId; + if (typeof id !== "string" || !id) return undefined; + const pageType = browse.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig?.pageType as string | undefined; + if (typeof pageType === "string" && pageType.includes("ALBUM")) return id; + if (isAlbumBrowseId(id)) return id; + return undefined; +} + +/** "Go to album" (and similar) on a row/card overflow menu. */ +export function albumBrowseIdFromMenu(raw: YtNode): string | undefined { + const items: YtNode[] = raw.menu?.menuRenderer?.items ?? []; + for (const it of items) { + const nav = + it.menuNavigationItemRenderer?.navigationEndpoint ?? + it.navigationEndpoint; + const id = albumBrowseIdFromEndpoint(nav); + if (id) return id; + } + return undefined; +} + +function albumFromSubtitleRuns(runs: YtNode[]): { + album?: string; + albumId?: string; +} { + let album: string | undefined; + let albumId: string | undefined; + for (const run of runs) { + const id = albumBrowseIdFromEndpoint(run.navigationEndpoint); + if (!id) continue; + album = (typeof run.text === "string" ? run.text : undefined) ?? album; + albumId = id; + } + return { album, albumId }; +} + /** * Map a `playlistPanelVideoRenderer` (the row shape /next returns inside * a playlistPanelRenderer — used for radio/autoplay tracks) to our @@ -490,21 +540,20 @@ export function mapPlaylistPanelVideo(raw: YtNode): ShelfItem | null { raw.longBylineText?.runs ?? raw.shortBylineText?.runs ?? []; const artists: { id?: string; name: string }[] = []; - let album: string | undefined; - let albumId: string | undefined; for (const run of subtitleRuns) { const browseId = run.navigationEndpoint?.browseEndpoint?.browseId as - string | undefined; + | string + | undefined; const pageType = run.navigationEndpoint?.browseEndpoint ?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig ?.pageType as string | undefined; if (browseId && pageType?.includes("ARTIST")) { artists.push({ id: browseId, name: run.text ?? "" }); - } else if (browseId && pageType?.includes("ALBUM")) { - album = run.text ?? album; - albumId = browseId; } } + const fromByline = albumFromSubtitleRuns(subtitleRuns); + const album = fromByline.album; + const albumId = fromByline.albumId ?? albumBrowseIdFromMenu(raw); const duration = parseDuration(readRuns(raw.lengthText)); const thumbnails = readThumbnails(raw.thumbnail); @@ -835,10 +884,13 @@ export function mapResponsiveListItem(raw: YtNode): ShelfItem | null { if (browseId && pageType?.includes("ARTIST")) { artists.push({ id: browseId, name: run.text ?? "" }); hadNav = true; - } else if (browseId && pageType?.includes("ALBUM")) { - album = run.text ?? album; - albumId = browseId; - hadNav = true; + } else { + const albumBrowse = albumBrowseIdFromEndpoint(nav); + if (albumBrowse) { + album = run.text ?? album; + albumId = albumBrowse; + hadNav = true; + } } } if (hadNav) continue; @@ -937,7 +989,7 @@ export function mapResponsiveListItem(raw: YtNode): ShelfItem | null { thumbnails, artists: artists.length ? artists : undefined, album, - albumId, + albumId: albumId ?? albumBrowseIdFromMenu(raw), duration, explicit: explicit || undefined, playCount, diff --git a/src/lib/store/playback.test.ts b/src/lib/store/playback.test.ts index 250120d..1343a03 100644 --- a/src/lib/store/playback.test.ts +++ b/src/lib/store/playback.test.ts @@ -185,4 +185,17 @@ describe("playback shelfItemToTrack albumId", () => { albumId: "MPREb_x", }); }); + + it("patchQueueTrack stamps albumId onto queued copies", () => { + setup({ queue: [track("vid")], index: 0 }); + usePlaybackStore.getState().patchQueueTrack("vid", { + albumId: "MPREb_later", + album: "Late Album", + }); + expect(usePlaybackStore.getState().queue[0]).toMatchObject({ + videoId: "vid", + albumId: "MPREb_later", + album: "Late Album", + }); + }); }); diff --git a/src/lib/store/playback.ts b/src/lib/store/playback.ts index 309d8b0..d69a5e2 100644 --- a/src/lib/store/playback.ts +++ b/src/lib/store/playback.ts @@ -74,6 +74,11 @@ export type PlaybackState = { clearQueue: () => void; setAutoRadio: (on: boolean) => void; setQueueContinuation: (token?: string) => void; + /** Stamp album metadata onto queued copies of a video (Go to album). */ + patchQueueTrack: ( + videoId: string, + patch: Partial>, + ) => void; // Actions — transport toggle: () => void; @@ -349,6 +354,21 @@ const playbackStateCreator: StateCreator = (set, get) => ({ setQueueContinuation: (token) => set({ queueContinuation: token }), + patchQueueTrack: (videoId, patch) => { + set((s) => { + let changed = false; + const queue = s.queue.map((t) => { + if (t.videoId !== videoId) return t; + const albumId = patch.albumId ?? t.albumId; + const album = patch.album ?? t.album; + if (albumId === t.albumId && album === t.album) return t; + changed = true; + return { ...t, album, albumId }; + }); + return changed ? { queue } : s; + }); + }, + toggle: () => { const { queue, playing } = get(); if (queue.length === 0) return; diff --git a/src/lib/use-track-album.ts b/src/lib/use-track-album.ts new file mode 100644 index 0000000..42fe641 --- /dev/null +++ b/src/lib/use-track-album.ts @@ -0,0 +1,42 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchAlbumIdForVideo } from "@/lib/innertube/album"; +import { usePlaybackStore } from "@/lib/store/playback"; + +/** + * Album browse id for a song/video. Uses the id already on the item when + * present; otherwise looks it up from `/next` and stamps the queue so + * the player ⋮ menu can show "Go to album". + */ +export function useTrackAlbumId( + videoId: string | undefined, + knownId?: string, +): string | undefined { + const query = useQuery({ + queryKey: ["track-album-id", videoId], + queryFn: async () => (await fetchAlbumIdForVideo(videoId!)) ?? null, + enabled: !!videoId && !knownId, + staleTime: Infinity, + retry: false, + }); + + useEffect(() => { + if (!videoId || !query.data) return; + usePlaybackStore.getState().patchQueueTrack(videoId, { + albumId: query.data, + }); + }, [videoId, query.data]); + + return knownId ?? query.data ?? undefined; +} + +/** Resolve the now-playing track's album id before the ⋮ menu opens. */ +export function useResolveCurrentAlbum(): void { + const videoId = usePlaybackStore((s) => + s.index >= 0 ? s.queue[s.index]?.videoId : undefined, + ); + const albumId = usePlaybackStore((s) => + s.index >= 0 ? s.queue[s.index]?.albumId : undefined, + ); + useTrackAlbumId(videoId, albumId); +}