fix(library): restore offline visibility and unify filters and sorting
Recover missing offline library entries, recognize cached-only libraries, and refresh availability after cache changes. Unify per-tab filters, compact the toolbar, and add reversible sort criteria with complete available purchase metadata. cx
This commit is contained in:
33 files changed
+1938
-547
No files matched your search
@@ -3895,6 +3895,7 @@ mod tests {
|
|||||||
streamable: None,
|
streamable: None,
|
||||||
streamable_at: None,
|
streamable_at: None,
|
||||||
release_date_stream: None,
|
release_date_stream: None,
|
||||||
|
release_date_original: None,
|
||||||
});
|
});
|
||||||
t
|
t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6858,3 +6858,80 @@ msgstr "Gruppe: Jahrzehnt"
|
|||||||
|
|
||||||
msgid "Popularity"
|
msgid "Popularity"
|
||||||
msgstr "Beliebtheit"
|
msgstr "Beliebtheit"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Purchases only"
|
||||||
|
msgstr "Nur Käufe"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Following only"
|
||||||
|
msgstr "Nur gefolgte Inhalte"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hi-Res only"
|
||||||
|
msgstr "Nur Hi-Res"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums from library"
|
||||||
|
msgstr "Lokale Alben in der Bibliothek ausblenden"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By you"
|
||||||
|
msgstr "Von dir"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By Qobuz"
|
||||||
|
msgstr "Von Qobuz"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By others"
|
||||||
|
msgstr "Von anderen"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your tracks"
|
||||||
|
msgstr "Deine Titel durchsuchen"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your releases"
|
||||||
|
msgstr "Deine Veröffentlichungen durchsuchen"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your playlists"
|
||||||
|
msgstr "Deine Playlists durchsuchen"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your artists"
|
||||||
|
msgstr "Deine Künstler durchsuchen"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your labels"
|
||||||
|
msgstr "Deine Labels durchsuchen"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only purchased items. Combines with the other enabled filters."
|
||||||
|
msgstr "Nur gekaufte Inhalte anzeigen. Wird mit den anderen aktiven Filtern kombiniert."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only favorites. Combines with the other enabled filters."
|
||||||
|
msgstr "Nur Favoriten anzeigen. Wird mit den anderen aktiven Filtern kombiniert."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only followed items. Combines with the other enabled filters."
|
||||||
|
msgstr "Nur gefolgte Inhalte anzeigen. Wird mit den anderen aktiven Filtern kombiniert."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only Hi-Res items. Combines with the other enabled filters."
|
||||||
|
msgstr "Nur Hi-Res-Inhalte anzeigen. Wird mit den anderen aktiven Filtern kombiniert."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums here. They remain available in Local Library."
|
||||||
|
msgstr "Lokale Alben hier ausblenden. Sie bleiben in der lokalen Bibliothek verfügbar."
|
||||||
|
|
||||||
|
msgid "Alphabetical"
|
||||||
|
msgstr "Alphabetisch"
|
||||||
|
|
||||||
|
msgid "Date updated"
|
||||||
|
msgstr "Aktualisierungsdatum"
|
||||||
|
|
||||||
|
msgid "Track count"
|
||||||
|
msgstr "Titelanzahl"
|
||||||
@@ -2442,3 +2442,96 @@ msgstr "Group: Decade"
|
|||||||
|
|
||||||
msgid "Popularity"
|
msgid "Popularity"
|
||||||
msgstr "Popularity"
|
msgstr "Popularity"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Purchases only"
|
||||||
|
msgstr "Purchases only"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Following only"
|
||||||
|
msgstr "Following only"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hi-Res only"
|
||||||
|
msgstr "Hi-Res only"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums from library"
|
||||||
|
msgstr "Hide local albums from library"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By you"
|
||||||
|
msgstr "By you"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By Qobuz"
|
||||||
|
msgstr "By Qobuz"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By others"
|
||||||
|
msgstr "By others"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your tracks"
|
||||||
|
msgstr "Search your tracks"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your releases"
|
||||||
|
msgstr "Search your releases"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your playlists"
|
||||||
|
msgstr "Search your playlists"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your artists"
|
||||||
|
msgstr "Search your artists"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your labels"
|
||||||
|
msgstr "Search your labels"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Releases"
|
||||||
|
msgstr "Releases"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only purchased items. Combines with the other enabled filters."
|
||||||
|
msgstr "Show only purchased items. Combines with the other enabled filters."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only favorites. Combines with the other enabled filters."
|
||||||
|
msgstr "Show only favorites. Combines with the other enabled filters."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only followed items. Combines with the other enabled filters."
|
||||||
|
msgstr "Show only followed items. Combines with the other enabled filters."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only Hi-Res items. Combines with the other enabled filters."
|
||||||
|
msgstr "Show only Hi-Res items. Combines with the other enabled filters."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums here. They remain available in Local Library."
|
||||||
|
msgstr "Hide local albums here. They remain available in Local Library."
|
||||||
|
|
||||||
|
msgid "Filters"
|
||||||
|
msgstr "Filters"
|
||||||
|
|
||||||
|
msgid "Alphabetical"
|
||||||
|
msgstr "Alphabetical"
|
||||||
|
|
||||||
|
msgid "Date updated"
|
||||||
|
msgstr "Date updated"
|
||||||
|
|
||||||
|
msgid "Track count"
|
||||||
|
msgstr "Track count"
|
||||||
|
|
||||||
|
msgid "Release"
|
||||||
|
msgstr "Release"
|
||||||
|
|
||||||
|
msgid "Release date"
|
||||||
|
msgstr "Release date"
|
||||||
|
|
||||||
|
msgid "Label"
|
||||||
|
msgstr "Label"
|
||||||
@@ -6864,3 +6864,80 @@ msgstr "Agrupar: Década"
|
|||||||
|
|
||||||
msgid "Popularity"
|
msgid "Popularity"
|
||||||
msgstr "Popularidad"
|
msgstr "Popularidad"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Purchases only"
|
||||||
|
msgstr "Solo compras"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Following only"
|
||||||
|
msgstr "Solo seguidos"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hi-Res only"
|
||||||
|
msgstr "Solo Hi-Res"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums from library"
|
||||||
|
msgstr "Ocultar álbumes locales de la biblioteca"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By you"
|
||||||
|
msgstr "Por ti"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By Qobuz"
|
||||||
|
msgstr "Por Qobuz"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By others"
|
||||||
|
msgstr "Por otros"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your tracks"
|
||||||
|
msgstr "Buscar en tus pistas"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your releases"
|
||||||
|
msgstr "Buscar en tus lanzamientos"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your playlists"
|
||||||
|
msgstr "Buscar en tus playlists"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your artists"
|
||||||
|
msgstr "Buscar en tus artistas"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your labels"
|
||||||
|
msgstr "Buscar en tus sellos"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only purchased items. Combines with the other enabled filters."
|
||||||
|
msgstr "Mostrar solo compras. Se combina con los demás filtros activos."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only favorites. Combines with the other enabled filters."
|
||||||
|
msgstr "Mostrar solo favoritos. Se combina con los demás filtros activos."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only followed items. Combines with the other enabled filters."
|
||||||
|
msgstr "Mostrar solo elementos seguidos. Se combina con los demás filtros activos."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only Hi-Res items. Combines with the other enabled filters."
|
||||||
|
msgstr "Mostrar solo contenido Hi-Res. Se combina con los demás filtros activos."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums here. They remain available in Local Library."
|
||||||
|
msgstr "Ocultar aquí los álbumes locales. Seguirán disponibles en Biblioteca local."
|
||||||
|
|
||||||
|
msgid "Alphabetical"
|
||||||
|
msgstr "Alfabético"
|
||||||
|
|
||||||
|
msgid "Date updated"
|
||||||
|
msgstr "Fecha de actualización"
|
||||||
|
|
||||||
|
msgid "Track count"
|
||||||
|
msgstr "Número de pistas"
|
||||||
@@ -6856,3 +6856,80 @@ msgstr "Grouper : Décennie"
|
|||||||
|
|
||||||
msgid "Popularity"
|
msgid "Popularity"
|
||||||
msgstr "Popularité"
|
msgstr "Popularité"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Purchases only"
|
||||||
|
msgstr "Achats uniquement"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Following only"
|
||||||
|
msgstr "Éléments suivis uniquement"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hi-Res only"
|
||||||
|
msgstr "Hi-Res uniquement"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums from library"
|
||||||
|
msgstr "Masquer les albums locaux de la bibliothèque"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By you"
|
||||||
|
msgstr "Par vous"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By Qobuz"
|
||||||
|
msgstr "Par Qobuz"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By others"
|
||||||
|
msgstr "Par les autres"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your tracks"
|
||||||
|
msgstr "Rechercher dans vos titres"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your releases"
|
||||||
|
msgstr "Rechercher dans vos sorties"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your playlists"
|
||||||
|
msgstr "Rechercher dans vos playlists"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your artists"
|
||||||
|
msgstr "Rechercher dans vos artistes"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your labels"
|
||||||
|
msgstr "Rechercher dans vos labels"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only purchased items. Combines with the other enabled filters."
|
||||||
|
msgstr "Afficher uniquement les achats. Se combine avec les autres filtres actifs."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only favorites. Combines with the other enabled filters."
|
||||||
|
msgstr "Afficher uniquement les favoris. Se combine avec les autres filtres actifs."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only followed items. Combines with the other enabled filters."
|
||||||
|
msgstr "Afficher uniquement les éléments suivis. Se combine avec les autres filtres actifs."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only Hi-Res items. Combines with the other enabled filters."
|
||||||
|
msgstr "Afficher uniquement le contenu Hi-Res. Se combine avec les autres filtres actifs."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums here. They remain available in Local Library."
|
||||||
|
msgstr "Masquer les albums locaux ici. Ils restent disponibles dans la bibliothèque locale."
|
||||||
|
|
||||||
|
msgid "Alphabetical"
|
||||||
|
msgstr "Alphabétique"
|
||||||
|
|
||||||
|
msgid "Date updated"
|
||||||
|
msgstr "Date de modification"
|
||||||
|
|
||||||
|
msgid "Track count"
|
||||||
|
msgstr "Nombre de pistes"
|
||||||
@@ -6839,3 +6839,80 @@ msgstr "グループ: 年代"
|
|||||||
|
|
||||||
msgid "Popularity"
|
msgid "Popularity"
|
||||||
msgstr "人気順"
|
msgstr "人気順"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Purchases only"
|
||||||
|
msgstr "購入済みのみ"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Following only"
|
||||||
|
msgstr "フォロー中のみ"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hi-Res only"
|
||||||
|
msgstr "Hi-Resのみ"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums from library"
|
||||||
|
msgstr "ライブラリでローカルアルバムを非表示"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By you"
|
||||||
|
msgstr "自分が作成"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By Qobuz"
|
||||||
|
msgstr "Qobuzが作成"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By others"
|
||||||
|
msgstr "他のユーザーが作成"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your tracks"
|
||||||
|
msgstr "トラックを検索"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your releases"
|
||||||
|
msgstr "リリースを検索"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your playlists"
|
||||||
|
msgstr "プレイリストを検索"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your artists"
|
||||||
|
msgstr "アーティストを検索"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your labels"
|
||||||
|
msgstr "レーベルを検索"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only purchased items. Combines with the other enabled filters."
|
||||||
|
msgstr "購入済みの項目のみ表示します。他の有効なフィルターと組み合わせて適用します。"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only favorites. Combines with the other enabled filters."
|
||||||
|
msgstr "お気に入りのみ表示します。他の有効なフィルターと組み合わせて適用します。"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only followed items. Combines with the other enabled filters."
|
||||||
|
msgstr "フォロー中の項目のみ表示します。他の有効なフィルターと組み合わせて適用します。"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only Hi-Res items. Combines with the other enabled filters."
|
||||||
|
msgstr "Hi-Resの項目のみ表示します。他の有効なフィルターと組み合わせて適用します。"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums here. They remain available in Local Library."
|
||||||
|
msgstr "ここではローカルアルバムを非表示にします。ローカルライブラリでは引き続き利用できます。"
|
||||||
|
|
||||||
|
msgid "Alphabetical"
|
||||||
|
msgstr "アルファベット順"
|
||||||
|
|
||||||
|
msgid "Date updated"
|
||||||
|
msgstr "更新日"
|
||||||
|
|
||||||
|
msgid "Track count"
|
||||||
|
msgstr "曲数"
|
||||||
@@ -6846,3 +6846,80 @@ msgstr "Groeperen: Decennium"
|
|||||||
|
|
||||||
msgid "Popularity"
|
msgid "Popularity"
|
||||||
msgstr "Populariteit"
|
msgstr "Populariteit"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Purchases only"
|
||||||
|
msgstr "Alleen aankopen"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Following only"
|
||||||
|
msgstr "Alleen gevolgd"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hi-Res only"
|
||||||
|
msgstr "Alleen Hi-Res"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums from library"
|
||||||
|
msgstr "Lokale albums verbergen in bibliotheek"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By you"
|
||||||
|
msgstr "Door jou"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By Qobuz"
|
||||||
|
msgstr "Door Qobuz"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By others"
|
||||||
|
msgstr "Door anderen"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your tracks"
|
||||||
|
msgstr "Zoek in je nummers"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your releases"
|
||||||
|
msgstr "Zoek in je uitgaven"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your playlists"
|
||||||
|
msgstr "Zoek in je afspeellijsten"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your artists"
|
||||||
|
msgstr "Zoek in je artiesten"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your labels"
|
||||||
|
msgstr "Zoek in je labels"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only purchased items. Combines with the other enabled filters."
|
||||||
|
msgstr "Toon alleen aankopen. Wordt gecombineerd met de andere actieve filters."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only favorites. Combines with the other enabled filters."
|
||||||
|
msgstr "Toon alleen favorieten. Wordt gecombineerd met de andere actieve filters."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only followed items. Combines with the other enabled filters."
|
||||||
|
msgstr "Toon alleen gevolgde items. Wordt gecombineerd met de andere actieve filters."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only Hi-Res items. Combines with the other enabled filters."
|
||||||
|
msgstr "Toon alleen Hi-Res-items. Wordt gecombineerd met de andere actieve filters."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums here. They remain available in Local Library."
|
||||||
|
msgstr "Verberg lokale albums hier. Ze blijven beschikbaar in de lokale bibliotheek."
|
||||||
|
|
||||||
|
msgid "Alphabetical"
|
||||||
|
msgstr "Alfabetisch"
|
||||||
|
|
||||||
|
msgid "Date updated"
|
||||||
|
msgstr "Datum bijgewerkt"
|
||||||
|
|
||||||
|
msgid "Track count"
|
||||||
|
msgstr "Aantal nummers"
|
||||||
@@ -6856,3 +6856,80 @@ msgstr "Agrupar: Década"
|
|||||||
|
|
||||||
msgid "Popularity"
|
msgid "Popularity"
|
||||||
msgstr "Popularidade"
|
msgstr "Popularidade"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Purchases only"
|
||||||
|
msgstr "Somente compras"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Following only"
|
||||||
|
msgstr "Somente seguidos"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hi-Res only"
|
||||||
|
msgstr "Somente Hi-Res"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums from library"
|
||||||
|
msgstr "Ocultar álbuns locais da biblioteca"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By you"
|
||||||
|
msgstr "Por você"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By Qobuz"
|
||||||
|
msgstr "Por Qobuz"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By others"
|
||||||
|
msgstr "Por outros"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your tracks"
|
||||||
|
msgstr "Buscar nas suas faixas"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your releases"
|
||||||
|
msgstr "Buscar nos seus lançamentos"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your playlists"
|
||||||
|
msgstr "Buscar nas suas playlists"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your artists"
|
||||||
|
msgstr "Buscar nos seus artistas"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your labels"
|
||||||
|
msgstr "Buscar nas suas gravadoras"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only purchased items. Combines with the other enabled filters."
|
||||||
|
msgstr "Mostrar somente compras. Combina com os outros filtros ativos."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only favorites. Combines with the other enabled filters."
|
||||||
|
msgstr "Mostrar somente favoritos. Combina com os outros filtros ativos."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only followed items. Combines with the other enabled filters."
|
||||||
|
msgstr "Mostrar somente itens seguidos. Combina com os outros filtros ativos."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only Hi-Res items. Combines with the other enabled filters."
|
||||||
|
msgstr "Mostrar somente conteúdo Hi-Res. Combina com os outros filtros ativos."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums here. They remain available in Local Library."
|
||||||
|
msgstr "Ocultar álbuns locais aqui. Eles continuam disponíveis na Biblioteca local."
|
||||||
|
|
||||||
|
msgid "Alphabetical"
|
||||||
|
msgstr "Alfabético"
|
||||||
|
|
||||||
|
msgid "Date updated"
|
||||||
|
msgstr "Data de atualização"
|
||||||
|
|
||||||
|
msgid "Track count"
|
||||||
|
msgstr "Número de faixas"
|
||||||
@@ -6877,3 +6877,80 @@ msgstr "Группа: Десятилетие"
|
|||||||
|
|
||||||
msgid "Popularity"
|
msgid "Popularity"
|
||||||
msgstr "Популярность"
|
msgstr "Популярность"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Purchases only"
|
||||||
|
msgstr "Только покупки"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Following only"
|
||||||
|
msgstr "Только подписки"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hi-Res only"
|
||||||
|
msgstr "Только Hi-Res"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums from library"
|
||||||
|
msgstr "Скрыть локальные альбомы в библиотеке"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By you"
|
||||||
|
msgstr "Ваши"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By Qobuz"
|
||||||
|
msgstr "От Qobuz"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "By others"
|
||||||
|
msgstr "От других"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your tracks"
|
||||||
|
msgstr "Поиск по вашим трекам"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your releases"
|
||||||
|
msgstr "Поиск по вашим релизам"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your playlists"
|
||||||
|
msgstr "Поиск по вашим плейлистам"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your artists"
|
||||||
|
msgstr "Поиск по вашим исполнителям"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Search your labels"
|
||||||
|
msgstr "Поиск по вашим лейблам"
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only purchased items. Combines with the other enabled filters."
|
||||||
|
msgstr "Показывать только покупки. Применяется вместе с другими включёнными фильтрами."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only favorites. Combines with the other enabled filters."
|
||||||
|
msgstr "Показывать только избранное. Применяется вместе с другими включёнными фильтрами."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only followed items. Combines with the other enabled filters."
|
||||||
|
msgstr "Показывать только подписки. Применяется вместе с другими включёнными фильтрами."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Show only Hi-Res items. Combines with the other enabled filters."
|
||||||
|
msgstr "Показывать только Hi-Res. Применяется вместе с другими включёнными фильтрами."
|
||||||
|
|
||||||
|
#: library toolbar
|
||||||
|
msgid "Hide local albums here. They remain available in Local Library."
|
||||||
|
msgstr "Скрыть здесь локальные альбомы. Они останутся доступны в локальной библиотеке."
|
||||||
|
|
||||||
|
msgid "Alphabetical"
|
||||||
|
msgstr "По алфавиту"
|
||||||
|
|
||||||
|
msgid "Date updated"
|
||||||
|
msgstr "Дата обновления"
|
||||||
|
|
||||||
|
msgid "Track count"
|
||||||
|
msgstr "Количество треков"
|
||||||
@@ -695,6 +695,9 @@ pub struct AlbumSummary {
|
|||||||
/// ISO streaming release date, used as the fallback upcoming signal.
|
/// ISO streaming release date, used as the fallback upcoming signal.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub release_date_stream: Option<String>,
|
pub release_date_stream: Option<String>,
|
||||||
|
/// Original release date, when included by the embedded album endpoint.
|
||||||
|
#[serde(default)]
|
||||||
|
pub release_date_original: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AlbumSummary {
|
impl AlbumSummary {
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ pub(crate) async fn try_cmaf_offline_download(
|
|||||||
let album_group_key = format!("{}|{}", metadata.album, album_artist);
|
let album_group_key = format!("{}|{}", metadata.album, album_artist);
|
||||||
let lib_opt = library_db.lock().await;
|
let lib_opt = library_db.lock().await;
|
||||||
if let Some(lib_guard) = lib_opt.as_ref() {
|
if let Some(lib_guard) = lib_opt.as_ref() {
|
||||||
let _ = lib_guard.insert_qobuz_cached_track_with_grouping(
|
let result = lib_guard.insert_qobuz_cached_track_with_grouping(
|
||||||
track_id,
|
track_id,
|
||||||
&metadata.title,
|
&metadata.title,
|
||||||
&metadata.artist,
|
&metadata.artist,
|
||||||
@@ -428,6 +428,9 @@ pub(crate) async fn try_cmaf_offline_download(
|
|||||||
bundle.sampling_rate.map(|r| r as f64),
|
bundle.sampling_rate.map(|r| r as f64),
|
||||||
artwork_path.as_deref(),
|
artwork_path.as_deref(),
|
||||||
);
|
);
|
||||||
|
if let Err(error) = result {
|
||||||
|
log::warn!("[offline-library] track={track_id} metadata insert failed: {error}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if let Err(e) = metadata {
|
} else if let Err(e) = metadata {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
@@ -666,7 +669,7 @@ pub fn spawn_track_cache_download(
|
|||||||
let album_group_key = format!("{}|{}", metadata.album, album_artist);
|
let album_group_key = format!("{}|{}", metadata.album, album_artist);
|
||||||
let lib_opt = library_db.lock().await;
|
let lib_opt = library_db.lock().await;
|
||||||
if let Some(lib_guard) = lib_opt.as_ref() {
|
if let Some(lib_guard) = lib_opt.as_ref() {
|
||||||
let _ = lib_guard.insert_qobuz_cached_track_with_grouping(
|
let result = lib_guard.insert_qobuz_cached_track_with_grouping(
|
||||||
track_id,
|
track_id,
|
||||||
&metadata.title,
|
&metadata.title,
|
||||||
&metadata.artist,
|
&metadata.artist,
|
||||||
@@ -683,6 +686,11 @@ pub fn spawn_track_cache_download(
|
|||||||
sample_rate_detected,
|
sample_rate_detected,
|
||||||
artwork_path_v1.as_deref(),
|
artwork_path_v1.as_deref(),
|
||||||
);
|
);
|
||||||
|
if let Err(error) = result {
|
||||||
|
log::warn!(
|
||||||
|
"[offline-library] track={track_id} metadata insert failed: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(db_guard) = db.lock().await.as_ref() {
|
if let Some(db_guard) = db.lock().await.as_ref() {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub mod db;
|
|||||||
pub mod downloader;
|
pub mod downloader;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod maintenance;
|
pub mod maintenance;
|
||||||
|
pub mod library_sync;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
pub mod path_validator;
|
pub mod path_validator;
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! Repair missing derived library rows using the ready download index only.
|
||||||
|
//! Never download, delete audio, or replace an existing library row.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::{OfflineCacheDb, OfflineCacheStatus};
|
||||||
|
use qbz_library::LibraryDatabase;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LibrarySyncReport {
|
||||||
|
pub ready: usize,
|
||||||
|
pub restored: usize,
|
||||||
|
pub unavailable: usize,
|
||||||
|
pub failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_missing_library_rows(
|
||||||
|
cache: &OfflineCacheDb,
|
||||||
|
library: &LibraryDatabase,
|
||||||
|
cache_root: &str,
|
||||||
|
track_id: Option<u64>,
|
||||||
|
) -> Result<LibrarySyncReport, String> {
|
||||||
|
let tracks = match track_id {
|
||||||
|
Some(id) => cache.get_track(id)?.into_iter().collect(),
|
||||||
|
None => cache.get_all_tracks()?,
|
||||||
|
};
|
||||||
|
let existing = library
|
||||||
|
.get_qobuz_download_tracks()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let ids: HashSet<u64> = existing
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.qobuz_track_id.map(|id| id as u64))
|
||||||
|
.collect();
|
||||||
|
// Reuse an album's established grouping when another cached occurrence
|
||||||
|
// supplies its album ID. This preserves compilation album artists.
|
||||||
|
let by_id: HashMap<u64, _> = existing
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.qobuz_track_id.map(|id| (id as u64, t)))
|
||||||
|
.collect();
|
||||||
|
let mut groups = HashMap::new();
|
||||||
|
for row in cache.get_all_tracks()? {
|
||||||
|
if let (Some(album_id), Some(local)) = (row.album_id, by_id.get(&row.track_id)) {
|
||||||
|
groups.insert(
|
||||||
|
album_id,
|
||||||
|
(
|
||||||
|
local.album_group_key.clone(),
|
||||||
|
local.album_group_title.clone(),
|
||||||
|
local.album_artist.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut report = LibrarySyncReport::default();
|
||||||
|
for row in tracks {
|
||||||
|
if row.status != OfflineCacheStatus::Ready {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
report.ready += 1;
|
||||||
|
if ids.contains(&row.track_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bundle = cache
|
||||||
|
.get_cmaf_bundle(row.track_id)?
|
||||||
|
.filter(|b| b.cache_format == 2);
|
||||||
|
let path = Path::new(&row.file_path);
|
||||||
|
let playable = if let Some(bundle) = bundle.as_ref() {
|
||||||
|
if !bundle
|
||||||
|
.init_path
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|p| Path::new(p).is_file())
|
||||||
|
|| !path.is_file()
|
||||||
|
{
|
||||||
|
report.unavailable += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
path.parent().unwrap_or(path)
|
||||||
|
} else {
|
||||||
|
if !path.is_file() {
|
||||||
|
report.unavailable += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
path
|
||||||
|
};
|
||||||
|
let album = row.album.as_deref().unwrap_or("Unknown Album");
|
||||||
|
let fallback = (format!("{}|{}", album, row.artist), album.to_owned(), None);
|
||||||
|
let (group, title, album_artist) = row
|
||||||
|
.album_id
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|id| groups.get(id))
|
||||||
|
.unwrap_or(&fallback);
|
||||||
|
let artwork = row.resolve_cover_path(cache_root);
|
||||||
|
// The Qobuz index stores sampling rates in kHz; local_tracks uses Hz.
|
||||||
|
let rate = row
|
||||||
|
.sample_rate
|
||||||
|
.filter(|v| v.is_finite() && *v > 0.0)
|
||||||
|
.map(|v| if v < 1000.0 { v * 1000.0 } else { v });
|
||||||
|
let result = library.insert_qobuz_cached_track_with_grouping(
|
||||||
|
row.track_id,
|
||||||
|
&row.title,
|
||||||
|
&row.artist,
|
||||||
|
row.album.as_deref(),
|
||||||
|
album_artist.as_deref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
row.duration_secs,
|
||||||
|
&playable.to_string_lossy(),
|
||||||
|
group,
|
||||||
|
title,
|
||||||
|
row.bit_depth,
|
||||||
|
rate,
|
||||||
|
artwork.as_deref(),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(()) => report.restored += 1,
|
||||||
|
Err(error) => {
|
||||||
|
report.failed += 1;
|
||||||
|
log::warn!(
|
||||||
|
"[offline-library] track={} repair failed: {error}",
|
||||||
|
row.track_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::TrackCacheInfo;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restores_ready_files_without_replacing_metadata_or_reviving_missing_downloads() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = OfflineCacheDb::new(&dir.path().join("index.db")).unwrap();
|
||||||
|
let library = LibraryDatabase::open(&dir.path().join("library.db")).unwrap();
|
||||||
|
for id in 1..=5 {
|
||||||
|
let path_id = if id == 5 { 1 } else { id };
|
||||||
|
let path = dir.path().join(format!("{path_id}.flac"));
|
||||||
|
if id != 3 {
|
||||||
|
std::fs::write(&path, b"fixture").unwrap();
|
||||||
|
}
|
||||||
|
cache
|
||||||
|
.insert_track(
|
||||||
|
&TrackCacheInfo {
|
||||||
|
track_id: id,
|
||||||
|
title: format!("Track {id}"),
|
||||||
|
artist: "Artist".into(),
|
||||||
|
album: Some("Album".into()),
|
||||||
|
album_id: Some("album-id".into()),
|
||||||
|
duration_secs: 90,
|
||||||
|
quality: "HiRes".into(),
|
||||||
|
bit_depth: Some(24),
|
||||||
|
sample_rate: Some(96.0),
|
||||||
|
},
|
||||||
|
&path.to_string_lossy(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
if id != 4 {
|
||||||
|
cache.mark_complete(id, 7).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
library
|
||||||
|
.insert_qobuz_cached_track_with_grouping(
|
||||||
|
1,
|
||||||
|
"Existing title",
|
||||||
|
"Artist",
|
||||||
|
Some("Album"),
|
||||||
|
Some("Compilation"),
|
||||||
|
Some(8),
|
||||||
|
Some(2),
|
||||||
|
Some(1990),
|
||||||
|
91,
|
||||||
|
&dir.path().join("1.flac").to_string_lossy(),
|
||||||
|
"existing-group",
|
||||||
|
"Album",
|
||||||
|
Some(24),
|
||||||
|
Some(96000.0),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let before = library.get_qobuz_download_tracks().unwrap()[0].clone();
|
||||||
|
let report =
|
||||||
|
restore_missing_library_rows(&cache, &library, &dir.path().to_string_lossy(), None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(report.ready, report.restored, report.unavailable),
|
||||||
|
(4, 1, 1)
|
||||||
|
);
|
||||||
|
assert_eq!(report.failed, 1);
|
||||||
|
let rows = library.get_qobuz_download_tracks().unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
let kept = rows.iter().find(|t| t.qobuz_track_id == Some(1)).unwrap();
|
||||||
|
assert_eq!(kept.id, before.id);
|
||||||
|
assert_eq!(kept.title, "Existing title");
|
||||||
|
assert_eq!(kept.track_number, Some(8));
|
||||||
|
let added = rows.iter().find(|t| t.qobuz_track_id == Some(2)).unwrap();
|
||||||
|
assert_eq!(added.album_group_key, "existing-group");
|
||||||
|
assert_eq!(added.sample_rate, 96000.0);
|
||||||
|
assert_eq!(
|
||||||
|
restore_missing_library_rows(&cache, &library, "", None)
|
||||||
|
.unwrap()
|
||||||
|
.restored,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
cache.delete_track(2).unwrap();
|
||||||
|
library.remove_qobuz_cached_track(2).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restore_missing_library_rows(&cache, &library, "", Some(2))
|
||||||
|
.unwrap()
|
||||||
|
.restored,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cmaf_requires_init_and_segments_and_uses_bundle_directory() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = OfflineCacheDb::new(&dir.path().join("index.db")).unwrap();
|
||||||
|
let library = LibraryDatabase::open(&dir.path().join("library.db")).unwrap();
|
||||||
|
let segments = dir.path().join("segments.bin");
|
||||||
|
let init = dir.path().join("init.mp4");
|
||||||
|
std::fs::write(&segments, b"encrypted-fixture").unwrap();
|
||||||
|
cache
|
||||||
|
.insert_track(
|
||||||
|
&TrackCacheInfo {
|
||||||
|
track_id: 8,
|
||||||
|
title: "CMAF".into(),
|
||||||
|
artist: "Artist".into(),
|
||||||
|
album: Some("Album".into()),
|
||||||
|
album_id: Some("a".into()),
|
||||||
|
duration_secs: 90,
|
||||||
|
quality: "HiRes".into(),
|
||||||
|
bit_depth: Some(24),
|
||||||
|
sample_rate: Some(192.0),
|
||||||
|
},
|
||||||
|
&segments.to_string_lossy(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
cache
|
||||||
|
.set_cmaf_bundle(
|
||||||
|
8,
|
||||||
|
&segments.to_string_lossy(),
|
||||||
|
&init.to_string_lossy(),
|
||||||
|
b"wrapped",
|
||||||
|
b"wrapped",
|
||||||
|
27,
|
||||||
|
1,
|
||||||
|
17,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
cache.mark_complete(8, 17).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restore_missing_library_rows(&cache, &library, "", None)
|
||||||
|
.unwrap()
|
||||||
|
.unavailable,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
std::fs::write(&init, b"init-fixture").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restore_missing_library_rows(&cache, &library, "", None)
|
||||||
|
.unwrap()
|
||||||
|
.restored,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let rows = library.get_qobuz_download_tracks().unwrap();
|
||||||
|
assert_eq!(rows[0].file_path, dir.path().to_string_lossy());
|
||||||
|
assert_eq!(std::fs::read(segments).unwrap(), b"encrypted-fixture");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1606,6 +1606,7 @@ mod tests {
|
|||||||
streamable: None,
|
streamable: None,
|
||||||
streamable_at: None,
|
streamable_at: None,
|
||||||
release_date_stream: None,
|
release_date_stream: None,
|
||||||
|
release_date_original: None,
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -2037,6 +2038,7 @@ mod tests {
|
|||||||
streamable: None,
|
streamable: None,
|
||||||
streamable_at: None,
|
streamable_at: None,
|
||||||
release_date_stream: None,
|
release_date_stream: None,
|
||||||
|
release_date_original: None,
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -2979,6 +2981,7 @@ mod entitlement_index_tests {
|
|||||||
streamable: None,
|
streamable: None,
|
||||||
streamable_at: None,
|
streamable_at: None,
|
||||||
release_date_stream: None,
|
release_date_stream: None,
|
||||||
|
release_date_original: None,
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -111,6 +111,30 @@ impl OfflineCacheState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SQLite and filesystem work stays off the async executor. Hold the
|
||||||
|
/// source index through insertion so removal cannot race a stale snapshot.
|
||||||
|
pub async fn restore_library_rows(
|
||||||
|
&self,
|
||||||
|
track_id: Option<u64>,
|
||||||
|
) -> Result<crate::library_sync::LibrarySyncReport, String> {
|
||||||
|
let cache = self.db.clone();
|
||||||
|
let library = self.library_db.clone();
|
||||||
|
let root = self.get_cache_path();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
// Download post-processing takes library before cache.
|
||||||
|
let library = library.blocking_lock();
|
||||||
|
let cache = cache.blocking_lock();
|
||||||
|
crate::library_sync::restore_missing_library_rows(
|
||||||
|
cache.as_ref().ok_or("Offline index is closed")?,
|
||||||
|
library.as_ref().ok_or("Offline library is closed")?,
|
||||||
|
&root,
|
||||||
|
track_id,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn teardown(&self) {
|
pub async fn teardown(&self) {
|
||||||
// Close library connection first (before main teardown)
|
// Close library connection first (before main teardown)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ mod tests {
|
|||||||
streamable: None,
|
streamable: None,
|
||||||
streamable_at: None,
|
streamable_at: None,
|
||||||
release_date_stream: None,
|
release_date_stream: None,
|
||||||
|
release_date_original: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,27 +29,66 @@ function textCompare(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sortAlbums(rows, sort, group) {
|
function sortAlbums(rows, sort, group) {
|
||||||
|
sort = normalizedMode(sort)
|
||||||
|
if (group === "off") return sortRows(rows, sort)
|
||||||
return rows.slice().sort(function (a, b) {
|
return rows.slice().sort(function (a, b) {
|
||||||
var grouped = 0
|
var grouped = 0
|
||||||
if (group === "year" || group === "decade") {
|
if (group === "year" || group === "decade") {
|
||||||
var x = groupNumber(a, group), y = groupNumber(b, group)
|
var x = groupNumber(a, group), y = groupNumber(b, group)
|
||||||
if (!x || !y) grouped = !x && !y ? 0 : (!x ? 1 : -1)
|
if (!x || !y) grouped = !x && !y ? 0 : (!x ? 1 : -1)
|
||||||
else grouped = sort === "newest" ? y - x : x - y
|
else grouped = sort === "release-date-desc" ? y - x : x - y
|
||||||
} else if (group === "artist") {
|
} else if (group === "artist") {
|
||||||
grouped = textCompare(a.artist, b.artist)
|
grouped = textCompare(a.artist, b.artist)
|
||||||
} else if (group === "alpha") {
|
} else if (group === "alpha") {
|
||||||
grouped = textCompare(String(a.title || "").charAt(0), String(b.title || "").charAt(0))
|
grouped = textCompare(String(a.title || "").charAt(0), String(b.title || "").charAt(0))
|
||||||
}
|
}
|
||||||
if (grouped) return grouped
|
if (grouped) return grouped
|
||||||
if (sort === "oldest" || sort === "newest")
|
if (sort !== "default") return compareItems(a, b, sort)
|
||||||
return compareDates(a, b, sort === "newest")
|
|
||||||
if (sort === "artist-asc") return textCompare(a.artist, b.artist)
|
|
||||||
if (sort === "title-asc" || sort === "title-desc") {
|
|
||||||
var title = textCompare(a.title, b.title)
|
|
||||||
return sort === "title-desc" ? -title : title
|
|
||||||
}
|
|
||||||
// Default retains server/feed order; alphabetical groups keep their
|
// Default retains server/feed order; alphabetical groups keep their
|
||||||
// established alphabetical order within each group.
|
// established alphabetical order within each group.
|
||||||
return group === "artist" || group === "alpha" ? textCompare(a.title, b.title) : 0
|
return group === "artist" || group === "alpha" ? textCompare(a.title, b.title) : 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedMode(mode) {
|
||||||
|
if (mode === "oldest") return "release-date-asc"
|
||||||
|
if (mode === "newest") return "release-date-desc"
|
||||||
|
return mode || "default"
|
||||||
|
}
|
||||||
|
function fieldOf(mode) {
|
||||||
|
return normalizedMode(mode).replace(/-(asc|desc|reverse)$/, "")
|
||||||
|
}
|
||||||
|
function ascending(mode) {
|
||||||
|
return mode === "default-reverse" || /-asc$/.test(normalizedMode(mode))
|
||||||
|
}
|
||||||
|
function compareItems(a, b, mode) {
|
||||||
|
mode = normalizedMode(mode)
|
||||||
|
if (mode === "default") return 0
|
||||||
|
if (mode === "default-reverse") return Number(b._feedOrder || 0) - Number(a._feedOrder || 0)
|
||||||
|
var field = fieldOf(mode), asc = ascending(mode)
|
||||||
|
if (field === "release-date") return compareDates(a, b, !asc)
|
||||||
|
if (field === "date") {
|
||||||
|
// Existing library feed recency proxy: lower rank is more recent.
|
||||||
|
var ar = a.added_rank === undefined ? a._feedOrder : a.added_rank
|
||||||
|
var br = b.added_rank === undefined ? b._feedOrder : b.added_rank
|
||||||
|
return (Number(ar || 0) - Number(br || 0)) * (asc ? -1 : 1)
|
||||||
|
}
|
||||||
|
var key = field === "release" ? "album" : field === "duration" ? "durationSecs"
|
||||||
|
: field === "track-count" ? "trackCount" : field === "updated" ? "updatedAt" : field
|
||||||
|
var x = a[key], y = b[key]
|
||||||
|
// An empty playlist has a known zero duration even though zero-valued
|
||||||
|
// durationSecs is omitted by the compact feed serializer.
|
||||||
|
var emptyA = field === "duration" && a.kind === "playlist" && a.trackCount === 0
|
||||||
|
var emptyB = field === "duration" && b.kind === "playlist" && b.trackCount === 0
|
||||||
|
if (emptyA) x = 0
|
||||||
|
if (emptyB) y = 0
|
||||||
|
var numeric = field === "duration" || field === "track-count" || field === "updated"
|
||||||
|
var missingX = x === undefined || x === null || x === "" || (field === "duration" && !x && !emptyA)
|
||||||
|
var missingY = y === undefined || y === null || y === "" || (field === "duration" && !y && !emptyB)
|
||||||
|
if (missingX || missingY) return missingX === missingY ? 0 : (missingX ? 1 : -1)
|
||||||
|
return (numeric ? Number(x) - Number(y) : textCompare(x, y)) * (asc ? 1 : -1)
|
||||||
|
}
|
||||||
|
function sortRows(rows, mode) {
|
||||||
|
if (mode === "default-reverse") return rows.slice().reverse()
|
||||||
|
return rows.slice().sort(function (a, b) { return compareItems(a, b, mode) })
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ Rectangle {
|
|||||||
// NO outline (it matches the favorites toolbar buttons), 12px label and a
|
// NO outline (it matches the favorites toolbar buttons), 12px label and a
|
||||||
// 14px chevron, and 40px narrower because toolbar selects were needlessly
|
// 14px chevron, and 40px narrower because toolbar selects were needlessly
|
||||||
// wide (QbzSelect.slint:86-88,102-105,118-119,126,147-148,299).
|
// wide (QbzSelect.slint:86-88,102-105,118-119,126,147-148,299).
|
||||||
|
// Optional sort selector: reselecting an option still emits selected.
|
||||||
|
property string sortDirection: ""
|
||||||
property bool sm: false
|
property bool sm: false
|
||||||
signal selected(int index)
|
signal selected(int index)
|
||||||
|
|
||||||
@@ -205,7 +207,9 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
QbzIcon {
|
QbzIcon {
|
||||||
name: selectRoot.popupPlacement === "left" ? "chevron-left" : "chevron-down"
|
name: selectRoot.sortDirection !== ""
|
||||||
|
? (selectRoot.sortDirection === "asc" ? "chevron-up" : "chevron-down")
|
||||||
|
: (selectRoot.popupPlacement === "left" ? "chevron-left" : "chevron-down")
|
||||||
width: selectRoot.sm ? 14 : 16
|
width: selectRoot.sm ? 14 : 16
|
||||||
height: selectRoot.sm ? 14 : 16
|
height: selectRoot.sm ? 14 : 16
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -409,6 +413,7 @@ Rectangle {
|
|||||||
Item {
|
Item {
|
||||||
id: rowBadge
|
id: rowBadge
|
||||||
visible: selectRoot.optHasBadges()
|
visible: selectRoot.optHasBadges()
|
||||||
|
|| (selectRoot.sortDirection !== "" && optRow.index === selectRoot.currentIndex)
|
||||||
width: visible ? 20 : 0
|
width: visible ? 20 : 0
|
||||||
height: parent.height
|
height: parent.height
|
||||||
Text {
|
Text {
|
||||||
@@ -422,7 +427,9 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
QbzIcon {
|
QbzIcon {
|
||||||
visible: !selectRoot.optBp(optRow.index)
|
visible: !selectRoot.optBp(optRow.index)
|
||||||
name: "volume-2"
|
name: selectRoot.sortDirection !== ""
|
||||||
|
? (selectRoot.sortDirection === "asc" ? "chevron-up" : "chevron-down")
|
||||||
|
: "volume-2"
|
||||||
width: 14
|
width: 14
|
||||||
height: 14
|
height: 14
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Rectangle {
|
|||||||
property string activeTab: "albums"
|
property string activeTab: "albums"
|
||||||
|
|
||||||
function tabLabel(id) {
|
function tabLabel(id) {
|
||||||
return id === "albums" ? root.t("Albums")
|
return id === "albums" ? root.t("Releases")
|
||||||
: id === "tracks" ? root.t("Tracks")
|
: id === "tracks" ? root.t("Tracks")
|
||||||
: id === "artists" ? root.t("Artists")
|
: id === "artists" ? root.t("Artists")
|
||||||
: id === "playlists" ? root.t("Playlists")
|
: id === "playlists" ? root.t("Playlists")
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ Item {
|
|||||||
"entries": [
|
"entries": [
|
||||||
{ "label": nav.trs("All"), "icon": "layout-grid", "view": "library", "tab": "all", "enabled": true },
|
{ "label": nav.trs("All"), "icon": "layout-grid", "view": "library", "tab": "all", "enabled": true },
|
||||||
{ "label": nav.trs("Tracks"), "icon": "music", "view": "library", "tab": "tracks", "enabled": true },
|
{ "label": nav.trs("Tracks"), "icon": "music", "view": "library", "tab": "tracks", "enabled": true },
|
||||||
{ "label": nav.trs("Albums"), "icon": "disc", "view": "library", "tab": "albums", "enabled": true },
|
{ "label": nav.trs("Releases"), "icon": "disc", "view": "library", "tab": "albums", "enabled": true },
|
||||||
{ "label": nav.trs("Artists"), "icon": "user", "view": "library", "tab": "artists", "enabled": true },
|
{ "label": nav.trs("Artists"), "icon": "user", "view": "library", "tab": "artists", "enabled": true },
|
||||||
{ "label": nav.trs("Playlists"), "icon": "list-music", "view": "library", "tab": "playlists", "enabled": true },
|
{ "label": nav.trs("Playlists"), "icon": "list-music", "view": "library", "tab": "playlists", "enabled": true },
|
||||||
{ "label": nav.trs("Labels"), "icon": "disc-3", "view": "library", "tab": "labels", "enabled": true }
|
{ "label": nav.trs("Labels"), "icon": "disc-3", "view": "library", "tab": "labels", "enabled": true }
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
//
|
//
|
||||||
// THIS FILE IS THE STATE + ORCHESTRATION HALF. The bodies live next door
|
// THIS FILE IS THE STATE + ORCHESTRATION HALF. The bodies live next door
|
||||||
// (track rule 2 — it used to be 1,881 lines):
|
// (track rule 2 — it used to be 1,881 lines):
|
||||||
// library/LibraryToolbar.qml the whole 56px chrome band
|
// library/LibraryToolbar.qml the two-row Library header
|
||||||
// library/FeedListRow.qml the All feed's mixed LIST row
|
// library/FeedListRow.qml the All feed's mixed LIST row
|
||||||
// library/LibraryAlbumsList.qml Albums in LIST mode + its bulk bar
|
// library/LibraryAlbumsList.qml Albums in LIST mode + its bulk bar
|
||||||
// library/LibraryArtistsPanel.qml Artists in SIDEPANEL mode
|
// library/LibraryArtistsPanel.qml Artists in SIDEPANEL mode
|
||||||
@@ -92,6 +92,28 @@ Rectangle {
|
|||||||
readonly property var counts: JSON.parse(QbzLibrary.libraryCountsJson)
|
readonly property var counts: JSON.parse(QbzLibrary.libraryCountsJson)
|
||||||
// Full merged feed (parsed once per publish — timed for the report).
|
// Full merged feed (parsed once per publish — timed for the report).
|
||||||
readonly property var feed: parseFeed(QbzLibrary.libraryJson)
|
readonly property var feed: parseFeed(QbzLibrary.libraryJson)
|
||||||
|
// The local catalog includes disk, download cache and self-hosted servers.
|
||||||
|
function isLocalFeedItem(item) {
|
||||||
|
return item.group === "local" || ["local", "plex", "jellyfin", "subsonic",
|
||||||
|
"navidrome", "gonic", "airsonic", "astiga"].indexOf(item.source) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideableLocalAlbum(item) {
|
||||||
|
// Matched Qobuz/download entries stay in the library when local is hidden.
|
||||||
|
var sources = item.sources || []
|
||||||
|
var isDownload = item.source === "offline" || item.source === "qobuz_download"
|
||||||
|
|| item.source === "qobuz_purchase" || sources.indexOf("offline") >= 0
|
||||||
|
|| sources.indexOf("qobuz_purchase") >= 0
|
||||||
|
return item.kind === "album" && item.source !== "qobuz"
|
||||||
|
&& isLocalFeedItem(item) && !isDownload
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property bool showSourceBadges: visibleRows.some(function (item) {
|
||||||
|
return isLocalFeedItem(item) || (item.sources || []).some(function (source) {
|
||||||
|
return source !== "qobuz"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function parseFeed(json) {
|
function parseFeed(json) {
|
||||||
var t = Date.now()
|
var t = Date.now()
|
||||||
var f = JSON.parse(json)
|
var f = JSON.parse(json)
|
||||||
@@ -103,6 +125,7 @@ Rectangle {
|
|||||||
var tabSeen = ({})
|
var tabSeen = ({})
|
||||||
var tabPurchased = ({})
|
var tabPurchased = ({})
|
||||||
var tabFavorites = ({})
|
var tabFavorites = ({})
|
||||||
|
var tabFollowing = ({})
|
||||||
// Normalize the strings once per Rust publish. Library > All can hold
|
// Normalize the strings once per Rust publish. Library > All can hold
|
||||||
// the complete local library, so repeating toLowerCase() in every
|
// the complete local library, so repeating toLowerCase() in every
|
||||||
// search pass and every sort comparison turns one key press into a
|
// search pass and every sort comparison turns one key press into a
|
||||||
@@ -110,6 +133,16 @@ Rectangle {
|
|||||||
// rows; they do not enlarge the Rust JSON transport.
|
// rows; they do not enlarge the Rust JSON transport.
|
||||||
for (var i = 0; i < f.length; i++) {
|
for (var i = 0; i < f.length; i++) {
|
||||||
var it = f[i]
|
var it = f[i]
|
||||||
|
it._feedOrder = i
|
||||||
|
// Local database IDs can coincide with Qobuz IDs.
|
||||||
|
var membershipKey = (isLocalFeedItem(it)
|
||||||
|
? it.source + ":" : "") + it.kind + ":" + it.id
|
||||||
|
it._membershipKey = membershipKey
|
||||||
|
if (it.group === "purchases") tabPurchased[membershipKey] = true
|
||||||
|
if (it.kind === "playlist" ? it.isFavorite === true
|
||||||
|
: it.group === "favorites" || (isLocalFeedItem(it) && it.isFavorite === true))
|
||||||
|
tabFavorites[membershipKey] = true
|
||||||
|
if (it.group === "following") tabFollowing[membershipKey] = true
|
||||||
it._titleLc = String(it.title || "").toLowerCase()
|
it._titleLc = String(it.title || "").toLowerCase()
|
||||||
it._artistLc = String(it.artist || "").toLowerCase()
|
it._artistLc = String(it.artist || "").toLowerCase()
|
||||||
it._albumLc = String(it.album || "").toLowerCase()
|
it._albumLc = String(it.album || "").toLowerCase()
|
||||||
@@ -125,7 +158,10 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (it.kind === "artist") totals.artists++
|
} else if (it.kind === "artist") totals.artists++
|
||||||
else if (it.kind === "playlist") totals.playlists++
|
else if (it.kind === "playlist" && !tabSeen[membershipKey]) {
|
||||||
|
totals.playlists++
|
||||||
|
tabSeen[membershipKey] = true
|
||||||
|
}
|
||||||
else if (it.kind === "label") totals.labels++
|
else if (it.kind === "label") totals.labels++
|
||||||
}
|
}
|
||||||
// The toolbar emptiness gate used to make a second full pass over the
|
// The toolbar emptiness gate used to make a second full pass over the
|
||||||
@@ -134,6 +170,7 @@ Rectangle {
|
|||||||
f._tabTotals = totals
|
f._tabTotals = totals
|
||||||
f._tabPurchased = tabPurchased
|
f._tabPurchased = tabPurchased
|
||||||
f._tabFavorites = tabFavorites
|
f._tabFavorites = tabFavorites
|
||||||
|
f._tabFollowing = tabFollowing
|
||||||
// Was a bare console.log, i.e. printed on every publish for every user
|
// Was a bare console.log, i.e. printed on every publish for every user
|
||||||
// forever. It belongs to the same investigation as the derive timing
|
// forever. It belongs to the same investigation as the derive timing
|
||||||
// right below it, so it now rides the same category and is silent
|
// right below it, so it now rides the same category and is silent
|
||||||
@@ -208,7 +245,14 @@ Rectangle {
|
|||||||
? QbzLibrary.sessionAlbumsShowFavorites : activeTab === "tracks"
|
? QbzLibrary.sessionAlbumsShowFavorites : activeTab === "tracks"
|
||||||
? QbzLibrary.sessionTracksShowFavorites : QbzLibrary.sessionShowFavorites
|
? QbzLibrary.sessionTracksShowFavorites : QbzLibrary.sessionShowFavorites
|
||||||
readonly property bool showFollowing: QbzLibrary.sessionShowFollowing
|
readonly property bool showFollowing: QbzLibrary.sessionShowFollowing
|
||||||
property bool showLocal: true
|
readonly property bool showLocal: !QbzLibrary.sessionHideLocalAlbums
|
||||||
|
readonly property bool hiresOnly: activeTab === "albums"
|
||||||
|
? QbzLibrary.sessionAlbumsHiresOnly : activeTab === "tracks"
|
||||||
|
? QbzLibrary.sessionTracksHiresOnly : false
|
||||||
|
function setHiresOnly(on) {
|
||||||
|
if (activeTab === "albums") QbzLibrary.sessionAlbumsHiresOnly = on
|
||||||
|
else if (activeTab === "tracks") QbzLibrary.sessionTracksHiresOnly = on
|
||||||
|
}
|
||||||
// Only bumped when a live heart change can alter membership in the
|
// Only bumped when a live heart change can alter membership in the
|
||||||
// current Tracks / Albums source filter. Kept separate from `feed` so the
|
// current Tracks / Albums source filter. Kept separate from `feed` so the
|
||||||
// normal card-heart path still avoids rebuilding a large scrolled model.
|
// normal card-heart path still avoids rebuilding a large scrolled model.
|
||||||
@@ -245,12 +289,15 @@ Rectangle {
|
|||||||
if (!p || p.albumsView === undefined) return
|
if (!p || p.albumsView === undefined) return
|
||||||
root.albumsView = p.albumsView
|
root.albumsView = p.albumsView
|
||||||
root.albumsSort = p.albumsSort
|
root.albumsSort = p.albumsSort
|
||||||
|
root.labelsSort = p.labelsSort || "default"
|
||||||
|
root.artistsSort = p.artistsSort || "default"
|
||||||
|
root.playlistsSort = p.playlistsSort || "default"
|
||||||
|
root.tracksSort = p.tracksSort || "default"
|
||||||
root.albumsGroup = p.albumsGroup
|
root.albumsGroup = p.albumsGroup
|
||||||
root.tracksGroup = p.tracksGroup
|
root.tracksGroup = p.tracksGroup
|
||||||
root.playlistsView = p.playlistsView
|
root.playlistsView = p.playlistsView
|
||||||
root.artistsGroup = p.artistsGroup
|
root.artistsGroup = p.artistsGroup
|
||||||
root.artistsView = p.artistsView
|
root.artistsView = p.artistsView
|
||||||
root.showLocal = p.allShowLocal === true
|
|
||||||
}
|
}
|
||||||
Component.onCompleted: root.applyPrefs()
|
Component.onCompleted: root.applyPrefs()
|
||||||
Connections {
|
Connections {
|
||||||
@@ -267,6 +314,10 @@ Rectangle {
|
|||||||
// list mode with a live selection would strand the bulk bar.
|
// list mode with a live selection would strand the bulk bar.
|
||||||
if (value !== "list") root.setAlbumsMultiSelect(false)
|
if (value !== "list") root.setAlbumsMultiSelect(false)
|
||||||
} else if (key === "albumsSort") root.albumsSort = value
|
} else if (key === "albumsSort") root.albumsSort = value
|
||||||
|
else if (key === "tracksSort") root.tracksSort = value
|
||||||
|
else if (key === "playlistsSort") root.playlistsSort = value
|
||||||
|
else if (key === "artistsSort") root.artistsSort = value
|
||||||
|
else if (key === "labelsSort") root.labelsSort = value
|
||||||
else if (key === "albumsGroup") root.albumsGroup = value
|
else if (key === "albumsGroup") root.albumsGroup = value
|
||||||
else if (key === "tracksGroup") root.tracksGroup = value
|
else if (key === "tracksGroup") root.tracksGroup = value
|
||||||
else if (key === "playlistsView") root.playlistsView = value
|
else if (key === "playlistsView") root.playlistsView = value
|
||||||
@@ -278,11 +329,9 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
QbzLibrary.setLibraryPref(key, String(value))
|
QbzLibrary.setLibraryPref(key, String(value))
|
||||||
}
|
}
|
||||||
/// The one SOURCE switch the reference persists (favorites_prefs.rs
|
/// Match the other session filters: false means no restriction.
|
||||||
/// `all_show_local`); the other three are session-local there too.
|
|
||||||
function setShowLocal(on) {
|
function setShowLocal(on) {
|
||||||
root.showLocal = on
|
QbzLibrary.sessionHideLocalAlbums = !on
|
||||||
QbzLibrary.setLibraryPref("allShowLocal", on ? "true" : "false")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genre selections are independent for each Library tab, too.
|
// Genre selections are independent for each Library tab, too.
|
||||||
@@ -316,36 +365,45 @@ Rectangle {
|
|||||||
/// declared LAST in this file and cannot be named from a child component.
|
/// declared LAST in this file and cannot be named from a child component.
|
||||||
function toggleGenrePopup() { libGenrePopup.toggle() }
|
function toggleGenrePopup() { libGenrePopup.toggle() }
|
||||||
|
|
||||||
/// The All tab's filters as the applied-filters tooltip wants them: the
|
/// Active restrictions for the genre tooltip.
|
||||||
/// selected genres, plus the four SOURCE switches — but only when one is
|
|
||||||
/// OFF, because all four on is the default and "everything is shown" is not
|
|
||||||
/// a filter worth naming. Genres come back in display casing here (the
|
|
||||||
/// lowercased copy above exists for matching, not for reading).
|
|
||||||
readonly property var filterSummaryGroups: {
|
readonly property var filterSummaryGroups: {
|
||||||
var tr = QbzSession.trRev
|
var tr = QbzSession.trRev
|
||||||
var hidden = []
|
var active = []
|
||||||
if (!root.showFavorites)
|
if (root.showPurchases) active.push(QbzSession.tr("Purchases only", tr))
|
||||||
hidden.push(QbzSession.tr("Favorites", tr))
|
if (root.showFavorites) active.push(QbzSession.tr("Favorites only", tr))
|
||||||
if (!root.showPurchases)
|
if (root.activeTab === "all" && root.showFollowing)
|
||||||
hidden.push(QbzSession.tr("Purchases", tr))
|
active.push(QbzSession.tr("Following only", tr))
|
||||||
// Following / Local are All-only sources. Do not claim they filter
|
if (root.hiresOnly) active.push(QbzSession.tr("Hi-Res only", tr))
|
||||||
// the Albums or Tracks toolbar when those switches are off on All.
|
if (root.activeTab === "all" && !root.showLocal)
|
||||||
if (root.activeTab === "all") {
|
active.push(QbzSession.tr("Hide local albums from library", tr))
|
||||||
if (!root.showFollowing)
|
return active.length ? [{ group: QbzSession.tr("Filters", tr), values: active }] : []
|
||||||
hidden.push(QbzSession.tr("Following", tr))
|
|
||||||
if (!root.showLocal)
|
|
||||||
hidden.push(QbzSession.tr("Local", tr))
|
|
||||||
}
|
|
||||||
var out = []
|
|
||||||
if (hidden.length > 0)
|
|
||||||
out.push({ group: QbzSession.tr("Hidden", tr), values: hidden })
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other-tab state.
|
// Other-tab state.
|
||||||
property string tabSearch: ""
|
property string tabSearch: ""
|
||||||
|
property string tracksSort: "default"
|
||||||
|
property string playlistsSort: "default"
|
||||||
|
property string artistsSort: "default"
|
||||||
|
property string labelsSort: "default"
|
||||||
|
readonly property string activeSort: activeTab === "all" ? sortBy + (sortAsc ? "-asc" : "-desc")
|
||||||
|
: activeTab === "albums" ? albumsSort : activeTab === "tracks" ? tracksSort
|
||||||
|
: activeTab === "playlists" ? playlistsSort : activeTab === "artists" ? artistsSort : labelsSort
|
||||||
|
|
||||||
|
function pickSort(field) {
|
||||||
|
var same = ReleaseSort.fieldOf(activeSort) === field
|
||||||
|
var asc = same ? !ReleaseSort.ascending(activeSort)
|
||||||
|
: ["default", "date", "updated", "duration", "track-count", "release-date"].indexOf(field) < 0
|
||||||
|
if (activeTab === "all") {
|
||||||
|
sortBy = field
|
||||||
|
sortAsc = asc
|
||||||
|
} else {
|
||||||
|
setPref(activeTab + "Sort", field === "default"
|
||||||
|
? (asc ? "default-reverse" : "default") : field + (asc ? "-asc" : "-desc"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
property string albumsSort: "default" // default|title-asc|title-desc|artist-asc|oldest|newest
|
property string albumsSort: "default" // default|title-asc|title-desc|artist-asc|oldest|newest
|
||||||
property string playlistsSubTab: "favorites"
|
property string playlistsSubTab: "all"
|
||||||
|
|
||||||
/// The All tab's search field debounces; the toolbar routes through here
|
/// The All tab's search field debounces; the toolbar routes through here
|
||||||
/// so the timer stays next to the window report it feeds.
|
/// so the timer stays next to the window report it feeds.
|
||||||
@@ -456,6 +514,21 @@ Rectangle {
|
|||||||
return _rows
|
return _rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit counts and geometry, never search text or library contents. This
|
||||||
|
// distinguishes an empty derive from a mounted view that cannot paint.
|
||||||
|
function reportSurfaceState() {
|
||||||
|
if (!root.visible || !content.ready) return
|
||||||
|
console.info("[library-view] tab=" + root.activeTab
|
||||||
|
+ " feed=" + root.feed.length + " rows=" + root.visibleRows.length
|
||||||
|
+ " searchActive=" + (root.tabSearch.length > 0)
|
||||||
|
+ " artistsView=" + root.artistsView
|
||||||
|
+ " grid=" + content.showGrid + " gridCount=" + grid.count
|
||||||
|
+ " viewport=" + grid.width + "x" + grid.height
|
||||||
|
+ " contentY=" + grid.contentY)
|
||||||
|
}
|
||||||
|
onVisibleRowsChanged: Qt.callLater(root.reportSurfaceState)
|
||||||
|
onVisibleChanged: Qt.callLater(root.reportSurfaceState)
|
||||||
|
|
||||||
/// A-Z bucket for a title: its first letter uppercased, "#" for anything
|
/// A-Z bucket for a title: its first letter uppercased, "#" for anything
|
||||||
/// that is not a letter (a leading digit, "*", punctuation). Matches the
|
/// that is not a letter (a leading digit, "*", punctuation). Matches the
|
||||||
/// jump strip's own buckets, so a letter in the strip always has a
|
/// jump strip's own buckets, so a letter in the strip always has a
|
||||||
@@ -501,10 +574,9 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesSources(isPurchased, isFavorite, isFollowing) {
|
function matchesSources(isPurchased, isFavorite, isFollowing) {
|
||||||
var following = activeTab === "all" && showFollowing
|
return (!showPurchases || isPurchased)
|
||||||
var any = showPurchases || showFavorites || following
|
&& (!showFavorites || isFavorite)
|
||||||
return !any || (showPurchases && isPurchased)
|
&& (activeTab !== "all" || !showFollowing || isFollowing)
|
||||||
|| (showFavorites && isFavorite) || (following && isFollowing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleItems() {
|
function visibleItems() {
|
||||||
@@ -520,17 +592,17 @@ Rectangle {
|
|||||||
// already merged every source in that order, so do not copy and
|
// already merged every source in that order, so do not copy and
|
||||||
// rescan a potentially 10k-row array merely to return it intact.
|
// rescan a potentially 10k-row array merely to return it intact.
|
||||||
if (needle === "" && genres.length === 0
|
if (needle === "" && genres.length === 0
|
||||||
&& showPurchases && showFavorites && showFollowing && showLocal
|
&& !showPurchases && !showFavorites && !showFollowing && showLocal
|
||||||
&& sortBy === "date" && !sortAsc)
|
&& sortBy === "date" && !sortAsc)
|
||||||
return feed
|
return feed
|
||||||
|
var allSeen = ({})
|
||||||
for (i = 0; i < feed.length; i++) {
|
for (i = 0; i < feed.length; i++) {
|
||||||
var it = feed[i]
|
var it = feed[i]
|
||||||
var isLocal = it.source === "local" || it.source === "plex"
|
if (!showLocal && hideableLocalAlbum(it)) continue
|
||||||
if (isLocal) {
|
var memberKey = it._membershipKey
|
||||||
if (!showLocal) continue
|
if (!matchesSources(feed._tabPurchased[memberKey] === true,
|
||||||
} else if (!matchesSources(it.group === "purchases",
|
feed._tabFavorites[memberKey] === true,
|
||||||
it.group === "favorites",
|
feed._tabFollowing[memberKey] === true)) continue
|
||||||
it.group === "following")) continue
|
|
||||||
if (needle !== ""
|
if (needle !== ""
|
||||||
&& it._titleLc.indexOf(needle) < 0
|
&& it._titleLc.indexOf(needle) < 0
|
||||||
&& it._artistLc.indexOf(needle) < 0) continue
|
&& it._artistLc.indexOf(needle) < 0) continue
|
||||||
@@ -543,20 +615,13 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
if (!genreHit) continue
|
if (!genreHit) continue
|
||||||
}
|
}
|
||||||
|
if (showPurchases || showFavorites || showFollowing) {
|
||||||
|
if (allSeen[memberKey]) continue
|
||||||
|
allSeen[memberKey] = true
|
||||||
|
}
|
||||||
items.push(it)
|
items.push(it)
|
||||||
}
|
}
|
||||||
// Canonical ascending order per field, then reverse for the
|
items = ReleaseSort.sortRows(items, root.activeSort)
|
||||||
// other direction; "date" keeps model order (newest-first),
|
|
||||||
// reversed for oldest (library_all.rs derive).
|
|
||||||
if (sortBy === "title") {
|
|
||||||
items.sort(function (a, b) { return a._titleLc < b._titleLc ? -1 : 1 })
|
|
||||||
if (!sortAsc) items.reverse()
|
|
||||||
} else if (sortBy === "artist") {
|
|
||||||
items.sort(function (a, b) { return a._artistLc < b._artistLc ? -1 : 1 })
|
|
||||||
if (!sortAsc) items.reverse()
|
|
||||||
} else if (sortAsc) {
|
|
||||||
items = items.slice().reverse()
|
|
||||||
}
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
var tabNeedle = tabSearch.toLowerCase()
|
var tabNeedle = tabSearch.toLowerCase()
|
||||||
@@ -568,9 +633,9 @@ Rectangle {
|
|||||||
var sourceTab = activeTab === "tracks" || activeTab === "albums"
|
var sourceTab = activeTab === "tracks" || activeTab === "albums"
|
||||||
var sourceKind = activeTab === "tracks" ? "track" : "album"
|
var sourceKind = activeTab === "tracks" ? "track" : "album"
|
||||||
var tabGenres = sourceTab ? root.genreNames : []
|
var tabGenres = sourceTab ? root.genreNames : []
|
||||||
// Enabled sources form a union in every tab. With none selected,
|
// Every enabled "only" filter restricts the result. With none enabled,
|
||||||
// retain All's unconstrained behavior. Prefer purchase metadata for
|
// show all entities. Prefer purchase metadata for an entity in both
|
||||||
// entities belonging to both sources, and render each entity once.
|
// sources, and render it once.
|
||||||
var purchased = feed._tabPurchased || ({})
|
var purchased = feed._tabPurchased || ({})
|
||||||
var favorites = feed._tabFavorites || ({})
|
var favorites = feed._tabFavorites || ({})
|
||||||
var tabSeen = ({})
|
var tabSeen = ({})
|
||||||
@@ -592,8 +657,14 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
} else if (activeTab === "artists") keep = x.kind === "artist"
|
} else if (activeTab === "artists") keep = x.kind === "artist"
|
||||||
else if (activeTab === "labels") keep = x.kind === "label"
|
else if (activeTab === "labels") keep = x.kind === "label"
|
||||||
else if (activeTab === "playlists") keep = x.kind === "playlist"
|
else if (activeTab === "playlists") {
|
||||||
&& (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites")
|
keep = x.kind === "playlist"
|
||||||
|
&& (playlistsSubTab === "all" || x.playlistCreator === playlistsSubTab)
|
||||||
|
if (keep) {
|
||||||
|
if (tabSeen["playlist:" + x.id]) keep = false
|
||||||
|
else tabSeen["playlist:" + x.id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
if (keep && tabGenres.length > 0) {
|
if (keep && tabGenres.length > 0) {
|
||||||
var tg = x._genreLc
|
var tg = x._genreLc
|
||||||
keep = false
|
keep = false
|
||||||
@@ -601,10 +672,12 @@ Rectangle {
|
|||||||
if (tg !== "" && tg.indexOf(tabGenres[tgi]) >= 0) { keep = true; break }
|
if (tg !== "" && tg.indexOf(tabGenres[tgi]) >= 0) { keep = true; break }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (keep && sourceTab && hiresOnly && x.qualityTier !== "hires") keep = false
|
||||||
if (keep && hit(x)) items.push(x)
|
if (keep && hit(x)) items.push(x)
|
||||||
}
|
}
|
||||||
if (activeTab === "albums")
|
if (activeTab === "albums")
|
||||||
items = ReleaseSort.sortAlbums(items, albumsSort, albumsGroup)
|
items = ReleaseSort.sortAlbums(items, albumsSort, albumsGroup)
|
||||||
|
else items = ReleaseSort.sortRows(items, root.activeSort)
|
||||||
// --- Group-by (favorites.rs::derive_tracks / derive_albums) ------
|
// --- Group-by (favorites.rs::derive_tracks / derive_albums) ------
|
||||||
// Grouping REORDERS the rows so a group's entries sit together. The
|
// Grouping REORDERS the rows so a group's entries sit together. The
|
||||||
// TRACKS list also gets separator rows (see withGroupHeaders); the
|
// TRACKS list also gets separator rows (see withGroupHeaders); the
|
||||||
@@ -628,12 +701,24 @@ Rectangle {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (activeTab === "tracks" && tracksGroup !== "off") {
|
if (activeTab === "tracks" && tracksGroup !== "off") {
|
||||||
if (tracksGroup === "album") by(["album", "title"])
|
if (tracksSort !== "default") {
|
||||||
|
var groupKey = tracksGroup === "name" ? "title" : tracksGroup
|
||||||
|
items.sort(function (a, b) {
|
||||||
|
var av = cachedKey(a, groupKey), bv = cachedKey(b, groupKey)
|
||||||
|
if (tracksGroup === "name") { av = av.charAt(0); bv = bv.charAt(0) }
|
||||||
|
return av !== bv ? (av < bv ? -1 : 1) : ReleaseSort.compareItems(a, b, tracksSort)
|
||||||
|
})
|
||||||
|
} else if (tracksGroup === "album") by(["album", "title"])
|
||||||
else if (tracksGroup === "artist") by(["artist", "album", "title"])
|
else if (tracksGroup === "artist") by(["artist", "album", "title"])
|
||||||
else if (tracksGroup === "name") by(["title"])
|
else if (tracksGroup === "name") by(["title"])
|
||||||
items = withGroupHeaders(items, tracksGroup)
|
items = withGroupHeaders(items, tracksGroup)
|
||||||
} else if (activeTab === "artists" && artistsGroup === "alpha") {
|
} else if (activeTab === "artists" && artistsGroup === "alpha" && artistsView === "grid") {
|
||||||
by(["title"])
|
items.sort(function (a, b) {
|
||||||
|
var av = root.alphaKey(a.title), bv = root.alphaKey(b.title)
|
||||||
|
return av !== bv ? (av < bv ? -1 : 1)
|
||||||
|
: artistsSort === "default" ? ReleaseSort.textCompare(a.title, b.title)
|
||||||
|
: ReleaseSort.compareItems(a, b, artistsSort)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
@@ -904,7 +989,11 @@ Rectangle {
|
|||||||
var f = root.feed
|
var f = root.feed
|
||||||
for (var i = 0; i < f.length; i++) {
|
for (var i = 0; i < f.length; i++) {
|
||||||
// A purchased favourite has one row per source; patch both.
|
// A purchased favourite has one row per source; patch both.
|
||||||
if (f[i].artKey === key) f[i].isFavorite = value
|
if (f[i].artKey === key) {
|
||||||
|
f[i].isFavorite = value
|
||||||
|
if (value) f._tabFavorites[f[i]._membershipKey] = true
|
||||||
|
else delete f._tabFavorites[f[i]._membershipKey]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Keep the entity-level source set live even when a newly-added
|
// Keep the entity-level source set live even when a newly-added
|
||||||
// favourite has no `favorites` row until the next API refresh.
|
// favourite has no `favorites` row until the next API refresh.
|
||||||
@@ -918,7 +1007,7 @@ Rectangle {
|
|||||||
// except Purchases-only. Re-derive only in those cases; elsewhere
|
// except Purchases-only. Re-derive only in those cases; elsewhere
|
||||||
// the mounted delegates consume this same signal directly and
|
// the mounted delegates consume this same signal directly and
|
||||||
// retain the existing no-scroll-reset path.
|
// retain the existing no-scroll-reset path.
|
||||||
if (sourceTab && (root.showFavorites || !root.showPurchases))
|
if ((sourceTab || root.activeTab === "all") && root.showFavorites)
|
||||||
root.favoriteFilterRev++
|
root.favoriteFilterRev++
|
||||||
}
|
}
|
||||||
// Pin state settled in the store. The row is patched IN PLACE and the
|
// Pin state settled in the store. The row is patched IN PLACE and the
|
||||||
@@ -1097,7 +1186,7 @@ Rectangle {
|
|||||||
Item {
|
Item {
|
||||||
id: content
|
id: content
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: parent.height - 57
|
height: parent.height - toolbar.height - 1
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
// The tracks bulk bar is pinned above the list (the Slint scrolls
|
// The tracks bulk bar is pinned above the list (the Slint scrolls
|
||||||
@@ -1216,6 +1305,7 @@ Rectangle {
|
|||||||
|
|
||||||
readonly property bool ready:
|
readonly property bool ready:
|
||||||
!QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
|
!QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
|
||||||
|
onReadyChanged: Qt.callLater(root.reportSurfaceState)
|
||||||
// Which surface owns the tab right now — one predicate per body,
|
// Which surface owns the tab right now — one predicate per body,
|
||||||
// so no two can mount at once.
|
// so no two can mount at once.
|
||||||
readonly property bool showAlbumsList:
|
readonly property bool showAlbumsList:
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ Item {
|
|||||||
// may additionally show the catalog mark in the mixed feed.
|
// may additionally show the catalog mark in the mixed feed.
|
||||||
source: cell.item.source
|
source: cell.item.source
|
||||||
sources: cell.item.sources || []
|
sources: cell.item.sources || []
|
||||||
showSourceBadge: cell.view.showLocal
|
showSourceBadge: cell.view.showSourceBadges
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Group separator. These are pseudo-rows injected into the SAME flat model
|
// Group separator. These are pseudo-rows injected into the SAME flat model
|
||||||
@@ -142,7 +142,7 @@ Item {
|
|||||||
TrackCard {
|
TrackCard {
|
||||||
item: cell.item
|
item: cell.item
|
||||||
artSource: cell.view.artMap[cell.item.artKey] || ""
|
artSource: cell.view.artMap[cell.item.artKey] || ""
|
||||||
showSourceBadge: cell.view.showLocal
|
showSourceBadge: cell.view.showSourceBadges
|
||||||
confirmReleaseRemoval: function (item) {
|
confirmReleaseRemoval: function (item) {
|
||||||
cell.view.askRemoveReleaseFavorites(item)
|
cell.view.askRemoveReleaseFavorites(item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ Rectangle {
|
|||||||
// Col — source glyph (only when local/Plex can appear).
|
// Col — source glyph (only when local/Plex can appear).
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: srcCol
|
id: srcCol
|
||||||
visible: feedRow.view.showLocal
|
visible: feedRow.view.showSourceBadges
|
||||||
width: 44
|
width: 44
|
||||||
height: parent.height
|
height: parent.height
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|||||||
@@ -63,20 +63,23 @@ Item {
|
|||||||
var artists = []
|
var artists = []
|
||||||
for (var i = 0; i < rows.length; i++)
|
for (var i = 0; i < rows.length; i++)
|
||||||
if (rows[i].kind === "artist") artists.push(rows[i])
|
if (rows[i].kind === "artist") artists.push(rows[i])
|
||||||
// The sidepanel rail is ALWAYS A-Z grouped (the reference reads
|
// Preserve the chosen sort; only alphabetical modes need letter headers.
|
||||||
// `artists-grouped` unconditionally at :2038, which is why its
|
var mode = root.view.artistsSort
|
||||||
// Group select is hidden in this mode).
|
var grouped = mode === "default" || mode === "default-reverse"
|
||||||
artists.sort(function (a, b) {
|
|| mode.indexOf("title-") === 0
|
||||||
var av = (a.title || "").toLowerCase(), bv = (b.title || "").toLowerCase()
|
if (mode === "default" || mode === "default-reverse") {
|
||||||
return av < bv ? -1 : av > bv ? 1 : 0
|
artists.sort(function(a, b) {
|
||||||
})
|
var result = (a.title || "").localeCompare(b.title || "")
|
||||||
|
return mode === "default-reverse" ? -result : result
|
||||||
|
})
|
||||||
|
}
|
||||||
var out = []
|
var out = []
|
||||||
var flat = []
|
var flat = []
|
||||||
var jumps = []
|
var jumps = []
|
||||||
var last = null
|
var last = null
|
||||||
for (i = 0; i < artists.length; i++) {
|
for (i = 0; i < artists.length; i++) {
|
||||||
var key = root.view.alphaKey(artists[i].title)
|
var key = root.view.alphaKey(artists[i].title)
|
||||||
if (key !== last) {
|
if (grouped && key !== last) {
|
||||||
jumps.push({ "letter": key, "index": out.length })
|
jumps.push({ "letter": key, "index": out.length })
|
||||||
out.push({ "t": 0, "label": key })
|
out.push({ "t": 0, "label": key })
|
||||||
flat.push(root.headerRow)
|
flat.push(root.headerRow)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// LibraryToolbar — the Library view's fixed chrome row: the six-tab menu on
|
// LibraryToolbar — two rows: navigation/actions/selects above, an always-open
|
||||||
// the left, the per-tab ACTION cluster beside it, and the per-tab controls on
|
// search and independent opt-in filter switches below. Narrow panes scroll
|
||||||
// the right (FavoritesView.slint:444-942, rows 1 and 2 folded into one 56px
|
// each control row horizontally instead of covering neighbouring controls.
|
||||||
// band because this port's header carries no "Library" title).
|
|
||||||
//
|
//
|
||||||
// EXTRACTED from views/LibraryView.qml (track rule 2 — the file was 1,881
|
// EXTRACTED from views/LibraryView.qml (track rule 2 — the file was 1,881
|
||||||
// lines). Everything it reads and writes goes through `view`, the LibraryView
|
// lines). Everything it reads and writes goes through `view`, the LibraryView
|
||||||
@@ -14,20 +13,45 @@ import QtQuick
|
|||||||
import com.blitzfc.qbz
|
import com.blitzfc.qbz
|
||||||
import "../../controls"
|
import "../../controls"
|
||||||
import "../../theme"
|
import "../../theme"
|
||||||
|
import "../../assets/release-sort.js" as ReleaseSort
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property var sortOptions: {
|
||||||
|
var tab = view.activeTab
|
||||||
|
var options = []
|
||||||
|
function add(value, label) { options.push({value: value, label: label}) }
|
||||||
|
if (tab !== "all") add("default", QbzSession.tr("Default", QbzSession.trRev))
|
||||||
|
add("date", QbzSession.tr("Date added", QbzSession.trRev))
|
||||||
|
if (tab === "playlists") add("updated", QbzSession.tr("Date updated", QbzSession.trRev))
|
||||||
|
add("title", QbzSession.tr("Alphabetical", QbzSession.trRev))
|
||||||
|
if (tab === "tracks") add("release", QbzSession.tr("Release", QbzSession.trRev))
|
||||||
|
if (["all", "tracks", "albums"].indexOf(tab) >= 0)
|
||||||
|
add("artist", QbzSession.tr("Artist", QbzSession.trRev))
|
||||||
|
if (tab === "tracks" || tab === "albums") {
|
||||||
|
add("label", QbzSession.tr("Label", QbzSession.trRev))
|
||||||
|
add("genre", QbzSession.tr("Genre", QbzSession.trRev))
|
||||||
|
add("release-date", QbzSession.tr("Release date", QbzSession.trRev))
|
||||||
|
}
|
||||||
|
if (tab === "playlists") add("track-count", QbzSession.tr("Track count", QbzSession.trRev))
|
||||||
|
if (["tracks", "albums", "playlists"].indexOf(tab) >= 0)
|
||||||
|
add("duration", QbzSession.tr("Duration", QbzSession.trRev))
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
/// The LibraryView root.
|
/// The LibraryView root.
|
||||||
property var view: null
|
property var view: null
|
||||||
|
|
||||||
QbzTheme { id: theme }
|
QbzTheme { id: theme }
|
||||||
|
|
||||||
height: 56
|
readonly property bool singleRow: view.activeTab === "labels" || view.activeTab === "playlists"
|
||||||
// The width here is the REAL content pane after the navigation and
|
|| view.activeTab === "artists"
|
||||||
// Queue/Lyrics columns have taken their space. At this breakpoint the
|
readonly property real rowGap: 8
|
||||||
// six-tab strip and the All controls fit in one row without either side
|
readonly property real secondRowY: 25 + Math.max(tabRow.height, 30) / 2 + rowGap
|
||||||
// eating the other.
|
readonly property real fullSearchWidth: Math.min(240, Math.max(140, root.width * 0.22))
|
||||||
|
height: singleRow ? 50 : secondRowY + searchBox.height + rowGap
|
||||||
|
// Compact only the count badges; genre and sort retain their labels.
|
||||||
readonly property bool compactChrome: width < 1040
|
readonly property bool compactChrome: width < 1040
|
||||||
|
|
||||||
// Small toolbar toggle button (ToggleButton sm): 30px, active = accent.
|
// Small toolbar toggle button (ToggleButton sm): 30px, active = accent.
|
||||||
@@ -62,7 +86,7 @@ Item {
|
|||||||
component GenreToolButton: Rectangle {
|
component GenreToolButton: Rectangle {
|
||||||
id: gtb
|
id: gtb
|
||||||
readonly property bool active: root.view.genreCount > 0
|
readonly property bool active: root.view.genreCount > 0
|
||||||
width: root.compactChrome ? 30 : gtbRow.implicitWidth
|
width: gtbRow.implicitWidth
|
||||||
height: 30
|
height: 30
|
||||||
radius: 6
|
radius: 6
|
||||||
color: gtb.active ? theme.accent
|
color: gtb.active ? theme.accent
|
||||||
@@ -71,9 +95,9 @@ Item {
|
|||||||
id: gtbRow
|
id: gtbRow
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
height: parent.height
|
height: parent.height
|
||||||
leftPadding: root.compactChrome ? 0 : 10
|
leftPadding: 10
|
||||||
rightPadding: root.compactChrome ? 0 : 12
|
rightPadding: 12
|
||||||
spacing: root.compactChrome ? 0 : 7
|
spacing: 7
|
||||||
QbzIcon {
|
QbzIcon {
|
||||||
name: "list-filter"
|
name: "list-filter"
|
||||||
width: 13
|
width: 13
|
||||||
@@ -91,7 +115,7 @@ Item {
|
|||||||
tintName: gtb.active ? theme.accentGlyphTint : "secondary"
|
tintName: gtb.active ? theme.accentGlyphTint : "secondary"
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
visible: !root.compactChrome
|
visible: true
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: root.view.genreCount === 0
|
text: root.view.genreCount === 0
|
||||||
? QbzSession.tr("Filter by genre", QbzSession.trRev)
|
? QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||||
@@ -127,7 +151,7 @@ Item {
|
|||||||
anchor: gtb
|
anchor: gtb
|
||||||
groups: {
|
groups: {
|
||||||
var out = []
|
var out = []
|
||||||
var names = (root.view.genreDoc.names || {})["library-all"] || []
|
var names = (root.view.genreDoc.names || {})[root.view.genreContext] || []
|
||||||
if (names.length > 0)
|
if (names.length > 0)
|
||||||
out.push({ group: QbzSession.tr("Genre", QbzSession.trRev),
|
out.push({ group: QbzSession.tr("Genre", QbzSession.trRev),
|
||||||
values: names })
|
values: names })
|
||||||
@@ -220,7 +244,17 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FavTabMenu (SegmentedTabBar with count badges).
|
// Navigation and labelled selects share the first row.
|
||||||
|
Flickable {
|
||||||
|
id: topChrome
|
||||||
|
width: parent.width
|
||||||
|
height: 52
|
||||||
|
contentWidth: Math.max(width, topControls.x + topControls.width + 32)
|
||||||
|
contentHeight: height
|
||||||
|
flickableDirection: Flickable.HorizontalFlick
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
clip: true
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: 32
|
x: 32
|
||||||
y: 25 - height / 2
|
y: 25 - height / 2
|
||||||
@@ -239,9 +273,9 @@ Item {
|
|||||||
tabs: [
|
tabs: [
|
||||||
{ "id": "all", "label": QbzSession.tr("All", QbzSession.trRev), "count": root.view.counts.all || 0 },
|
{ "id": "all", "label": QbzSession.tr("All", QbzSession.trRev), "count": root.view.counts.all || 0 },
|
||||||
{ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.tabTotals.tracks || 0 },
|
{ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.tabTotals.tracks || 0 },
|
||||||
{ "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.tabTotals.albums || 0 },
|
{ "id": "albums", "label": QbzSession.tr("Releases", QbzSession.trRev), "count": root.view.tabTotals.albums || 0 },
|
||||||
{ "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
|
{ "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
|
||||||
{ "id": "playlists", "label": QbzSession.tr("Playlists", QbzSession.trRev), "count": root.view.counts.playlists || 0 },
|
{ "id": "playlists", "label": QbzSession.tr("Playlists", QbzSession.trRev), "count": root.view.tabTotals.playlists || 0 },
|
||||||
{ "id": "labels", "label": QbzSession.tr("Labels", QbzSession.trRev), "count": root.view.counts.labels || 0 },
|
{ "id": "labels", "label": QbzSession.tr("Labels", QbzSession.trRev), "count": root.view.counts.labels || 0 },
|
||||||
]
|
]
|
||||||
onSelected: function (id) { root.view.activeTab = id }
|
onSelected: function (id) { root.view.activeTab = id }
|
||||||
@@ -249,292 +283,23 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Per-tab ACTION cluster (FavoritesView.slint:462-565) -------------
|
|
||||||
// The reference draws these in its own row, to the right of a "Library"
|
|
||||||
// title this port's header does not have; they sit beside the tab bar
|
|
||||||
// instead. FUNCTION is 1:1 — this is issue #554 ("users reported the
|
|
||||||
// feature as removed"), so what matters is that Play all / Shuffle / the
|
|
||||||
// per-tab random / the multi-select toggles exist and work.
|
|
||||||
//
|
|
||||||
// Each gates on its tab having something to act on, exactly as the
|
|
||||||
// reference's `*-visible.length > 0` guards do: an action that would play
|
|
||||||
// nothing is not drawn.
|
|
||||||
Row {
|
|
||||||
id: actionRow
|
|
||||||
x: 32 + tabRow.width + 12
|
|
||||||
y: 25 - height / 2
|
|
||||||
height: 30
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
function randomVisibleId(kind) {
|
|
||||||
var pool = []
|
|
||||||
var rows = root.view.visibleRows
|
|
||||||
for (var i = 0; i < rows.length; i++)
|
|
||||||
if (rows[i].kind === kind) pool.push(rows[i])
|
|
||||||
if (pool.length === 0) return null
|
|
||||||
return pool[Math.floor(Math.random() * pool.length)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracks — play all / shuffle all / select multiple.
|
|
||||||
QbzIconButton {
|
|
||||||
visible: root.view.activeTab === "tracks" && root.view.tabHasItems
|
|
||||||
btnSize: 30
|
|
||||||
name: "play-fill"
|
|
||||||
onClicked: QbzLibrary.libraryPlayAll(
|
|
||||||
JSON.stringify(root.view.visibleTrackIds()), false)
|
|
||||||
}
|
|
||||||
QbzIconButton {
|
|
||||||
visible: root.view.activeTab === "tracks" && root.view.tabHasItems
|
|
||||||
btnSize: 30
|
|
||||||
name: "shuffle"
|
|
||||||
onClicked: QbzLibrary.libraryPlayAll(
|
|
||||||
JSON.stringify(root.view.visibleTrackIds()), true)
|
|
||||||
}
|
|
||||||
ToolToggle {
|
|
||||||
visible: root.view.activeTab === "tracks" && root.view.tabHasItems
|
|
||||||
name: "square-check-big"
|
|
||||||
active: root.view.tracksMultiSelect
|
|
||||||
onClicked: root.view.setTracksMultiSelect(!root.view.tracksMultiSelect)
|
|
||||||
}
|
|
||||||
// Albums — play a RANDOM album (the reference's `albums_shuffle`,
|
|
||||||
// which picks one visible album, not a shuffled play of every album).
|
|
||||||
QbzIconButton {
|
|
||||||
visible: root.view.activeTab === "albums" && root.view.tabHasItems
|
|
||||||
btnSize: 30
|
|
||||||
name: "shuffle"
|
|
||||||
onClicked: {
|
|
||||||
var pick = actionRow.randomVisibleId("album")
|
|
||||||
if (pick) QbzPlayer.playAlbum(pick.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Albums multi-select — LIST mode ONLY (owner 2026-07-24,
|
|
||||||
// FavoritesView.slint:512-523): the grid card has no checkbox slot.
|
|
||||||
ToolToggle {
|
|
||||||
visible: root.view.activeTab === "albums" && root.view.tabHasItems
|
|
||||||
&& root.view.albumsView === "list"
|
|
||||||
name: "square-check-big"
|
|
||||||
active: root.view.albumsMultiSelect
|
|
||||||
onClicked: root.view.setAlbumsMultiSelect(!root.view.albumsMultiSelect)
|
|
||||||
}
|
|
||||||
// Artists — open a random artist.
|
|
||||||
QbzIconButton {
|
|
||||||
visible: root.view.activeTab === "artists" && root.view.tabHasItems
|
|
||||||
btnSize: 30
|
|
||||||
name: "shuffle"
|
|
||||||
onClicked: {
|
|
||||||
var pick = actionRow.randomVisibleId("artist")
|
|
||||||
if (pick) QbzArtist.openArtist(pick.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Playlists — play a random playlist.
|
|
||||||
QbzIconButton {
|
|
||||||
visible: root.view.activeTab === "playlists" && root.view.tabHasItems
|
|
||||||
btnSize: 30
|
|
||||||
name: "shuffle"
|
|
||||||
onClicked: {
|
|
||||||
var pick = actionRow.randomVisibleId("playlist")
|
|
||||||
if (pick) QbzBridge.openPlaylist(pick.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Labels — open a random label's landing.
|
|
||||||
QbzIconButton {
|
|
||||||
visible: root.view.activeTab === "labels" && root.view.tabHasItems
|
|
||||||
btnSize: 30
|
|
||||||
name: "shuffle"
|
|
||||||
onClicked: {
|
|
||||||
var pick = actionRow.randomVisibleId("label")
|
|
||||||
if (pick) QbzHome.openLabel(pick.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-tab controls.
|
// Per-tab controls.
|
||||||
Row {
|
Row {
|
||||||
x: parent.width - width - 32
|
id: topControls
|
||||||
|
x: Math.max(32 + tabRow.width + 16, topChrome.width - width - 32)
|
||||||
y: 25 - height / 2
|
y: 25 - height / 2
|
||||||
height: 30
|
height: 30
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
// ===== All toolbar =====
|
Item {
|
||||||
Row {
|
id: firstRowSearchSlot
|
||||||
visible: root.view.activeTab === "all"
|
visible: root.singleRow
|
||||||
spacing: 8
|
width: searchBox.width
|
||||||
height: parent.height
|
height: 30
|
||||||
// FavoritesView.slint:801-813 — collapsed magnifier that
|
|
||||||
// opens LEFT over the tab bar; the All tab is the only
|
|
||||||
// one that debounces after the keystroke.
|
|
||||||
QbzLineEdit {
|
|
||||||
searchMode: true
|
|
||||||
expandable: true
|
|
||||||
sm: true
|
|
||||||
elevated: false // ExpandableSearch fill = surface-card
|
|
||||||
openWidth: 196 // = max-open-width
|
|
||||||
placeholder: QbzSession.tr("Search your library", QbzSession.trRev)
|
|
||||||
onEdited: function (v) { root.view.setAllSearch(v) }
|
|
||||||
}
|
|
||||||
// Source switches (tooltips are the Slint copies).
|
|
||||||
ToolToggle { name: "shopping-bag"; active: root.view.showPurchases; onClicked: root.view.setShowPurchases(!root.view.showPurchases) }
|
|
||||||
ToolToggle { name: "heart"; active: root.view.showFavorites; onClicked: root.view.setShowFavorites(!root.view.showFavorites) }
|
|
||||||
ToolToggle { name: "user-plus"; active: root.view.showFollowing; onClicked: root.view.setShowFollowing(!root.view.showFollowing) }
|
|
||||||
// The ONE source switch the reference persists (favorites_prefs.rs
|
|
||||||
// `all_show_local`) — the other three are session-local there too.
|
|
||||||
ToolToggle {
|
|
||||||
name: "hard-drive"
|
|
||||||
active: root.view.showLocal
|
|
||||||
onClicked: root.view.setShowLocal(!root.view.showLocal)
|
|
||||||
}
|
|
||||||
// Filter by genre — shared popup, own "library-all"
|
|
||||||
// context (FavoritesView.slint:864).
|
|
||||||
GenreToolButton { }
|
|
||||||
// Grid / list toggle.
|
|
||||||
ToolToggle {
|
|
||||||
name: root.view.viewMode === "list" ? "layout-grid" : "list"
|
|
||||||
active: false
|
|
||||||
onClicked: root.view.viewMode = root.view.viewMode === "list" ? "grid" : "list"
|
|
||||||
}
|
|
||||||
// Sort menu (PlaylistView-style: field + direction caret).
|
|
||||||
Rectangle {
|
|
||||||
width: root.compactChrome ? 40 : allSortRow.implicitWidth
|
|
||||||
height: 30
|
|
||||||
radius: 6
|
|
||||||
color: allSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
|
||||||
Row {
|
|
||||||
id: allSortRow
|
|
||||||
anchors.centerIn: parent
|
|
||||||
height: parent.height
|
|
||||||
leftPadding: root.compactChrome ? 8 : 10
|
|
||||||
rightPadding: root.compactChrome ? 8 : 10
|
|
||||||
spacing: root.compactChrome ? 0 : 6
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: root.compactChrome ? "A-Z"
|
|
||||||
: QbzSession.tr("Sort", QbzSession.trRev) + ": " + (
|
|
||||||
root.view.sortBy === "title" ? QbzSession.tr("Title", QbzSession.trRev)
|
|
||||||
: root.view.sortBy === "artist" ? QbzSession.tr("Artist", QbzSession.trRev)
|
|
||||||
: QbzSession.tr("Date added", QbzSession.trRev))
|
|
||||||
color: theme.textSecondary
|
|
||||||
font.pixelSize: 12
|
|
||||||
}
|
|
||||||
QbzIcon {
|
|
||||||
visible: !root.compactChrome
|
|
||||||
name: root.view.sortAsc ? "chevron-up" : "chevron-down"
|
|
||||||
width: 12
|
|
||||||
height: 12
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
tintName: "secondary"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MouseArea {
|
|
||||||
id: allSortArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: allSortMenu.openBelowRight(allSortArea)
|
|
||||||
}
|
|
||||||
QbzContextMenu {
|
|
||||||
id: allSortMenu
|
|
||||||
menuWidth: 172
|
|
||||||
Repeater {
|
|
||||||
model: [
|
|
||||||
{ "field": "date", "label": QbzSession.tr("Date added", QbzSession.trRev) },
|
|
||||||
{ "field": "title", "label": QbzSession.tr("Title", QbzSession.trRev) },
|
|
||||||
{ "field": "artist", "label": QbzSession.tr("Artist", QbzSession.trRev) },
|
|
||||||
]
|
|
||||||
delegate: Rectangle {
|
|
||||||
required property var modelData
|
|
||||||
width: parent ? parent.width : 0
|
|
||||||
height: 33
|
|
||||||
radius: 5
|
|
||||||
color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
||||||
Row {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.leftMargin: 8
|
|
||||||
spacing: 6
|
|
||||||
Text {
|
|
||||||
width: parent.width - 26
|
|
||||||
height: parent.height
|
|
||||||
text: modelData.label
|
|
||||||
color: theme.textSecondary
|
|
||||||
font.pixelSize: 13
|
|
||||||
font.weight: root.view.sortBy === modelData.field ? theme.weightSemibold : theme.weightRegular
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
QbzIcon {
|
|
||||||
visible: root.view.sortBy === modelData.field
|
|
||||||
name: root.view.sortAsc ? "chevron-up" : "chevron-down"
|
|
||||||
width: 12
|
|
||||||
height: 12
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
tintName: "accent"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MouseArea {
|
|
||||||
id: sortOptArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: {
|
|
||||||
// Re-pick flips direction; a new field
|
|
||||||
// resets to its natural default.
|
|
||||||
if (root.view.sortBy === modelData.field) {
|
|
||||||
root.view.sortAsc = !root.view.sortAsc
|
|
||||||
} else {
|
|
||||||
root.view.sortBy = modelData.field
|
|
||||||
root.view.sortAsc = modelData.field !== "date"
|
|
||||||
}
|
|
||||||
allSortMenu.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Other-tab toolbars =====
|
|
||||||
// ONE search instance for the five tabs: the reference clears the
|
|
||||||
// query on every tab entry (see LibraryView.onActiveTabChanged), so
|
|
||||||
// per-tab instances would only implement a persistence that does not
|
|
||||||
// exist. A Row skips `visible: false` children, so the 30px slot
|
|
||||||
// appears/disappears cleanly and, because the ROOT width never leaves
|
|
||||||
// collapsedSize while the field opens, nothing to its right ever moves.
|
|
||||||
QbzLineEdit {
|
|
||||||
id: tabSearchBox
|
|
||||||
objectName: "libraryTabSearch"
|
|
||||||
visible: root.view.activeTab !== "all" && root.view.tabHasItems
|
|
||||||
searchMode: true
|
|
||||||
expandable: true
|
|
||||||
sm: true
|
|
||||||
elevated: false
|
|
||||||
openWidth: 196
|
|
||||||
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
|
||||||
onEdited: function (v) { root.view.tabSearch = v }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracks / Albums source switches. They deliberately sit between the
|
|
||||||
// fixed 30px search slot and Filter by genre: ExpandableSearch grows
|
|
||||||
// left, so opening it can never cover or push either toggle.
|
|
||||||
ToolToggle {
|
|
||||||
visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums")
|
|
||||||
&& root.view.tabHasItems
|
|
||||||
name: "shopping-bag"
|
|
||||||
active: root.view.showPurchases
|
|
||||||
onClicked: root.view.setShowPurchases(!root.view.showPurchases)
|
|
||||||
}
|
|
||||||
ToolToggle {
|
|
||||||
visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums")
|
|
||||||
&& root.view.tabHasItems
|
|
||||||
name: "heart"
|
|
||||||
active: root.view.showFavorites
|
|
||||||
onClicked: root.view.setShowFavorites(!root.view.showFavorites)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by genre — the SAME shared popup the All toolbar uses. The
|
|
||||||
// reference draws it on Tracks and Albums too (FavoritesView.slint:
|
|
||||||
// 609, :652); this port had it on All only, so the two biggest tabs
|
|
||||||
// could not be filtered at all.
|
|
||||||
GenreToolButton {
|
GenreToolButton {
|
||||||
visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums")
|
visible: ["all", "tracks", "albums"].indexOf(root.view.activeTab) >= 0
|
||||||
&& root.view.tabHasItems
|
&& root.view.tabHasItems
|
||||||
}
|
}
|
||||||
GroupSelect {
|
GroupSelect {
|
||||||
@@ -560,9 +325,7 @@ Item {
|
|||||||
]
|
]
|
||||||
onPicked: function (v) { root.view.setPref("albumsGroup", v) }
|
onPicked: function (v) { root.view.setPref("albumsGroup", v) }
|
||||||
}
|
}
|
||||||
// Artists group (A-Z) — GRID mode only, like the reference
|
// Grid grouping is independent of the chosen sort.
|
||||||
// (FavoritesView.slint:763: "Tauri hides it in sidepanel"; the
|
|
||||||
// sidepanel's left rail is ALWAYS A-Z grouped).
|
|
||||||
GroupSelect {
|
GroupSelect {
|
||||||
visible: root.view.activeTab === "artists" && root.view.tabHasItems
|
visible: root.view.activeTab === "artists" && root.view.tabHasItems
|
||||||
&& root.view.artistsView === "grid"
|
&& root.view.artistsView === "grid"
|
||||||
@@ -574,152 +337,324 @@ Item {
|
|||||||
onPicked: function (v) { root.view.setPref("artistsGroup", v) }
|
onPicked: function (v) { root.view.setPref("artistsGroup", v) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Albums sort popup (real, JS-side).
|
QbzSelect {
|
||||||
|
id: sortSelect
|
||||||
|
sm: true
|
||||||
|
menuWidth: 190
|
||||||
|
popupWidth: 200
|
||||||
|
options: root.sortOptions
|
||||||
|
searchable: options.length > 8
|
||||||
|
currentIndex: {
|
||||||
|
var field = ReleaseSort.fieldOf(root.view.activeSort)
|
||||||
|
for (var i = 0; i < options.length; ++i)
|
||||||
|
if (options[i].value === field) return i
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sortDirection: ReleaseSort.ascending(root.view.activeSort) ? "asc" : "desc"
|
||||||
|
onSelected: function(index) { root.view.pickSort(options[index].value) }
|
||||||
|
}
|
||||||
|
QbzSelect {
|
||||||
|
visible: root.view.activeTab === "playlists"
|
||||||
|
sm: true
|
||||||
|
menuWidth: 180
|
||||||
|
options: [QbzSession.tr("All", QbzSession.trRev),
|
||||||
|
QbzSession.tr("By you", QbzSession.trRev),
|
||||||
|
QbzSession.tr("By Qobuz", QbzSession.trRev),
|
||||||
|
QbzSession.tr("By others", QbzSession.trRev)]
|
||||||
|
currentIndex: Math.max(0, ["all", "you", "qobuz", "others"].indexOf(root.view.playlistsSubTab))
|
||||||
|
onSelected: function (index) {
|
||||||
|
root.view.playlistsSubTab = ["all", "you", "qobuz", "others"][index]
|
||||||
|
}
|
||||||
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
visible: root.view.activeTab === "albums" && root.view.tabHasItems
|
visible: rightButtons.width > 0
|
||||||
width: albumsSortRow.width
|
width: 1
|
||||||
|
height: 20
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: theme.borderSubtle
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
id: rightButtons
|
||||||
height: 30
|
height: 30
|
||||||
radius: 6
|
spacing: 8
|
||||||
color: albumsSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
|
||||||
Row {
|
Row {
|
||||||
id: albumsSortRow
|
id: actionRow
|
||||||
height: parent.height
|
height: 30
|
||||||
leftPadding: 10
|
spacing: 8
|
||||||
rightPadding: 10
|
|
||||||
spacing: 6
|
function randomVisibleId(kind) {
|
||||||
Text {
|
var pool = []
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
var rows = root.view.visibleRows
|
||||||
text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + (
|
for (var i = 0; i < rows.length; i++)
|
||||||
root.view.albumsSort === "title-asc" ? QbzSession.tr("Title A-Z", QbzSession.trRev)
|
if (rows[i].kind === kind) pool.push(rows[i])
|
||||||
: root.view.albumsSort === "title-desc" ? QbzSession.tr("Title Z-A", QbzSession.trRev)
|
if (pool.length === 0) return null
|
||||||
: root.view.albumsSort === "artist-asc" ? QbzSession.tr("Artist A-Z", QbzSession.trRev)
|
return pool[Math.floor(Math.random() * pool.length)]
|
||||||
: root.view.albumsSort === "newest" ? QbzSession.tr("Newest", QbzSession.trRev)
|
|
||||||
: root.view.albumsSort === "oldest" ? QbzSession.tr("Oldest", QbzSession.trRev)
|
|
||||||
: QbzSession.tr("Default", QbzSession.trRev))
|
|
||||||
color: theme.textSecondary
|
|
||||||
font.pixelSize: 12
|
|
||||||
}
|
}
|
||||||
QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
|
|
||||||
}
|
// Tracks — play all / shuffle all / select multiple.
|
||||||
MouseArea {
|
QbzIconButton {
|
||||||
id: albumsSortArea
|
visible: root.view.activeTab === "tracks" && root.view.tabHasItems
|
||||||
anchors.fill: parent
|
btnSize: 30
|
||||||
hoverEnabled: true
|
name: "play-fill"
|
||||||
cursorShape: Qt.PointingHandCursor
|
onClicked: QbzLibrary.libraryPlayAll(
|
||||||
onClicked: albumsSortMenu.openBelowRight(albumsSortArea)
|
JSON.stringify(root.view.visibleTrackIds()), false)
|
||||||
}
|
}
|
||||||
QbzContextMenu {
|
QbzIconButton {
|
||||||
id: albumsSortMenu
|
visible: root.view.activeTab === "tracks" && root.view.tabHasItems
|
||||||
menuWidth: 172
|
btnSize: 30
|
||||||
Repeater {
|
name: "shuffle"
|
||||||
model: [
|
onClicked: QbzLibrary.libraryPlayAll(
|
||||||
{ "value": "default", "label": QbzSession.tr("Default", QbzSession.trRev) },
|
JSON.stringify(root.view.visibleTrackIds()), true)
|
||||||
{ "value": "newest", "label": QbzSession.tr("Newest", QbzSession.trRev) },
|
}
|
||||||
{ "value": "oldest", "label": QbzSession.tr("Oldest", QbzSession.trRev) },
|
ToolToggle {
|
||||||
{ "value": "title-asc", "label": QbzSession.tr("Title A-Z", QbzSession.trRev) },
|
visible: root.view.activeTab === "tracks" && root.view.tabHasItems
|
||||||
{ "value": "title-desc", "label": QbzSession.tr("Title Z-A", QbzSession.trRev) },
|
name: "square-check-big"
|
||||||
{ "value": "artist-asc", "label": QbzSession.tr("Artist A-Z", QbzSession.trRev) },
|
active: root.view.tracksMultiSelect
|
||||||
]
|
onClicked: root.view.setTracksMultiSelect(!root.view.tracksMultiSelect)
|
||||||
delegate: Rectangle {
|
}
|
||||||
required property var modelData
|
// Albums — play a RANDOM album (the reference's `albums_shuffle`,
|
||||||
width: parent ? parent.width : 0
|
// which picks one visible album, not a shuffled play of every album).
|
||||||
height: 33
|
QbzIconButton {
|
||||||
radius: 5
|
visible: root.view.activeTab === "albums" && root.view.tabHasItems
|
||||||
color: abSortOptArea.containsMouse ? theme.surfaceHover : "transparent"
|
btnSize: 30
|
||||||
Text {
|
name: "shuffle"
|
||||||
anchors.fill: parent
|
onClicked: {
|
||||||
anchors.leftMargin: 8
|
var pick = actionRow.randomVisibleId("album")
|
||||||
text: modelData.label
|
if (pick) QbzPlayer.playAlbum(pick.id)
|
||||||
color: theme.textSecondary
|
}
|
||||||
font.pixelSize: 13
|
}
|
||||||
font.weight: root.view.albumsSort === modelData.value ? theme.weightSemibold : theme.weightRegular
|
// Albums multi-select — LIST mode ONLY (owner 2026-07-24,
|
||||||
verticalAlignment: Text.AlignVCenter
|
// FavoritesView.slint:512-523): the grid card has no checkbox slot.
|
||||||
}
|
ToolToggle {
|
||||||
MouseArea {
|
visible: root.view.activeTab === "albums" && root.view.tabHasItems
|
||||||
id: abSortOptArea
|
&& root.view.albumsView === "list"
|
||||||
anchors.fill: parent
|
name: "square-check-big"
|
||||||
hoverEnabled: true
|
active: root.view.albumsMultiSelect
|
||||||
cursorShape: Qt.PointingHandCursor
|
onClicked: root.view.setAlbumsMultiSelect(!root.view.albumsMultiSelect)
|
||||||
onClicked: {
|
}
|
||||||
root.view.setPref("albumsSort", modelData.value)
|
// Artists — open a random artist.
|
||||||
albumsSortMenu.close()
|
QbzIconButton {
|
||||||
}
|
visible: root.view.activeTab === "artists" && root.view.tabHasItems
|
||||||
}
|
btnSize: 30
|
||||||
|
name: "shuffle"
|
||||||
|
onClicked: {
|
||||||
|
var pick = actionRow.randomVisibleId("artist")
|
||||||
|
if (pick) QbzArtist.openArtist(pick.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Playlists — play a random playlist.
|
||||||
|
QbzIconButton {
|
||||||
|
visible: root.view.activeTab === "playlists" && root.view.tabHasItems
|
||||||
|
btnSize: 30
|
||||||
|
name: "shuffle"
|
||||||
|
onClicked: {
|
||||||
|
var pick = actionRow.randomVisibleId("playlist")
|
||||||
|
if (pick) QbzBridge.openPlaylist(pick.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Labels — open a random label's landing.
|
||||||
|
QbzIconButton {
|
||||||
|
visible: root.view.activeTab === "labels" && root.view.tabHasItems
|
||||||
|
btnSize: 30
|
||||||
|
name: "shuffle"
|
||||||
|
onClicked: {
|
||||||
|
var pick = actionRow.randomVisibleId("label")
|
||||||
|
if (pick) QbzHome.openLabel(pick.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolToggle {
|
||||||
|
visible: root.view.activeTab === "all"
|
||||||
|
name: root.view.viewMode === "list" ? "layout-grid" : "list"
|
||||||
|
active: false
|
||||||
|
onClicked: root.view.viewMode = root.view.viewMode === "list" ? "grid" : "list"
|
||||||
|
}
|
||||||
|
// Albums grid / list toggle (FavoritesView.slint:697-705 ViewToggle).
|
||||||
|
ToolToggle {
|
||||||
|
visible: root.view.activeTab === "albums" && root.view.tabHasItems
|
||||||
|
name: root.view.albumsView === "list" ? "layout-grid" : "list"
|
||||||
|
active: false
|
||||||
|
onClicked: root.view.setPref("albumsView",
|
||||||
|
root.view.albumsView === "list" ? "grid" : "list")
|
||||||
|
}
|
||||||
|
// Playlists grid / list toggle (FavoritesView.slint:737-745).
|
||||||
|
ToolToggle {
|
||||||
|
visible: root.view.activeTab === "playlists" && root.view.tabHasItems
|
||||||
|
name: root.view.playlistsView === "list" ? "layout-grid" : "list"
|
||||||
|
active: false
|
||||||
|
onClicked: root.view.setPref("playlistsView",
|
||||||
|
root.view.playlistsView === "list" ? "grid" : "list")
|
||||||
|
}
|
||||||
|
// Artists grid / sidepanel toggle (FavoritesView.slint:780-793 —
|
||||||
|
// `active: false` there too, "keeps the two icon states visually
|
||||||
|
// identical").
|
||||||
|
ToolToggle {
|
||||||
|
visible: root.view.activeTab === "artists" && root.view.tabHasItems
|
||||||
|
name: root.view.artistsView === "sidepanel" ? "layout-grid" : "list"
|
||||||
|
active: false
|
||||||
|
onClicked: root.view.setPref("artistsView",
|
||||||
|
root.view.artistsView === "sidepanel" ? "grid" : "sidepanel")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Albums grid / list toggle (FavoritesView.slint:697-705 ViewToggle).
|
}
|
||||||
ToolToggle {
|
|
||||||
visible: root.view.activeTab === "albums" && root.view.tabHasItems
|
} // topChrome
|
||||||
name: root.view.albumsView === "list" ? "layout-grid" : "list"
|
|
||||||
active: false
|
// Search remains visible, including when a restriction produces no rows.
|
||||||
onClicked: root.view.setPref("albumsView",
|
QbzLineEdit {
|
||||||
root.view.albumsView === "list" ? "grid" : "list")
|
id: searchBox
|
||||||
|
objectName: "librarySearch"
|
||||||
|
parent: root.singleRow ? firstRowSearchSlot : root
|
||||||
|
x: root.singleRow ? 0 : 34
|
||||||
|
y: root.singleRow ? 0 : root.secondRowY
|
||||||
|
width: root.fullSearchWidth * (root.singleRow ? 0.6 : 1)
|
||||||
|
searchMode: true
|
||||||
|
expandable: false
|
||||||
|
sm: true
|
||||||
|
text: root.view.activeTab === "all" ? root.view.search : root.view.tabSearch
|
||||||
|
placeholder: root.view.activeTab === "tracks" ? QbzSession.tr("Search your tracks", QbzSession.trRev)
|
||||||
|
: root.view.activeTab === "albums" ? QbzSession.tr("Search your releases", QbzSession.trRev)
|
||||||
|
: root.view.activeTab === "playlists" ? QbzSession.tr("Search your playlists", QbzSession.trRev)
|
||||||
|
: root.view.activeTab === "artists" ? QbzSession.tr("Search your artists", QbzSession.trRev)
|
||||||
|
: root.view.activeTab === "labels" ? QbzSession.tr("Search your labels", QbzSession.trRev)
|
||||||
|
: QbzSession.tr("Search your library", QbzSession.trRev)
|
||||||
|
onEdited: function (value) {
|
||||||
|
if (root.view.activeTab === "all") root.view.setAllSearch(value)
|
||||||
|
else root.view.tabSearch = value
|
||||||
}
|
}
|
||||||
// Playlists grid / list toggle (FavoritesView.slint:737-745).
|
}
|
||||||
ToolToggle {
|
|
||||||
visible: root.view.activeTab === "playlists" && root.view.tabHasItems
|
// Compact Library-only switch: glyph travels inside the thumb.
|
||||||
name: root.view.playlistsView === "list" ? "layout-grid" : "list"
|
component SourceSwitch: Rectangle {
|
||||||
active: false
|
id: sourceSwitch
|
||||||
onClicked: root.view.setPref("playlistsView",
|
property string label: ""
|
||||||
root.view.playlistsView === "list" ? "grid" : "list")
|
property string explanation: ""
|
||||||
|
property string glyph: ""
|
||||||
|
property bool checked: false
|
||||||
|
signal toggled(bool value)
|
||||||
|
width: 46
|
||||||
|
height: 26
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
radius: 13
|
||||||
|
color: checked ? theme.accent : theme.surfaceHover
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 0
|
||||||
|
border.color: theme.accent
|
||||||
|
Accessible.role: Accessible.CheckBox
|
||||||
|
Accessible.name: label
|
||||||
|
Accessible.description: explanation
|
||||||
|
Accessible.checked: checked
|
||||||
|
Accessible.onToggleAction: sourceSwitch.toggled(!checked)
|
||||||
|
Keys.onPressed: function (event) {
|
||||||
|
if (event.key === Qt.Key_Space || event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||||
|
if (!event.isAutoRepeat) sourceSwitch.toggled(!checked)
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Artists grid / sidepanel toggle (FavoritesView.slint:780-793 —
|
Rectangle {
|
||||||
// `active: false` there too, "keeps the two icon states visually
|
width: 20
|
||||||
// identical").
|
height: 20
|
||||||
ToolToggle {
|
radius: 10
|
||||||
visible: root.view.activeTab === "artists" && root.view.tabHasItems
|
x: sourceSwitch.checked ? sourceSwitch.width - width - 3 : 3
|
||||||
name: root.view.artistsView === "sidepanel" ? "layout-grid" : "list"
|
y: 3
|
||||||
active: false
|
color: theme.textPrimary
|
||||||
onClicked: root.view.setPref("artistsView",
|
QbzIcon {
|
||||||
root.view.artistsView === "sidepanel" ? "grid" : "sidepanel")
|
anchors.centerIn: parent
|
||||||
|
width: 15
|
||||||
|
height: 15
|
||||||
|
visible: sourceSwitch.glyph !== ""
|
||||||
|
name: sourceSwitch.glyph
|
||||||
|
tintName: theme.isDark ? "black" : "white"
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: sourceSwitch.glyph === ""
|
||||||
|
text: "Hi-Res"
|
||||||
|
font.pixelSize: 7
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
color: theme.surfaceMain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPressed: sourceSwitch.forceActiveFocus()
|
||||||
|
onClicked: sourceSwitch.toggled(!sourceSwitch.checked)
|
||||||
}
|
}
|
||||||
// Playlists sub-tab (Library / Following).
|
HoverHandler { onHoveredChanged: hovered ? switchTip.enter() : switchTip.exit() }
|
||||||
Rectangle {
|
QbzFilterTip {
|
||||||
visible: root.view.activeTab === "playlists" && root.view.tabHasItems
|
id: switchTip
|
||||||
width: plSubRow.width
|
ownerKey: "library-switch-" + sourceSwitch.label
|
||||||
|
anchor: sourceSwitch
|
||||||
|
groups: [{group: sourceSwitch.label, values: [sourceSwitch.explanation]}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: filterChrome
|
||||||
|
x: 34 + searchBox.width + 20
|
||||||
|
width: Math.max(0, root.width - x - 32)
|
||||||
|
y: root.secondRowY
|
||||||
|
visible: !root.singleRow
|
||||||
|
height: 30
|
||||||
|
clip: true
|
||||||
|
contentWidth: Math.max(width, switches.width)
|
||||||
|
contentHeight: height
|
||||||
|
flickableDirection: Flickable.HorizontalFlick
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
Row {
|
||||||
|
id: switches
|
||||||
|
x: Math.max(0, filterChrome.width - width)
|
||||||
height: 30
|
height: 30
|
||||||
radius: 6
|
spacing: 12
|
||||||
color: theme.surfaceElevated
|
readonly property bool sourceTab: root.view.activeTab === "all"
|
||||||
Row {
|
|| root.view.activeTab === "tracks" || root.view.activeTab === "albums"
|
||||||
id: plSubRow
|
SourceSwitch {
|
||||||
padding: 3
|
visible: switches.sourceTab
|
||||||
spacing: 4
|
glyph: "shopping-bag"
|
||||||
Repeater {
|
label: QbzSession.tr("Purchases only", QbzSession.trRev)
|
||||||
model: [
|
explanation: QbzSession.tr("Show only purchased items. Combines with the other enabled filters.", QbzSession.trRev)
|
||||||
{ "id": "favorites", "label": QbzSession.tr("Library", QbzSession.trRev) },
|
checked: root.view.showPurchases
|
||||||
{ "id": "following", "label": QbzSession.tr("Following", QbzSession.trRev) },
|
onToggled: function (value) { root.view.setShowPurchases(value) }
|
||||||
]
|
}
|
||||||
delegate: Rectangle {
|
SourceSwitch {
|
||||||
required property var modelData
|
visible: switches.sourceTab
|
||||||
property bool active: root.view.playlistsSubTab === modelData.id
|
glyph: "heart"
|
||||||
width: plSubText.implicitWidth + 20
|
label: QbzSession.tr("Favorites only", QbzSession.trRev)
|
||||||
height: 24
|
explanation: QbzSession.tr("Show only favorites. Combines with the other enabled filters.", QbzSession.trRev)
|
||||||
radius: 4
|
checked: root.view.showFavorites
|
||||||
color: active ? theme.surfaceMain : "transparent"
|
onToggled: function (value) { root.view.setShowFavorites(value) }
|
||||||
Text {
|
}
|
||||||
id: plSubText
|
SourceSwitch {
|
||||||
anchors.centerIn: parent
|
visible: root.view.activeTab === "all"
|
||||||
text: modelData.label
|
glyph: "user-plus"
|
||||||
color: parent.active ? theme.textPrimary : theme.textMuted
|
label: QbzSession.tr("Following only", QbzSession.trRev)
|
||||||
font.pixelSize: 12
|
explanation: QbzSession.tr("Show only followed items. Combines with the other enabled filters.", QbzSession.trRev)
|
||||||
font.weight: theme.weightMedium
|
checked: root.view.showFollowing
|
||||||
}
|
onToggled: function (value) { root.view.setShowFollowing(value) }
|
||||||
MouseArea {
|
}
|
||||||
anchors.fill: parent
|
SourceSwitch {
|
||||||
cursorShape: Qt.PointingHandCursor
|
visible: root.view.activeTab === "tracks" || root.view.activeTab === "albums"
|
||||||
onClicked: root.view.playlistsSubTab = modelData.id
|
label: QbzSession.tr("Hi-Res only", QbzSession.trRev)
|
||||||
}
|
explanation: QbzSession.tr("Show only Hi-Res items. Combines with the other enabled filters.", QbzSession.trRev)
|
||||||
}
|
checked: root.view.hiresOnly
|
||||||
}
|
onToggled: function (value) { root.view.setHiresOnly(value) }
|
||||||
|
}
|
||||||
|
SourceSwitch {
|
||||||
|
visible: root.view.activeTab === "all"
|
||||||
|
glyph: "hard-drive"
|
||||||
|
label: QbzSession.tr("Hide local albums from library", QbzSession.trRev)
|
||||||
|
explanation: QbzSession.tr("Hide local albums here. They remain available in Local Library.", QbzSession.trRev)
|
||||||
|
checked: !root.view.showLocal
|
||||||
|
onToggled: function (value) { root.view.setShowLocal(!value) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collapse the shared per-tab search field (LibraryView calls this on
|
|
||||||
/// every tab change — the reference destroys and re-creates it collapsed).
|
|
||||||
function closeTabSearch() {
|
function closeTabSearch() {
|
||||||
if (tabSearchBox) tabSearchBox.closeSearch()
|
searchBox.text = Qt.binding(function () {
|
||||||
|
return root.view.activeTab === "all" ? root.view.search : root.view.tabSearch
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,6 +45,9 @@ pub mod qbz_library_bridge {
|
|||||||
// Process/session-only source filters, stored separately per tab. They do not
|
// Process/session-only source filters, stored separately per tab. They do not
|
||||||
// enter favorites_ui.json, but the singleton outlives LibraryView so
|
// enter favorites_ui.json, but the singleton outlives LibraryView so
|
||||||
// navigation cannot reset them with the component.
|
// navigation cannot reset them with the component.
|
||||||
|
#[qproperty(bool, session_albums_hires_only)]
|
||||||
|
#[qproperty(bool, session_tracks_hires_only)]
|
||||||
|
#[qproperty(bool, session_hide_local_albums)]
|
||||||
#[qproperty(bool, session_show_purchases)]
|
#[qproperty(bool, session_show_purchases)]
|
||||||
#[qproperty(bool, session_show_favorites)]
|
#[qproperty(bool, session_show_favorites)]
|
||||||
#[qproperty(bool, session_show_following)]
|
#[qproperty(bool, session_show_following)]
|
||||||
@@ -174,6 +177,9 @@ pub struct QbzLibraryRust {
|
|||||||
library_json: QString,
|
library_json: QString,
|
||||||
library_counts_json: QString,
|
library_counts_json: QString,
|
||||||
library_prefs_json: QString,
|
library_prefs_json: QString,
|
||||||
|
session_albums_hires_only: bool,
|
||||||
|
session_tracks_hires_only: bool,
|
||||||
|
session_hide_local_albums: bool,
|
||||||
session_show_purchases: bool,
|
session_show_purchases: bool,
|
||||||
session_show_favorites: bool,
|
session_show_favorites: bool,
|
||||||
session_show_following: bool,
|
session_show_following: bool,
|
||||||
@@ -194,13 +200,16 @@ impl Default for QbzLibraryRust {
|
|||||||
// Parseable defaults so QML's JSON.parse never throws on frame 1
|
// Parseable defaults so QML's JSON.parse never throws on frame 1
|
||||||
// (the real values arrive in `boot`).
|
// (the real values arrive in `boot`).
|
||||||
library_prefs_json: QString::from("{}"),
|
library_prefs_json: QString::from("{}"),
|
||||||
session_show_purchases: true,
|
session_albums_hires_only: false,
|
||||||
session_show_favorites: true,
|
session_tracks_hires_only: false,
|
||||||
session_show_following: true,
|
session_hide_local_albums: false,
|
||||||
session_albums_show_purchases: true,
|
session_show_purchases: false,
|
||||||
session_albums_show_favorites: true,
|
session_show_favorites: false,
|
||||||
session_tracks_show_purchases: true,
|
session_show_following: false,
|
||||||
session_tracks_show_favorites: true,
|
session_albums_show_purchases: false,
|
||||||
|
session_albums_show_favorites: false,
|
||||||
|
session_tracks_show_purchases: false,
|
||||||
|
session_tracks_show_favorites: false,
|
||||||
sidepanel_json: QString::from("{}"),
|
sidepanel_json: QString::from("{}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,3 +333,23 @@ impl qbz_library_bridge::QbzLibrary {
|
|||||||
crate::sidebar_qt::is_pinned(&kind.to_string(), &id.to_string())
|
crate::sidebar_qt::is_pinned(&kind.to_string(), &id.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod filter_defaults_tests {
|
||||||
|
use super::QbzLibraryRust;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_only_filter_starts_unrestricted() {
|
||||||
|
let state = QbzLibraryRust::default();
|
||||||
|
assert!(!state.session_show_purchases);
|
||||||
|
assert!(!state.session_show_favorites);
|
||||||
|
assert!(!state.session_show_following);
|
||||||
|
assert!(!state.session_albums_show_purchases);
|
||||||
|
assert!(!state.session_albums_show_favorites);
|
||||||
|
assert!(!state.session_tracks_show_purchases);
|
||||||
|
assert!(!state.session_tracks_show_favorites);
|
||||||
|
assert!(!state.session_albums_hires_only);
|
||||||
|
assert!(!state.session_tracks_hires_only);
|
||||||
|
assert!(!state.session_hide_local_albums);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ pub struct Prefs {
|
|||||||
pub albums_view: String,
|
pub albums_view: String,
|
||||||
#[serde(default = "d_default")]
|
#[serde(default = "d_default")]
|
||||||
pub albums_sort: String,
|
pub albums_sort: String,
|
||||||
|
#[serde(default = "d_default")]
|
||||||
|
pub labels_sort: String,
|
||||||
|
#[serde(default = "d_default")]
|
||||||
|
pub artists_sort: String,
|
||||||
|
#[serde(default = "d_default")]
|
||||||
|
pub playlists_sort: String,
|
||||||
|
#[serde(default = "d_default")]
|
||||||
|
pub tracks_sort: String,
|
||||||
#[serde(default = "d_off")]
|
#[serde(default = "d_off")]
|
||||||
pub albums_group: String,
|
pub albums_group: String,
|
||||||
#[serde(default = "d_off")]
|
#[serde(default = "d_off")]
|
||||||
@@ -76,6 +84,10 @@ impl Default for Prefs {
|
|||||||
Self {
|
Self {
|
||||||
albums_view: d_grid(),
|
albums_view: d_grid(),
|
||||||
albums_sort: d_default(),
|
albums_sort: d_default(),
|
||||||
|
labels_sort: d_default(),
|
||||||
|
artists_sort: d_default(),
|
||||||
|
playlists_sort: d_default(),
|
||||||
|
tracks_sort: d_default(),
|
||||||
albums_group: d_off(),
|
albums_group: d_off(),
|
||||||
tracks_group: d_off(),
|
tracks_group: d_off(),
|
||||||
playlists_view: d_grid(),
|
playlists_view: d_grid(),
|
||||||
@@ -112,6 +124,10 @@ pub fn to_json() -> String {
|
|||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"albumsView": p.albums_view,
|
"albumsView": p.albums_view,
|
||||||
"albumsSort": p.albums_sort,
|
"albumsSort": p.albums_sort,
|
||||||
|
"labelsSort": p.labels_sort,
|
||||||
|
"artistsSort": p.artists_sort,
|
||||||
|
"playlistsSort": p.playlists_sort,
|
||||||
|
"tracksSort": p.tracks_sort,
|
||||||
"albumsGroup": p.albums_group,
|
"albumsGroup": p.albums_group,
|
||||||
"tracksGroup": p.tracks_group,
|
"tracksGroup": p.tracks_group,
|
||||||
"playlistsView": p.playlists_view,
|
"playlistsView": p.playlists_view,
|
||||||
@@ -179,6 +195,10 @@ pub fn set(key: &str, value: &str) {
|
|||||||
match key {
|
match key {
|
||||||
"albumsView" => update(|p| p.albums_view = v),
|
"albumsView" => update(|p| p.albums_view = v),
|
||||||
"albumsSort" => update(|p| p.albums_sort = v),
|
"albumsSort" => update(|p| p.albums_sort = v),
|
||||||
|
"labelsSort" => update(|p| p.labels_sort = v),
|
||||||
|
"artistsSort" => update(|p| p.artists_sort = v),
|
||||||
|
"playlistsSort" => update(|p| p.playlists_sort = v),
|
||||||
|
"tracksSort" => update(|p| p.tracks_sort = v),
|
||||||
"albumsGroup" => update(|p| p.albums_group = v),
|
"albumsGroup" => update(|p| p.albums_group = v),
|
||||||
"tracksGroup" => update(|p| p.tracks_group = v),
|
"tracksGroup" => update(|p| p.tracks_group = v),
|
||||||
"playlistsView" => update(|p| p.playlists_view = v),
|
"playlistsView" => update(|p| p.playlists_view = v),
|
||||||
|
|||||||
@@ -149,6 +149,12 @@ pub struct FeedItem {
|
|||||||
#[serde(default, rename = "cacheStatus", skip_serializing_if = "is_zero_i32")]
|
#[serde(default, rename = "cacheStatus", skip_serializing_if = "is_zero_i32")]
|
||||||
pub cache_status: i32,
|
pub cache_status: i32,
|
||||||
pub genre: String,
|
pub genre: String,
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub label: String,
|
||||||
|
#[serde(default, rename = "updatedAt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
#[serde(default, rename = "trackCount", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub track_count: Option<u32>,
|
||||||
pub year: String,
|
pub year: String,
|
||||||
#[serde(
|
#[serde(
|
||||||
default,
|
default,
|
||||||
@@ -167,6 +173,12 @@ pub struct FeedItem {
|
|||||||
pub explicit: bool,
|
pub explicit: bool,
|
||||||
#[serde(rename = "playlistOwned")]
|
#[serde(rename = "playlistOwned")]
|
||||||
pub playlist_owned: bool,
|
pub playlist_owned: bool,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
rename = "playlistCreator",
|
||||||
|
skip_serializing_if = "String::is_empty"
|
||||||
|
)]
|
||||||
|
pub playlist_creator: String,
|
||||||
#[serde(rename = "playlistFollowing")]
|
#[serde(rename = "playlistFollowing")]
|
||||||
pub playlist_following: bool,
|
pub playlist_following: bool,
|
||||||
#[serde(rename = "isPinned", default)]
|
#[serde(rename = "isPinned", default)]
|
||||||
@@ -551,6 +563,15 @@ fn map_track(track: Track) -> FeedItem {
|
|||||||
.performer
|
.performer
|
||||||
.map(|p| (p.name, p.id.to_string()))
|
.map(|p| (p.name, p.id.to_string()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let label = track
|
||||||
|
.album
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|a| a.label.as_ref())
|
||||||
|
.map(|label| label.name.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let release_sort_key = qbz_text_utils::dates::release_sort_key(
|
||||||
|
track.album.as_ref().and_then(|a| a.release_date_original.as_deref()),
|
||||||
|
);
|
||||||
FeedItem {
|
FeedItem {
|
||||||
kind: "track".into(),
|
kind: "track".into(),
|
||||||
group: "favorites".into(),
|
group: "favorites".into(),
|
||||||
@@ -565,6 +586,8 @@ fn map_track(track: Track) -> FeedItem {
|
|||||||
album_artist,
|
album_artist,
|
||||||
album_artist_id,
|
album_artist_id,
|
||||||
genre,
|
genre,
|
||||||
|
label,
|
||||||
|
release_sort_key,
|
||||||
duration: mmss(duration_secs),
|
duration: mmss(duration_secs),
|
||||||
isrc,
|
isrc,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
@@ -625,6 +648,9 @@ fn map_album(album: Album, ready_offline_tracks: usize) -> FeedItem {
|
|||||||
subtitle: artist.clone(),
|
subtitle: artist.clone(),
|
||||||
artist,
|
artist,
|
||||||
artist_id: album.artist.id.to_string(),
|
artist_id: album.artist.id.to_string(),
|
||||||
|
label: album.label.map(|label| label.name).unwrap_or_default(),
|
||||||
|
duration_secs: album.duration.unwrap_or(0),
|
||||||
|
track_count: Some(track_count as u32),
|
||||||
genre: album.genre.map(|g| g.name).unwrap_or_default(),
|
genre: album.genre.map(|g| g.name).unwrap_or_default(),
|
||||||
year: qbz_text_utils::dates::release_label(date.as_deref()),
|
year: qbz_text_utils::dates::release_label(date.as_deref()),
|
||||||
release_sort_key: qbz_text_utils::dates::release_sort_key(date.as_deref()),
|
release_sort_key: qbz_text_utils::dates::release_sort_key(date.as_deref()),
|
||||||
@@ -739,6 +765,22 @@ pub(crate) fn playlist_subtitle(owner: &str, tracks_count: u32) -> String {
|
|||||||
subtitle
|
subtitle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn playlist_creator(owned: bool, owner_name: &str) -> &'static str {
|
||||||
|
if owned {
|
||||||
|
"you"
|
||||||
|
} else if owner_name
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case("qobuz"))
|
||||||
|
{
|
||||||
|
"qobuz"
|
||||||
|
} else if owner_name.trim().is_empty() {
|
||||||
|
"unknown"
|
||||||
|
} else {
|
||||||
|
"others"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
|
fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
|
||||||
// The card's single image is the playlist's own Qobuz artwork ONLY.
|
// The card's single image is the playlist's own Qobuz artwork ONLY.
|
||||||
// Falling back to `images300[0]` here is what put a member ALBUM cover
|
// Falling back to `images300[0]` here is what put a member ALBUM cover
|
||||||
@@ -762,6 +804,9 @@ fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
|
|||||||
id: playlist.id.to_string(),
|
id: playlist.id.to_string(),
|
||||||
title: playlist.name.clone(),
|
title: playlist.name.clone(),
|
||||||
subtitle,
|
subtitle,
|
||||||
|
track_count: Some(playlist.tracks_count),
|
||||||
|
duration_secs: playlist.duration,
|
||||||
|
updated_at: playlist.updated_at,
|
||||||
playlist_own_image: !cover_url.is_empty(),
|
playlist_own_image: !cover_url.is_empty(),
|
||||||
image_url: cover_url,
|
image_url: cover_url,
|
||||||
covers,
|
covers,
|
||||||
@@ -775,6 +820,7 @@ fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
|
|||||||
// still available to callers as `group` / `playlist_following`.
|
// still available to callers as `group` / `playlist_following`.
|
||||||
is_favorite: crate::fav_cache_qt::is_favorite("playlist", &playlist.id.to_string()),
|
is_favorite: crate::fav_cache_qt::is_favorite("playlist", &playlist.id.to_string()),
|
||||||
playlist_owned: owned,
|
playlist_owned: owned,
|
||||||
|
playlist_creator: playlist_creator(owned, &playlist.owner.name).into(),
|
||||||
playlist_following: is_following,
|
playlist_following: is_following,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -1148,11 +1194,11 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
|
|||||||
// A failed availability query must not turn every favorite into a
|
// A failed availability query must not turn every favorite into a
|
||||||
// tombstone. `None` means "unknown" and leaves rows interactive;
|
// tombstone. `None` means "unknown" and leaves rows interactive;
|
||||||
// the next successful Library load will settle them.
|
// the next successful Library load will settle them.
|
||||||
let existing_albums: Option<HashSet<String>> = if album_candidates.is_empty() {
|
let existing_albums: Option<HashMap<String, Vec<String>>> = if album_candidates.is_empty() {
|
||||||
Some(HashSet::new())
|
Some(HashMap::new())
|
||||||
} else {
|
} else {
|
||||||
match tokio::task::spawn_blocking(move || {
|
match tokio::task::spawn_blocking(move || {
|
||||||
crate::local_albums::existing_favorite_album_ids_blocking(album_candidates)
|
crate::local_albums::existing_favorite_album_sources_blocking(album_candidates)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1172,12 +1218,18 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
|
|||||||
let source_unavailable = lf.kind == "album"
|
let source_unavailable = lf.kind == "album"
|
||||||
&& existing_albums
|
&& existing_albums
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|ids| !ids.contains(&lf.id));
|
.is_some_and(|ids| !ids.contains_key(&lf.id));
|
||||||
|
let sources = existing_albums
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|albums| albums.get(&lf.id))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
feed.push(
|
feed.push(
|
||||||
FeedItem {
|
FeedItem {
|
||||||
kind: lf.kind,
|
kind: lf.kind,
|
||||||
group: "local".into(),
|
group: "local".into(),
|
||||||
source: lf.source,
|
source: lf.source,
|
||||||
|
sources,
|
||||||
subtitle: lf.subtitle,
|
subtitle: lf.subtitle,
|
||||||
artist: lf.artist.clone(),
|
artist: lf.artist.clone(),
|
||||||
image_url: lf.artwork_url,
|
image_url: lf.artwork_url,
|
||||||
@@ -1509,6 +1561,9 @@ fn map_purchased_album(a: qbz_models::PurchaseAlbum) -> FeedItem {
|
|||||||
release_sort_key: qbz_text_utils::dates::release_sort_key(
|
release_sort_key: qbz_text_utils::dates::release_sort_key(
|
||||||
a.release_date_original.as_deref(),
|
a.release_date_original.as_deref(),
|
||||||
),
|
),
|
||||||
|
label: a.label.as_ref().map(|label| label.name.clone()).unwrap_or_default(),
|
||||||
|
duration_secs: a.duration.unwrap_or(0),
|
||||||
|
track_count: a.tracks_count,
|
||||||
genre: a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),
|
genre: a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),
|
||||||
id: a.id,
|
id: a.id,
|
||||||
title: a.title,
|
title: a.title,
|
||||||
@@ -1572,6 +1627,14 @@ async fn fetch_purchases(
|
|||||||
subtitle: t.performer.name.clone(),
|
subtitle: t.performer.name.clone(),
|
||||||
artist: t.performer.name,
|
artist: t.performer.name,
|
||||||
artist_id: t.performer.id.to_string(),
|
artist_id: t.performer.id.to_string(),
|
||||||
|
duration: mmss(t.duration),
|
||||||
|
duration_secs: t.duration,
|
||||||
|
label: t.album.as_ref().and_then(|a| a.label.as_ref())
|
||||||
|
.map(|label| label.name.clone()).unwrap_or_default(),
|
||||||
|
genre: t.album.as_ref().and_then(|a| a.genre.as_ref())
|
||||||
|
.map(|genre| genre.name.clone()).unwrap_or_default(),
|
||||||
|
release_sort_key: qbz_text_utils::dates::release_sort_key(
|
||||||
|
t.album.as_ref().and_then(|a| a.release_date_original.as_deref())),
|
||||||
album: alb,
|
album: alb,
|
||||||
album_id: aid,
|
album_id: aid,
|
||||||
album_artist_id,
|
album_artist_id,
|
||||||
@@ -2311,11 +2374,13 @@ pub(crate) fn insert_playlist_row(
|
|||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
subtitle: playlist_subtitle(owner, tracks_count),
|
subtitle: playlist_subtitle(owner, tracks_count),
|
||||||
|
track_count: Some(tracks_count),
|
||||||
playlist_own_image: !cover_url.is_empty(),
|
playlist_own_image: !cover_url.is_empty(),
|
||||||
image_url: cover_url.to_string(),
|
image_url: cover_url.to_string(),
|
||||||
covers,
|
covers,
|
||||||
is_favorite: crate::fav_cache_qt::is_favorite("playlist", id),
|
is_favorite: crate::fav_cache_qt::is_favorite("playlist", id),
|
||||||
playlist_owned: !following,
|
playlist_owned: !following,
|
||||||
|
playlist_creator: playlist_creator(!following, owner).into(),
|
||||||
playlist_following: following,
|
playlist_following: following,
|
||||||
// 0.0 = most-recently added, which is what this just made it. The
|
// 0.0 = most-recently added, which is what this just made it. The
|
||||||
// feed is kept sorted by this proxy, so the row lands at the head
|
// feed is kept sorted by this proxy, so the row lands at the head
|
||||||
@@ -2601,6 +2666,57 @@ pub fn play_from_visible(visible_ids_json: String, clicked_id: String) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn purchased_album_sort_metadata_is_preserved() {
|
||||||
|
let album = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "a", "title": "A", "release_date_original": "1980-04-01",
|
||||||
|
"duration": 2500, "tracks_count": 11, "label": {"id": 1, "name": "Label"}
|
||||||
|
})).unwrap();
|
||||||
|
let row = map_purchased_album(album);
|
||||||
|
assert_eq!(row.release_sort_key, 19800401);
|
||||||
|
assert_eq!(row.duration_secs, 2500);
|
||||||
|
assert_eq!(row.track_count, Some(11));
|
||||||
|
assert_eq!(row.label, "Label");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_sort_metadata_uses_original_date_and_keeps_missing_dates_unknown() {
|
||||||
|
let track: Track = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": 42, "duration": 234,
|
||||||
|
"album": {"id": "a", "title": "A", "release_date_original": "1980-04-01",
|
||||||
|
"release_date_stream": "2015-06-01", "label": {"id": 1, "name": "Label"}}
|
||||||
|
})).unwrap();
|
||||||
|
let row = map_track(track);
|
||||||
|
assert_eq!(row.release_sort_key, 19800401);
|
||||||
|
assert_eq!(row.label, "Label");
|
||||||
|
assert_eq!(row.duration_secs, 234);
|
||||||
|
let track: Track = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": 43, "album": {"release_date_stream": "2015-06-01"}
|
||||||
|
})).unwrap();
|
||||||
|
assert_eq!(map_track(track).release_sort_key, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playlist_sort_metadata_retains_numeric_values_and_empty_track_count() {
|
||||||
|
let playlist: Playlist = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": 42, "name": "List", "tracks_count": 0, "duration": 0,
|
||||||
|
"updated_at": 1700000000
|
||||||
|
})).unwrap();
|
||||||
|
let row = serde_json::to_value(map_playlist_row(&playlist, false)).unwrap();
|
||||||
|
assert_eq!(row["trackCount"], 0);
|
||||||
|
assert_eq!(row["updatedAt"], 1700000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playlist_creator_filters_distinguish_ownership_and_editorial_names() {
|
||||||
|
assert_eq!(playlist_creator(true, "Qobuz fan"), "you");
|
||||||
|
assert_eq!(playlist_creator(false, "Qobuz"), "qobuz");
|
||||||
|
assert_eq!(playlist_creator(false, "Qobuz Latinoamérica"), "qobuz");
|
||||||
|
assert_eq!(playlist_creator(false, "My Qobuz playlist"), "others");
|
||||||
|
assert_eq!(playlist_creator(false, "Listener"), "others");
|
||||||
|
assert_eq!(playlist_creator(false, ""), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn favorites_paging_passes_ten_thousand_and_preserves_every_track() {
|
async fn favorites_paging_passes_ten_thousand_and_preserves_every_track() {
|
||||||
for reported_total in [0, 10_751] {
|
for reported_total in [0, 10_751] {
|
||||||
|
|||||||
@@ -559,16 +559,18 @@ pub fn counts() -> LocalCounts {
|
|||||||
state(|s| s.counts.clone())
|
state(|s| s.counts.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the local library is usable at all (a per-user db with at least
|
/// Whether any registered folder or indexed track makes the library usable.
|
||||||
/// one registered folder). Drives the "nothing indexed yet" empty state.
|
/// Offline downloads are indexed without registering a music folder.
|
||||||
/// A server-only setup is ALSO usable — the browse union has content even
|
/// A server-only setup is ALSO usable — the browse union has content even
|
||||||
/// with no registered on-disk folder.
|
/// with no registered on-disk folder.
|
||||||
pub fn has_library() -> bool {
|
pub fn has_library() -> bool {
|
||||||
let has_folders = with_db(|db| db.get_folders())
|
let has_folders = with_db(|db| db.get_folders())
|
||||||
.map(|f| !f.is_empty())
|
.map(|f| !f.is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
let indexed_tracks = with_db(|db| db.count_all_local_tracks()).unwrap_or(0);
|
||||||
library_sources_available(
|
library_sources_available(
|
||||||
has_folders,
|
has_folders,
|
||||||
|
indexed_tracks,
|
||||||
crate::local_plex::is_configured(),
|
crate::local_plex::is_configured(),
|
||||||
crate::local_plex::cached_track_count(),
|
crate::local_plex::cached_track_count(),
|
||||||
crate::media_servers_qt::cached_track_count(),
|
crate::media_servers_qt::cached_track_count(),
|
||||||
@@ -577,11 +579,12 @@ pub fn has_library() -> bool {
|
|||||||
|
|
||||||
fn library_sources_available(
|
fn library_sources_available(
|
||||||
has_folders: bool,
|
has_folders: bool,
|
||||||
|
indexed_tracks: u64,
|
||||||
plex_enabled: bool,
|
plex_enabled: bool,
|
||||||
plex_tracks: i64,
|
plex_tracks: i64,
|
||||||
remote_tracks: i64,
|
remote_tracks: i64,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
has_folders || (plex_enabled && plex_tracks > 0) || remote_tracks > 0
|
has_folders || indexed_tracks > 0 || (plex_enabled && plex_tracks > 0) || remote_tracks > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -646,11 +649,21 @@ mod phase_a_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_only_installation_has_a_library() {
|
fn remote_only_installation_has_a_library() {
|
||||||
assert!(library_sources_available(true, false, 0, 0));
|
assert!(library_sources_available(true, 0, false, 0, 0));
|
||||||
assert!(library_sources_available(false, true, 5_137, 0));
|
assert!(library_sources_available(false, 0, true, 5_137, 0));
|
||||||
assert!(library_sources_available(false, false, 0, 4_924));
|
assert!(library_sources_available(false, 0, false, 0, 4_924));
|
||||||
assert!(library_sources_available(false, false, 0, 6_678));
|
assert!(library_sources_available(false, 0, false, 0, 6_678));
|
||||||
assert!(!library_sources_available(false, false, 0, 0));
|
assert!(!library_sources_available(false, 0, false, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offline_only_installation_has_a_library_without_folders_or_servers() {
|
||||||
|
// #758: 7 albums / 97 indexed downloaded tracks, no music folders.
|
||||||
|
assert!(library_sources_available(false, 97, false, 0, 0));
|
||||||
|
assert!(library_sources_available(false, 1, false, 0, 0));
|
||||||
|
// Removing the last download restores the genuine empty state.
|
||||||
|
assert!(!library_sources_available(false, 0, false, 0, 0));
|
||||||
|
assert!(!library_sources_available(false, 0, false, 20, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -122,6 +122,47 @@ fn track_cache_info(
|
|||||||
|
|
||||||
fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) {
|
fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) {
|
||||||
let file_path = off.track_file_path(track_id, "flac");
|
let file_path = off.track_file_path(track_id, "flac");
|
||||||
|
let state = Arc::downgrade(off);
|
||||||
|
let rows = row_sink();
|
||||||
|
let sink = Arc::new(move |event: CacheEvent| {
|
||||||
|
let refresh = matches!(
|
||||||
|
event,
|
||||||
|
CacheEvent::Completed { .. } | CacheEvent::Processed { .. }
|
||||||
|
);
|
||||||
|
rows(event);
|
||||||
|
if refresh {
|
||||||
|
let state = state.clone();
|
||||||
|
crate::spawn(async move {
|
||||||
|
let Some(state) = state.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(current) = offline_qt::get().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !Arc::ptr_eq(&state, ¤t) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match state.restore_library_rows(Some(track_id)).await {
|
||||||
|
Ok(report) => log::info!(
|
||||||
|
"[offline-library] track={track_id} restored={} unavailable={} failed={}",
|
||||||
|
report.restored,
|
||||||
|
report.unavailable,
|
||||||
|
report.failed
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("[offline-library] track={track_id} repair failed: {error}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if offline_qt::get()
|
||||||
|
.await
|
||||||
|
.is_some_and(|current| Arc::ptr_eq(&state, ¤t))
|
||||||
|
{
|
||||||
|
crate::local_catalog_qt::request_catch_up();
|
||||||
|
crate::local_bridge_ops::publish_availability();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
qbz_offline_cache::spawn_track_cache_download(
|
qbz_offline_cache::spawn_track_cache_download(
|
||||||
track_id,
|
track_id,
|
||||||
file_path,
|
file_path,
|
||||||
@@ -130,7 +171,7 @@ fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) {
|
|||||||
off.db.clone(),
|
off.db.clone(),
|
||||||
off.get_cache_path(),
|
off.get_cache_path(),
|
||||||
off.library_db.clone(),
|
off.library_db.clone(),
|
||||||
row_sink(),
|
sink,
|
||||||
off.cache_semaphore.clone(),
|
off.cache_semaphore.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -723,6 +764,8 @@ pub fn remove_album(album_id: String) {
|
|||||||
offline_qt::mark_cached(*id, false);
|
offline_qt::mark_cached(*id, false);
|
||||||
push_status(*id, 0, 0.0);
|
push_status(*id, 0, 0.0);
|
||||||
}
|
}
|
||||||
|
crate::local_catalog_qt::request_catch_up();
|
||||||
|
crate::local_bridge_ops::publish_availability();
|
||||||
crate::toast_qt::success(qbz_i18n::t("Removed album from offline"));
|
crate::toast_qt::success(qbz_i18n::t("Removed album from offline"));
|
||||||
crate::offline_manager_qt::refresh_if_open().await;
|
crate::offline_manager_qt::refresh_if_open().await;
|
||||||
});
|
});
|
||||||
@@ -765,6 +808,8 @@ pub fn clear_all() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
offline_qt::clear_cached_ids();
|
offline_qt::clear_cached_ids();
|
||||||
|
crate::local_catalog_qt::request_catch_up();
|
||||||
|
crate::local_bridge_ops::publish_availability();
|
||||||
crate::toast_qt::success(qbz_i18n::t("Cache cleared"));
|
crate::toast_qt::success(qbz_i18n::t("Cache cleared"));
|
||||||
crate::offline_manager_qt::refresh_if_open().await;
|
crate::offline_manager_qt::refresh_if_open().await;
|
||||||
});
|
});
|
||||||
@@ -825,6 +870,8 @@ async fn remove_cached_inner(id: u64, toast: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
offline_qt::mark_cached(id, false);
|
offline_qt::mark_cached(id, false);
|
||||||
|
crate::local_catalog_qt::request_catch_up();
|
||||||
|
crate::local_bridge_ops::publish_availability();
|
||||||
push_status(id, 0, 0.0);
|
push_status(id, 0, 0.0);
|
||||||
if toast {
|
if toast {
|
||||||
crate::toast_qt::success(qbz_i18n::t("Removed from offline"));
|
crate::toast_qt::success(qbz_i18n::t("Removed from offline"));
|
||||||
|
|||||||
@@ -78,7 +78,19 @@ pub async fn activate(user_id: u64) {
|
|||||||
log::warn!("[qbz-qt] offline: library connection failed: {e}");
|
log::warn!("[qbz-qt] offline: library connection failed: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match state.restore_library_rows(None).await {
|
||||||
|
Ok(report) => log::info!(
|
||||||
|
"[offline-library] startup ready={} restored={} unavailable={} failed={}",
|
||||||
|
report.ready,
|
||||||
|
report.restored,
|
||||||
|
report.unavailable,
|
||||||
|
report.failed
|
||||||
|
),
|
||||||
|
Err(error) => log::warn!("[offline-library] startup repair failed: {error}"),
|
||||||
|
}
|
||||||
*slot().lock().await = Some(Arc::new(state));
|
*slot().lock().await = Some(Arc::new(state));
|
||||||
|
crate::local_catalog_qt::request_catch_up();
|
||||||
|
crate::local_bridge_ops::publish_availability();
|
||||||
log::info!("[qbz-qt] offline cache activated for user {user_id}");
|
log::info!("[qbz-qt] offline cache activated for user {user_id}");
|
||||||
load_cached_ids().await;
|
load_cached_ids().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -778,6 +778,7 @@ mod tests {
|
|||||||
streamable: None,
|
streamable: None,
|
||||||
streamable_at: None,
|
streamable_at: None,
|
||||||
release_date_stream: None,
|
release_date_stream: None,
|
||||||
|
release_date_original: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let qt = track_to_queue_track(&track);
|
let qt = track_to_queue_track(&track);
|
||||||
|
|||||||
@@ -79,22 +79,24 @@ function qmlFunction(name) {
|
|||||||
return libraryQml.slice(start, stop);
|
return libraryQml.slice(start, stop);
|
||||||
}
|
}
|
||||||
const bridge = {
|
const bridge = {
|
||||||
sessionShowPurchases: true, sessionShowFavorites: true, sessionShowFollowing: true,
|
sessionShowPurchases: false, sessionShowFavorites: false, sessionShowFollowing: false,
|
||||||
sessionAlbumsShowPurchases: true, sessionAlbumsShowFavorites: true,
|
sessionAlbumsShowPurchases: false, sessionAlbumsShowFavorites: false,
|
||||||
sessionTracksShowPurchases: true, sessionTracksShowFavorites: true,
|
sessionTracksShowPurchases: false, sessionTracksShowFavorites: false,
|
||||||
|
sessionAlbumsHiresOnly:false, sessionTracksHiresOnly:false, sessionHideLocalAlbums:false,
|
||||||
};
|
};
|
||||||
const lib = vm.createContext({
|
const lib = vm.createContext({
|
||||||
QbzLibrary: bridge, ReleaseSort: sort, Qt: {callLater() {}},
|
QbzLibrary: bridge, ReleaseSort: sort, Qt: {callLater() {}},
|
||||||
activeTab: 'all', search: '', tabSearch: '', showLocal: true,
|
activeTab: 'all', search: '', tabSearch: '', showLocal: true,
|
||||||
sortBy: 'date', sortAsc: false, albumsSort: 'oldest', albumsGroup: 'off',
|
sortBy: 'date', sortAsc: false, albumsSort: 'oldest', albumsGroup: 'off',
|
||||||
tracksGroup: 'off', artistsGroup: 'off', genreNames: [],
|
tracksGroup: 'off', artistsGroup: 'off', artistsView:'grid', genreNames: [],
|
||||||
|
tracksSort:'default', playlistsSort:'default', artistsSort:'default', labelsSort:'default',
|
||||||
});
|
});
|
||||||
lib.root = lib;
|
lib.root = lib;
|
||||||
for (const name of ['showPurchases', 'showFavorites', 'showFollowing', 'genreContext']) {
|
for (const name of ['showPurchases', 'showFavorites', 'showFollowing', 'genreContext', 'hiresOnly', 'activeSort']) {
|
||||||
const expr = libraryQml.match(new RegExp(`readonly property (?:bool|string) ${name}:([^\\n]*(?:\\n [^\\n]*)*)`))[1];
|
const expr = libraryQml.match(new RegExp(`readonly property (?:bool|string) ${name}:([^\\n]*(?:\\n [^\\n]*)*)`))[1];
|
||||||
Object.defineProperty(lib, name, {get() { return vm.runInContext(expr, lib); }});
|
Object.defineProperty(lib, name, {get() { return vm.runInContext(expr, lib); }});
|
||||||
}
|
}
|
||||||
for (const name of ['parseFeed', 'visibleItems', 'matchesSources', 'setShowPurchases', 'setShowFavorites', 'setShowFollowing']) {
|
for (const name of ['alphaKey', 'pickSort', 'isLocalFeedItem', 'hideableLocalAlbum', 'parseFeed', 'visibleItems', 'matchesSources', 'setShowPurchases', 'setShowFavorites', 'setShowFollowing', 'setHiresOnly']) {
|
||||||
vm.runInContext(qmlFunction(name), lib);
|
vm.runInContext(qmlFunction(name), lib);
|
||||||
}
|
}
|
||||||
const libraryRows = [];
|
const libraryRows = [];
|
||||||
@@ -114,7 +116,7 @@ for (const tab of ['all', 'albums', 'tracks']) {
|
|||||||
lib.activeTab = tab;
|
lib.activeTab = tab;
|
||||||
if (tab === 'all') lib.setShowFollowing(false);
|
if (tab === 'all') lib.setShowFollowing(false);
|
||||||
for (const [purchases, favorites, expected] of [
|
for (const [purchases, favorites, expected] of [
|
||||||
[true, true, ['both','favorite','purchased']],
|
[true, true, ['both']],
|
||||||
[true, false, ['both','purchased']],
|
[true, false, ['both','purchased']],
|
||||||
[false, true, ['both','favorite']],
|
[false, true, ['both','favorite']],
|
||||||
[false, false, ['both','favorite','purchased']],
|
[false, false, ['both','favorite','purchased']],
|
||||||
@@ -128,7 +130,7 @@ for (const tab of ['all', 'albums', 'tracks']) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lib.activeTab = 'albums'; lib.setShowPurchases(true); lib.setShowFavorites(true);
|
lib.activeTab = 'albums'; lib.setShowPurchases(false); lib.setShowFavorites(false);
|
||||||
assert.deepEqual(ids(lib.visibleItems()), ['favorite', 'both', 'purchased']);
|
assert.deepEqual(ids(lib.visibleItems()), ['favorite', 'both', 'purchased']);
|
||||||
lib.albumsSort = 'newest';
|
lib.albumsSort = 'newest';
|
||||||
assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both', 'favorite']);
|
assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both', 'favorite']);
|
||||||
@@ -146,7 +148,7 @@ lib.activeTab = 'all'; lib.setShowPurchases(false); lib.setShowFavorites(false);
|
|||||||
assert.deepEqual(ids(lib.visibleItems()), ['followed']);
|
assert.deepEqual(ids(lib.visibleItems()), ['followed']);
|
||||||
lib.activeTab = 'albums';
|
lib.activeTab = 'albums';
|
||||||
assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both'], 'Following cannot gate Albums');
|
assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both'], 'Following cannot gate Albums');
|
||||||
console.log('Library source union, per-tab state, purchase-row dates, deduplication and chronology: passed');
|
console.log('Library only-filter intersections, per-tab state, purchase-row dates, deduplication and chronology: passed');
|
||||||
|
|
||||||
// Popularity leaves server order intact, and changing its paging order must
|
// Popularity leaves server order intact, and changing its paging order must
|
||||||
// discard the old overlay/cursor while a local date/title sort keeps them.
|
// discard the old overlay/cursor while a local date/title sort keeps them.
|
||||||
@@ -173,3 +175,135 @@ for (const [from,to,reset] of [['default','relevant',true],['relevant','oldest',
|
|||||||
if (reset) assert.equal(artistState.releasePending.album, undefined);
|
if (reset) assert.equal(artistState.releasePending.album, undefined);
|
||||||
}
|
}
|
||||||
console.log('Artist popularity preserves server rank and resets pagination across server-order changes: passed');
|
console.log('Artist popularity preserves server rank and resets pagination across server-order changes: passed');
|
||||||
|
|
||||||
|
// Artists must remain independent of album/track source and genre switches.
|
||||||
|
// The #758 log contains 19 artists; it does not establish a rendering cause.
|
||||||
|
const artistRows = Array.from({length:19}, (_, i) => ({kind:'artist', id:`artist-${i}`,
|
||||||
|
title:`Artist ${i}`, group:'favorites', source:'qobuz'}));
|
||||||
|
lib.feed = lib.parseFeed(JSON.stringify([...libraryRows, ...artistRows]));
|
||||||
|
lib.activeTab = 'artists';
|
||||||
|
lib.genreNames = ['genre absent from every artist'];
|
||||||
|
for (const group of ['off', 'alpha']) {
|
||||||
|
lib.artistsGroup = group;
|
||||||
|
for (const enabled of [false, true]) {
|
||||||
|
bridge.sessionShowPurchases = enabled;
|
||||||
|
bridge.sessionShowFavorites = enabled;
|
||||||
|
bridge.sessionAlbumsShowPurchases = enabled;
|
||||||
|
bridge.sessionTracksShowFavorites = enabled;
|
||||||
|
assert.equal(lib.visibleItems().length, 19);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lib.tabSearch = 'Artist 18';
|
||||||
|
assert.deepEqual(ids(lib.visibleItems()), ['artist-18']);
|
||||||
|
lib.tabSearch = '';
|
||||||
|
assert.equal(lib.visibleItems().length, 19);
|
||||||
|
console.log('Library Artists preserves all 19 rows independently of source/genre switches: passed');
|
||||||
|
|
||||||
|
// Each Hi-Res restriction combines with source restrictions in its own tab.
|
||||||
|
lib.genreNames = [];
|
||||||
|
lib.feed = lib.parseFeed(JSON.stringify(libraryRows.map(row => ({...row,
|
||||||
|
qualityTier: row.id === 'both' ? 'hires' : 'cd'}))));
|
||||||
|
lib.activeTab = 'albums'; lib.setShowPurchases(false); lib.setShowFavorites(false);
|
||||||
|
lib.setHiresOnly(true);
|
||||||
|
assert.deepEqual(ids(lib.visibleItems()), ['both']);
|
||||||
|
lib.activeTab = 'tracks'; lib.setShowPurchases(false); lib.setShowFavorites(false);
|
||||||
|
assert.equal(lib.visibleItems().length, 3);
|
||||||
|
lib.setHiresOnly(true); lib.setShowFavorites(true);
|
||||||
|
assert.deepEqual(ids(lib.visibleItems()), ['both']);
|
||||||
|
lib.activeTab = 'all'; lib.setShowFollowing(false);
|
||||||
|
lib.setShowPurchases(false); lib.setShowFavorites(false);
|
||||||
|
lib.feed = lib.parseFeed(JSON.stringify([
|
||||||
|
...['local','plex','jellyfin','subsonic'].map(source => ({
|
||||||
|
kind:'album',id:source+':album',source,group:'local'})),
|
||||||
|
{kind:'track',id:'local-track',source:'local',group:'local'},
|
||||||
|
{kind:'album',id:'online',source:'qobuz',group:'favorites'},
|
||||||
|
{kind:'album',id:'matched-cache',source:'qobuz',group:'favorites',sources:['offline']},
|
||||||
|
{kind:'album',id:'cache',source:'local',group:'local',sources:['offline']},
|
||||||
|
]));
|
||||||
|
lib.showLocal = false;
|
||||||
|
assert.deepEqual(ids(lib.visibleItems()).sort(), ['cache','local-track','matched-cache','online']);
|
||||||
|
lib.showLocal = true;
|
||||||
|
assert.equal(lib.visibleItems().length, 8);
|
||||||
|
const playlists = ['you','qobuz','others','unknown'].map((creator, i) => ({
|
||||||
|
id:String(i), kind:'playlist', source:'qobuz', group:i ? 'following':'favorites',
|
||||||
|
title:creator, playlistCreator:creator, isFavorite:i===2,
|
||||||
|
}));
|
||||||
|
lib.feed = lib.parseFeed(JSON.stringify([...playlists, {...playlists[2],group:'favorites'}]));
|
||||||
|
lib.activeTab = 'playlists';
|
||||||
|
for (const creator of ['all','you','qobuz','others']) {
|
||||||
|
lib.playlistsSubTab = creator;
|
||||||
|
assert.equal(lib.visibleItems().length, creator === 'all' ? 4 : 1);
|
||||||
|
if (creator !== 'all') assert.equal(lib.visibleItems()[0].playlistCreator, creator);
|
||||||
|
}
|
||||||
|
assert.equal(lib.feed._tabTotals.playlists, 4);
|
||||||
|
console.log('Hi-Res intersections, per-tab isolation, local-album exclusion and playlist author select: passed');
|
||||||
|
|
||||||
|
// Catalog IDs and local row IDs live in different namespaces.
|
||||||
|
lib.feed = lib.parseFeed(JSON.stringify([
|
||||||
|
{kind:'track',id:'7',source:'local',group:'local'},
|
||||||
|
{kind:'track',id:'7',source:'qobuz',group:'purchases'},
|
||||||
|
]));
|
||||||
|
lib.activeTab = 'all'; lib.setShowPurchases(true); lib.setShowFavorites(false); lib.setShowFollowing(false);
|
||||||
|
assert.equal(lib.visibleItems().length, 1);
|
||||||
|
assert.equal(lib.visibleItems()[0].source, 'qobuz');
|
||||||
|
|
||||||
|
// Re-selecting one criterion flips it; other tabs retain their own choices.
|
||||||
|
lib.setPref = (key, value) => { lib[key] = value; };
|
||||||
|
for (const tab of ['all','tracks','albums','artists','labels','playlists']) {
|
||||||
|
lib.activeTab = tab;
|
||||||
|
lib.pickSort('title');
|
||||||
|
assert.equal(lib.activeSort, 'title-asc');
|
||||||
|
lib.pickSort('title');
|
||||||
|
assert.equal(lib.activeSort, 'title-desc');
|
||||||
|
lib.pickSort('date');
|
||||||
|
assert.equal(lib.activeSort, 'date-desc');
|
||||||
|
lib.pickSort('date');
|
||||||
|
assert.equal(lib.activeSort, 'date-asc');
|
||||||
|
}
|
||||||
|
lib.activeTab='albums'; lib.albumsSort='oldest'; lib.pickSort('release-date');
|
||||||
|
assert.equal(lib.albumsSort,'release-date-desc');
|
||||||
|
lib.activeTab='tracks'; assert.equal(lib.tracksSort,'date-asc');
|
||||||
|
const sortRows = [
|
||||||
|
{id:'b',title:'Bravo',artist:'B',album:'B',label:'B',genre:'Rock',releaseSortKey:20000101,durationSecs:200,trackCount:10,updatedAt:200,added_rank:0},
|
||||||
|
{id:'a',title:'Alpha',artist:'A',album:'A',label:'A',genre:'Jazz',releaseSortKey:19800101,durationSecs:100,trackCount:0,updatedAt:100,added_rank:1},
|
||||||
|
{id:'missing'}
|
||||||
|
];
|
||||||
|
for (const field of ['title','artist','release','label','genre','release-date','duration','track-count','updated']) {
|
||||||
|
assert.deepEqual(ids(sort.sortRows(sortRows,field+'-asc')),['a','b','missing'],field);
|
||||||
|
assert.deepEqual(ids(sort.sortRows(sortRows,field+'-desc')),['b','a','missing'],field);
|
||||||
|
}
|
||||||
|
assert.deepEqual(ids(sort.sortRows(sortRows.slice(0,2),'date-desc')),['b','a']);
|
||||||
|
assert.deepEqual(ids(sort.sortRows(sortRows.slice(0,2),'date-asc')),['a','b']);
|
||||||
|
assert.deepEqual(ids(sort.sortRows(sortRows,'default-reverse')),['missing','a','b']);
|
||||||
|
assert.deepEqual(ids(sortRows),['b','a','missing']);
|
||||||
|
console.log('All six tabs: one criterion, repeat-click reversal, independent state, numeric metadata, unknowns last: passed');
|
||||||
|
|
||||||
|
// Exercise tab derivation, so a grouping or old forced sort cannot override it.
|
||||||
|
lib.genreNames=[]; lib.tabSearch=''; lib.tracksGroup='off'; lib.artistsGroup='off';
|
||||||
|
bridge.sessionTracksHiresOnly=false; bridge.sessionAlbumsHiresOnly=false;
|
||||||
|
bridge.sessionTracksShowFavorites=false; bridge.sessionTracksShowPurchases=false;
|
||||||
|
bridge.sessionAlbumsShowFavorites=false; bridge.sessionAlbumsShowPurchases=false;
|
||||||
|
for (const [tab,kind] of [['tracks','track'],['albums','album'],['artists','artist'],['labels','label'],['playlists','playlist']]) {
|
||||||
|
lib.activeTab=tab; lib.playlistsSubTab='all';
|
||||||
|
lib.feed=lib.parseFeed(JSON.stringify(sortRows.map(row=>({...row,kind,source:'qobuz',group:'favorites'}))));
|
||||||
|
lib[tab+'Sort']='title-asc'; assert.deepEqual(ids(lib.visibleItems()),['a','b','missing'],tab);
|
||||||
|
lib[tab+'Sort']='title-desc'; assert.deepEqual(ids(lib.visibleItems()),['b','a','missing'],tab);
|
||||||
|
}
|
||||||
|
lib.activeTab='artists'; lib.artistsGroup='alpha'; lib.artistsView='sidepanel';
|
||||||
|
lib.artistsSort='date-desc';
|
||||||
|
lib.feed=lib.parseFeed(JSON.stringify(sortRows.slice(0,2).map(row=>({...row,kind:'artist',source:'qobuz',group:'favorites'}))));
|
||||||
|
assert.deepEqual(ids(lib.visibleItems()),['b','a'],'Sidepanel must not inherit grid grouping');
|
||||||
|
console.log('Every Library tab applies chosen order through the real visible-items pipeline: passed');
|
||||||
|
|
||||||
|
assert.deepEqual(ids(sort.sortRows([
|
||||||
|
{id:'full',kind:'playlist',trackCount:2,durationSecs:200},
|
||||||
|
{id:'empty',kind:'playlist',trackCount:0},
|
||||||
|
{id:'unknown',kind:'playlist',trackCount:2}
|
||||||
|
], 'duration-asc')), ['empty','full','unknown']);
|
||||||
|
|
||||||
|
lib.activeTab='albums'; lib.albumsSort='title-asc';
|
||||||
|
lib.pickSort('default'); assert.equal(lib.albumsSort,'default');
|
||||||
|
assert.equal(sort.ascending(lib.activeSort),false);
|
||||||
|
lib.pickSort('default'); assert.equal(lib.albumsSort,'default-reverse');
|
||||||
|
assert.equal(sort.ascending(lib.activeSort),true);
|
||||||
|
lib.pickSort('default'); assert.equal(lib.albumsSort,'default');
|
||||||
Reference in new issue
Block a user