From 232796fc3d00bf36f5a151906c61a0d956754ffd Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 15 May 2026 22:39:24 +0200 Subject: [PATCH] feat(library): open genre detail page from tags grid 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. --- docs/features/library.md | 2 +- src-tauri/src/commands/browse.rs | 163 ++++++++++ src-tauri/src/lib.rs | 1 + src/components/layout/AppLayout.tsx | 23 ++ src/components/views/GenreDetailView.tsx | 377 +++++++++++++++++++++++ src/components/views/LibraryView.tsx | 20 +- src/i18n/locales/ar.json | 12 + src/i18n/locales/de.json | 12 + src/i18n/locales/en.json | 12 + src/i18n/locales/es.json | 12 + src/i18n/locales/fr.json | 12 + src/i18n/locales/hi.json | 12 + src/i18n/locales/id.json | 12 + src/i18n/locales/it.json | 12 + src/i18n/locales/ja.json | 12 + src/i18n/locales/kr.json | 12 + src/i18n/locales/nl.json | 12 + src/i18n/locales/pt-BR.json | 12 + src/i18n/locales/pt.json | 12 + src/i18n/locales/ru.json | 12 + src/i18n/locales/tr.json | 12 + src/i18n/locales/zh-CN.json | 12 + src/i18n/locales/zh-TW.json | 12 + src/lib/tauri/detail.ts | 14 + src/types/index.ts | 3 +- 25 files changed, 800 insertions(+), 7 deletions(-) create mode 100644 src/components/views/GenreDetailView.tsx diff --git a/docs/features/library.md b/docs/features/library.md index 02b1888..0c3b5ba 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -23,7 +23,7 @@ The scanner splits `"Artist A, Artist B"` (and `;` / `feat.` / `&` variants) on ## Browsing -- **Library tabs** — Morceaux, Albums, Artistes, Genres, Dossiers; each tab keeps its own scroll position and sort memory (per profile). +- **Library tabs** — Morceaux, Albums, Artistes, Genres, Dossiers; each tab keeps its own scroll position and sort memory (per profile). Clicking a genre tile opens a Spotify-style genre detail page (`get_genre_detail` in [`browse.rs`](../../src-tauri/src/commands/browse.rs)) with every track tagged with that genre, sorted Artist → Album → Disc → Track. - **A-Z navigator** — letter rail on the artists tab, NFD-normalised so accents (É → E, Ñ → N) bucket correctly. - **Multi-select** — ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport. - **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. diff --git a/src-tauri/src/commands/browse.rs b/src-tauri/src/commands/browse.rs index 728325c..05defb0 100644 --- a/src-tauri/src/commands/browse.rs +++ b/src-tauri/src/commands/browse.rs @@ -956,6 +956,169 @@ pub async fn get_artist_detail( }) } +// ── Genre detail ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct GenreDetail { + pub id: i64, + pub name: String, + pub track_count: i64, + pub total_duration_ms: i64, + pub tracks: Vec, +} + +#[derive(FromRow)] +struct GenreHeaderRaw { + id: i64, + name: String, +} + +#[derive(FromRow)] +struct GenreTrackRaw { + id: i64, + library_id: i64, + title: String, + album_id: Option, + album_title: Option, + artist_id: Option, + artist_name: Option, + artist_ids: Option, + duration_ms: i64, + track_number: Option, + disc_number: Option, + year: Option, + bitrate: Option, + sample_rate: Option, + channels: Option, + bit_depth: Option, + codec: Option, + musical_key: Option, + file_path: String, + file_size: i64, + added_at: i64, + artwork_hash: Option, + artwork_format: Option, + rating: Option, +} + +/// Return full genre detail: header (name, totals) and every track tagged +/// with this genre across the active profile, ordered by artist → album → +/// disc → track number to match `list_tracks`'s default layout. +#[tauri::command] +pub async fn get_genre_detail( + state: tauri::State<'_, AppState>, + genre_id: i64, +) -> AppResult { + let pool = state.require_profile_pool().await?; + let profile_id = state.require_profile_id().await?; + let artwork_dir = state.paths.profile_artwork_dir(profile_id); + + let header = sqlx::query_as::<_, GenreHeaderRaw>( + r#"SELECT id, name FROM genre WHERE id = ?"#, + ) + .bind(genre_id) + .fetch_optional(&pool) + .await? + .ok_or_else(|| crate::error::AppError::Other("genre not found".into()))?; + + let rows = sqlx::query_as::<_, GenreTrackRaw>( + r#" + SELECT t.id, t.library_id, t.title, + t.album_id, + al.title AS album_title, + t.primary_artist AS artist_id, + (SELECT GROUP_CONCAT(name, ', ') FROM ( + SELECT ar2.name FROM track_artist ta2 + JOIN artist ar2 ON ar2.id = ta2.artist_id + WHERE ta2.track_id = t.id + ORDER BY ta2.position + )) AS artist_name, + (SELECT GROUP_CONCAT(id, ',') FROM ( + SELECT ta2.artist_id AS id FROM track_artist ta2 + WHERE ta2.track_id = t.id + ORDER BY ta2.position + )) AS artist_ids, + t.duration_ms, t.track_number, t.disc_number, t.year, + t.bitrate, t.sample_rate, t.channels, + t.bit_depth, t.codec, t.musical_key, + t.file_path, t.file_size, t.added_at, + aw.hash AS artwork_hash, + aw.format AS artwork_format, + t.rating AS rating + FROM track t + JOIN track_genre tg ON tg.track_id = t.id + LEFT JOIN album al ON al.id = t.album_id + LEFT JOIN artist ar ON ar.id = t.primary_artist + LEFT JOIN artwork aw ON aw.id = al.artwork_id + WHERE tg.genre_id = ? AND t.is_available = 1 + ORDER BY ar.canonical_name COLLATE NOCASE, + al.canonical_title COLLATE NOCASE, + t.disc_number, + t.track_number, + t.title COLLATE NOCASE + "#, + ) + .bind(genre_id) + .fetch_all(&pool) + .await?; + + let tracks: Vec = rows + .into_iter() + .map(|row| { + let (artwork_path, artwork_path_1x, artwork_path_2x) = + match (row.artwork_hash.as_deref(), row.artwork_format.as_deref()) { + (Some(hash), Some(format)) => { + let full = artwork_dir + .join(format!("{}.{}", hash, format)) + .to_string_lossy() + .to_string(); + let (p1, p2) = crate::thumbnails::thumbnail_paths_for(&artwork_dir, hash); + (Some(full), p1, p2) + } + _ => (None, None, None), + }; + crate::commands::track::Track { + id: row.id, + library_id: row.library_id, + title: row.title, + album_id: row.album_id, + album_title: row.album_title, + artist_id: row.artist_id, + artist_name: row.artist_name, + artist_ids: row.artist_ids, + duration_ms: row.duration_ms, + track_number: row.track_number, + disc_number: row.disc_number, + year: row.year, + bitrate: row.bitrate, + sample_rate: row.sample_rate, + channels: row.channels, + bit_depth: row.bit_depth, + codec: row.codec, + musical_key: row.musical_key, + file_path: row.file_path, + file_size: row.file_size, + added_at: row.added_at, + artwork_path, + artwork_path_1x, + artwork_path_2x, + rating: row.rating, + } + }) + .collect(); + + let track_count = tracks.len() as i64; + let total_duration_ms = tracks.iter().map(|t| t.duration_ms).sum(); + + Ok(GenreDetail { + id: header.id, + name: header.name, + track_count, + total_duration_ms, + tracks, + }) +} + // ─── Play history (Last.fm-style chronological scrubber) ───────── // // Distinct from `list_recent_plays`, which deduplicates per track. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81577b4..a6dbdd0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -423,6 +423,7 @@ pub fn run() { commands::browse::get_profile_stats, commands::browse::get_album_detail, commands::browse::get_artist_detail, + commands::browse::get_genre_detail, commands::deezer::enrich_album_deezer, commands::deezer::enrich_artist_deezer, commands::deezer::search_albums_deezer, diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 1534d1d..26b7782 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -94,6 +94,11 @@ const ArtistDetailView = lazy(() => default: module.ArtistDetailView, })), ); +const GenreDetailView = lazy(() => + import("../views/GenreDetailView").then((module) => ({ + default: module.GenreDetailView, + })), +); export function AppLayout() { const { t } = useTranslation(); @@ -114,6 +119,7 @@ export function AppLayout() { const [activePlaylistId, setActivePlaylistId] = useState(null); const [activeAlbumId, setActiveAlbumId] = useState(null); const [activeArtistId, setActiveArtistId] = useState(null); + const [activeGenreId, setActiveGenreId] = useState(null); // Year requested when navigating into the Wrapped overlay. `null` // tells WrappedView to pick the most recent year with plays. const [activeWrappedYear, setActiveWrappedYear] = useState( @@ -230,6 +236,14 @@ export function AppLayout() { [setActiveView], ); + const navigateToGenre = useCallback( + (genreId: number) => { + setActiveGenreId(genreId); + setActiveView("genre-detail"); + }, + [setActiveView], + ); + const navigateToPlaylist = useCallback( (playlistId: number) => { setActivePlaylistId(playlistId); @@ -274,6 +288,7 @@ export function AppLayout() { setActiveTab={setLibraryTab} onNavigateToAlbum={navigateToAlbum} onNavigateToArtist={navigateToArtist} + onNavigateToGenre={navigateToGenre} /> ); case "settings": @@ -333,6 +348,14 @@ export function AppLayout() { onNavigateToArtist={navigateToArtist} /> ); + case "genre-detail": + return ( + + ); } } diff --git a/src/components/views/GenreDetailView.tsx b/src/components/views/GenreDetailView.tsx new file mode 100644 index 0000000..87ad9a9 --- /dev/null +++ b/src/components/views/GenreDetailView.tsx @@ -0,0 +1,377 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Play, Shuffle, Clock, Music2, Heart, Tags } from "lucide-react"; +import { Artwork } from "../common/Artwork"; +import { ArtistLink } from "../common/ArtistLink"; +import { EmptyState } from "../common/EmptyState"; +import { CreatePlaylistModal } from "../common/CreatePlaylistModal"; +import { HiResBadge } from "../common/HiResBadge"; +import { PlayingIndicator } from "../common/PlayingIndicator"; +import { usePlayer } from "../../hooks/usePlayer"; +import { usePlaylist } from "../../hooks/usePlaylist"; +import { useTrackContextMenu } from "../../hooks/useTrackContextMenu"; +import { useTrackUpdated } from "../../hooks/useTrackUpdated"; +import { getGenreDetail, type GenreDetail } from "../../lib/tauri/detail"; +import { + formatDuration, + listLikedTrackIds, + toggleLikeTrack, + type Track, +} from "../../lib/tauri/track"; + +interface GenreDetailViewProps { + genreId: number | null; + onNavigateToAlbum: (albumId: number) => void; + onNavigateToArtist: (artistId: number) => void; +} + +export function GenreDetailView({ + genreId, + onNavigateToAlbum, + onNavigateToArtist, +}: GenreDetailViewProps) { + const { t } = useTranslation(); + const { playTracks, currentTrack, toggleShuffle, isPlaying } = usePlayer(); + const { createPlaylist } = usePlaylist(); + + const [genre, setGenre] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [likedIds, setLikedIds] = useState>(new Set()); + const [isCreatePlaylistModalOpen, setIsCreatePlaylistModalOpen] = + useState(false); + + const trackContextMenu = useTrackContextMenu({ + likedIds, + onLikedChanged: (trackId, nowLiked) => + setLikedIds((prev) => { + const next = new Set(prev); + if (nowLiked) next.add(trackId); + else next.delete(trackId); + return next; + }), + onCreatePlaylist: () => setIsCreatePlaylistModalOpen(true), + onNavigateToAlbum, + onNavigateToArtist, + }); + + const [editRefetch, setEditRefetch] = useState(0); + useTrackUpdated(useCallback(() => setEditRefetch((k) => k + 1), [])); + + useEffect(() => { + if (genreId == null) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setGenre(null); + return; + } + let cancelled = false; + (async () => { + setIsLoading(true); + try { + const detail = await getGenreDetail(genreId); + if (!cancelled) setGenre(detail); + } catch (err) { + console.error("[GenreDetailView] load failed", err); + if (!cancelled) setGenre(null); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [genreId, editRefetch]); + + useEffect(() => { + listLikedTrackIds() + .then((ids) => setLikedIds(new Set(ids))) + .catch(() => {}); + }, [genreId]); + + 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; + }); + }; + + if (genreId == null || (!genre && !isLoading)) { + return ( + } + title={t("genreDetail.emptyTitle")} + description={t("genreDetail.emptyDescription")} + className="py-20" + /> + ); + } + + if (!genre) return null; + + const tracks = genre.tracks; + + const handlePlayAll = async () => { + if (tracks.length === 0) return; + await playTracks(tracks, 0, { type: "library", id: null }); + }; + + const handleShufflePlay = async () => { + if (tracks.length === 0) return; + await playTracks(tracks, 0, { type: "library", id: null }); + await toggleShuffle(); + }; + + return ( +
+ {/* Header */} +
+
+ +
+ +
+
+ {t("genreDetail.badge")} +
+

+ {genre.name} +

+ +
+ + {t("genreDetail.trackCount", { count: genre.track_count })} + + · + {formatDuration(genre.total_duration_ms)} +
+ +
+ + +
+
+
+ + {/* Tracks */} + {tracks.length > 0 ? ( + + playTracks(tracks, index, { type: "library", id: null }) + } + onNavigateToAlbum={onNavigateToAlbum} + onNavigateToArtist={onNavigateToArtist} + onContextMenuRow={trackContextMenu.open} + t={t} + /> + ) : ( + } + title={t("genreDetail.emptyTracksTitle")} + description={t("genreDetail.emptyTracksDescription")} + className="py-20" + /> + )} + + setIsCreatePlaylistModalOpen(false)} + onCreate={async (data) => { + try { + await createPlaylist({ + name: data.name, + description: data.description || null, + color_id: data.colorId, + icon_id: data.iconId, + }); + } catch (err) { + console.error("[GenreDetailView] create playlist failed", err); + } + }} + /> + + {trackContextMenu.render()} +
+ ); +} + +// ── Track table ───────────────────────────────────────────────────── + +function GenreTrackTable({ + tracks, + isLoading, + currentTrackId, + isPlaying, + likedIds, + onToggleLike, + onPlayTrack, + onNavigateToAlbum, + onNavigateToArtist, + onContextMenuRow, + t, +}: { + tracks: Track[]; + isLoading: boolean; + currentTrackId: number | null; + isPlaying: boolean; + likedIds: Set; + 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; +}) { + const gridCols = "grid-cols-[3rem_2.75rem_1.5fr_1fr_1fr_5rem_2rem]"; + return ( +
+
+ {t("library.table.number")} +
+ +
    + {tracks.map((track, index) => { + const isCurrent = track.id === currentTrackId; + return ( +
  • 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" + }`} + > + + {isCurrent ? ( + + ) : ( + index + 1 + )} + + + + {track.title} + + + + + + + {track.album_id != null && track.album_title ? ( + + ) : ( + (track.album_title ?? t("library.table.unknown")) + )} + + + {formatDuration(track.duration_ms)} + +
    + +
    +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index de23687..6dd51b4 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -88,6 +88,7 @@ interface LibraryViewProps { setActiveTab: (tab: LibraryTab) => void; onNavigateToAlbum: (albumId: number) => void; onNavigateToArtist: (artistId: number) => void; + onNavigateToGenre: (genreId: number) => void; } type Translator = (key: string, options?: Record) => string; @@ -121,6 +122,7 @@ export function LibraryView({ setActiveTab, onNavigateToAlbum, onNavigateToArtist, + onNavigateToGenre, }: LibraryViewProps) { const { t } = useTranslation(); const { @@ -635,7 +637,12 @@ export function LibraryView({ )} {activeTab === "genres" && ( - + )} {activeTab === "dossiers" && ( void; } -function GenreList({ genres, isLoading, t }: GenreListProps) { +function GenreList({ genres, isLoading, t, onSelect }: GenreListProps) { return (
{genres.map((genre) => ( -
onSelect(genre.id)} + className="flex items-center space-x-3 p-4 rounded-2xl border border-zinc-200 bg-white hover:bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-800/40 dark:hover:bg-zinc-800/70 transition-colors cursor-pointer text-left focus:outline-none focus:ring-2 focus:ring-emerald-500/40" >
@@ -1629,7 +1639,7 @@ function GenreList({ genres, isLoading, t }: GenreListProps) { {t("library.genreList.trackCount", { count: genre.track_count })}
-
+ ))} ); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 217f807..9be9b34 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -826,6 +826,18 @@ "notInLibrary": "ليس في مكتبتك" } }, + "genreDetail": { + "badge": "النوع", + "playAll": "تشغيل الكل", + "shuffle": "تشغيل عشوائي", + "trackCount_zero": "0 مقطع", + "trackCount_one": "{{count}} مقطع", + "trackCount_other": "{{count}} مقاطع", + "emptyTitle": "النوع غير موجود", + "emptyDescription": "هذا النوع لم يعد موجوداً في الملف الشخصي النشط.", + "emptyTracksTitle": "لا توجد مقاطع", + "emptyTracksDescription": "هذا النوع لا يحتوي على أي مقاطع متاحة." + }, "nowPlaying": { "title": "التشغيل الآن", "empty": "لا توجد مقطوعة قيد التشغيل", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index d86cb65..204d5d1 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -826,6 +826,18 @@ "notInLibrary": "Nicht in deiner Bibliothek" } }, + "genreDetail": { + "badge": "Genre", + "playAll": "Alle abspielen", + "shuffle": "Zufallswiedergabe", + "trackCount_zero": "0 Titel", + "trackCount_one": "{{count}} Titel", + "trackCount_other": "{{count}} Titel", + "emptyTitle": "Genre nicht gefunden", + "emptyDescription": "Dieses Genre existiert im aktiven Profil nicht mehr.", + "emptyTracksTitle": "Keine Titel", + "emptyTracksDescription": "Dieses Genre enthält keine verfügbaren Titel." + }, "nowPlaying": { "title": "Jetzt spielen", "empty": "Es wird kein Titel abgespielt", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1ec14b3..c65f8b3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -834,6 +834,18 @@ "notInLibrary": "Not in your library" } }, + "genreDetail": { + "badge": "Genre", + "playAll": "Play all", + "shuffle": "Shuffle", + "trackCount_zero": "0 tracks", + "trackCount_one": "{{count}} track", + "trackCount_other": "{{count}} tracks", + "emptyTitle": "Genre not found", + "emptyDescription": "This genre no longer exists in the active profile.", + "emptyTracksTitle": "No tracks", + "emptyTracksDescription": "This genre contains no available tracks." + }, "nowPlaying": { "title": "Now playing", "empty": "No track playing", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 372a1ee..96dc9f8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -826,6 +826,18 @@ "notInLibrary": "Fuera de tu biblioteca" } }, + "genreDetail": { + "badge": "Género", + "playAll": "Reproducir todo", + "shuffle": "Aleatorio", + "trackCount_zero": "0 canciones", + "trackCount_one": "{{count}} canción", + "trackCount_other": "{{count}} canciones", + "emptyTitle": "Género no encontrado", + "emptyDescription": "Este género ya no existe en el perfil activo.", + "emptyTracksTitle": "Sin canciones", + "emptyTracksDescription": "Este género no contiene canciones disponibles." + }, "nowPlaying": { "title": "Reproduciendo", "empty": "No se está reproduciendo ninguna pista", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0c7b0b2..6650c24 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -834,6 +834,18 @@ "notInLibrary": "Pas dans votre bibliothèque" } }, + "genreDetail": { + "badge": "Genre", + "playAll": "Tout lire", + "shuffle": "Lecture aléatoire", + "trackCount_zero": "0 titre", + "trackCount_one": "{{count}} titre", + "trackCount_other": "{{count}} titres", + "emptyTitle": "Genre introuvable", + "emptyDescription": "Ce genre n'existe plus dans le profil actif.", + "emptyTracksTitle": "Aucun morceau", + "emptyTracksDescription": "Ce genre ne contient aucun morceau disponible." + }, "nowPlaying": { "title": "En cours de lecture", "empty": "Aucun titre en cours", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 210339f..c90ef5b 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -826,6 +826,18 @@ "notInLibrary": "आपकी लाइब्रेरी में नहीं" } }, + "genreDetail": { + "badge": "शैली", + "playAll": "सभी चलाएँ", + "shuffle": "शफल", + "trackCount_zero": "0 ट्रैक", + "trackCount_one": "{{count}} ट्रैक", + "trackCount_other": "{{count}} ट्रैक", + "emptyTitle": "शैली नहीं मिली", + "emptyDescription": "यह शैली सक्रिय प्रोफ़ाइल में अब मौजूद नहीं है।", + "emptyTracksTitle": "कोई ट्रैक नहीं", + "emptyTracksDescription": "इस शैली में कोई उपलब्ध ट्रैक नहीं है।" + }, "nowPlaying": { "title": "अब चल रहा है", "empty": "कोई ट्रैक नहीं चल रहा है", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 56dd0d3..6ef1945 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -826,6 +826,18 @@ "notInLibrary": "Tidak di pustaka Anda" } }, + "genreDetail": { + "badge": "Genre", + "playAll": "Putar semua", + "shuffle": "Acak", + "trackCount_zero": "0 lagu", + "trackCount_one": "{{count}} lagu", + "trackCount_other": "{{count}} lagu", + "emptyTitle": "Genre tidak ditemukan", + "emptyDescription": "Genre ini tidak ada lagi di profil aktif.", + "emptyTracksTitle": "Tidak ada lagu", + "emptyTracksDescription": "Genre ini tidak memiliki lagu yang tersedia." + }, "nowPlaying": { "title": "Sekarang diputar", "empty": "Tidak ada trek yang sedang diputar", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4d72507..a5296c9 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -826,6 +826,18 @@ "notInLibrary": "Non nella tua libreria" } }, + "genreDetail": { + "badge": "Genere", + "playAll": "Riproduci tutto", + "shuffle": "Riproduzione casuale", + "trackCount_zero": "0 brani", + "trackCount_one": "{{count}} brano", + "trackCount_other": "{{count}} brani", + "emptyTitle": "Genere non trovato", + "emptyDescription": "Questo genere non esiste più nel profilo attivo.", + "emptyTracksTitle": "Nessun brano", + "emptyTracksDescription": "Questo genere non contiene brani disponibili." + }, "nowPlaying": { "title": "Ora in riproduzione", "empty": "Nessun brano in riproduzione", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 72201af..bf1cb0a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -826,6 +826,18 @@ "notInLibrary": "ライブラリにない" } }, + "genreDetail": { + "badge": "ジャンル", + "playAll": "すべて再生", + "shuffle": "シャッフル", + "trackCount_zero": "0 曲", + "trackCount_one": "{{count}} 曲", + "trackCount_other": "{{count}} 曲", + "emptyTitle": "ジャンルが見つかりません", + "emptyDescription": "このジャンルはアクティブなプロファイルに存在しません。", + "emptyTracksTitle": "曲がありません", + "emptyTracksDescription": "このジャンルには利用可能な曲がありません。" + }, "nowPlaying": { "title": "再生中", "empty": "再生中のトラックはありません", diff --git a/src/i18n/locales/kr.json b/src/i18n/locales/kr.json index cc0cce1..0b47006 100644 --- a/src/i18n/locales/kr.json +++ b/src/i18n/locales/kr.json @@ -826,6 +826,18 @@ "notInLibrary": "라이브러리에 없음" } }, + "genreDetail": { + "badge": "장르", + "playAll": "모두 재생", + "shuffle": "셔플", + "trackCount_zero": "0곡", + "trackCount_one": "{{count}}곡", + "trackCount_other": "{{count}}곡", + "emptyTitle": "장르를 찾을 수 없음", + "emptyDescription": "이 장르는 활성 프로필에 더 이상 존재하지 않습니다.", + "emptyTracksTitle": "곡 없음", + "emptyTracksDescription": "이 장르에는 사용 가능한 곡이 없습니다." + }, "nowPlaying": { "title": "지금 재생 중", "empty": "재생 중인 트랙 없음", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 0b3dc5e..bb91d96 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -826,6 +826,18 @@ "notInLibrary": "Niet in je bibliotheek" } }, + "genreDetail": { + "badge": "Genre", + "playAll": "Alles afspelen", + "shuffle": "Willekeurig afspelen", + "trackCount_zero": "0 nummers", + "trackCount_one": "{{count}} nummer", + "trackCount_other": "{{count}} nummers", + "emptyTitle": "Genre niet gevonden", + "emptyDescription": "Dit genre bestaat niet meer in het actieve profiel.", + "emptyTracksTitle": "Geen nummers", + "emptyTracksDescription": "Dit genre bevat geen beschikbare nummers." + }, "nowPlaying": { "title": "Nu afspelen", "empty": "Er wordt geen nummer afgespeeld", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index e2d0bdc..4b16049 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -826,6 +826,18 @@ "notInLibrary": "Fora da sua biblioteca" } }, + "genreDetail": { + "badge": "Gênero", + "playAll": "Reproduzir tudo", + "shuffle": "Aleatório", + "trackCount_zero": "0 faixas", + "trackCount_one": "{{count}} faixa", + "trackCount_other": "{{count}} faixas", + "emptyTitle": "Gênero não encontrado", + "emptyDescription": "Este gênero não existe mais no perfil ativo.", + "emptyTracksTitle": "Sem faixas", + "emptyTracksDescription": "Este gênero não contém faixas disponíveis." + }, "nowPlaying": { "title": "Reproduzindo agora", "empty": "Nenhuma faixa em reprodução", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index ebbacdf..590ecb6 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -826,6 +826,18 @@ "notInLibrary": "Fora da sua biblioteca" } }, + "genreDetail": { + "badge": "Género", + "playAll": "Reproduzir tudo", + "shuffle": "Aleatório", + "trackCount_zero": "0 faixas", + "trackCount_one": "{{count}} faixa", + "trackCount_other": "{{count}} faixas", + "emptyTitle": "Género não encontrado", + "emptyDescription": "Este género já não existe no perfil ativo.", + "emptyTracksTitle": "Sem faixas", + "emptyTracksDescription": "Este género não contém faixas disponíveis." + }, "nowPlaying": { "title": "Agora a reproduzir", "empty": "Nenhuma faixa a ser reproduzida", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c2ba8f5..a0053f1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -826,6 +826,18 @@ "notInLibrary": "Нет в вашей библиотеке" } }, + "genreDetail": { + "badge": "Жанр", + "playAll": "Воспроизвести всё", + "shuffle": "Перемешать", + "trackCount_zero": "0 треков", + "trackCount_one": "{{count}} трек", + "trackCount_other": "{{count}} треков", + "emptyTitle": "Жанр не найден", + "emptyDescription": "Этот жанр больше не существует в активном профиле.", + "emptyTracksTitle": "Нет треков", + "emptyTracksDescription": "В этом жанре нет доступных треков." + }, "nowPlaying": { "title": "Сейчас играет", "empty": "Трек не воспроизводится", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index c74dbd7..2fe8c63 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -826,6 +826,18 @@ "notInLibrary": "Kütüphanenizde değil" } }, + "genreDetail": { + "badge": "Tür", + "playAll": "Tümünü çal", + "shuffle": "Karıştır", + "trackCount_zero": "0 parça", + "trackCount_one": "{{count}} parça", + "trackCount_other": "{{count}} parça", + "emptyTitle": "Tür bulunamadı", + "emptyDescription": "Bu tür artık aktif profilde mevcut değil.", + "emptyTracksTitle": "Parça yok", + "emptyTracksDescription": "Bu tür kullanılabilir parça içermiyor." + }, "nowPlaying": { "title": "Şimdi çalıyor", "empty": "Çalınan parça yok", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 4416dc7..7b96a77 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -826,6 +826,18 @@ "notInLibrary": "不在你的曲库中" } }, + "genreDetail": { + "badge": "流派", + "playAll": "播放全部", + "shuffle": "随机播放", + "trackCount_zero": "0 首", + "trackCount_one": "{{count}} 首", + "trackCount_other": "{{count}} 首", + "emptyTitle": "未找到流派", + "emptyDescription": "此流派在当前配置文件中已不存在。", + "emptyTracksTitle": "无曲目", + "emptyTracksDescription": "此流派没有可用曲目。" + }, "nowPlaying": { "title": "正在播放", "empty": "没有正在播放的曲目", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 2b85cc6..7e64ef5 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -826,6 +826,18 @@ "notInLibrary": "不在你的曲庫中" } }, + "genreDetail": { + "badge": "曲風", + "playAll": "播放全部", + "shuffle": "隨機播放", + "trackCount_zero": "0 首", + "trackCount_one": "{{count}} 首", + "trackCount_other": "{{count}} 首", + "emptyTitle": "找不到曲風", + "emptyDescription": "此曲風在目前的設定檔中已不存在。", + "emptyTracksTitle": "無曲目", + "emptyTracksDescription": "此曲風沒有可用的曲目。" + }, "nowPlaying": { "title": "現在播放", "empty": "目前沒有正在播放的曲目", diff --git a/src/lib/tauri/detail.ts b/src/lib/tauri/detail.ts index 401aa6a..97a1f92 100644 --- a/src/lib/tauri/detail.ts +++ b/src/lib/tauri/detail.ts @@ -77,6 +77,20 @@ export function getArtistDetail(artistId: number): Promise { return invoke("get_artist_detail", { artistId }); } +// ── Genre detail ──────────────────────────────────────────────────── + +export interface GenreDetail { + id: number; + name: string; + track_count: number; + total_duration_ms: number; + tracks: Track[]; +} + +export function getGenreDetail(genreId: number): Promise { + return invoke("get_genre_detail", { genreId }); +} + // ── Deezer enrichment ─────────────────────────────────────────────── export interface DeezerAlbumEnrichment { diff --git a/src/types/index.ts b/src/types/index.ts index 91a3248..a1ce3a9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,7 +13,8 @@ export type ViewId = | "spotify" | "wrapped" | "album-detail" - | "artist-detail"; + | "artist-detail" + | "genre-detail"; export type LibraryTab = | "morceaux"