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_at: None,
|
||||
release_date_stream: None,
|
||||
release_date_original: None,
|
||||
});
|
||||
t
|
||||
}
|
||||
|
||||
@@ -6858,3 +6858,80 @@ msgstr "Gruppe: Jahrzehnt"
|
||||
|
||||
msgid "Popularity"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
|
||||
@@ -408,7 +408,7 @@ pub(crate) async fn try_cmaf_offline_download(
|
||||
let album_group_key = format!("{}|{}", metadata.album, album_artist);
|
||||
let lib_opt = library_db.lock().await;
|
||||
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,
|
||||
&metadata.title,
|
||||
&metadata.artist,
|
||||
@@ -428,6 +428,9 @@ pub(crate) async fn try_cmaf_offline_download(
|
||||
bundle.sampling_rate.map(|r| r as f64),
|
||||
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 {
|
||||
log::warn!(
|
||||
@@ -666,7 +669,7 @@ pub fn spawn_track_cache_download(
|
||||
let album_group_key = format!("{}|{}", metadata.album, album_artist);
|
||||
let lib_opt = library_db.lock().await;
|
||||
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,
|
||||
&metadata.title,
|
||||
&metadata.artist,
|
||||
@@ -683,6 +686,11 @@ pub fn spawn_track_cache_download(
|
||||
sample_rate_detected,
|
||||
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() {
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod db;
|
||||
pub mod downloader;
|
||||
pub mod event;
|
||||
pub mod maintenance;
|
||||
pub mod library_sync;
|
||||
pub mod metadata;
|
||||
pub mod migration;
|
||||
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_at: None,
|
||||
release_date_stream: None,
|
||||
release_date_original: None,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -2037,6 +2038,7 @@ mod tests {
|
||||
streamable: None,
|
||||
streamable_at: None,
|
||||
release_date_stream: None,
|
||||
release_date_original: None,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -2979,6 +2981,7 @@ mod entitlement_index_tests {
|
||||
streamable: None,
|
||||
streamable_at: None,
|
||||
release_date_stream: None,
|
||||
release_date_original: None,
|
||||
}),
|
||||
..Default::default()
|
||||
}],
|
||||
|
||||
@@ -111,6 +111,30 @@ impl OfflineCacheState {
|
||||
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) {
|
||||
// Close library connection first (before main teardown)
|
||||
{
|
||||
|
||||
@@ -433,6 +433,7 @@ mod tests {
|
||||
streamable: None,
|
||||
streamable_at: None,
|
||||
release_date_stream: None,
|
||||
release_date_original: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,27 +29,66 @@ function textCompare(a, b) {
|
||||
}
|
||||
|
||||
function sortAlbums(rows, sort, group) {
|
||||
sort = normalizedMode(sort)
|
||||
if (group === "off") return sortRows(rows, sort)
|
||||
return rows.slice().sort(function (a, b) {
|
||||
var grouped = 0
|
||||
if (group === "year" || group === "decade") {
|
||||
var x = groupNumber(a, group), y = groupNumber(b, group)
|
||||
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") {
|
||||
grouped = textCompare(a.artist, b.artist)
|
||||
} else if (group === "alpha") {
|
||||
grouped = textCompare(String(a.title || "").charAt(0), String(b.title || "").charAt(0))
|
||||
}
|
||||
if (grouped) return grouped
|
||||
if (sort === "oldest" || sort === "newest")
|
||||
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
|
||||
}
|
||||
if (sort !== "default") return compareItems(a, b, sort)
|
||||
// Default retains server/feed order; alphabetical groups keep their
|
||||
// established alphabetical order within each group.
|
||||
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
|
||||
// 14px chevron, and 40px narrower because toolbar selects were needlessly
|
||||
// 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
|
||||
signal selected(int index)
|
||||
|
||||
@@ -205,7 +207,9 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
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
|
||||
height: selectRoot.sm ? 14 : 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -409,6 +413,7 @@ Rectangle {
|
||||
Item {
|
||||
id: rowBadge
|
||||
visible: selectRoot.optHasBadges()
|
||||
|| (selectRoot.sortDirection !== "" && optRow.index === selectRoot.currentIndex)
|
||||
width: visible ? 20 : 0
|
||||
height: parent.height
|
||||
Text {
|
||||
@@ -422,7 +427,9 @@ Rectangle {
|
||||
}
|
||||
QbzIcon {
|
||||
visible: !selectRoot.optBp(optRow.index)
|
||||
name: "volume-2"
|
||||
name: selectRoot.sortDirection !== ""
|
||||
? (selectRoot.sortDirection === "asc" ? "chevron-up" : "chevron-down")
|
||||
: "volume-2"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -69,7 +69,7 @@ Rectangle {
|
||||
property string activeTab: "albums"
|
||||
|
||||
function tabLabel(id) {
|
||||
return id === "albums" ? root.t("Albums")
|
||||
return id === "albums" ? root.t("Releases")
|
||||
: id === "tracks" ? root.t("Tracks")
|
||||
: id === "artists" ? root.t("Artists")
|
||||
: id === "playlists" ? root.t("Playlists")
|
||||
|
||||
@@ -191,7 +191,7 @@ Item {
|
||||
"entries": [
|
||||
{ "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("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("Playlists"), "icon": "list-music", "view": "library", "tab": "playlists", "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
|
||||
// (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/LibraryAlbumsList.qml Albums in LIST mode + its bulk bar
|
||||
// library/LibraryArtistsPanel.qml Artists in SIDEPANEL mode
|
||||
@@ -92,6 +92,28 @@ Rectangle {
|
||||
readonly property var counts: JSON.parse(QbzLibrary.libraryCountsJson)
|
||||
// Full merged feed (parsed once per publish — timed for the report).
|
||||
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) {
|
||||
var t = Date.now()
|
||||
var f = JSON.parse(json)
|
||||
@@ -103,6 +125,7 @@ Rectangle {
|
||||
var tabSeen = ({})
|
||||
var tabPurchased = ({})
|
||||
var tabFavorites = ({})
|
||||
var tabFollowing = ({})
|
||||
// Normalize the strings once per Rust publish. Library > All can hold
|
||||
// the complete local library, so repeating toLowerCase() in every
|
||||
// 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.
|
||||
for (var i = 0; i < f.length; 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._artistLc = String(it.artist || "").toLowerCase()
|
||||
it._albumLc = String(it.album || "").toLowerCase()
|
||||
@@ -125,7 +158,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
} 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++
|
||||
}
|
||||
// The toolbar emptiness gate used to make a second full pass over the
|
||||
@@ -134,6 +170,7 @@ Rectangle {
|
||||
f._tabTotals = totals
|
||||
f._tabPurchased = tabPurchased
|
||||
f._tabFavorites = tabFavorites
|
||||
f._tabFollowing = tabFollowing
|
||||
// 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
|
||||
// right below it, so it now rides the same category and is silent
|
||||
@@ -208,7 +245,14 @@ Rectangle {
|
||||
? QbzLibrary.sessionAlbumsShowFavorites : activeTab === "tracks"
|
||||
? QbzLibrary.sessionTracksShowFavorites : QbzLibrary.sessionShowFavorites
|
||||
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
|
||||
// current Tracks / Albums source filter. Kept separate from `feed` so the
|
||||
// normal card-heart path still avoids rebuilding a large scrolled model.
|
||||
@@ -245,12 +289,15 @@ Rectangle {
|
||||
if (!p || p.albumsView === undefined) return
|
||||
root.albumsView = p.albumsView
|
||||
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.tracksGroup = p.tracksGroup
|
||||
root.playlistsView = p.playlistsView
|
||||
root.artistsGroup = p.artistsGroup
|
||||
root.artistsView = p.artistsView
|
||||
root.showLocal = p.allShowLocal === true
|
||||
}
|
||||
Component.onCompleted: root.applyPrefs()
|
||||
Connections {
|
||||
@@ -267,6 +314,10 @@ Rectangle {
|
||||
// list mode with a live selection would strand the bulk bar.
|
||||
if (value !== "list") root.setAlbumsMultiSelect(false)
|
||||
} 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 === "tracksGroup") root.tracksGroup = value
|
||||
else if (key === "playlistsView") root.playlistsView = value
|
||||
@@ -278,11 +329,9 @@ Rectangle {
|
||||
}
|
||||
QbzLibrary.setLibraryPref(key, String(value))
|
||||
}
|
||||
/// The one SOURCE switch the reference persists (favorites_prefs.rs
|
||||
/// `all_show_local`); the other three are session-local there too.
|
||||
/// Match the other session filters: false means no restriction.
|
||||
function setShowLocal(on) {
|
||||
root.showLocal = on
|
||||
QbzLibrary.setLibraryPref("allShowLocal", on ? "true" : "false")
|
||||
QbzLibrary.sessionHideLocalAlbums = !on
|
||||
}
|
||||
|
||||
// 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.
|
||||
function toggleGenrePopup() { libGenrePopup.toggle() }
|
||||
|
||||
/// The All tab's filters as the applied-filters tooltip wants them: the
|
||||
/// 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).
|
||||
/// Active restrictions for the genre tooltip.
|
||||
readonly property var filterSummaryGroups: {
|
||||
var tr = QbzSession.trRev
|
||||
var hidden = []
|
||||
if (!root.showFavorites)
|
||||
hidden.push(QbzSession.tr("Favorites", tr))
|
||||
if (!root.showPurchases)
|
||||
hidden.push(QbzSession.tr("Purchases", tr))
|
||||
// Following / Local are All-only sources. Do not claim they filter
|
||||
// the Albums or Tracks toolbar when those switches are off on All.
|
||||
if (root.activeTab === "all") {
|
||||
if (!root.showFollowing)
|
||||
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
|
||||
var active = []
|
||||
if (root.showPurchases) active.push(QbzSession.tr("Purchases only", tr))
|
||||
if (root.showFavorites) active.push(QbzSession.tr("Favorites only", tr))
|
||||
if (root.activeTab === "all" && root.showFollowing)
|
||||
active.push(QbzSession.tr("Following only", tr))
|
||||
if (root.hiresOnly) active.push(QbzSession.tr("Hi-Res only", tr))
|
||||
if (root.activeTab === "all" && !root.showLocal)
|
||||
active.push(QbzSession.tr("Hide local albums from library", tr))
|
||||
return active.length ? [{ group: QbzSession.tr("Filters", tr), values: active }] : []
|
||||
}
|
||||
|
||||
// Other-tab state.
|
||||
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 playlistsSubTab: "favorites"
|
||||
property string playlistsSubTab: "all"
|
||||
|
||||
/// The All tab's search field debounces; the toolbar routes through here
|
||||
/// so the timer stays next to the window report it feeds.
|
||||
@@ -456,6 +514,21 @@ Rectangle {
|
||||
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
|
||||
/// 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
|
||||
@@ -501,10 +574,9 @@ Rectangle {
|
||||
}
|
||||
|
||||
function matchesSources(isPurchased, isFavorite, isFollowing) {
|
||||
var following = activeTab === "all" && showFollowing
|
||||
var any = showPurchases || showFavorites || following
|
||||
return !any || (showPurchases && isPurchased)
|
||||
|| (showFavorites && isFavorite) || (following && isFollowing)
|
||||
return (!showPurchases || isPurchased)
|
||||
&& (!showFavorites || isFavorite)
|
||||
&& (activeTab !== "all" || !showFollowing || isFollowing)
|
||||
}
|
||||
|
||||
function visibleItems() {
|
||||
@@ -520,17 +592,17 @@ Rectangle {
|
||||
// already merged every source in that order, so do not copy and
|
||||
// rescan a potentially 10k-row array merely to return it intact.
|
||||
if (needle === "" && genres.length === 0
|
||||
&& showPurchases && showFavorites && showFollowing && showLocal
|
||||
&& !showPurchases && !showFavorites && !showFollowing && showLocal
|
||||
&& sortBy === "date" && !sortAsc)
|
||||
return feed
|
||||
var allSeen = ({})
|
||||
for (i = 0; i < feed.length; i++) {
|
||||
var it = feed[i]
|
||||
var isLocal = it.source === "local" || it.source === "plex"
|
||||
if (isLocal) {
|
||||
if (!showLocal) continue
|
||||
} else if (!matchesSources(it.group === "purchases",
|
||||
it.group === "favorites",
|
||||
it.group === "following")) continue
|
||||
if (!showLocal && hideableLocalAlbum(it)) continue
|
||||
var memberKey = it._membershipKey
|
||||
if (!matchesSources(feed._tabPurchased[memberKey] === true,
|
||||
feed._tabFavorites[memberKey] === true,
|
||||
feed._tabFollowing[memberKey] === true)) continue
|
||||
if (needle !== ""
|
||||
&& it._titleLc.indexOf(needle) < 0
|
||||
&& it._artistLc.indexOf(needle) < 0) continue
|
||||
@@ -543,20 +615,13 @@ Rectangle {
|
||||
}
|
||||
if (!genreHit) continue
|
||||
}
|
||||
if (showPurchases || showFavorites || showFollowing) {
|
||||
if (allSeen[memberKey]) continue
|
||||
allSeen[memberKey] = true
|
||||
}
|
||||
items.push(it)
|
||||
}
|
||||
// Canonical ascending order per field, then reverse for the
|
||||
// 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()
|
||||
}
|
||||
items = ReleaseSort.sortRows(items, root.activeSort)
|
||||
return items
|
||||
}
|
||||
var tabNeedle = tabSearch.toLowerCase()
|
||||
@@ -568,9 +633,9 @@ Rectangle {
|
||||
var sourceTab = activeTab === "tracks" || activeTab === "albums"
|
||||
var sourceKind = activeTab === "tracks" ? "track" : "album"
|
||||
var tabGenres = sourceTab ? root.genreNames : []
|
||||
// Enabled sources form a union in every tab. With none selected,
|
||||
// retain All's unconstrained behavior. Prefer purchase metadata for
|
||||
// entities belonging to both sources, and render each entity once.
|
||||
// Every enabled "only" filter restricts the result. With none enabled,
|
||||
// show all entities. Prefer purchase metadata for an entity in both
|
||||
// sources, and render it once.
|
||||
var purchased = feed._tabPurchased || ({})
|
||||
var favorites = feed._tabFavorites || ({})
|
||||
var tabSeen = ({})
|
||||
@@ -592,8 +657,14 @@ Rectangle {
|
||||
}
|
||||
} else if (activeTab === "artists") keep = x.kind === "artist"
|
||||
else if (activeTab === "labels") keep = x.kind === "label"
|
||||
else if (activeTab === "playlists") keep = x.kind === "playlist"
|
||||
&& (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites")
|
||||
else if (activeTab === "playlists") {
|
||||
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) {
|
||||
var tg = x._genreLc
|
||||
keep = false
|
||||
@@ -601,10 +672,12 @@ Rectangle {
|
||||
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 (activeTab === "albums")
|
||||
items = ReleaseSort.sortAlbums(items, albumsSort, albumsGroup)
|
||||
else items = ReleaseSort.sortRows(items, root.activeSort)
|
||||
// --- Group-by (favorites.rs::derive_tracks / derive_albums) ------
|
||||
// Grouping REORDERS the rows so a group's entries sit together. The
|
||||
// TRACKS list also gets separator rows (see withGroupHeaders); the
|
||||
@@ -628,12 +701,24 @@ Rectangle {
|
||||
})
|
||||
}
|
||||
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 === "name") by(["title"])
|
||||
items = withGroupHeaders(items, tracksGroup)
|
||||
} else if (activeTab === "artists" && artistsGroup === "alpha") {
|
||||
by(["title"])
|
||||
} else if (activeTab === "artists" && artistsGroup === "alpha" && artistsView === "grid") {
|
||||
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
|
||||
}
|
||||
@@ -904,7 +989,11 @@ Rectangle {
|
||||
var f = root.feed
|
||||
for (var i = 0; i < f.length; i++) {
|
||||
// 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
|
||||
// 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
|
||||
// the mounted delegates consume this same signal directly and
|
||||
// retain the existing no-scroll-reset path.
|
||||
if (sourceTab && (root.showFavorites || !root.showPurchases))
|
||||
if ((sourceTab || root.activeTab === "all") && root.showFavorites)
|
||||
root.favoriteFilterRev++
|
||||
}
|
||||
// Pin state settled in the store. The row is patched IN PLACE and the
|
||||
@@ -1097,7 +1186,7 @@ Rectangle {
|
||||
Item {
|
||||
id: content
|
||||
width: parent.width
|
||||
height: parent.height - 57
|
||||
height: parent.height - toolbar.height - 1
|
||||
clip: true
|
||||
|
||||
// The tracks bulk bar is pinned above the list (the Slint scrolls
|
||||
@@ -1216,6 +1305,7 @@ Rectangle {
|
||||
|
||||
readonly property bool ready:
|
||||
!QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
|
||||
onReadyChanged: Qt.callLater(root.reportSurfaceState)
|
||||
// Which surface owns the tab right now — one predicate per body,
|
||||
// so no two can mount at once.
|
||||
readonly property bool showAlbumsList:
|
||||
|
||||
@@ -114,7 +114,7 @@ Item {
|
||||
// may additionally show the catalog mark in the mixed feed.
|
||||
source: cell.item.source
|
||||
sources: cell.item.sources || []
|
||||
showSourceBadge: cell.view.showLocal
|
||||
showSourceBadge: cell.view.showSourceBadges
|
||||
}
|
||||
}
|
||||
// Group separator. These are pseudo-rows injected into the SAME flat model
|
||||
@@ -142,7 +142,7 @@ Item {
|
||||
TrackCard {
|
||||
item: cell.item
|
||||
artSource: cell.view.artMap[cell.item.artKey] || ""
|
||||
showSourceBadge: cell.view.showLocal
|
||||
showSourceBadge: cell.view.showSourceBadges
|
||||
confirmReleaseRemoval: function (item) {
|
||||
cell.view.askRemoveReleaseFavorites(item)
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ Rectangle {
|
||||
// Col — source glyph (only when local/Plex can appear).
|
||||
Rectangle {
|
||||
id: srcCol
|
||||
visible: feedRow.view.showLocal
|
||||
visible: feedRow.view.showSourceBadges
|
||||
width: 44
|
||||
height: parent.height
|
||||
color: "transparent"
|
||||
|
||||
@@ -63,20 +63,23 @@ Item {
|
||||
var artists = []
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
if (rows[i].kind === "artist") artists.push(rows[i])
|
||||
// The sidepanel rail is ALWAYS A-Z grouped (the reference reads
|
||||
// `artists-grouped` unconditionally at :2038, which is why its
|
||||
// Group select is hidden in this mode).
|
||||
artists.sort(function (a, b) {
|
||||
var av = (a.title || "").toLowerCase(), bv = (b.title || "").toLowerCase()
|
||||
return av < bv ? -1 : av > bv ? 1 : 0
|
||||
})
|
||||
// Preserve the chosen sort; only alphabetical modes need letter headers.
|
||||
var mode = root.view.artistsSort
|
||||
var grouped = mode === "default" || mode === "default-reverse"
|
||||
|| mode.indexOf("title-") === 0
|
||||
if (mode === "default" || mode === "default-reverse") {
|
||||
artists.sort(function(a, b) {
|
||||
var result = (a.title || "").localeCompare(b.title || "")
|
||||
return mode === "default-reverse" ? -result : result
|
||||
})
|
||||
}
|
||||
var out = []
|
||||
var flat = []
|
||||
var jumps = []
|
||||
var last = null
|
||||
for (i = 0; i < artists.length; i++) {
|
||||
var key = root.view.alphaKey(artists[i].title)
|
||||
if (key !== last) {
|
||||
if (grouped && key !== last) {
|
||||
jumps.push({ "letter": key, "index": out.length })
|
||||
out.push({ "t": 0, "label": key })
|
||||
flat.push(root.headerRow)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// LibraryToolbar — the Library view's fixed chrome row: the six-tab menu on
|
||||
// the left, the per-tab ACTION cluster beside it, and the per-tab controls on
|
||||
// the right (FavoritesView.slint:444-942, rows 1 and 2 folded into one 56px
|
||||
// band because this port's header carries no "Library" title).
|
||||
// LibraryToolbar — two rows: navigation/actions/selects above, an always-open
|
||||
// search and independent opt-in filter switches below. Narrow panes scroll
|
||||
// each control row horizontally instead of covering neighbouring controls.
|
||||
//
|
||||
// EXTRACTED from views/LibraryView.qml (track rule 2 — the file was 1,881
|
||||
// lines). Everything it reads and writes goes through `view`, the LibraryView
|
||||
@@ -14,20 +13,45 @@ import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
import "../../assets/release-sort.js" as ReleaseSort
|
||||
|
||||
Item {
|
||||
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.
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
height: 56
|
||||
// The width here is the REAL content pane after the navigation and
|
||||
// Queue/Lyrics columns have taken their space. At this breakpoint the
|
||||
// six-tab strip and the All controls fit in one row without either side
|
||||
// eating the other.
|
||||
readonly property bool singleRow: view.activeTab === "labels" || view.activeTab === "playlists"
|
||||
|| view.activeTab === "artists"
|
||||
readonly property real rowGap: 8
|
||||
readonly property real secondRowY: 25 + Math.max(tabRow.height, 30) / 2 + rowGap
|
||||
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
|
||||
|
||||
// Small toolbar toggle button (ToggleButton sm): 30px, active = accent.
|
||||
@@ -62,7 +86,7 @@ Item {
|
||||
component GenreToolButton: Rectangle {
|
||||
id: gtb
|
||||
readonly property bool active: root.view.genreCount > 0
|
||||
width: root.compactChrome ? 30 : gtbRow.implicitWidth
|
||||
width: gtbRow.implicitWidth
|
||||
height: 30
|
||||
radius: 6
|
||||
color: gtb.active ? theme.accent
|
||||
@@ -71,9 +95,9 @@ Item {
|
||||
id: gtbRow
|
||||
anchors.centerIn: parent
|
||||
height: parent.height
|
||||
leftPadding: root.compactChrome ? 0 : 10
|
||||
rightPadding: root.compactChrome ? 0 : 12
|
||||
spacing: root.compactChrome ? 0 : 7
|
||||
leftPadding: 10
|
||||
rightPadding: 12
|
||||
spacing: 7
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 13
|
||||
@@ -91,7 +115,7 @@ Item {
|
||||
tintName: gtb.active ? theme.accentGlyphTint : "secondary"
|
||||
}
|
||||
Text {
|
||||
visible: !root.compactChrome
|
||||
visible: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.view.genreCount === 0
|
||||
? QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||
@@ -127,7 +151,7 @@ Item {
|
||||
anchor: gtb
|
||||
groups: {
|
||||
var out = []
|
||||
var names = (root.view.genreDoc.names || {})["library-all"] || []
|
||||
var names = (root.view.genreDoc.names || {})[root.view.genreContext] || []
|
||||
if (names.length > 0)
|
||||
out.push({ group: QbzSession.tr("Genre", QbzSession.trRev),
|
||||
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 {
|
||||
x: 32
|
||||
y: 25 - height / 2
|
||||
@@ -239,9 +273,9 @@ Item {
|
||||
tabs: [
|
||||
{ "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": "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": "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 },
|
||||
]
|
||||
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.
|
||||
Row {
|
||||
x: parent.width - width - 32
|
||||
id: topControls
|
||||
x: Math.max(32 + tabRow.width + 16, topChrome.width - width - 32)
|
||||
y: 25 - height / 2
|
||||
height: 30
|
||||
spacing: 8
|
||||
|
||||
// ===== All toolbar =====
|
||||
Row {
|
||||
visible: root.view.activeTab === "all"
|
||||
spacing: 8
|
||||
height: parent.height
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item {
|
||||
id: firstRowSearchSlot
|
||||
visible: root.singleRow
|
||||
width: searchBox.width
|
||||
height: 30
|
||||
}
|
||||
|
||||
// ===== 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 {
|
||||
visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums")
|
||||
visible: ["all", "tracks", "albums"].indexOf(root.view.activeTab) >= 0
|
||||
&& root.view.tabHasItems
|
||||
}
|
||||
GroupSelect {
|
||||
@@ -560,9 +325,7 @@ Item {
|
||||
]
|
||||
onPicked: function (v) { root.view.setPref("albumsGroup", v) }
|
||||
}
|
||||
// Artists group (A-Z) — GRID mode only, like the reference
|
||||
// (FavoritesView.slint:763: "Tauri hides it in sidepanel"; the
|
||||
// sidepanel's left rail is ALWAYS A-Z grouped).
|
||||
// Grid grouping is independent of the chosen sort.
|
||||
GroupSelect {
|
||||
visible: root.view.activeTab === "artists" && root.view.tabHasItems
|
||||
&& root.view.artistsView === "grid"
|
||||
@@ -574,152 +337,324 @@ Item {
|
||||
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 {
|
||||
visible: root.view.activeTab === "albums" && root.view.tabHasItems
|
||||
width: albumsSortRow.width
|
||||
visible: rightButtons.width > 0
|
||||
width: 1
|
||||
height: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
Row {
|
||||
id: rightButtons
|
||||
height: 30
|
||||
radius: 6
|
||||
color: albumsSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
spacing: 8
|
||||
Row {
|
||||
id: albumsSortRow
|
||||
height: parent.height
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
spacing: 6
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + (
|
||||
root.view.albumsSort === "title-asc" ? QbzSession.tr("Title A-Z", QbzSession.trRev)
|
||||
: root.view.albumsSort === "title-desc" ? QbzSession.tr("Title Z-A", QbzSession.trRev)
|
||||
: root.view.albumsSort === "artist-asc" ? QbzSession.tr("Artist A-Z", QbzSession.trRev)
|
||||
: 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
|
||||
id: actionRow
|
||||
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)]
|
||||
}
|
||||
QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
|
||||
}
|
||||
MouseArea {
|
||||
id: albumsSortArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: albumsSortMenu.openBelowRight(albumsSortArea)
|
||||
}
|
||||
QbzContextMenu {
|
||||
id: albumsSortMenu
|
||||
menuWidth: 172
|
||||
Repeater {
|
||||
model: [
|
||||
{ "value": "default", "label": QbzSession.tr("Default", QbzSession.trRev) },
|
||||
{ "value": "newest", "label": QbzSession.tr("Newest", QbzSession.trRev) },
|
||||
{ "value": "oldest", "label": QbzSession.tr("Oldest", QbzSession.trRev) },
|
||||
{ "value": "title-asc", "label": QbzSession.tr("Title A-Z", QbzSession.trRev) },
|
||||
{ "value": "title-desc", "label": QbzSession.tr("Title Z-A", QbzSession.trRev) },
|
||||
{ "value": "artist-asc", "label": QbzSession.tr("Artist A-Z", QbzSession.trRev) },
|
||||
]
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
height: 33
|
||||
radius: 5
|
||||
color: abSortOptArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
text: modelData.label
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: root.view.albumsSort === modelData.value ? theme.weightSemibold : theme.weightRegular
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
MouseArea {
|
||||
id: abSortOptArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.view.setPref("albumsSort", modelData.value)
|
||||
albumsSortMenu.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
name: root.view.albumsView === "list" ? "layout-grid" : "list"
|
||||
active: false
|
||||
onClicked: root.view.setPref("albumsView",
|
||||
root.view.albumsView === "list" ? "grid" : "list")
|
||||
}
|
||||
|
||||
} // topChrome
|
||||
|
||||
// Search remains visible, including when a restriction produces no rows.
|
||||
QbzLineEdit {
|
||||
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
|
||||
name: root.view.playlistsView === "list" ? "layout-grid" : "list"
|
||||
active: false
|
||||
onClicked: root.view.setPref("playlistsView",
|
||||
root.view.playlistsView === "list" ? "grid" : "list")
|
||||
}
|
||||
|
||||
// Compact Library-only switch: glyph travels inside the thumb.
|
||||
component SourceSwitch: Rectangle {
|
||||
id: sourceSwitch
|
||||
property string label: ""
|
||||
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 —
|
||||
// `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")
|
||||
Rectangle {
|
||||
width: 20
|
||||
height: 20
|
||||
radius: 10
|
||||
x: sourceSwitch.checked ? sourceSwitch.width - width - 3 : 3
|
||||
y: 3
|
||||
color: theme.textPrimary
|
||||
QbzIcon {
|
||||
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).
|
||||
Rectangle {
|
||||
visible: root.view.activeTab === "playlists" && root.view.tabHasItems
|
||||
width: plSubRow.width
|
||||
HoverHandler { onHoveredChanged: hovered ? switchTip.enter() : switchTip.exit() }
|
||||
QbzFilterTip {
|
||||
id: switchTip
|
||||
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
|
||||
radius: 6
|
||||
color: theme.surfaceElevated
|
||||
Row {
|
||||
id: plSubRow
|
||||
padding: 3
|
||||
spacing: 4
|
||||
Repeater {
|
||||
model: [
|
||||
{ "id": "favorites", "label": QbzSession.tr("Library", QbzSession.trRev) },
|
||||
{ "id": "following", "label": QbzSession.tr("Following", QbzSession.trRev) },
|
||||
]
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
property bool active: root.view.playlistsSubTab === modelData.id
|
||||
width: plSubText.implicitWidth + 20
|
||||
height: 24
|
||||
radius: 4
|
||||
color: active ? theme.surfaceMain : "transparent"
|
||||
Text {
|
||||
id: plSubText
|
||||
anchors.centerIn: parent
|
||||
text: modelData.label
|
||||
color: parent.active ? theme.textPrimary : theme.textMuted
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.view.playlistsSubTab = modelData.id
|
||||
}
|
||||
}
|
||||
}
|
||||
spacing: 12
|
||||
readonly property bool sourceTab: root.view.activeTab === "all"
|
||||
|| root.view.activeTab === "tracks" || root.view.activeTab === "albums"
|
||||
SourceSwitch {
|
||||
visible: switches.sourceTab
|
||||
glyph: "shopping-bag"
|
||||
label: QbzSession.tr("Purchases only", QbzSession.trRev)
|
||||
explanation: QbzSession.tr("Show only purchased items. Combines with the other enabled filters.", QbzSession.trRev)
|
||||
checked: root.view.showPurchases
|
||||
onToggled: function (value) { root.view.setShowPurchases(value) }
|
||||
}
|
||||
SourceSwitch {
|
||||
visible: switches.sourceTab
|
||||
glyph: "heart"
|
||||
label: QbzSession.tr("Favorites only", QbzSession.trRev)
|
||||
explanation: QbzSession.tr("Show only favorites. Combines with the other enabled filters.", QbzSession.trRev)
|
||||
checked: root.view.showFavorites
|
||||
onToggled: function (value) { root.view.setShowFavorites(value) }
|
||||
}
|
||||
SourceSwitch {
|
||||
visible: root.view.activeTab === "all"
|
||||
glyph: "user-plus"
|
||||
label: QbzSession.tr("Following only", QbzSession.trRev)
|
||||
explanation: QbzSession.tr("Show only followed items. Combines with the other enabled filters.", QbzSession.trRev)
|
||||
checked: root.view.showFollowing
|
||||
onToggled: function (value) { root.view.setShowFollowing(value) }
|
||||
}
|
||||
SourceSwitch {
|
||||
visible: root.view.activeTab === "tracks" || root.view.activeTab === "albums"
|
||||
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() {
|
||||
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
|
||||
// enter favorites_ui.json, but the singleton outlives LibraryView so
|
||||
// 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_favorites)]
|
||||
#[qproperty(bool, session_show_following)]
|
||||
@@ -174,6 +177,9 @@ pub struct QbzLibraryRust {
|
||||
library_json: QString,
|
||||
library_counts_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_favorites: 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
|
||||
// (the real values arrive in `boot`).
|
||||
library_prefs_json: QString::from("{}"),
|
||||
session_show_purchases: true,
|
||||
session_show_favorites: true,
|
||||
session_show_following: true,
|
||||
session_albums_show_purchases: true,
|
||||
session_albums_show_favorites: true,
|
||||
session_tracks_show_purchases: true,
|
||||
session_tracks_show_favorites: true,
|
||||
session_albums_hires_only: false,
|
||||
session_tracks_hires_only: false,
|
||||
session_hide_local_albums: false,
|
||||
session_show_purchases: false,
|
||||
session_show_favorites: false,
|
||||
session_show_following: false,
|
||||
session_albums_show_purchases: false,
|
||||
session_albums_show_favorites: false,
|
||||
session_tracks_show_purchases: false,
|
||||
session_tracks_show_favorites: false,
|
||||
sidepanel_json: QString::from("{}"),
|
||||
}
|
||||
}
|
||||
@@ -324,3 +333,23 @@ impl qbz_library_bridge::QbzLibrary {
|
||||
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,
|
||||
#[serde(default = "d_default")]
|
||||
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")]
|
||||
pub albums_group: String,
|
||||
#[serde(default = "d_off")]
|
||||
@@ -76,6 +84,10 @@ impl Default for Prefs {
|
||||
Self {
|
||||
albums_view: d_grid(),
|
||||
albums_sort: d_default(),
|
||||
labels_sort: d_default(),
|
||||
artists_sort: d_default(),
|
||||
playlists_sort: d_default(),
|
||||
tracks_sort: d_default(),
|
||||
albums_group: d_off(),
|
||||
tracks_group: d_off(),
|
||||
playlists_view: d_grid(),
|
||||
@@ -112,6 +124,10 @@ pub fn to_json() -> String {
|
||||
serde_json::json!({
|
||||
"albumsView": p.albums_view,
|
||||
"albumsSort": p.albums_sort,
|
||||
"labelsSort": p.labels_sort,
|
||||
"artistsSort": p.artists_sort,
|
||||
"playlistsSort": p.playlists_sort,
|
||||
"tracksSort": p.tracks_sort,
|
||||
"albumsGroup": p.albums_group,
|
||||
"tracksGroup": p.tracks_group,
|
||||
"playlistsView": p.playlists_view,
|
||||
@@ -179,6 +195,10 @@ pub fn set(key: &str, value: &str) {
|
||||
match key {
|
||||
"albumsView" => update(|p| p.albums_view = 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),
|
||||
"tracksGroup" => update(|p| p.tracks_group = 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")]
|
||||
pub cache_status: i32,
|
||||
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,
|
||||
#[serde(
|
||||
default,
|
||||
@@ -167,6 +173,12 @@ pub struct FeedItem {
|
||||
pub explicit: bool,
|
||||
#[serde(rename = "playlistOwned")]
|
||||
pub playlist_owned: bool,
|
||||
#[serde(
|
||||
default,
|
||||
rename = "playlistCreator",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
pub playlist_creator: String,
|
||||
#[serde(rename = "playlistFollowing")]
|
||||
pub playlist_following: bool,
|
||||
#[serde(rename = "isPinned", default)]
|
||||
@@ -551,6 +563,15 @@ fn map_track(track: Track) -> FeedItem {
|
||||
.performer
|
||||
.map(|p| (p.name, p.id.to_string()))
|
||||
.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 {
|
||||
kind: "track".into(),
|
||||
group: "favorites".into(),
|
||||
@@ -565,6 +586,8 @@ fn map_track(track: Track) -> FeedItem {
|
||||
album_artist,
|
||||
album_artist_id,
|
||||
genre,
|
||||
label,
|
||||
release_sort_key,
|
||||
duration: mmss(duration_secs),
|
||||
isrc,
|
||||
duration_secs,
|
||||
@@ -625,6 +648,9 @@ fn map_album(album: Album, ready_offline_tracks: usize) -> FeedItem {
|
||||
subtitle: artist.clone(),
|
||||
artist,
|
||||
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(),
|
||||
year: qbz_text_utils::dates::release_label(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
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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
|
||||
@@ -762,6 +804,9 @@ fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
|
||||
id: playlist.id.to_string(),
|
||||
title: playlist.name.clone(),
|
||||
subtitle,
|
||||
track_count: Some(playlist.tracks_count),
|
||||
duration_secs: playlist.duration,
|
||||
updated_at: playlist.updated_at,
|
||||
playlist_own_image: !cover_url.is_empty(),
|
||||
image_url: cover_url,
|
||||
covers,
|
||||
@@ -775,6 +820,7 @@ fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
|
||||
// still available to callers as `group` / `playlist_following`.
|
||||
is_favorite: crate::fav_cache_qt::is_favorite("playlist", &playlist.id.to_string()),
|
||||
playlist_owned: owned,
|
||||
playlist_creator: playlist_creator(owned, &playlist.owner.name).into(),
|
||||
playlist_following: is_following,
|
||||
..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
|
||||
// tombstone. `None` means "unknown" and leaves rows interactive;
|
||||
// the next successful Library load will settle them.
|
||||
let existing_albums: Option<HashSet<String>> = if album_candidates.is_empty() {
|
||||
Some(HashSet::new())
|
||||
let existing_albums: Option<HashMap<String, Vec<String>>> = if album_candidates.is_empty() {
|
||||
Some(HashMap::new())
|
||||
} else {
|
||||
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
|
||||
{
|
||||
@@ -1172,12 +1218,18 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
|
||||
let source_unavailable = lf.kind == "album"
|
||||
&& existing_albums
|
||||
.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(
|
||||
FeedItem {
|
||||
kind: lf.kind,
|
||||
group: "local".into(),
|
||||
source: lf.source,
|
||||
sources,
|
||||
subtitle: lf.subtitle,
|
||||
artist: lf.artist.clone(),
|
||||
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(
|
||||
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(),
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
@@ -1572,6 +1627,14 @@ async fn fetch_purchases(
|
||||
subtitle: t.performer.name.clone(),
|
||||
artist: t.performer.name,
|
||||
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_id: aid,
|
||||
album_artist_id,
|
||||
@@ -2311,11 +2374,13 @@ pub(crate) fn insert_playlist_row(
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
subtitle: playlist_subtitle(owner, tracks_count),
|
||||
track_count: Some(tracks_count),
|
||||
playlist_own_image: !cover_url.is_empty(),
|
||||
image_url: cover_url.to_string(),
|
||||
covers,
|
||||
is_favorite: crate::fav_cache_qt::is_favorite("playlist", id),
|
||||
playlist_owned: !following,
|
||||
playlist_creator: playlist_creator(!following, owner).into(),
|
||||
playlist_following: following,
|
||||
// 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
|
||||
@@ -2601,6 +2666,57 @@ pub fn play_from_visible(visible_ids_json: String, clicked_id: String) {
|
||||
mod tests {
|
||||
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]
|
||||
async fn favorites_paging_passes_ten_thousand_and_preserves_every_track() {
|
||||
for reported_total in [0, 10_751] {
|
||||
|
||||
@@ -559,16 +559,18 @@ pub fn counts() -> LocalCounts {
|
||||
state(|s| s.counts.clone())
|
||||
}
|
||||
|
||||
/// Whether the local library is usable at all (a per-user db with at least
|
||||
/// one registered folder). Drives the "nothing indexed yet" empty state.
|
||||
/// Whether any registered folder or indexed track makes the library usable.
|
||||
/// Offline downloads are indexed without registering a music folder.
|
||||
/// A server-only setup is ALSO usable — the browse union has content even
|
||||
/// with no registered on-disk folder.
|
||||
pub fn has_library() -> bool {
|
||||
let has_folders = with_db(|db| db.get_folders())
|
||||
.map(|f| !f.is_empty())
|
||||
.unwrap_or(false);
|
||||
let indexed_tracks = with_db(|db| db.count_all_local_tracks()).unwrap_or(0);
|
||||
library_sources_available(
|
||||
has_folders,
|
||||
indexed_tracks,
|
||||
crate::local_plex::is_configured(),
|
||||
crate::local_plex::cached_track_count(),
|
||||
crate::media_servers_qt::cached_track_count(),
|
||||
@@ -577,11 +579,12 @@ pub fn has_library() -> bool {
|
||||
|
||||
fn library_sources_available(
|
||||
has_folders: bool,
|
||||
indexed_tracks: u64,
|
||||
plex_enabled: bool,
|
||||
plex_tracks: i64,
|
||||
remote_tracks: i64,
|
||||
) -> 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)]
|
||||
@@ -646,11 +649,21 @@ mod phase_a_tests {
|
||||
|
||||
#[test]
|
||||
fn remote_only_installation_has_a_library() {
|
||||
assert!(library_sources_available(true, false, 0, 0));
|
||||
assert!(library_sources_available(false, true, 5_137, 0));
|
||||
assert!(library_sources_available(false, false, 0, 4_924));
|
||||
assert!(library_sources_available(false, false, 0, 6_678));
|
||||
assert!(!library_sources_available(false, false, 0, 0));
|
||||
assert!(library_sources_available(true, 0, false, 0, 0));
|
||||
assert!(library_sources_available(false, 0, true, 5_137, 0));
|
||||
assert!(library_sources_available(false, 0, false, 0, 4_924));
|
||||
assert!(library_sources_available(false, 0, false, 0, 6_678));
|
||||
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]
|
||||
|
||||
@@ -122,6 +122,47 @@ fn track_cache_info(
|
||||
|
||||
fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) {
|
||||
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(
|
||||
track_id,
|
||||
file_path,
|
||||
@@ -130,7 +171,7 @@ fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) {
|
||||
off.db.clone(),
|
||||
off.get_cache_path(),
|
||||
off.library_db.clone(),
|
||||
row_sink(),
|
||||
sink,
|
||||
off.cache_semaphore.clone(),
|
||||
);
|
||||
}
|
||||
@@ -723,6 +764,8 @@ pub fn remove_album(album_id: String) {
|
||||
offline_qt::mark_cached(*id, false);
|
||||
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::offline_manager_qt::refresh_if_open().await;
|
||||
});
|
||||
@@ -765,6 +808,8 @@ pub fn clear_all() {
|
||||
return;
|
||||
}
|
||||
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::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);
|
||||
crate::local_catalog_qt::request_catch_up();
|
||||
crate::local_bridge_ops::publish_availability();
|
||||
push_status(id, 0, 0.0);
|
||||
if toast {
|
||||
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}");
|
||||
}
|
||||
|
||||
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));
|
||||
crate::local_catalog_qt::request_catch_up();
|
||||
crate::local_bridge_ops::publish_availability();
|
||||
log::info!("[qbz-qt] offline cache activated for user {user_id}");
|
||||
load_cached_ids().await;
|
||||
}
|
||||
|
||||
@@ -778,6 +778,7 @@ mod tests {
|
||||
streamable: None,
|
||||
streamable_at: None,
|
||||
release_date_stream: None,
|
||||
release_date_original: None,
|
||||
});
|
||||
|
||||
let qt = track_to_queue_track(&track);
|
||||
|
||||
@@ -79,22 +79,24 @@ function qmlFunction(name) {
|
||||
return libraryQml.slice(start, stop);
|
||||
}
|
||||
const bridge = {
|
||||
sessionShowPurchases: true, sessionShowFavorites: true, sessionShowFollowing: true,
|
||||
sessionAlbumsShowPurchases: true, sessionAlbumsShowFavorites: true,
|
||||
sessionTracksShowPurchases: true, sessionTracksShowFavorites: true,
|
||||
sessionShowPurchases: false, sessionShowFavorites: false, sessionShowFollowing: false,
|
||||
sessionAlbumsShowPurchases: false, sessionAlbumsShowFavorites: false,
|
||||
sessionTracksShowPurchases: false, sessionTracksShowFavorites: false,
|
||||
sessionAlbumsHiresOnly:false, sessionTracksHiresOnly:false, sessionHideLocalAlbums:false,
|
||||
};
|
||||
const lib = vm.createContext({
|
||||
QbzLibrary: bridge, ReleaseSort: sort, Qt: {callLater() {}},
|
||||
activeTab: 'all', search: '', tabSearch: '', showLocal: true,
|
||||
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;
|
||||
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];
|
||||
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);
|
||||
}
|
||||
const libraryRows = [];
|
||||
@@ -114,7 +116,7 @@ for (const tab of ['all', 'albums', 'tracks']) {
|
||||
lib.activeTab = tab;
|
||||
if (tab === 'all') lib.setShowFollowing(false);
|
||||
for (const [purchases, favorites, expected] of [
|
||||
[true, true, ['both','favorite','purchased']],
|
||||
[true, true, ['both']],
|
||||
[true, false, ['both','purchased']],
|
||||
[false, true, ['both','favorite']],
|
||||
[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']);
|
||||
lib.albumsSort = 'newest';
|
||||
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']);
|
||||
lib.activeTab = '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
|
||||
// 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);
|
||||
}
|
||||
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