Skip to content
Open
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
25 changes: 17 additions & 8 deletions src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,6 +92,7 @@ function useGlobalShortcuts() {

export function AppShell({ children }: { children: ReactNode }) {
useAudioEngine();
useResolveCurrentAlbum();
useYtdlpSetup();
useUpdateStartupCheck();
useWhatsNewOnUpdate();
Expand Down Expand Up @@ -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]);

Expand Down
6 changes: 3 additions & 3 deletions src/components/layout/player-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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). */}
<div className="flex items-center justify-between gap-2 px-3 pt-2 pb-3">
<div className="flex items-center gap-0.5">
<LyricsSourceButton state={lyricsState} />
Expand Down
15 changes: 14 additions & 1 deletion src/components/layout/player-cover-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function PlayerCoverMenuMain(props: Props) {
<PlayerCoverMenuInner
{...props}
onGoToArtist={(id) => navigate({ to: "/artist/$id", params: { id } })}
onGoToAlbum={(id) => navigate({ to: "/album/$id", params: { id } })}
/>
);
}
Expand All @@ -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 */
});
}}
/>
);
}
Expand All @@ -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
Expand All @@ -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: [] };
Expand Down Expand Up @@ -133,6 +145,7 @@ function PlayerCoverMenuInner({
controller={controller}
primitives={ctxPrimitives}
onGoToArtist={onGoToArtist}
onGoToAlbum={onGoToAlbum}
/>
<ContextMenuSeparator />
<ContextMenuItem
Expand Down
19 changes: 17 additions & 2 deletions src/components/layout/player-more-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ type Props = {
* Triple-dot overflow menu for the player surfaces. Wraps the same
* `TrackMenuItems` block used by the right-click context menu on
* track rows, so the actions (Play next, Add to queue, Start radio,
* Like / Remove from liked, Add to playlist, Go to artist, Share)
* Like / Remove from liked, Add to playlist, Go to artist / album, Share)
* stay in sync between every entry point.
*
* Splits into a main-window branch (uses `useNavigate` directly) and
Expand All @@ -82,6 +82,9 @@ function PlayerMoreMenuMain(props: Props) {
onGoToArtist={(id) =>
navigate({ to: "/artist/$id", params: { id } })
}
onGoToAlbum={(id) =>
navigate({ to: "/album/$id", params: { id } })
}
/>
);
}
Expand All @@ -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 */
});
}}
/>
);
}
Expand All @@ -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",
Expand All @@ -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: [] };
Expand Down Expand Up @@ -162,6 +176,7 @@ function PlayerMoreMenuInner({
controller={controller}
primitives={dropPrimitives}
onGoToArtist={onGoToArtist}
onGoToAlbum={onGoToAlbum}
/>
</>
) : null}
Expand Down
27 changes: 16 additions & 11 deletions src/components/shared/track-context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -248,6 +249,7 @@ export function TrackMenuItems({
primitives,
removal,
onGoToArtist,
onGoToAlbum,
}: {
item: ShelfItem;
context?: TrackContext;
Expand All @@ -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;
Expand All @@ -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 (
<>
Expand Down Expand Up @@ -384,16 +391,8 @@ export function TrackMenuItems({
Go to artist
</Item>
)}
{albumBrowseId && (
<Item
onSelect={() => {
// 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 && (
<Item onSelect={() => onGoToAlbum(albumBrowseId)}>
<DiscAlbumIcon />
Go to album
</Item>
Expand Down Expand Up @@ -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 } })
}
/>
</ContextMenuContent>
</ContextMenu>
Expand Down Expand Up @@ -512,6 +514,9 @@ export function TrackMoreMenu({
onGoToArtist={(id) =>
navigate({ to: "/artist/$id", params: { id } })
}
onGoToAlbum={(id) =>
navigate({ to: "/album/$id", params: { id } })
}
/>
</DropdownMenuContent>
</DropdownMenu>
Expand Down
94 changes: 94 additions & 0 deletions src/lib/innertube/album.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading