// Library > Favorites — the user's saved tracks / albums / artists / // playlists / labels. A fixed chrome block (back/forward + title + the // tab menu on row 1; per-view action buttons + toolbar on row 2) sits // over a single scrolling content area. Tracks reuse the shared TrackRow, // albums the shared AlbumCollectionView, artists/playlists round/tile // grids, labels a row list. import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { FavoritesState, FavoritesActions, AlbumCardItem, TrackItem, FavoriteArtistItem, FavoriteLabelItem, SearchPlaylistItem, DragState, GenreFilterState, GenreFilterActions, AlphaJump, } from "../state.slint"; import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint"; import { PlaylistCard } from "../discover/PlaylistCard.slint"; import { GenreFilterPopup } from "../discover/GenreFilterPopup.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; import { ExpandableSearch } from "../primitives/ExpandableSearch.slint"; import { CircleAction } from "../primitives/CircleAction.slint"; import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { ToggleButton } from "../primitives/ToggleButton.slint"; import { QbzSelect } from "../primitives/QbzSelect.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ContextMenu } from "../primitives/ContextMenu.slint"; import { ContextMenuItem } from "../primitives/ContextMenuItem.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { ListView } from "std-widgets.slint"; // One tab in the favorites tab menu. component FavTab inherits Rectangle { in property label; in property id; in property count; callback selected(string); property active: FavoritesState.active-tab == root.id; height: 34px; width: tab-row.preferred-width; tab-row := HorizontalLayout { padding-left: 4px; padding-right: 4px; spacing: 6px; Text { text: root.label; color: root.active ? Theme.text-primary : Theme.text-muted; font-size: Typography.body; font-weight: root.active ? Typography.semibold : Typography.regular; vertical-alignment: center; } if root.count > 0: Text { text: root.count; color: Theme.text-muted; font-size: Typography.legal; vertical-alignment: center; } } // Active underline. Rectangle { y: parent.height - 2px; height: 2px; width: parent.width; background: root.active ? Theme.accent : transparent; border-radius: 1px; } ta := TouchArea { mouse-cursor: pointer; clicked => { root.selected(root.id); } } } // The 5 favorites tabs as one floating group (row 1, right side). Counts // stay live (FavoritesState.*-total are seeded up front). component FavTabMenu inherits HorizontalLayout { spacing: 18px; VerticalLayout { alignment: center; FavTab { label: @tr("Tracks"); id: "tracks"; count: FavoritesState.tracks-total; selected(id) => { FavoritesActions.select-tab(id); } } } VerticalLayout { alignment: center; FavTab { label: @tr("Albums"); id: "albums"; count: FavoritesState.albums-total; selected(id) => { FavoritesActions.select-tab(id); } } } VerticalLayout { alignment: center; FavTab { label: @tr("Artists"); id: "artists"; count: FavoritesState.artists-total; selected(id) => { FavoritesActions.select-tab(id); } } } VerticalLayout { alignment: center; FavTab { label: @tr("Playlists"); id: "playlists"; count: FavoritesState.playlists-total; selected(id) => { FavoritesActions.select-tab(id); } } } VerticalLayout { alignment: center; FavTab { label: @tr("Labels"); id: "labels"; count: FavoritesState.labels-total; selected(id) => { FavoritesActions.select-tab(id); } } } } // One underline sub-tab for the Playlists tab (Library / Following). // Underline style, NOT a pill (ADR-008). Mirrors the FavTab pattern. component PlaylistSubTab inherits Rectangle { in property label; in property tab-id; // "favorites" | "following" in property count: 0; property active: FavoritesState.playlists-sub-tab == root.tab-id; height: 36px; width: row.preferred-width; row := HorizontalLayout { padding-left: 2px; padding-right: 2px; spacing: 6px; Text { text: root.label; color: root.active ? Theme.text-primary : Theme.text-muted; font-size: Typography.body; font-weight: root.active ? Typography.semibold : Typography.regular; vertical-alignment: center; } if root.count > 0: Text { text: root.count; color: Theme.text-muted; font-size: Typography.legal; vertical-alignment: center; } } // Active underline. Rectangle { y: parent.height - 2px; height: 2px; width: parent.width; background: root.active ? Theme.accent : transparent; border-radius: 1px; } ta := TouchArea { mouse-cursor: pointer; clicked => { FavoritesActions.playlists-set-sub-tab(root.tab-id); } } } // Compact list row for the Favorites Playlists list view (Slint-better; // Tauri ships grid only). Thumb + name + owner·tracks + play + more-menu. component PlaylistListRow inherits Rectangle { in property playlist; in property index; callback media-action(string, string, string); property row-hover: body-ta.has-hover || play-ta.has-hover || more-ta.has-hover; height: 60px; border-radius: 8px; background: root.row-hover ? #ffffff14 : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent); // Full-row click target (behind the action buttons) -> open the playlist. body-ta := TouchArea { mouse-cursor: pointer; clicked => { root.media-action("playlist", root.playlist.id, "open"); } } HorizontalLayout { padding-left: 10px; padding-right: 10px; spacing: 12px; VerticalLayout { alignment: center; Rectangle { width: 44px; height: 44px; border-radius: 6px; background: Theme.surface-elevated; clip: true; Image { width: 100%; height: 100%; image-fit: cover; source: root.playlist.cover1; } if root.playlist.cover-count == 0: QbzIcon { source: @image-url("../assets/icons/list-music.svg"); width: 20px; height: 20px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; tint: Theme.text-muted; } } } VerticalLayout { alignment: center; horizontal-stretch: 1; Text { text: root.playlist.title; color: Theme.text-primary; font-size: Typography.body; font-weight: Typography.medium; overflow: elide; } Text { text: root.playlist.subtitle; color: Theme.text-muted; font-size: Typography.legal; overflow: elide; } } VerticalLayout { alignment: center; play-ta := TouchArea { width: 30px; height: 30px; mouse-cursor: pointer; clicked => { root.media-action("playlist", root.playlist.id, "play"); } QbzIcon { source: @image-url("../assets/icons/play-fill.svg"); width: 16px; height: 16px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; tint: root.row-hover ? Theme.text-primary : Theme.text-muted; } } } VerticalLayout { alignment: center; more-ta := TouchArea { width: 30px; height: 30px; mouse-cursor: pointer; clicked => { row-menu.show(); } QbzIcon { source: @image-url("../assets/icons/ellipsis.svg"); width: 16px; height: 16px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; tint: Theme.text-secondary; } row-menu := PopupWindow { x: -166px; y: 32px; width: 196px; height: 150px; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/play.svg"); label: @tr("Play"); clicked => { root.media-action("playlist", root.playlist.id, "play"); } } ContextMenuItem { icon: @image-url("../assets/icons/list-plus.svg"); label: @tr("Play next"); clicked => { root.media-action("playlist", root.playlist.id, "play-next"); } } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); clicked => { root.media-action("playlist", root.playlist.id, "queue"); } } ContextMenuItem { icon: @image-url("../assets/icons/heart.svg"); label: @tr("Favorite"); clicked => { root.media-action("playlist", root.playlist.id, "favorite"); } } } } } } } } // One label tile in the Favorites > Labels grid — a circular cover (or // an indigo->violet gradient + disc placeholder), name, and album count. // Mirrors FavArtistCard; the gradient placeholder is the 1:1 match with // Tauri's label card. component FavLabelCard inherits Rectangle { in property item; callback clicked(string, string); width: 160px; height: 200px; VerticalLayout { spacing: 10px; Rectangle { width: 140px; height: 140px; x: (parent.width - self.width) / 2; border-radius: 70px; background: root.item.image-url == "" ? @linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) : Theme.surface-elevated; clip: true; Image { width: 100%; height: 100%; source: root.item.image; image-fit: cover; } if root.item.image-url == "": QbzIcon { source: @image-url("../assets/icons/disc-3.svg"); width: 44px; height: 44px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; tint: #ffffff; } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(root.item.id, root.item.name); } } } Text { text: root.item.name; color: Theme.text-primary; font-size: Typography.body; font-weight: Typography.medium; horizontal-alignment: center; overflow: elide; width: 160px; } if root.item.albums-line != "": Text { text: root.item.albums-line; color: Theme.text-muted; font-size: Typography.legal; horizontal-alignment: center; width: 160px; } } } component FavArtistCard inherits Rectangle { in property item; callback clicked(string); width: 160px; height: 200px; VerticalLayout { spacing: 10px; Rectangle { width: 120px; height: 120px; x: (parent.width - self.width) / 2; border-radius: 60px; background: Theme.surface-elevated; clip: true; Image { width: 100%; height: 100%; source: root.item.image; image-fit: cover; } if root.item.image-url == "": QbzIcon { source: @image-url("../assets/icons/user.svg"); width: 44px; height: 44px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; tint: Theme.text-muted; } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(root.item.id); } } } // albums_count line intentionally omitted (Tauri #169). Text { text: root.item.name; color: Theme.text-primary; font-size: Typography.body; font-weight: Typography.medium; horizontal-alignment: center; overflow: elide; width: 160px; } } } // Genre-filter trigger for the favorites toolbars — opens the shared // popup in the "favorites" context. Accent fill + "N genres" when active. component FavGenreButton inherits Rectangle { property active: GenreFilterState.selected-count > 0; width: genre-row.preferred-width; height: 30px; border-radius: 6px; background: root.active ? Theme.accent : (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated); genre-row := HorizontalLayout { padding-left: 10px; padding-right: 12px; spacing: 7px; VerticalLayout { alignment: center; QbzIcon { source: @image-url("../assets/icons/list-filter.svg"); width: 13px; height: 13px; tint: root.active ? #ffffff : Theme.text-secondary; } } Text { text: root.active ? (GenreFilterState.selected-count == 1 ? @tr("1 genre") : @tr("{} genres", GenreFilterState.selected-count)) : @tr("Filter by genre"); color: root.active ? #ffffff : Theme.text-secondary; font-size: 12px; vertical-alignment: center; } } ta := TouchArea { mouse-cursor: pointer; clicked => { GenreFilterActions.set-context("favorites"); GenreFilterState.open = !GenreFilterState.open; } } } // Grid/list segmented toggle. Parameterized so albums + playlists can both // drive it with their own view-mode state. component ViewToggle inherits Rectangle { in property view-mode; // current "grid" | "list" callback set-view(string); width: 60px; height: 30px; border-radius: 6px; background: Theme.surface-elevated; clip: true; HorizontalLayout { ToggleButton { icon: @image-url("../assets/icons/layout-grid.svg"); active: root.view-mode == "grid"; sm: true; clicked => { root.set-view("grid"); } } ToggleButton { icon: @image-url("../assets/icons/list.svg"); active: root.view-mode == "list"; sm: true; clicked => { root.set-view("list"); } } } } // Vertical A-Z jump strip overlaid on the right edge of a grouped list / // grid. Emits jump(ordinal, index); the host scrolls its list/flickable. component AlphaStrip inherits Rectangle { in property <[AlphaJump]> jumps; callback jump(int /* ordinal */, int /* index */); width: 18px; VerticalLayout { alignment: center; spacing: 0px; for j[k] in root.jumps: Rectangle { height: 15px; width: 18px; Text { text: j.letter; color: la.has-hover ? Theme.accent : Theme.text-muted; font-size: 9px; font-weight: Typography.semibold; horizontal-alignment: center; vertical-alignment: center; } la := TouchArea { mouse-cursor: pointer; clicked => { root.jump(k, j.index); } } } } } export component FavoritesView inherits Rectangle { callback open-album(string); callback open-artist(string); property artist-w: 160px; property artist-h: 200px; property gap: 24px; background: Theme.surface-main; VerticalLayout { spacing: 0px; // ===================== FIXED CHROME ===================== // Row 1 — back/forward + title (left), tab menu (right). HorizontalLayout { padding-left: 32px; padding-right: 32px; padding-top: 11px; spacing: 16px; VerticalLayout { alignment: center; NavButtons { } } VerticalLayout { alignment: center; Text { text: @tr("Favorites"); color: Theme.text-primary; font-size: Typography.section; font-weight: Typography.bold; } } Rectangle { horizontal-stretch: 1; } VerticalLayout { alignment: center; FavTabMenu { } } } Rectangle { height: 12px; } // Row 2 — per-view action buttons (left), toolbar (right). HorizontalLayout { padding-left: 32px; padding-right: 32px; spacing: 8px; // --- Left: action buttons --- if FavoritesState.active-tab == "tracks" && FavoritesState.tracks-visible.length > 0: HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; CircleAction { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; clicked => { FavoritesActions.play-all-tracks(); } } } VerticalLayout { alignment: center; CircleAction { icon: @image-url("../assets/icons/shuffle.svg"); clicked => { FavoritesActions.shuffle-tracks(); } } } VerticalLayout { alignment: center; CircleAction { icon: @image-url("../assets/icons/square-check-big.svg"); active: FavoritesState.tracks-multi-select; clicked => { FavoritesActions.toggle-multi-select(); } } } } if FavoritesState.active-tab == "albums" && FavoritesState.albums-visible.length > 0: HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; CircleAction { icon: @image-url("../assets/icons/shuffle.svg"); clicked => { FavoritesActions.albums-shuffle(); } } } } Rectangle { horizontal-stretch: 1; } // --- Right: per-view toolbar --- if FavoritesState.active-tab == "tracks" && FavoritesState.tracks.length > 0: HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; ExpandableSearch { placeholder: @tr("Search"); sm: true; query <=> FavoritesState.tracks-search; edited(q) => { FavoritesActions.search-tracks(q); } } } VerticalLayout { alignment: center; FavGenreButton { } } VerticalLayout { alignment: center; QbzSelect { menu-width: 150px; options: [ @tr("Group: Off"), @tr("Group: Album"), @tr("Group: Artist"), @tr("Group: Name"), ]; sm: true; current-index: FavoritesState.tracks-group-mode == "off" ? 0 : FavoritesState.tracks-group-mode == "album" ? 1 : FavoritesState.tracks-group-mode == "artist" ? 2 : 3; selected(i) => { FavoritesActions.tracks-set-group( i == 0 ? "off" : i == 1 ? "album" : i == 2 ? "artist" : "name"); } } } } if FavoritesState.active-tab == "albums" && FavoritesState.albums.length > 0: HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; ExpandableSearch { placeholder: @tr("Search"); sm: true; query <=> FavoritesState.albums-search; edited(q) => { FavoritesActions.albums-search(q); } } } VerticalLayout { alignment: center; FavGenreButton { } } VerticalLayout { alignment: center; QbzSelect { menu-width: 150px; options: [ @tr("Group: Off"), @tr("Group: A-Z"), @tr("Group: Artist"), ]; sm: true; current-index: FavoritesState.albums-group-mode == "off" ? 0 : FavoritesState.albums-group-mode == "alpha" ? 1 : 2; selected(i) => { FavoritesActions.albums-set-group( i == 0 ? "off" : i == 1 ? "alpha" : "artist"); } } } VerticalLayout { alignment: center; QbzSelect { menu-width: 168px; options: [ @tr("Default"), @tr("Title A-Z"), @tr("Title Z-A"), @tr("Artist A-Z"), ]; sm: true; current-index: FavoritesState.albums-sort-by == "default" ? 0 : FavoritesState.albums-sort-by == "title-asc" ? 1 : FavoritesState.albums-sort-by == "title-desc" ? 2 : 3; selected(i) => { FavoritesActions.albums-set-sort( i == 0 ? "default" : i == 1 ? "title-asc" : i == 2 ? "title-desc" : "artist-asc"); } } } VerticalLayout { alignment: center; ViewToggle { view-mode: FavoritesState.albums-view-mode; set-view(v) => { FavoritesActions.albums-set-view(v); } } } } if FavoritesState.active-tab == "labels" && FavoritesState.labels.length > 0: HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; ExpandableSearch { placeholder: @tr("Search"); sm: true; query <=> FavoritesState.labels-search; edited(q) => { FavoritesActions.search-labels(q); } } } } if FavoritesState.active-tab == "playlists" && (FavoritesState.playlists-favorites.length > 0 || FavoritesState.playlists-following.length > 0): HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; ExpandableSearch { placeholder: @tr("Search"); sm: true; query <=> FavoritesState.playlists-search; edited(q) => { FavoritesActions.search-playlists(q); } } } VerticalLayout { alignment: center; ViewToggle { view-mode: FavoritesState.playlists-view-mode; set-view(v) => { FavoritesActions.playlists-set-view(v); } } } } } Rectangle { height: 14px; } Rectangle { height: 1px; background: Theme.border-subtle; } // ===================== SCROLLING CONTENT ===================== Rectangle { vertical-stretch: 1; clip: true; // Loading (any tab). if FavoritesState.loading: VerticalLayout { alignment: center; HorizontalLayout { alignment: center; LoadingSpinner { size: 36px; } } } // Load error + retry (any tab). if !FavoritesState.loading && FavoritesState.load-error != "": VerticalLayout { alignment: center; spacing: 10px; Text { text: @tr("Couldn't load favorites."); color: Theme.text-primary; font-size: Typography.body; font-weight: Typography.semibold; horizontal-alignment: center; } Text { text: FavoritesState.load-error; color: Theme.text-muted; font-size: Typography.legal; horizontal-alignment: center; } HorizontalLayout { alignment: center; Rectangle { width: retry-label.preferred-width + 28px; height: 32px; border-radius: 6px; background: retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; border-width: 1px; border-color: Theme.border-subtle; retry-label := Text { text: @tr("Retry"); color: Theme.text-primary; font-size: Typography.legal; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } retry-ta := TouchArea { mouse-cursor: pointer; clicked => { FavoritesActions.retry-load(); } } } } } // --- Tracks tab — virtualized self-scrolling list -------------- if !FavoritesState.loading && FavoritesState.load-error == "" && FavoritesState.active-tab == "tracks": VerticalLayout { padding-left: 32px; padding-right: 32px; padding-top: 16px; spacing: 0px; // Bulk action bar — visible in multi-select edit mode. if FavoritesState.tracks-multi-select: MultiSelectBar { selected-count: FavoritesState.tracks-selected-count; actions: [ { id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false }, { id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true }, { id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true }, { id: "remove-selected", label: @tr("Remove from favorites"), danger: true, needs-selection: true }, { id: "clear", label: @tr("Clear"), danger: false, needs-selection: true }, ]; action(id) => { FavoritesActions.bulk-action(id); } } if FavoritesState.tracks-multi-select: Rectangle { height: 8px; } if FavoritesState.tracks.length == 0: Text { text: @tr("No favorite tracks yet."); color: Theme.text-muted; font-size: Typography.body; } if FavoritesState.tracks.length > 0 && FavoritesState.tracks-visible.length == 0: Text { text: @tr("No tracks match your search."); color: Theme.text-muted; font-size: Typography.body; } Rectangle { vertical-stretch: 1; track-list := ListView { width: 100%; height: 100%; vertical-scrollbar-policy: ScrollBarPolicy.always-off; for track[i] in FavoritesState.tracks-visible: VerticalLayout { spacing: 0px; // Group header rows when grouping by album / artist. if FavoritesState.tracks-group-mode == "album" && track.album != "" && (i == 0 || FavoritesState.tracks-visible[i - 1].album != track.album): Rectangle { height: 32px; Text { x: 2px; width: parent.width - 4px; height: 100%; text: track.album; color: Theme.text-secondary; font-size: Typography.legal; font-weight: Typography.semibold; vertical-alignment: center; overflow: elide; } } if FavoritesState.tracks-group-mode == "artist" && track.artist != "" && (i == 0 || FavoritesState.tracks-visible[i - 1].artist != track.artist): Rectangle { height: 32px; Text { x: 2px; width: parent.width - 4px; height: 100%; text: track.artist; color: Theme.text-secondary; font-size: Typography.legal; font-weight: Typography.semibold; vertical-alignment: center; overflow: elide; } } TrackRow { track: track; index: i; show-artwork: true; show-album: FavoritesState.tracks-group-mode != "album"; show-favorite: true; multi-select-mode: FavoritesState.tracks-multi-select; media-action(kind, id, action) => { if (kind == "album" && action == "open") { FavoritesActions.open-album(id); } else if (kind == "artist" && action == "open") { FavoritesActions.open-artist(id); } else if (action == "favorite") { FavoritesActions.unfavorite-track(id); } else { FavoritesActions.track-action(id, action); } } } } } ListScrollbar { x: parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: track-list.viewport-height; visible-height: track-list.visible-height; viewport-y <=> track-list.viewport-y; } // A-Z jump strip (name grouping) — accurate (uniform rows). if FavoritesState.tracks-group-mode == "name" && FavoritesState.tracks-alpha.length > 0: AlphaStrip { x: parent.width - self.width - 20px; height: 100%; jumps: FavoritesState.tracks-alpha; jump(ord, idx) => { track-list.viewport-y = -Math.min( idx * 50px / 1px, Math.max(0, (track-list.viewport-height - track-list.visible-height) / 1px)) * 1px; } } } } // --- Albums / Artists / Playlists / Labels — scrolling --------- if !FavoritesState.loading && FavoritesState.load-error == "" && FavoritesState.active-tab != "tracks": Rectangle { other-flick := Flickable { interactive: !DragState.active; viewport-width: self.width; viewport-height: other-page.preferred-height; other-page := VerticalLayout { width: parent.width; padding-left: 32px; padding-right: 32px; padding-top: 16px; padding-bottom: 100px; spacing: 0px; alignment: start; // --- Albums tab --- if FavoritesState.active-tab == "albums": VerticalLayout { spacing: 0px; if FavoritesState.albums.length == 0: Text { text: @tr("No favorite albums yet."); color: Theme.text-muted; font-size: Typography.body; } if FavoritesState.albums.length > 0 && FavoritesState.albums-shown == 0: Text { text: @tr("No albums match your search."); color: Theme.text-muted; font-size: Typography.body; } AlbumCollectionView { albums: FavoritesState.albums-visible; grouped: FavoritesState.albums-grouped; view-mode: FavoritesState.albums-view-mode; is-grouped: FavoritesState.albums-group-mode != "off"; show-source: false; open-album(id) => { root.open-album(id); } open-artist(id) => { root.open-artist(id); } media-action(kind, id, action) => { if (action == "favorite") { FavoritesActions.unfavorite-album(id); } else { FavoritesActions.album-action(id, action); } } } } // --- Artists tab --- if FavoritesState.active-tab == "artists": artists-grid := Rectangle { property columns: Math.max( 1, Math.floor((self.width + root.gap) / (root.artist-w + root.gap))); property rows: Math.ceil(FavoritesState.artists.length / self.columns); height: self.rows > 0 ? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap : 0px; if FavoritesState.artists.length == 0: Text { text: @tr("No favorite artists yet."); color: Theme.text-muted; font-size: Typography.body; } for artist[i] in FavoritesState.artists: FavArtistCard { x: Math.mod(i, artists-grid.columns) * (root.artist-w + root.gap); y: Math.floor(i / artists-grid.columns) * (root.artist-h + root.gap); item: artist; clicked(id) => { root.open-artist(id); } } } // --- Playlists tab (two sub-tabs: Library / Following) --- if FavoritesState.active-tab == "playlists": VerticalLayout { spacing: 0px; // Sub-tab selector (underline tabs, no pills — ADR-008). HorizontalLayout { spacing: 24px; alignment: start; PlaylistSubTab { label: @tr("Library"); tab-id: "favorites"; } PlaylistSubTab { label: @tr("Following"); tab-id: "following"; count: FavoritesState.playlists-following-count; } } Rectangle { height: 16px; } if FavoritesState.playlists-visible.length == 0 && FavoritesState.playlists-search != "": Text { text: @tr("No playlists match your search."); color: Theme.text-muted; font-size: Typography.body; } if FavoritesState.playlists-visible.length == 0 && FavoritesState.playlists-search == "" && FavoritesState.playlists-sub-tab == "following": Text { text: @tr("No followed playlists yet."); color: Theme.text-muted; font-size: Typography.body; } if FavoritesState.playlists-visible.length == 0 && FavoritesState.playlists-search == "" && FavoritesState.playlists-sub-tab != "following": Text { text: @tr("No favorite playlists yet."); color: Theme.text-muted; font-size: Typography.body; } if FavoritesState.playlists-view-mode != "list": playlists-grid := Rectangle { property pl-w: 220px; property pl-h: 266px; property columns: Math.max( 1, Math.floor((self.width + root.gap) / (self.pl-w + root.gap))); property rows: Math.ceil(FavoritesState.playlists-visible.length / self.columns); height: self.rows > 0 ? self.rows * self.pl-h + Math.max(0, self.rows - 1) * root.gap : 0px; for pl[i] in FavoritesState.playlists-visible: PlaylistCard { x: Math.mod(i, playlists-grid.columns) * (playlists-grid.pl-w + root.gap); y: Math.floor(i / playlists-grid.columns) * (playlists-grid.pl-h + root.gap); playlist: pl; body-opens: true; media-action(k, id, a) => { if (a == "open") { FavoritesActions.open-playlist(id); } else { FavoritesActions.playlist-action(id, a); } } } } if FavoritesState.playlists-view-mode == "list": VerticalLayout { spacing: 2px; for pl[i] in FavoritesState.playlists-visible: PlaylistListRow { playlist: pl; index: i; media-action(k, id, a) => { if (a == "open") { FavoritesActions.open-playlist(id); } else { FavoritesActions.playlist-action(id, a); } } } } } // --- Labels tab --- if FavoritesState.active-tab == "labels": labels-grid := Rectangle { property columns: Math.max( 1, Math.floor((self.width + root.gap) / (root.artist-w + root.gap))); property rows: Math.ceil(FavoritesState.labels-visible.length / self.columns); height: self.rows > 0 ? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap : 0px; if FavoritesState.labels.length == 0: Text { text: @tr("No favorite labels yet."); color: Theme.text-muted; font-size: Typography.body; } if FavoritesState.labels.length > 0 && FavoritesState.labels-visible.length == 0: Text { text: @tr("No labels match your search."); color: Theme.text-muted; font-size: Typography.body; } for label[i] in FavoritesState.labels-visible: FavLabelCard { x: Math.mod(i, labels-grid.columns) * (root.artist-w + root.gap); y: Math.floor(i / labels-grid.columns) * (root.artist-h + root.gap); item: label; clicked(id, name) => { FavoritesActions.open-label(id, name); } } } } } ListScrollbar { x: parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: other-flick.viewport-height; visible-height: other-flick.height; viewport-y <=> other-flick.viewport-y; } // A-Z jump strip (albums alpha grouping) — proportional jump. if FavoritesState.active-tab == "albums" && FavoritesState.albums-group-mode == "alpha" && FavoritesState.albums-alpha.length > 0: AlphaStrip { x: parent.width - self.width - 20px; height: 100%; jumps: FavoritesState.albums-alpha; jump(ord, idx) => { other-flick.viewport-y = -(ord / Math.max(1, FavoritesState.albums-alpha.length - 1)) * Math.max(0, (other-flick.viewport-height - other-flick.height) / 1px) * 1px; } } } } } // Genre filter overlay — the shared popup, favorites context. Anchored // under the toolbar's genre button (below the fixed chrome). if GenreFilterState.open: Rectangle { width: 100%; height: 100%; TouchArea { clicked => { GenreFilterState.open = false; } } } if GenreFilterState.open: GenreFilterPopup { x: root.width - self.width - 32px; y: 104px; } }