Skip to content

feat(library): open genre detail page from tags grid#23

Merged
InstaZDLL merged 1 commit into
mainfrom
feat/genre-detail-view
May 15, 2026
Merged

feat(library): open genre detail page from tags grid#23
InstaZDLL merged 1 commit into
mainfrom
feat/genre-detail-view

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 15, 2026

Summary

  • Genre tiles in the library Genres tab were styled clickable (cursor-pointer, hover state) but had no click handler β€” clicking did nothing.
  • Adds a Spotify-style GenreDetailView (header + Play all / Shuffle + track table with clickable artist / album columns) reached by clicking a tile, mirroring the existing AlbumDetailView / ArtistDetailView pattern.
  • Backed by a new get_genre_detail(genre_id) command that joins track_genre and returns tracks sorted Artist β†’ Album β†’ Disc β†’ Track. New genre-detail ViewId plumbed through AppLayout's history stack so back/forward navigation works.
  • New genreDetail i18n block translated across all 17 locales.

Test plan

  • Library β†’ Genres tab β†’ click any tile β†’ opens the detail page with the genre name, track count, total duration.
  • Play all / Shuffle queue the full track list.
  • Clicking an artist or album in a row navigates to the matching detail view.
  • Back / Forward (TopBar arrows) restore the previous view correctly.
  • Switch language β†’ header badge, counts, empty states all translate.
  • bun run typecheck βœ…
  • cargo check --manifest-path src-tauri/Cargo.toml --all-targets βœ…

Summary by CodeRabbit

Release Notes

  • New Features

    • Genre tiles are now clickable, opening a dedicated genre detail page.
    • Genre detail page displays all tracks sorted by artist, album, disc, and track number.
    • Play all tracks or shuffle from the genre view.
    • Like/unlike tracks and navigate to artist or album details from the genre page.
  • Documentation

    • Clarified that library tabs maintain independent scroll positions and sort settings per profile.
    • Documented genre tile interaction and genre detail page track ordering.

Review Change Stack

genre tiles in the library grid were styled clickable but had no
handler. add a spotify-style detail view (header + play/shuffle +
track table) backed by a new `get_genre_detail` command, plumb the
navigation through ViewId, and translate the new keys across the
17 locales.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

πŸ“ Walkthrough

Walkthrough

This PR implements genre detail browsing, enabling users to click a genre tile in the Library view to navigate to a dedicated page showing all tracks for that genre. The feature spans backend query logic, frontend routing, component rendering, and internationalized UI strings across 16 locales.

Changes

Genre Detail Browsing Feature

Layer / File(s) Summary
Backend genre detail endpoint
src-tauri/src/commands/browse.rs, src-tauri/src/lib.rs
New get_genre_detail Tauri command fetches genre metadata and ordered track list with full media information (artwork, metadata, ratings). Command is registered in the Tauri handler list.
Frontend type contracts
src/types/index.ts, src/lib/tauri/detail.ts
ViewId type union adds "genre-detail" route. GenreDetail interface defined with genre id/name, track count, total duration, and track list.
Genre detail route and view rendering
src/components/layout/AppLayout.tsx, src/components/views/GenreDetailView.tsx
AppLayout introduces activeGenreId state, navigateToGenre callback, and genre-detail switch case in renderView(). GenreDetailView component fetches genre details, manages liked-track state, renders genre header with play/shuffle controls, and implements GenreTrackTable with row interactions (double-click play, context menu, like button, album navigation).
Genre tile interactivity
src/components/views/LibraryView.tsx
LibraryViewProps and GenreList gain onNavigateToGenre/onSelect callbacks. Genre tiles are converted from non-interactive containers to clickable buttons triggering genre detail navigation.
Internationalization and documentation
docs/features/library.md, src/i18n/locales/*.json (16 files)
Library feature docs clarified. New genreDetail i18n namespace added across all 16 locale files (ar, de, en, es, fr, hi, id, it, ja, kr, nl, pt-BR, pt, ru, tr, zh-CN, zh-TW) with UI strings for badge, play/shuffle labels, pluralized track counts, and empty-state messages.

Sequence Diagram

sequenceDiagram
  participant User
  participant AppLayout
  participant LibraryView
  participant GenreDetailView
  participant Backend
  User->>LibraryView: Click genre tile
  LibraryView->>AppLayout: onNavigateToGenre(genreId)
  AppLayout->>AppLayout: Set activeGenreId, push genre-detail view
  AppLayout->>GenreDetailView: Render with genreId
  GenreDetailView->>Backend: getGenreDetail(genreId)
  Backend-->>GenreDetailView: GenreDetail{name, tracks, duration}
  GenreDetailView->>GenreDetailView: Render genre header and track table
  User->>GenreDetailView: Double-click track or click play
  GenreDetailView->>Backend: Execute playback action
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

type: feat, size: xl, scope: frontend, scope: i18n, scope: docs

Poem

🐰 A genre detail path is born,
Where tiles now click and users roam,
Through tracks by artist, disc, and song,
Sixteen tongues sing along.
Come explore the music's home!

πŸš₯ Pre-merge checks | βœ… 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR description covers key changes (clickable genre tiles, GenreDetailView, backend command, i18n) with test plan, but misses several Conventional Commits checklist items. Confirm PR title follows Conventional Commits format (feat(library): ...), verify all lint/typecheck/cargo checks passed locally, and clarify if CLAUDE.md or docs/features/ need updates.
βœ… Passed checks (3 passed)
Check name Status Explanation
Title check βœ… Passed The PR title clearly and concisely describes the main change: enabling genre detail page navigation from the genre tiles grid in the library. It is specific, follows Conventional Commits format, and directly relates to the primary objective.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
πŸ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/genre-detail-view

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added scope: frontend React/Vite frontend (src/) scope: backend Rust/Tauri backend (src-tauri/) scope: i18n Translations (src/i18n/) scope: docs Docs, README, assets type: feat New feature size: xl > 500 lines labels May 15, 2026
@InstaZDLL InstaZDLL self-assigned this May 15, 2026
@InstaZDLL InstaZDLL enabled auto-merge (squash) May 15, 2026 20:40
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/layout/AppLayout.tsx`:
- Around line 239-245: The navigation currently only pushes the view id into
history, leaving activeGenreId out of the history payload; update the
navigateToGenre callback to push a history entry that includes both the view id
and the genre id (e.g. { id: "genre-detail", genreId }) instead of just
"genre-detail", and remove reliance on external activeGenreId when rendering by
reading the genreId from the history entry payload to set the shown genre; also
apply the same pattern to the similar handler referenced around lines 351-358 so
back/forward restores the correct detail entry.

In `@src/components/views/GenreDetailView.tsx`:
- Around line 223-377: GenreTrackTable currently renders all rows with
tracks.map (no virtualization) β€” replace the full mapping with a virtualized
list using `@tanstack/react-virtual` and consume the shared scroll element from
usePageScroll() so the table participates in the global page scroller.
Concretely: in GenreTrackTable import and use useVirtualizer (or
createVirtualizer) from `@tanstack/react-virtual`, call usePageScroll() to get the
scrollElement and pass it as the parentRef/scrollElement to the virtualizer,
compute virtualItems from tracks.length, and render only virtualItems instead of
tracks.map; keep existing row behavior (onDoubleClick -> onPlayTrack(index),
onContextMenu -> onContextMenuRow, onToggleLike, onNavigateToAlbum/Artist,
PlayingIndicator, Artwork, formatDuration, likedIds checks) and ensure each
virtual row key still uniquely references track.id and index and that container
height/style matches prior grid layout so measuring stays correct.
- Around line 90-98: The handleToggleLike handler currently awaits
toggleLikeTrack(trackId) with no error handling which can cause unhandled
rejections and leave setLikedIds out-of-sync; update handleToggleLike to wrap
the await in try/catch, perform the optimistic update only after success (or do
an optimistic update but revert on error), call setLikedIds inside the success
path, and log or surface the error (using console.error or an existing UI error
handler) so failures from toggleLikeTrack are caught and the UI remains
consistent.
- Around line 120-124: The Shuffle button currently calls toggleShuffle() inside
handleShufflePlay which will disable shuffle if it is already on; change
handleShufflePlay to ensure shuffle is explicitly enabled instead of toggled:
use the player state from usePlayer (e.g., the current shuffle boolean) and
either call a dedicated setShuffle(true) API if available or gate
toggleShuffle() behind a check (only call toggleShuffle() when shuffle is false)
after invoking playTracks(tracks, 0, { type: "library", id: null }); ensure you
reference handleShufflePlay and toggleShuffle (and the shuffle state from
usePlayer) when making the change.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 09599abb-4abf-4a1d-84f7-024727a82cf7

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 194d7ee and 232796f.

πŸ“’ Files selected for processing (25)
  • docs/features/library.md
  • src-tauri/src/commands/browse.rs
  • src-tauri/src/lib.rs
  • src/components/layout/AppLayout.tsx
  • src/components/views/GenreDetailView.tsx
  • src/components/views/LibraryView.tsx
  • src/i18n/locales/ar.json
  • src/i18n/locales/de.json
  • src/i18n/locales/en.json
  • src/i18n/locales/es.json
  • src/i18n/locales/fr.json
  • src/i18n/locales/hi.json
  • src/i18n/locales/id.json
  • src/i18n/locales/it.json
  • src/i18n/locales/ja.json
  • src/i18n/locales/kr.json
  • src/i18n/locales/nl.json
  • src/i18n/locales/pt-BR.json
  • src/i18n/locales/pt.json
  • src/i18n/locales/ru.json
  • src/i18n/locales/tr.json
  • src/i18n/locales/zh-CN.json
  • src/i18n/locales/zh-TW.json
  • src/lib/tauri/detail.ts
  • src/types/index.ts

Comment on lines +239 to +245
const navigateToGenre = useCallback(
(genreId: number) => {
setActiveGenreId(genreId);
setActiveView("genre-detail");
},
[setActiveView],
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | πŸ—οΈ Heavy lift

Back/forward cannot restore previously opened genre details.

Line 239 pushes only "genre-detail" into history, while the active id lives outside history (activeGenreId). If users open Genre A then Genre B, Back lands on "genre-detail" but still shows Genre B.

Suggested direction
- const [viewHistory, setViewHistory] = useState<ViewId[]>(["home"]);
+ type ViewState =
+   | { id: "home" }
+   | { id: "library" }
+   | { id: "genre-detail"; genreId: number }
+   | { id: "album-detail"; albumId: number }
+   | { id: "artist-detail"; artistId: number }
+   // ...other views
+ ;
+ const [viewHistory, setViewHistory] = useState<ViewState[]>([{ id: "home" }]);

Then push {"id":"genre-detail","genreId"} instead of only "genre-detail", and render from the history entry payload.

Also applies to: 351-358

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/AppLayout.tsx` around lines 239 - 245, The navigation
currently only pushes the view id into history, leaving activeGenreId out of the
history payload; update the navigateToGenre callback to push a history entry
that includes both the view id and the genre id (e.g. { id: "genre-detail",
genreId }) instead of just "genre-detail", and remove reliance on external
activeGenreId when rendering by reading the genreId from the history entry
payload to set the shown genre; also apply the same pattern to the similar
handler referenced around lines 351-358 so back/forward restores the correct
detail entry.

Comment on lines +90 to +98
const handleToggleLike = async (trackId: number) => {
const nowLiked = await toggleLikeTrack(trackId);
setLikedIds((prev) => {
const next = new Set(prev);
if (nowLiked) next.add(trackId);
else next.delete(trackId);
return next;
});
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Missing error handling for like toggle path.

Line 91 can reject (IPC/backend failure), but the handler has no catch path. That can surface unhandled promise rejections and leave UI state inconsistent.

Suggested fix
- const handleToggleLike = async (trackId: number) => {
-   const nowLiked = await toggleLikeTrack(trackId);
-   setLikedIds((prev) => {
-     const next = new Set(prev);
-     if (nowLiked) next.add(trackId);
-     else next.delete(trackId);
-     return next;
-   });
- };
+ const handleToggleLike = async (trackId: number) => {
+   try {
+     const nowLiked = await toggleLikeTrack(trackId);
+     setLikedIds((prev) => {
+       const next = new Set(prev);
+       if (nowLiked) next.add(trackId);
+       else next.delete(trackId);
+       return next;
+     });
+   } catch (err) {
+     console.error("[GenreDetailView] toggle like failed", err);
+   }
+ };
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/views/GenreDetailView.tsx` around lines 90 - 98, The
handleToggleLike handler currently awaits toggleLikeTrack(trackId) with no error
handling which can cause unhandled rejections and leave setLikedIds out-of-sync;
update handleToggleLike to wrap the await in try/catch, perform the optimistic
update only after success (or do an optimistic update but revert on error), call
setLikedIds inside the success path, and log or surface the error (using
console.error or an existing UI error handler) so failures from toggleLikeTrack
are caught and the UI remains consistent.

Comment on lines +120 to +124
const handleShufflePlay = async () => {
if (tracks.length === 0) return;
await playTracks(tracks, 0, { type: "library", id: null });
await toggleShuffle();
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Shuffle action is toggle-based and can disable shuffle.

Line 123 uses toggleShuffle() after starting playback. If shuffle is already enabled, this button turns it off, which contradicts a deterministic β€œShuffle” action.

Suggested fix
- await playTracks(tracks, 0, { type: "library", id: null });
- await toggleShuffle();
+ await playTracks(tracks, 0, { type: "library", id: null });
+ // Ensure shuffle is enabled, do not toggle blindly.
+ await ensureShuffleEnabled();

(Or gate toggleShuffle() behind current shuffle state if that state is available in usePlayer.)

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/views/GenreDetailView.tsx` around lines 120 - 124, The Shuffle
button currently calls toggleShuffle() inside handleShufflePlay which will
disable shuffle if it is already on; change handleShufflePlay to ensure shuffle
is explicitly enabled instead of toggled: use the player state from usePlayer
(e.g., the current shuffle boolean) and either call a dedicated setShuffle(true)
API if available or gate toggleShuffle() behind a check (only call
toggleShuffle() when shuffle is false) after invoking playTracks(tracks, 0, {
type: "library", id: null }); ensure you reference handleShufflePlay and
toggleShuffle (and the shuffle state from usePlayer) when making the change.

Comment on lines +223 to +377
function GenreTrackTable({
tracks,
isLoading,
currentTrackId,
isPlaying,
likedIds,
onToggleLike,
onPlayTrack,
onNavigateToAlbum,
onNavigateToArtist,
onContextMenuRow,
t,
}: {
tracks: Track[];
isLoading: boolean;
currentTrackId: number | null;
isPlaying: boolean;
likedIds: Set<number>;
onToggleLike: (trackId: number) => void;
onPlayTrack: (index: number) => void;
onNavigateToAlbum: (albumId: number) => void;
onNavigateToArtist: (artistId: number) => void;
onContextMenuRow: (event: React.MouseEvent, track: Track) => void;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const gridCols = "grid-cols-[3rem_2.75rem_1.5fr_1fr_1fr_5rem_2rem]";
return (
<div className="rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-800/40 overflow-hidden">
<div
className={`grid ${gridCols} gap-4 px-5 py-3 text-[10px] font-bold tracking-widest text-zinc-400 uppercase border-b border-zinc-100 dark:border-zinc-800`}
>
<span className="text-right">{t("library.table.number")}</span>
<span aria-hidden="true" />
<span>{t("library.table.title")}</span>
<span>{t("library.table.artist")}</span>
<span>{t("library.table.album")}</span>
<span
className="flex justify-end"
aria-label={t("library.table.duration")}
>
<Clock size={14} />
</span>
<span aria-hidden="true" />
</div>

<ul
className={`divide-y divide-zinc-100 dark:divide-zinc-800/60 ${
isLoading ? "opacity-50" : ""
}`}
>
{tracks.map((track, index) => {
const isCurrent = track.id === currentTrackId;
return (
<li
key={`${track.id}-${index}`}
onDoubleClick={() => onPlayTrack(index)}
onContextMenu={(e) => onContextMenuRow(e, track)}
className={`grid ${gridCols} gap-4 px-5 py-2 items-center select-none transition-colors cursor-pointer ${
isCurrent
? "bg-emerald-50 dark:bg-emerald-900/20"
: "hover:bg-zinc-50 dark:hover:bg-zinc-800/60"
}`}
>
<span
className={`text-right text-sm tabular-nums flex items-center justify-end ${
isCurrent ? "text-emerald-500 font-semibold" : "text-zinc-400"
}`}
>
{isCurrent ? (
<PlayingIndicator isPlaying={isPlaying} />
) : (
index + 1
)}
</span>
<Artwork
path={track.artwork_path}
path1x={track.artwork_path_1x}
path2x={track.artwork_path_2x}
size="1x"
className="w-10 h-10"
iconSize={18}
alt={track.album_title ?? track.title}
rounded="md"
/>
<span
className={`text-sm truncate flex items-center gap-2 ${
isCurrent
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "text-zinc-800 dark:text-zinc-200"
}`}
>
<span className="truncate">{track.title}</span>
<HiResBadge
bitDepth={track.bit_depth}
sampleRate={track.sample_rate}
codec={track.codec}
variant="inline"
/>
</span>
<span className="text-sm text-zinc-500 truncate">
<ArtistLink
name={track.artist_name}
artistIds={track.artist_ids}
onNavigate={onNavigateToArtist}
fallback={t("library.table.unknown")}
/>
</span>
<span className="text-sm text-zinc-500 truncate">
{track.album_id != null && track.album_title ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNavigateToAlbum(track.album_id!);
}}
className="hover:underline truncate text-left"
>
{track.album_title}
</button>
) : (
(track.album_title ?? t("library.table.unknown"))
)}
</span>
<span className="text-sm tabular-nums text-zinc-400 text-right">
{formatDuration(track.duration_ms)}
</span>
<div className="flex justify-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleLike(track.id);
}}
aria-label={
likedIds.has(track.id) ? t("liked.unlike") : t("liked.like")
}
className={`p-1 rounded-full transition-colors ${
likedIds.has(track.id)
? "text-pink-500"
: "text-zinc-300 dark:text-zinc-600 hover:text-pink-500"
}`}
>
<Heart
size={14}
className={likedIds.has(track.id) ? "fill-current" : ""}
/>
</button>
</div>
</li>
);
})}
</ul>
</div>
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion | 🟠 Major | πŸ—οΈ Heavy lift

Track table is not virtualized.

GenreTrackTable renders the full list (tracks.map) and does not use the shared page scroller. This is a performance/compliance gap for large genres.

As per coding guidelines, src/**/*{Table,List,View}.{ts,tsx} requires: "Virtual scroll must be used everywhere for performance β€” TrackTable uses @tanstack/react-virtual and virtualized tables must consume usePageScroll() for the scroll element instead of nesting their own overflow-y-auto".

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/views/GenreDetailView.tsx` around lines 223 - 377,
GenreTrackTable currently renders all rows with tracks.map (no virtualization) β€”
replace the full mapping with a virtualized list using `@tanstack/react-virtual`
and consume the shared scroll element from usePageScroll() so the table
participates in the global page scroller. Concretely: in GenreTrackTable import
and use useVirtualizer (or createVirtualizer) from `@tanstack/react-virtual`, call
usePageScroll() to get the scrollElement and pass it as the
parentRef/scrollElement to the virtualizer, compute virtualItems from
tracks.length, and render only virtualItems instead of tracks.map; keep existing
row behavior (onDoubleClick -> onPlayTrack(index), onContextMenu ->
onContextMenuRow, onToggleLike, onNavigateToAlbum/Artist, PlayingIndicator,
Artwork, formatDuration, likedIds checks) and ensure each virtual row key still
uniquely references track.id and index and that container height/style matches
prior grid layout so measuring stays correct.

@InstaZDLL InstaZDLL disabled auto-merge May 15, 2026 20:53
@InstaZDLL InstaZDLL merged commit 0a5fbb9 into main May 15, 2026
13 checks passed
@InstaZDLL InstaZDLL deleted the feat/genre-detail-view branch May 15, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: backend Rust/Tauri backend (src-tauri/) scope: docs Docs, README, assets scope: frontend React/Vite frontend (src/) scope: i18n Translations (src/i18n/) size: xl > 500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant