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);
+}