diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index d6fc7bd..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(); @@ -190,16 +192,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..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"; @@ -248,6 +249,7 @@ export function TrackMenuItems({ primitives, removal, onGoToArtist, + onGoToAlbum, }: { item: ShelfItem; context?: TrackContext; @@ -262,6 +264,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 +282,10 @@ export function TrackMenuItems({ } = controller; const artist = item.artists?.find((a) => !!a.id); - const albumBrowseId = undefined; + const albumBrowseId = useTrackAlbumId( + item.kind === "song" || item.kind === "video" ? item.id : undefined, + item.albumId, + ); return ( <> @@ -384,16 +391,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 +448,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 +514,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.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 74b0bb9..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 ?? @@ -77,7 +126,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/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 5963b86..1343a03 100644 --- a/src/lib/store/playback.test.ts +++ b/src/lib/store/playback.test.ts @@ -166,3 +166,36 @@ 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", + }); + }); + + 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 546e476..d69a5e2 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; @@ -72,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; @@ -104,6 +111,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, }; @@ -346,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); +}