diff --git a/Sources/Kaset/Models/PlaylistSortOrder.swift b/Sources/Kaset/Models/PlaylistSortOrder.swift new file mode 100644 index 00000000..8fff7621 --- /dev/null +++ b/Sources/Kaset/Models/PlaylistSortOrder.swift @@ -0,0 +1,36 @@ +import Foundation + +// MARK: - PlaylistSortKey + +/// A sort key for the playlist detail track list. +enum PlaylistSortKey: String, CaseIterable, Identifiable { + case original + case title + case artist + case duration + case album + + var id: String { + self.rawValue + } + + var displayName: String { + switch self { + case .original: String(localized: "Original Order") + case .title: String(localized: "Title") + case .artist: String(localized: "Artist") + case .duration: String(localized: "Duration") + case .album: String(localized: "Album") + } + } +} + +// MARK: - PlaylistSortOrder + +/// A sort key plus direction. `.original` preserves the server-provided order. +struct PlaylistSortOrder: Equatable { + var key: PlaylistSortKey + var ascending: Bool + + static let `default` = PlaylistSortOrder(key: .original, ascending: true) +} diff --git a/Sources/Kaset/Models/Song.swift b/Sources/Kaset/Models/Song.swift index a1835d85..b306ac69 100644 --- a/Sources/Kaset/Models/Song.swift +++ b/Sources/Kaset/Models/Song.swift @@ -154,6 +154,16 @@ struct Song: Identifiable, Codable, Hashable { self.artists.map(\.name).joined(separator: ", ") } + /// Stable per-occurrence identity, also safe as a SwiftUI `ForEach` id — position isn't + /// identity in a sortable list. The blank guard and namespacing prevent collisions: a + /// blank set id would collapse every such row onto one identity. + var rowIdentity: String { + if let setVideoId = self.playlistSetVideoId, !setVideoId.isEmpty { + return "set:\(setVideoId)" + } + return "video:\(self.videoId)" + } + /// Formatted duration string (e.g., "3:45"). var durationDisplay: String { guard let duration else { return "--:--" } diff --git a/Sources/Kaset/Resources/Localizable.xcstrings b/Sources/Kaset/Resources/Localizable.xcstrings index 9743a91b..723be210 100644 --- a/Sources/Kaset/Resources/Localizable.xcstrings +++ b/Sources/Kaset/Resources/Localizable.xcstrings @@ -56666,6 +56666,854 @@ } } } + }, + "Original Order": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الترتيب الأصلي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ursprüngliche Reihenfolge" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Original Order" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Orden original" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ordre d’origine" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Urutan Asli" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ordine originale" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원래 순서" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Oorspronkelijke volgorde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolejność oryginalna" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ordem original" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исходный порядок" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Ursprunglig ordning" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Orijinal Sıra" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Початковий порядок" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原始顺序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原始順序" + } + } + } + }, + "Title": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "العنوان" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Title" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Título" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Titre" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Judul" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Titolo" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제목" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Titel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tytuł" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Título" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Название" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Titel" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Başlık" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Назва" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標題" + } + } + } + }, + "Duration": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المدة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dauer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Duration" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Duración" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Durée" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Durasi" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Durata" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재생 시간" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Duur" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Czas trwania" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Duração" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Длительность" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Längd" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Süre" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Тривалість" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "时长" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "時長" + } + } + } + }, + "Sort songs": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فرز الأغاني" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel sortieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort songs" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ordenar canciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Trier les titres" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Urutkan lagu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ordina i brani" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노래 정렬" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Nummers sorteren" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sortuj utwory" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ordenar músicas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сортировать песни" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Sortera låtar" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şarkıları sırala" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сортувати пісні" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "排序歌曲" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排序歌曲" + } + } + } + }, + "Search in Playlist": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث في قائمة التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Playlist suchen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Search in Playlist" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar en la lista" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher dans la playlist" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Cari di playlist" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cerca nella playlist" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재생목록에서 검색" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Zoeken in afspeellijst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szukaj w playliście" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar na playlist" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск в плейлисте" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Sök i spellista" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalma listesinde ara" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Пошук у плейлисті" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在播放列表中搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在播放清單中搜尋" + } + } + } + }, + "Loading all songs…": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل كل الأغاني…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Titel werden geladen …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading all songs…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando todas las canciones…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement de tous les titres…" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Memuat semua lagu…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Caricamento di tutti i brani…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 노래 불러오는 중…" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Alle nummers laden…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ładowanie wszystkich utworów…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregando todas as músicas…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка всех песен…" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Läser in alla låtar…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tüm şarkılar yükleniyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Завантаження всіх пісень…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载全部歌曲…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在載入全部歌曲…" + } + } + } + }, + "Load All Songs When Opening a Playlist": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحميل كل الأغاني عند فتح قائمة التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Öffnen einer Playlist alle Titel laden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Load All Songs When Opening a Playlist" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargar todas las canciones al abrir una lista" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charger tous les titres à l’ouverture d’une playlist" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Muat semua lagu saat membuka playlist" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Carica tutti i brani all’apertura di una playlist" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재생목록을 열 때 모든 노래 불러오기" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Alle nummers laden bij openen van afspeellijst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wczytuj wszystkie utwory po otwarciu playlisty" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregar todas as músicas ao abrir uma playlist" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загружать все песни при открытии плейлиста" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Läs in alla låtar när en spellista öppnas" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir çalma listesi açıldığında tüm şarkıları yükle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Завантажувати всі пісні під час відкриття плейлиста" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开播放列表时加载全部歌曲" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟播放清單時載入全部歌曲" + } + } + } + }, + "When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عند الإيقاف، تُحمَّل الأغاني صفحةً تلو الأخرى أثناء التمرير. عند التفعيل، يؤدي فتح قائمة تشغيل كبيرة إلى جلب كل الصفحات مسبقًا، ما قد يُنشئ طلبات كثيرة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn aus, werden Titel beim Scrollen seitenweise geladen. Wenn an, werden beim Öffnen einer großen Playlist alle Seiten sofort geladen, was viele Anfragen verursachen kann." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Si está desactivado, las canciones se cargan página por página al desplazarte. Si está activado, abrir una lista grande carga todas las páginas de inmediato, lo que puede generar muchas solicitudes." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé, les titres se chargent page par page au défilement. Activé, l’ouverture d’une grande playlist charge toutes les pages d’emblée, ce qui peut générer de nombreuses requêtes." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Jika nonaktif, lagu dimuat per halaman saat Anda menggulir. Jika aktif, membuka playlist besar akan mengambil semua halaman di awal, yang dapat menghasilkan banyak permintaan." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Se disattivo, i brani si caricano una pagina alla volta durante lo scorrimento. Se attivo, l’apertura di una playlist grande carica subito tutte le pagine, generando potenzialmente molte richieste." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄면 스크롤할 때 노래가 페이지 단위로 로드됩니다. 켜면 큰 재생목록을 열 때 모든 페이지를 미리 가져오므로 요청이 많아질 수 있습니다." + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Uitgeschakeld laden nummers per pagina terwijl je scrolt. Ingeschakeld haalt het openen van een grote afspeellijst meteen alle pagina’s op, wat veel verzoeken kan veroorzaken." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Gdy wyłączone, utwory ładują się stronami podczas przewijania. Gdy włączone, otwarcie dużej playlisty od razu pobiera wszystkie strony, co może generować wiele żądań." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Quando desativado, as músicas carregam uma página por vez conforme você rola. Quando ativado, abrir uma playlist grande busca todas as páginas de uma vez, o que pode gerar muitas solicitações." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Когда выключено, песни загружаются постранично при прокрутке. Когда включено, открытие большого плейлиста сразу загружает все страницы, что может создать много запросов." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "När det är av läses låtar in en sida i taget när du bläddrar. När det är på hämtar en stor spellista alla sidor direkt, vilket kan ge många förfrågningar." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapalıyken, kaydırdıkça şarkılar sayfa sayfa yüklenir. Açıkken, büyük bir çalma listesini açmak tüm sayfaları önceden getirir; bu çok sayıda istek oluşturabilir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Коли вимкнено, пісні завантажуються посторінково під час прокручування. Коли ввімкнено, відкриття великого плейлиста одразу завантажує всі сторінки, що може створити багато запитів." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭时,滚动时按页加载歌曲。开启时,打开大型播放列表会一次性获取全部分页,可能产生大量请求。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉時,捲動時逐頁載入歌曲。開啟時,打開大型播放清單會一次擷取全部分頁,可能產生大量請求。" + } + } + } } }, "version": "1.1" diff --git a/Sources/Kaset/Resources/ar.lproj/Localizable.strings b/Sources/Kaset/Resources/ar.lproj/Localizable.strings index 9ba5fc70..262b8f04 100644 --- a/Sources/Kaset/Resources/ar.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ar.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "ابدأ محادثة جديدة للمتابعة."; "Ask Gemini response ready" = "رد «اسأل Gemini» جاهز"; "New Ask Gemini chat ready" = "محادثة «اسأل Gemini» الجديدة جاهزة"; +"Original Order" = "الترتيب الأصلي"; +"Title" = "العنوان"; +"Duration" = "المدة"; +"Sort songs" = "فرز الأغاني"; +"Search in Playlist" = "البحث في قائمة التشغيل"; +"Loading all songs…" = "جارٍ تحميل كل الأغاني…"; +"Load All Songs When Opening a Playlist" = "تحميل كل الأغاني عند فتح قائمة التشغيل"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "عند الإيقاف، تُحمَّل الأغاني صفحةً تلو الأخرى أثناء التمرير. عند التفعيل، يؤدي فتح قائمة تشغيل كبيرة إلى جلب كل الصفحات مسبقًا، ما قد يُنشئ طلبات كثيرة."; diff --git a/Sources/Kaset/Resources/de.lproj/Localizable.strings b/Sources/Kaset/Resources/de.lproj/Localizable.strings index 52a032fa..1bffa2f9 100644 --- a/Sources/Kaset/Resources/de.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/de.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Starte einen neuen Chat, um fortzufahren."; "Ask Gemini response ready" = "Antwort von „Gemini fragen“ ist bereit"; "New Ask Gemini chat ready" = "Neuer Chat mit „Gemini fragen“ ist bereit"; +"Original Order" = "Ursprüngliche Reihenfolge"; +"Title" = "Titel"; +"Duration" = "Dauer"; +"Sort songs" = "Titel sortieren"; +"Search in Playlist" = "In Playlist suchen"; +"Loading all songs…" = "Alle Titel werden geladen …"; +"Load All Songs When Opening a Playlist" = "Beim Öffnen einer Playlist alle Titel laden"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Wenn aus, werden Titel beim Scrollen seitenweise geladen. Wenn an, werden beim Öffnen einer großen Playlist alle Seiten sofort geladen, was viele Anfragen verursachen kann."; diff --git a/Sources/Kaset/Resources/en.lproj/Localizable.strings b/Sources/Kaset/Resources/en.lproj/Localizable.strings index af2f1cbb..6d7b74b0 100644 --- a/Sources/Kaset/Resources/en.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/en.lproj/Localizable.strings @@ -35,3 +35,11 @@ "Start a new chat to continue." = "Start a new chat to continue."; "Ask Gemini response ready" = "Ask Gemini response ready"; "New Ask Gemini chat ready" = "New Ask Gemini chat ready"; +"Original Order" = "Original Order"; +"Title" = "Title"; +"Duration" = "Duration"; +"Sort songs" = "Sort songs"; +"Search in Playlist" = "Search in Playlist"; +"Loading all songs…" = "Loading all songs…"; +"Load All Songs When Opening a Playlist" = "Load All Songs When Opening a Playlist"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests."; diff --git a/Sources/Kaset/Resources/es.lproj/Localizable.strings b/Sources/Kaset/Resources/es.lproj/Localizable.strings index 38e87e5e..09b074a8 100644 --- a/Sources/Kaset/Resources/es.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/es.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Inicia una nueva conversación para continuar."; "Ask Gemini response ready" = "Respuesta de Preguntar a Gemini lista"; "New Ask Gemini chat ready" = "Nueva conversación de Preguntar a Gemini lista"; +"Original Order" = "Orden original"; +"Title" = "Título"; +"Duration" = "Duración"; +"Sort songs" = "Ordenar canciones"; +"Search in Playlist" = "Buscar en la lista"; +"Loading all songs…" = "Cargando todas las canciones…"; +"Load All Songs When Opening a Playlist" = "Cargar todas las canciones al abrir una lista"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Si está desactivado, las canciones se cargan página por página al desplazarte. Si está activado, abrir una lista grande carga todas las páginas de inmediato, lo que puede generar muchas solicitudes."; diff --git a/Sources/Kaset/Resources/fr.lproj/Localizable.strings b/Sources/Kaset/Resources/fr.lproj/Localizable.strings index 49ff25e8..fab102f7 100644 --- a/Sources/Kaset/Resources/fr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/fr.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Commencez une nouvelle conversation pour continuer."; "Ask Gemini response ready" = "Réponse de Demander à Gemini prête"; "New Ask Gemini chat ready" = "La nouvelle conversation « Demander à Gemini » est prête"; +"Original Order" = "Ordre d’origine"; +"Title" = "Titre"; +"Duration" = "Durée"; +"Sort songs" = "Trier les titres"; +"Search in Playlist" = "Rechercher dans la playlist"; +"Loading all songs…" = "Chargement de tous les titres…"; +"Load All Songs When Opening a Playlist" = "Charger tous les titres à l’ouverture d’une playlist"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Désactivé, les titres se chargent page par page au défilement. Activé, l’ouverture d’une grande playlist charge toutes les pages d’emblée, ce qui peut générer de nombreuses requêtes."; diff --git a/Sources/Kaset/Resources/id.lproj/Localizable.strings b/Sources/Kaset/Resources/id.lproj/Localizable.strings index 0e1585c7..0447dd0c 100644 --- a/Sources/Kaset/Resources/id.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/id.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Mulai chat baru untuk melanjutkan."; "Ask Gemini response ready" = "Respons Tanya Gemini siap"; "New Ask Gemini chat ready" = "Chat Tanya Gemini baru siap"; +"Original Order" = "Urutan Asli"; +"Title" = "Judul"; +"Duration" = "Durasi"; +"Sort songs" = "Urutkan lagu"; +"Search in Playlist" = "Cari di playlist"; +"Loading all songs…" = "Memuat semua lagu…"; +"Load All Songs When Opening a Playlist" = "Muat semua lagu saat membuka playlist"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Jika nonaktif, lagu dimuat per halaman saat Anda menggulir. Jika aktif, membuka playlist besar akan mengambil semua halaman di awal, yang dapat menghasilkan banyak permintaan."; diff --git a/Sources/Kaset/Resources/it.lproj/Localizable.strings b/Sources/Kaset/Resources/it.lproj/Localizable.strings index 074d1e53..92009c8c 100644 --- a/Sources/Kaset/Resources/it.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/it.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Avvia una nuova chat per continuare."; "Ask Gemini response ready" = "Risposta di Chiedi a Gemini pronta"; "New Ask Gemini chat ready" = "Nuova chat di Chiedi a Gemini pronta"; +"Original Order" = "Ordine originale"; +"Title" = "Titolo"; +"Duration" = "Durata"; +"Sort songs" = "Ordina i brani"; +"Search in Playlist" = "Cerca nella playlist"; +"Loading all songs…" = "Caricamento di tutti i brani…"; +"Load All Songs When Opening a Playlist" = "Carica tutti i brani all’apertura di una playlist"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Se disattivo, i brani si caricano una pagina alla volta durante lo scorrimento. Se attivo, l’apertura di una playlist grande carica subito tutte le pagine, generando potenzialmente molte richieste."; diff --git a/Sources/Kaset/Resources/ko.lproj/Localizable.strings b/Sources/Kaset/Resources/ko.lproj/Localizable.strings index 5c43e0a7..3e4487e9 100644 --- a/Sources/Kaset/Resources/ko.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ko.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "계속하려면 새 채팅을 시작하세요."; "Ask Gemini response ready" = "Gemini에게 질문 응답 준비됨"; "New Ask Gemini chat ready" = "새 Gemini 채팅이 준비되었습니다"; +"Original Order" = "원래 순서"; +"Title" = "제목"; +"Duration" = "재생 시간"; +"Sort songs" = "노래 정렬"; +"Search in Playlist" = "재생목록에서 검색"; +"Loading all songs…" = "모든 노래 불러오는 중…"; +"Load All Songs When Opening a Playlist" = "재생목록을 열 때 모든 노래 불러오기"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "끄면 스크롤할 때 노래가 페이지 단위로 로드됩니다. 켜면 큰 재생목록을 열 때 모든 페이지를 미리 가져오므로 요청이 많아질 수 있습니다."; diff --git a/Sources/Kaset/Resources/nl.lproj/Localizable.strings b/Sources/Kaset/Resources/nl.lproj/Localizable.strings index 97c3270e..8ac38687 100644 --- a/Sources/Kaset/Resources/nl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/nl.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Start een nieuwe chat om door te gaan."; "Ask Gemini response ready" = "Antwoord van Vraag Gemini is klaar"; "New Ask Gemini chat ready" = "Nieuwe chat met Vraag Gemini is klaar"; +"Original Order" = "Oorspronkelijke volgorde"; +"Title" = "Titel"; +"Duration" = "Duur"; +"Sort songs" = "Nummers sorteren"; +"Search in Playlist" = "Zoeken in afspeellijst"; +"Loading all songs…" = "Alle nummers laden…"; +"Load All Songs When Opening a Playlist" = "Alle nummers laden bij openen van afspeellijst"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Uitgeschakeld laden nummers per pagina terwijl je scrolt. Ingeschakeld haalt het openen van een grote afspeellijst meteen alle pagina’s op, wat veel verzoeken kan veroorzaken."; diff --git a/Sources/Kaset/Resources/pl.lproj/Localizable.strings b/Sources/Kaset/Resources/pl.lproj/Localizable.strings index 6378e36e..6032d9a8 100644 --- a/Sources/Kaset/Resources/pl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pl.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Rozpocznij nowy czat, aby kontynuować."; "Ask Gemini response ready" = "Odpowiedź Zapytaj Gemini jest gotowa"; "New Ask Gemini chat ready" = "Nowy czat Zapytaj Gemini jest gotowy"; +"Original Order" = "Kolejność oryginalna"; +"Title" = "Tytuł"; +"Duration" = "Czas trwania"; +"Sort songs" = "Sortuj utwory"; +"Search in Playlist" = "Szukaj w playliście"; +"Loading all songs…" = "Ładowanie wszystkich utworów…"; +"Load All Songs When Opening a Playlist" = "Wczytuj wszystkie utwory po otwarciu playlisty"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Gdy wyłączone, utwory ładują się stronami podczas przewijania. Gdy włączone, otwarcie dużej playlisty od razu pobiera wszystkie strony, co może generować wiele żądań."; diff --git a/Sources/Kaset/Resources/pt.lproj/Localizable.strings b/Sources/Kaset/Resources/pt.lproj/Localizable.strings index 5545e277..166ea5df 100644 --- a/Sources/Kaset/Resources/pt.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pt.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Inicie uma nova conversa para continuar."; "Ask Gemini response ready" = "Resposta do Perguntar ao Gemini pronta"; "New Ask Gemini chat ready" = "Nova conversa com o Gemini pronta"; +"Original Order" = "Ordem original"; +"Title" = "Título"; +"Duration" = "Duração"; +"Sort songs" = "Ordenar músicas"; +"Search in Playlist" = "Pesquisar na playlist"; +"Loading all songs…" = "Carregando todas as músicas…"; +"Load All Songs When Opening a Playlist" = "Carregar todas as músicas ao abrir uma playlist"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Quando desativado, as músicas carregam uma página por vez conforme você rola. Quando ativado, abrir uma playlist grande busca todas as páginas de uma vez, o que pode gerar muitas solicitações."; diff --git a/Sources/Kaset/Resources/ru.lproj/Localizable.strings b/Sources/Kaset/Resources/ru.lproj/Localizable.strings index 6d623cbb..3ce9c66a 100644 --- a/Sources/Kaset/Resources/ru.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ru.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Начните новый чат, чтобы продолжить."; "Ask Gemini response ready" = "Ответ «Спросить Gemini» готов"; "New Ask Gemini chat ready" = "Новый чат «Спросить Gemini» готов"; +"Original Order" = "Исходный порядок"; +"Title" = "Название"; +"Duration" = "Длительность"; +"Sort songs" = "Сортировать песни"; +"Search in Playlist" = "Поиск в плейлисте"; +"Loading all songs…" = "Загрузка всех песен…"; +"Load All Songs When Opening a Playlist" = "Загружать все песни при открытии плейлиста"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Когда выключено, песни загружаются постранично при прокрутке. Когда включено, открытие большого плейлиста сразу загружает все страницы, что может создать много запросов."; diff --git a/Sources/Kaset/Resources/sv.lproj/Localizable.strings b/Sources/Kaset/Resources/sv.lproj/Localizable.strings index 9c7df9c6..1eac552d 100644 --- a/Sources/Kaset/Resources/sv.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/sv.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Starta en ny chatt för att fortsätta."; "Ask Gemini response ready" = "Svaret från Fråga Gemini är klart"; "New Ask Gemini chat ready" = "Ny Fråga Gemini-chatt är klar"; +"Original Order" = "Ursprunglig ordning"; +"Title" = "Titel"; +"Duration" = "Längd"; +"Sort songs" = "Sortera låtar"; +"Search in Playlist" = "Sök i spellista"; +"Loading all songs…" = "Läser in alla låtar…"; +"Load All Songs When Opening a Playlist" = "Läs in alla låtar när en spellista öppnas"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "När det är av läses låtar in en sida i taget när du bläddrar. När det är på hämtar en stor spellista alla sidor direkt, vilket kan ge många förfrågningar."; diff --git a/Sources/Kaset/Resources/tr.lproj/Localizable.strings b/Sources/Kaset/Resources/tr.lproj/Localizable.strings index 9efd3847..f3275b88 100644 --- a/Sources/Kaset/Resources/tr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/tr.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Devam etmek için yeni bir sohbet başlatın."; "Ask Gemini response ready" = "Gemini'ye Sor yanıtı hazır"; "New Ask Gemini chat ready" = "Yeni Gemini sohbeti hazır"; +"Original Order" = "Orijinal Sıra"; +"Title" = "Başlık"; +"Duration" = "Süre"; +"Sort songs" = "Şarkıları sırala"; +"Search in Playlist" = "Çalma listesinde ara"; +"Loading all songs…" = "Tüm şarkılar yükleniyor…"; +"Load All Songs When Opening a Playlist" = "Bir çalma listesi açıldığında tüm şarkıları yükle"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Kapalıyken, kaydırdıkça şarkılar sayfa sayfa yüklenir. Açıkken, büyük bir çalma listesini açmak tüm sayfaları önceden getirir; bu çok sayıda istek oluşturabilir."; diff --git a/Sources/Kaset/Resources/uk.lproj/Localizable.strings b/Sources/Kaset/Resources/uk.lproj/Localizable.strings index 256f9522..d4a3af51 100644 --- a/Sources/Kaset/Resources/uk.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/uk.lproj/Localizable.strings @@ -566,3 +566,11 @@ "Start a new chat to continue." = "Почніть новий чат, щоб продовжити."; "Ask Gemini response ready" = "Відповідь «Запитати Gemini» готова"; "New Ask Gemini chat ready" = "Новий чат «Запитати Gemini» готовий"; +"Original Order" = "Початковий порядок"; +"Title" = "Назва"; +"Duration" = "Тривалість"; +"Sort songs" = "Сортувати пісні"; +"Search in Playlist" = "Пошук у плейлисті"; +"Loading all songs…" = "Завантаження всіх пісень…"; +"Load All Songs When Opening a Playlist" = "Завантажувати всі пісні під час відкриття плейлиста"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "Коли вимкнено, пісні завантажуються посторінково під час прокручування. Коли ввімкнено, відкриття великого плейлиста одразу завантажує всі сторінки, що може створити багато запитів."; diff --git a/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings index 65877186..7d1340e0 100644 --- a/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings @@ -564,3 +564,11 @@ "e.g., Remove slow songs, reorder by energy..." = "例如:移除慢歌、按能量重新排序…"; "songs" = "首歌曲"; "tracks" = "首曲目"; +"Original Order" = "原始顺序"; +"Title" = "标题"; +"Duration" = "时长"; +"Sort songs" = "排序歌曲"; +"Search in Playlist" = "在播放列表中搜索"; +"Loading all songs…" = "正在加载全部歌曲…"; +"Load All Songs When Opening a Playlist" = "打开播放列表时加载全部歌曲"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "关闭时,滚动时按页加载歌曲。开启时,打开大型播放列表会一次性获取全部分页,可能产生大量请求。"; diff --git a/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings b/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings index a54805cf..6e599dd9 100644 --- a/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings @@ -564,3 +564,11 @@ "e.g., Remove slow songs, reorder by energy..." = "例如:移除慢歌、依能量重新排序…"; "songs" = "首歌曲"; "tracks" = "首曲目"; +"Original Order" = "原始順序"; +"Title" = "標題"; +"Duration" = "時長"; +"Sort songs" = "排序歌曲"; +"Search in Playlist" = "在播放清單中搜尋"; +"Loading all songs…" = "正在載入全部歌曲…"; +"Load All Songs When Opening a Playlist" = "開啟播放清單時載入全部歌曲"; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests." = "關閉時,捲動時逐頁載入歌曲。開啟時,打開大型播放清單會一次擷取全部分頁,可能產生大量請求。"; diff --git a/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift b/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift index 6b98c99a..5a260b46 100644 --- a/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift +++ b/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift @@ -137,11 +137,11 @@ enum PlaylistPlaybackActions { static func remainingTracks(after initialTracks: [Song], in fullTracks: [Song]) -> [Song] { var unmatchedInitialCounts: [String: Int] = [:] for track in initialTracks { - unmatchedInitialCounts[self.playlistOccurrenceIdentity(for: track), default: 0] += 1 + unmatchedInitialCounts[track.rowIdentity, default: 0] += 1 } return fullTracks.filter { track in - let identity = self.playlistOccurrenceIdentity(for: track) + let identity = track.rowIdentity guard let remainingCount = unmatchedInitialCounts[identity], remainingCount > 0 else { return true } @@ -209,11 +209,4 @@ enum PlaylistPlaybackActions { playlistSetVideoId: song.playlistSetVideoId ) } - - private static func playlistOccurrenceIdentity(for song: Song) -> String { - if let setVideoId = song.playlistSetVideoId, !setVideoId.isEmpty { - return "set:\(setVideoId)" - } - return "video:\(song.videoId)" - } } diff --git a/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift b/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift new file mode 100644 index 00000000..3f72e120 --- /dev/null +++ b/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift @@ -0,0 +1,146 @@ +import Foundation + +/// Pure filter + sort for the playlist detail track list. No state, no networking. +/// +/// Sort and search run client-side over the fully-loaded track set because YouTube +/// Music's server-side playlist sort only reorders the returned window — its +/// continuation tokens carry no sort state, so paginated results revert to the +/// default order (see `docs/api-discovery.md`). +enum PlaylistTrackListPresenter { + /// Returns the tracks to display after filtering by `searchQuery` and sorting by + /// `sortOrder`. Filtering happens first, then sorting. + static func displayedTracks( + from tracks: [Song], + sortOrder: PlaylistSortOrder, + searchQuery: String + ) -> [Song] { + let filtered = Self.filter(tracks, query: searchQuery) + return Self.sort(filtered, order: sortOrder) + } + + // MARK: - Filtering + + private static func filter(_ tracks: [Song], query: String) -> [Song] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return tracks } + return tracks.filter { song in + if Self.contains(song.title, trimmed) { + return true + } + if let albumTitle = song.album?.title, Self.contains(albumTitle, trimmed) { + return true + } + return song.artists.contains { Self.contains($0.name, trimmed) } + } + } + + private static func contains(_ haystack: String, _ needle: String) -> Bool { + haystack.range( + of: needle, + options: [.caseInsensitive, .diacriticInsensitive] + ) != nil + } + + // MARK: - Sorting + + /// A track decorated with its sort key and original position. The key is built once + /// per track, not inside the comparator: `artistsDisplay` re-joins the artist array on + /// every access. The index keeps ties — and the whole comparison — stable. + private struct Decorated { + let song: Song + let index: Int + let stringKey: String? + let duration: TimeInterval? + } + + private static func sort(_ tracks: [Song], order: PlaylistSortOrder) -> [Song] { + guard order.key != .original else { return tracks } + + let decorated = tracks.enumerated().map { index, song in + Decorated( + song: song, + index: index, + stringKey: Self.stringKey(for: song, key: order.key), + duration: order.key == .duration ? song.duration : nil + ) + } + + return decorated + .sorted { Self.less($0, $1, order: order) } + .map(\.song) + } + + private static func stringKey(for song: Song, key: PlaylistSortKey) -> String? { + switch key { + case .title: + song.title + case .artist: + song.artistsDisplay.isEmpty ? nil : song.artistsDisplay + case .album: + song.album?.title + case .original, .duration: + nil + } + } + + private static func less(_ lhs: Decorated, _ rhs: Decorated, order: PlaylistSortOrder) -> Bool { + switch self.compareKeys(lhs, rhs, key: order.key) { + case .orderedSame: + lhs.index < rhs.index // stable tie-break, direction-independent + case .orderedAscending: + order.ascending + case .orderedDescending: + !order.ascending + case .lhsMissing: + false // missing keys always sort last + case .rhsMissing: + true + } + } + + private enum KeyComparison { + case orderedSame + case orderedAscending + case orderedDescending + case lhsMissing + case rhsMissing + } + + private static func compareKeys(_ lhs: Decorated, _ rhs: Decorated, key: PlaylistSortKey) -> KeyComparison { + switch key { + case .original: + .orderedSame + case .duration: + self.compareDurations(lhs.duration, rhs.duration) + case .title, .artist, .album: + self.compareStrings(lhs.stringKey, rhs.stringKey) + } + } + + private static func compareStrings(_ lhs: String?, _ rhs: String?) -> KeyComparison { + switch (lhs, rhs) { + case (nil, nil): .orderedSame + case (nil, _): .lhsMissing + case (_, nil): .rhsMissing + case let (l?, r?): + switch l.localizedStandardCompare(r) { + case .orderedSame: .orderedSame + case .orderedAscending: .orderedAscending + case .orderedDescending: .orderedDescending + } + } + } + + private static func compareDurations(_ lhs: TimeInterval?, _ rhs: TimeInterval?) -> KeyComparison { + switch (lhs, rhs) { + case (nil, nil): return .orderedSame + case (nil, _): return .lhsMissing + case (_, nil): return .rhsMissing + case let (l?, r?): + if l == r { + return .orderedSame + } + return l < r ? .orderedAscending : .orderedDescending + } + } +} diff --git a/Sources/Kaset/Services/SettingsManager.swift b/Sources/Kaset/Services/SettingsManager.swift index 05e7667c..b40ed397 100644 --- a/Sources/Kaset/Services/SettingsManager.swift +++ b/Sources/Kaset/Services/SettingsManager.swift @@ -34,6 +34,7 @@ final class SettingsManager { static let ambientBackdropEnabled = "settings.ambientBackdropEnabled" static let ambientBackdropStyle = "settings.ambientBackdropStyle" static let popOutVideoOnNavigateAway = "settings.popOutVideoOnNavigateAway" + static let autoLoadFullPlaylistOnOpen = "settings.autoLoadFullPlaylistOnOpen" #if DEBUG static let useLegacyMacOS15UI = "settings.debug.useLegacyMacOS15UI" #endif @@ -269,6 +270,13 @@ final class SettingsManager { } } + /// Whether opening a playlist immediately loads every page instead of paging on scroll. + var autoLoadFullPlaylistOnOpen: Bool { + didSet { + UserDefaults.standard.set(self.autoLoadFullPlaylistOnOpen, forKey: Keys.autoLoadFullPlaylistOnOpen) + } + } + /// Whether to remember shuffle/repeat settings across app restarts. var rememberPlaybackSettings: Bool { didSet { @@ -498,6 +506,7 @@ final class SettingsManager { // Load persisted settings or use defaults self.showNowPlayingNotifications = UserDefaults.standard.object(forKey: Keys.showNowPlayingNotifications) as? Bool ?? true self.hapticFeedbackEnabled = UserDefaults.standard.object(forKey: Keys.hapticFeedbackEnabled) as? Bool ?? true + self.autoLoadFullPlaylistOnOpen = UserDefaults.standard.object(forKey: Keys.autoLoadFullPlaylistOnOpen) as? Bool ?? false self.rememberPlaybackSettings = UserDefaults.standard.object(forKey: Keys.rememberPlaybackSettings) as? Bool ?? false // Load per-service enabled flags, migrating from legacy lastFMEnabled if needed diff --git a/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift b/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift index 05bb668b..13b6481e 100644 --- a/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift +++ b/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift @@ -65,12 +65,33 @@ final class PlaylistDetailViewModel { /// Current loading state. private(set) var loadingState: LoadingState = .idle - /// The loaded playlist detail. - private(set) var playlistDetail: PlaylistDetail? + /// The loaded playlist detail. Any mutation — including appending a page — invalidates + /// the cached `displayedTracks`, so no load path has to remember to refresh. + private(set) var playlistDetail: PlaylistDetail? { + didSet { + self.refreshDisplayedTracks() + } + } /// Whether more tracks are available to load. private(set) var hasMore: Bool = false + /// Current client-side sort order for the displayed track list. + private(set) var sortOrder: PlaylistSortOrder = .default + + /// Current client-side search query for the displayed track list. + private(set) var searchQuery: String = "" + + /// The tracks to display, after applying the current search filter and sort order. + /// Cached; recomputed only when the track set, sort order, or query changes. + private(set) var displayedTracks: [Song] = [] + + /// Debounce window, in milliseconds, between the last keystroke and the drain it triggers. + private static let searchDrainDebounce = 300 + + /// Pending debounced drain kicked off by typing in the search field. + @ObservationIgnored private var searchDrainTask: Task? + private let playlist: Playlist /// The API client (exposed for add to library action). let client: any YTMusicClientProtocol @@ -130,7 +151,70 @@ final class PlaylistDetailViewModel { self.playlist.id } + /// Whether a non-default sort or a non-empty search is currently active. + var isFilteringOrSorting: Bool { + self.sortOrder.key != .original + || !self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Updates the sort order and, when the playlist is still paging, drains every + /// remaining page so the sort covers the complete track set. + func setSortOrder(_ order: PlaylistSortOrder) { + guard order != self.sortOrder else { return } + self.sortOrder = order + self.refreshDisplayedTracks() + self.drainAllIfNeededForDisplay() + } + + /// Updates the search query. Filtering applies immediately; only the drain is debounced. + func setSearchQuery(_ query: String) { + guard query != self.searchQuery else { return } + self.searchQuery = query + self.refreshDisplayedTracks() + self.scheduleSearchDrain() + } + + /// Resets sort and search back to the default (server order, no filter). + func resetSortAndSearch() { + self.searchDrainTask?.cancel() + self.searchDrainTask = nil + guard self.sortOrder != .default || !self.searchQuery.isEmpty else { return } + self.sortOrder = .default + self.searchQuery = "" + self.refreshDisplayedTracks() + } + + /// Recomputes the cached display list. Deliberately not a computed property: the detail + /// body re-evaluates on every observed change, and each one would re-run the full + /// filter + sort — thousands of collation calls per frame on a large playlist. + private func refreshDisplayedTracks() { + self.displayedTracks = PlaylistTrackListPresenter.displayedTracks( + from: self.playlistDetail?.tracks ?? [], + sortOrder: self.sortOrder, + searchQuery: self.searchQuery + ) + } + + /// Debounces the drain, so typing a word doesn't start a round of continuation + /// requests per keystroke. + private func scheduleSearchDrain() { + self.searchDrainTask?.cancel() + self.searchDrainTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(Self.searchDrainDebounce)) + guard !Task.isCancelled else { return } + self.drainAllIfNeededForDisplay() + } + } + + /// Kicks a single-flight full drain when a sort/search is active and pages remain, so + /// client-side ordering covers every track rather than just the loaded window. + private func drainAllIfNeededForDisplay() { + guard self.isFilteringOrSorting, self.hasMore else { return } + Task { await self.loadAllRemaining() } + } + deinit { + self.searchDrainTask?.cancel() self.loadTask?.cancel() self.fullLoadTask?.cancel() self.pagingTask?.cancel() @@ -187,6 +271,12 @@ final class PlaylistDetailViewModel { self.loadTask = task await task.value self.loadTask = nil + + // When the user opts in, eagerly drain every page on open so sort/search + // (and scrolling) see the complete playlist without incremental paging. + if SettingsManager.shared.autoLoadFullPlaylistOnOpen, self.hasMore { + Task { await self.loadAllRemaining() } + } } /// Drives pagination to completion (every track), for callers that need the full playlist diff --git a/Sources/Kaset/Views/MusicSettingsView.swift b/Sources/Kaset/Views/MusicSettingsView.swift index aed8feca..8b3d489e 100644 --- a/Sources/Kaset/Views/MusicSettingsView.swift +++ b/Sources/Kaset/Views/MusicSettingsView.swift @@ -86,6 +86,21 @@ struct MusicSettingsView: View { } header: { Text(String(localized: "Lyrics")) } + + // MARK: - Playlists Section + + Section { + Toggle( + String(localized: "Load All Songs When Opening a Playlist"), + isOn: self.$settings.autoLoadFullPlaylistOnOpen + ) + } header: { + Text(String(localized: "Playlists")) + } footer: { + // The request cost belongs here, not in a tooltip: it's the reason to leave + // this off, and a footer is always visible. + Text(String(localized: "When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front, which can make many requests.")) + } } .formStyle(.grouped) .frame(minWidth: 400, minHeight: 300) diff --git a/Sources/Kaset/Views/PlaylistDetailView+ArtistFormatting.swift b/Sources/Kaset/Views/PlaylistDetailView+ArtistFormatting.swift new file mode 100644 index 00000000..07dcbf7a --- /dev/null +++ b/Sources/Kaset/Views/PlaylistDetailView+ArtistFormatting.swift @@ -0,0 +1,87 @@ +import Foundation + +// MARK: - Artist Formatting Helpers + +@available(macOS 26.0, *) +extension PlaylistDetailView { + func headerArtists(for detail: PlaylistDetail) -> [Artist] { + if let author = self.cleanedArtist(detail.author) { + return [author] + } + + return self.uniqueArtists(from: detail.tracks.flatMap(\.artists)) + } + + func trackArtistsDisplay(for track: Song, fallbackAuthor: String?) -> String? { + let artists = self.uniqueArtists(from: track.artists) + if !artists.isEmpty { + return artists.map(\.name).joined(separator: ", ") + } + + guard let fallbackArtist = self.cleanedArtistName(fallbackAuthor) else { return nil } + return fallbackArtist + } + + /// The row's secondary line: artists, plus the album for playlist rows. Album appears + /// because it is sortable — sorting by a field the row never shows leaves the result + /// unreadable. Album pages omit it; it's already in the header. + func trackSubtitle(for track: Song, fallbackAuthor: String?, isAlbum: Bool) -> String? { + let artists = self.trackArtistsDisplay(for: track, fallbackAuthor: fallbackAuthor) + guard !isAlbum, + let albumTitle = track.album?.title.trimmingCharacters(in: .whitespacesAndNewlines), + !albumTitle.isEmpty + else { return artists } + + guard let artists, !artists.isEmpty else { return albumTitle } + return "\(artists) • \(albumTitle)" + } + + func uniqueArtists(from artists: [Artist]) -> [Artist] { + var seen = Set() + var uniqueArtists: [Artist] = [] + + for artist in artists { + guard let cleanedArtist = self.cleanedArtist(artist) else { continue } + let key = cleanedArtist.hasNavigableId ? cleanedArtist.id : cleanedArtist.name.lowercased() + guard seen.insert(key).inserted else { continue } + uniqueArtists.append(cleanedArtist) + } + + return uniqueArtists + } + + func cleanedArtist(_ artist: Artist?) -> Artist? { + guard let artist, + let name = self.cleanedArtistName(artist.name) + else { return nil } + + return Artist( + id: artist.id, + name: name, + thumbnailURL: artist.thumbnailURL, + subtitle: artist.subtitle, + profileKind: artist.profileKind + ) + } + + func cleanedArtistName(_ name: String?) -> String? { + guard var cleanName = name?.trimmingCharacters(in: .whitespacesAndNewlines), + !cleanName.isEmpty + else { return nil } + + if cleanName == "Album" { + return nil + } + + if cleanName.hasPrefix("Album, ") { + cleanName = String(cleanName.dropFirst(7)) + } else if cleanName.contains("Album,") { + let parts = cleanName.split(separator: ",", maxSplits: 1) + if parts.count > 1 { + cleanName = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + return cleanName.isEmpty ? nil : cleanName + } +} diff --git a/Sources/Kaset/Views/PlaylistDetailView+HeaderActions.swift b/Sources/Kaset/Views/PlaylistDetailView+HeaderActions.swift index 62c11efe..eec991eb 100644 --- a/Sources/Kaset/Views/PlaylistDetailView+HeaderActions.swift +++ b/Sources/Kaset/Views/PlaylistDetailView+HeaderActions.swift @@ -15,8 +15,10 @@ extension PlaylistDetailView { func headerButtons(_ detail: PlaylistDetail) -> some View { let fallbackAlbum = self.makeFallbackAlbum(from: detail) + // What you see is what you play: these act on the displayed list, so they follow an + // active sort and stay inside an active search — the same list a row tap queues. let playableTracks = self.playableTracks( - detail.tracks, + self.viewModel.displayedTracks, fallbackArtist: detail.author?.name, fallbackAlbum: fallbackAlbum ) @@ -68,7 +70,7 @@ extension PlaylistDetailView { ) -> some View { Button { self.playAll( - detail.tracks, fallbackArtist: detail.author?.name, + self.viewModel.displayedTracks, fallbackArtist: detail.author?.name, fallbackAlbum: fallbackAlbum ) } label: { diff --git a/Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift b/Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift new file mode 100644 index 00000000..b9e27fee --- /dev/null +++ b/Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift @@ -0,0 +1,107 @@ +import SwiftUI + +// MARK: - Sort & Search UI + +@available(macOS 26.0, *) +extension PlaylistDetailView { + /// Whether a non-empty search query is currently active. + var hasActiveSearch: Bool { + !self.viewModel.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Two-way binding over the view model's search query, for `.searchable`. + var searchBinding: Binding { + Binding( + get: { self.viewModel.searchQuery }, + set: { self.viewModel.setSearchQuery($0) } + ) + } + + /// Progress shown while a sort or search is active and pages are still arriving. It + /// sits above the list because each page re-sorts the rows under the user, and a + /// bottom-of-list spinner is unreachable exactly when they need to know why. + @ViewBuilder + var drainProgressBanner: some View { + let loaded = self.viewModel.playlistDetail?.tracks.count ?? 0 + let total = self.viewModel.playlistDetail?.trackCount + + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(String(localized: "Loading all songs…")) + .font(.footnote) + .foregroundStyle(.secondary) + if let total, total > loaded { + Text(verbatim: "\(loaded) / \(total)") + .font(.footnote.monospacedDigit()) + .foregroundStyle(.tertiary) + } + Spacer(minLength: 0) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .compatGlass(in: .capsule) + } + + /// Toolbar menu that chooses the client-side sort order for the track list. + var sortMenu: some View { + Menu { + ForEach(PlaylistSortKey.allCases) { key in + Button { + self.selectSortKey(key) + } label: { + self.sortMenuLabel(for: key) + } + } + } label: { + self.sortMenuButtonLabel + } + .labelStyle(.titleAndIcon) + .help(String(localized: "Sort songs")) + } + + /// The collapsed toolbar label. It names the active key and direction, so the order is + /// readable without opening the menu. + @ViewBuilder + private var sortMenuButtonLabel: some View { + let order = self.viewModel.sortOrder + if order.key == .original { + Image(systemName: "arrow.up.arrow.down") + } else { + Label( + order.key.displayName, + systemImage: order.ascending ? "arrow.up" : "arrow.down" + ) + } + } + + @ViewBuilder + private func sortMenuLabel(for key: PlaylistSortKey) -> some View { + if self.viewModel.sortOrder.key == key, key != .original { + Label( + key.displayName, + systemImage: self.viewModel.sortOrder.ascending ? "chevron.up" : "chevron.down" + ) + } else if self.viewModel.sortOrder.key == key { + Label(key.displayName, systemImage: "checkmark") + } else { + Text(key.displayName) + } + } + + /// Selecting the active key toggles direction; a new key sorts ascending; + /// `.original` returns to server order. + func selectSortKey(_ key: PlaylistSortKey) { + if key == .original { + self.viewModel.setSortOrder(.default) + return + } + if self.viewModel.sortOrder.key == key { + self.viewModel.setSortOrder( + PlaylistSortOrder(key: key, ascending: !self.viewModel.sortOrder.ascending) + ) + } else { + self.viewModel.setSortOrder(PlaylistSortOrder(key: key, ascending: true)) + } + } +} diff --git a/Sources/Kaset/Views/PlaylistDetailView.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index 2b0f64e9..d16f38b8 100644 --- a/Sources/Kaset/Views/PlaylistDetailView.swift +++ b/Sources/Kaset/Views/PlaylistDetailView.swift @@ -29,6 +29,10 @@ struct PlaylistDetailView: View { @State private var isRefining: Bool = false /// Error message from refine operation. @State private var refineError: String? + /// Whether this presentation already cleared sort/search left on a reused view model. + @State private var hasResetSortAndSearch: Bool = false + /// Focus of the toolbar search field, so clicking the page body can resign it. + @FocusState private var isSearchFocused: Bool /// Computed property to check if playlist is in library. var isInLibrary: Bool { if self.playlist.isAlbum { @@ -87,6 +91,21 @@ struct PlaylistDetailView: View { ) .navigationTitle(self.playlist.title) .toolbarBackgroundVisibility(.hidden, for: .automatic) + .toolbar { + if self.viewModel.playlistDetail != nil { + ToolbarItem(placement: .automatic) { + self.sortMenu + } + } + } + // In the toolbar, not the scrolling content: it stays reachable — and clearable — + // deep into a long playlist, and sits next to the sort control it works with. + .searchable( + text: self.searchBinding, + placement: .toolbar, + prompt: Text(String(localized: "Search in Playlist")) + ) + .searchFocused(self.$isSearchFocused) .safeAreaInset(edge: .bottom, spacing: 0) { if case .error = self.viewModel.loadingState { } else { @@ -101,6 +120,15 @@ struct PlaylistDetailView: View { .refreshable { await self.viewModel.refresh() } + .onAppear { + // Not `.onDisappear`: that also fires when this screen is pushed over — + // measured firing with an active sort — so it dropped the sort on the way to + // an artist page and back. `@State` survives a push/pop pair, so this clears + // a reused view model's stale sort only on a genuinely new open. + guard !self.hasResetSortAndSearch else { return } + self.hasResetSortAndSearch = true + self.viewModel.resetSortAndSearch() + } .onChange(of: self.likeStatusManager.lastLikeEventBatch) { _, batch in guard let batch, batch.accountID == self.likeStatusManager.activeAccountID else { return } for event in batch.events { @@ -148,12 +176,37 @@ struct PlaylistDetailView: View { year: nil, trackCount: detail.trackCount ?? detail.tracks.count ) - self.tracksView( - detail.tracks, isAlbum: detail.isAlbum, author: detail.author?.name, - fallbackAlbum: fallbackAlbum - ) + let displayed = self.viewModel.displayedTracks + let isDraining = self.viewModel.isFilteringOrSorting && self.viewModel.hasMore + + if isDraining { + self.drainProgressBanner + } + + if displayed.isEmpty, self.hasActiveSearch { + // Only claim no matches once every page has been searched — mid-drain + // the track may still be coming, and the banner already explains it. + if !isDraining { + ContentUnavailableView.search(text: self.viewModel.searchQuery) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } + } else { + self.tracksView( + displayed, isAlbum: detail.isAlbum, author: detail.author?.name, + fallbackAlbum: fallbackAlbum + ) + } } .padding(.vertical, 24) + // Track rows are Buttons, which don't take first responder on macOS, and the + // scroll view isn't focusable — so without somewhere for focus to go, the + // search field never blurs. Clicking the page body resigns it. + .background { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { self.isSearchFocused = false } + } } // Inset the resting content while the scroll view stays edge-to-edge so // content extends under the floating glass sidebar; the accent backdrop @@ -256,14 +309,18 @@ struct PlaylistDetailView: View { _ tracks: [Song], isAlbum: Bool, author: String?, fallbackAlbum: Album? = nil ) -> some View { LazyVStack(spacing: 0) { - ForEach(Array(tracks.enumerated()), id: \.offset) { index, track in + ForEach(Array(tracks.enumerated()), id: \.element.rowIdentity) { index, track in self.trackRow( track, index: index, tracks: tracks, isAlbum: isAlbum, author: author, fallbackAlbum: fallbackAlbum ) .onAppear { - // Load more when reaching the last few items - if index >= tracks.count - 3, self.viewModel.hasMore { + // Position only maps to the load frontier in the natural order; once + // sorted or filtered, loadAllRemaining() drives completeness instead. + if !self.viewModel.isFilteringOrSorting, + index >= tracks.count - 3, + self.viewModel.hasMore + { Task { await self.viewModel.loadMore() } } } @@ -276,7 +333,7 @@ struct PlaylistDetailView: View { } } - // Loading indicator for pagination + // Scroll pagination only; the sort/search drain reports via `drainProgressBanner`. if self.viewModel.loadingState == .loadingMore { HStack { Spacer() @@ -297,7 +354,7 @@ struct PlaylistDetailView: View { track: track, index: index, isAlbum: isAlbum, - subtitle: self.trackArtistsDisplay(for: track, fallbackAuthor: author), + subtitle: self.trackSubtitle(for: track, fallbackAuthor: author, isAlbum: isAlbum), allowsLikeActions: self.hasPersonalAccount, onPlay: { self.playTrackInQueue( @@ -318,73 +375,6 @@ struct PlaylistDetailView: View { .staggeredAppearance(index: min(index, 10)) } - private func headerArtists(for detail: PlaylistDetail) -> [Artist] { - if let author = self.cleanedArtist(detail.author) { - return [author] - } - - return self.uniqueArtists(from: detail.tracks.flatMap(\.artists)) - } - - private func trackArtistsDisplay(for track: Song, fallbackAuthor: String?) -> String? { - let artists = self.uniqueArtists(from: track.artists) - if !artists.isEmpty { - return artists.map(\.name).joined(separator: ", ") - } - - guard let fallbackArtist = self.cleanedArtistName(fallbackAuthor) else { return nil } - return fallbackArtist - } - - private func uniqueArtists(from artists: [Artist]) -> [Artist] { - var seen = Set() - var uniqueArtists: [Artist] = [] - - for artist in artists { - guard let cleanedArtist = self.cleanedArtist(artist) else { continue } - let key = cleanedArtist.hasNavigableId ? cleanedArtist.id : cleanedArtist.name.lowercased() - guard seen.insert(key).inserted else { continue } - uniqueArtists.append(cleanedArtist) - } - - return uniqueArtists - } - - private func cleanedArtist(_ artist: Artist?) -> Artist? { - guard let artist, - let name = self.cleanedArtistName(artist.name) - else { return nil } - - return Artist( - id: artist.id, - name: name, - thumbnailURL: artist.thumbnailURL, - subtitle: artist.subtitle, - profileKind: artist.profileKind - ) - } - - private func cleanedArtistName(_ name: String?) -> String? { - guard var cleanName = name?.trimmingCharacters(in: .whitespacesAndNewlines), - !cleanName.isEmpty - else { return nil } - - if cleanName == "Album" { - return nil - } - - if cleanName.hasPrefix("Album, ") { - cleanName = String(cleanName.dropFirst(7)) - } else if cleanName.contains("Album,") { - let parts = cleanName.split(separator: ",", maxSplits: 1) - if parts.count > 1 { - cleanName = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) - } - } - - return cleanName.isEmpty ? nil : cleanName - } - // MARK: - Actions @ViewBuilder @@ -530,11 +520,32 @@ struct PlaylistDetailView: View { fallbackArtist: String?, fallbackAlbum: Album? ) { let intent = self.playerService.beginMusicPlaybackIntent() + let honorsDisplayedList = self.viewModel.isFilteringOrSorting Task { @MainActor in + var tracks = cleanedTracks + var startIndex = index + + // A partially-paged queue is the wrong *set* here, not merely a short one: the + // top-up below would append tracks the search excluded, in the order the sort + // replaced. The drain is already running, so wait and queue what the list shows. + if honorsDisplayedList, self.viewModel.hasMore { + let anchor = cleanedTracks.indices.contains(index) + ? cleanedTracks[index].rowIdentity + : nil + await self.viewModel.loadAllRemaining() + tracks = self.playableTracks( + self.viewModel.displayedTracks, + fallbackArtist: fallbackArtist, fallbackAlbum: fallbackAlbum + ) + startIndex = anchor + .flatMap { id in tracks.firstIndex { $0.rowIdentity == id } } ?? 0 + guard !tracks.isEmpty else { return } + } + let willDeferLoad = self.viewModel.hasMore let loadGeneration = await self.playerService.playQueue( - cleanedTracks, - startingAt: index, + tracks, + startingAt: startIndex, deferringSmartShuffleFill: willDeferLoad, intent: intent ) @@ -549,11 +560,13 @@ struct PlaylistDetailView: View { guard self.playerService.isCurrentQueueLoad(loadGeneration) else { return } let fullTracks = self.playableTracks( - self.viewModel.playlistDetail?.tracks ?? [], + honorsDisplayedList + ? self.viewModel.displayedTracks + : (self.viewModel.playlistDetail?.tracks ?? []), fallbackArtist: fallbackArtist, fallbackAlbum: fallbackAlbum ) let remaining = PlaylistPlaybackActions.remainingTracks( - after: cleanedTracks, + after: tracks, in: fullTracks ) self.playerService.appendOriginalTracks(remaining) diff --git a/Tests/KasetTests/PlaylistAutoLoadSettingTests.swift b/Tests/KasetTests/PlaylistAutoLoadSettingTests.swift new file mode 100644 index 00000000..665c21cf --- /dev/null +++ b/Tests/KasetTests/PlaylistAutoLoadSettingTests.swift @@ -0,0 +1,20 @@ +import Foundation +import Testing +@testable import Kaset + +@MainActor +@Suite(.serialized, .tags(.viewModel)) +struct PlaylistAutoLoadSettingTests { + @Test("autoLoadFullPlaylistOnOpen persists round-trip") + func persistsRoundTrip() { + let manager = SettingsManager.shared + let original = manager.autoLoadFullPlaylistOnOpen + defer { manager.autoLoadFullPlaylistOnOpen = original } + + manager.autoLoadFullPlaylistOnOpen = true + #expect(UserDefaults.standard.bool(forKey: "settings.autoLoadFullPlaylistOnOpen") == true) + + manager.autoLoadFullPlaylistOnOpen = false + #expect(UserDefaults.standard.object(forKey: "settings.autoLoadFullPlaylistOnOpen") as? Bool == false) + } +} diff --git a/Tests/KasetTests/PlaylistDetailViewModelTests.swift b/Tests/KasetTests/PlaylistDetailViewModelTests.swift index cd945e96..4bbb9608 100644 --- a/Tests/KasetTests/PlaylistDetailViewModelTests.swift +++ b/Tests/KasetTests/PlaylistDetailViewModelTests.swift @@ -396,6 +396,249 @@ struct PlaylistDetailViewModelTests { #expect(self.viewModel.hasMore == false) } + // MARK: - Sort & Search + + @Test("displayedTracks reflects sort order over loaded tracks") + func displayedTracksSorted() async { + let tracks = [ + TestFixtures.makeSong(id: "1", title: "Zulu"), + TestFixtures.makeSong(id: "2", title: "Alpha"), + ] + let playlist = TestFixtures.makePlaylist(id: "VL-sort", title: "Sort") + self.mockClient.playlistDetails["VL-sort"] = PlaylistDetail( + playlist: playlist, tracks: tracks, duration: nil + ) + let vm = PlaylistDetailViewModel( + playlist: playlist, client: self.mockClient, likeStatusManager: self.likeStatusManager + ) + await vm.ensureLoaded() + + #expect(vm.displayedTracks.map(\.videoId) == ["1", "2"]) + vm.setSortOrder(PlaylistSortOrder(key: .title, ascending: true)) + #expect(vm.displayedTracks.map(\.videoId) == ["2", "1"]) + #expect(vm.isFilteringOrSorting == true) + vm.setSortOrder(.default) + #expect(vm.displayedTracks.map(\.videoId) == ["1", "2"]) + #expect(vm.isFilteringOrSorting == false) + } + + @Test("displayedTracks filters by search query") + func displayedTracksFiltered() async { + let tracks = [ + TestFixtures.makeSong(id: "1", title: "Hello"), + TestFixtures.makeSong(id: "2", title: "Goodbye"), + ] + let playlist = TestFixtures.makePlaylist(id: "VL-search", title: "Search") + self.mockClient.playlistDetails["VL-search"] = PlaylistDetail( + playlist: playlist, tracks: tracks, duration: nil + ) + let vm = PlaylistDetailViewModel( + playlist: playlist, client: self.mockClient, likeStatusManager: self.likeStatusManager + ) + await vm.ensureLoaded() + + vm.setSearchQuery("hello") + #expect(vm.displayedTracks.map(\.videoId) == ["1"]) + #expect(vm.isFilteringOrSorting == true) + vm.setSearchQuery("") + #expect(vm.displayedTracks.count == 2) + #expect(vm.isFilteringOrSorting == false) + } + + @Test("displayedTracks is the single playback source for sort and search") + func displayedTracksDrivesPlayback() async { + let tracks = [ + TestFixtures.makeSong(id: "1", title: "Zulu"), + TestFixtures.makeSong(id: "2", title: "Alpha"), + TestFixtures.makeSong(id: "3", title: "Mango"), + ] + let playlist = TestFixtures.makePlaylist(id: "VL-playback", title: "Playback") + self.mockClient.playlistDetails["VL-playback"] = PlaylistDetail( + playlist: playlist, tracks: tracks, duration: nil + ) + let vm = PlaylistDetailViewModel( + playlist: playlist, client: self.mockClient, likeStatusManager: self.likeStatusManager + ) + await vm.ensureLoaded() + + // Header actions and row taps all queue `displayedTracks`. + vm.setSortOrder(PlaylistSortOrder(key: .title, ascending: true)) + #expect(vm.displayedTracks.map(\.videoId) == ["2", "3", "1"]) + #expect(vm.displayedTracks.count == vm.playlistDetail?.tracks.count) + + vm.setSearchQuery("mango") + #expect(vm.displayedTracks.map(\.videoId) == ["3"]) + #expect(vm.displayedTracks.count < (vm.playlistDetail?.tracks.count ?? 0)) + } + + @Test("Newly paged-in tracks are folded into the active sort") + func pagedTracksRespectActiveSort() async { + let playlist = TestFixtures.makePlaylist(id: "VL-page-sort", title: "Paged") + self.mockClient.playlistDetails["VL-page-sort"] = PlaylistDetail( + playlist: playlist, + tracks: [TestFixtures.makeSong(id: "1", title: "Mango")], + duration: nil + ) + self.mockClient.playlistContinuationTracks["VL-page-sort"] = [ + [TestFixtures.makeSong(id: "2", title: "Apple")], + ] + let vm = PlaylistDetailViewModel( + playlist: playlist, client: self.mockClient, likeStatusManager: self.likeStatusManager + ) + await vm.ensureLoaded() + + vm.setSortOrder(PlaylistSortOrder(key: .title, ascending: true)) + #expect(vm.displayedTracks.map(\.videoId) == ["1"]) + + // The cached display list must be invalidated by the append, not just by + // setSortOrder — otherwise the page arrives and is never sorted in. + await vm.loadAllRemaining() + #expect(vm.displayedTracks.map(\.videoId) == ["2", "1"]) + } + + @Test("Draining before playback yields the complete filtered set, with the tapped track still locatable") + func drainBeforePlaybackCompletesTheFilteredSet() async { + let playlist = TestFixtures.makePlaylist(id: "VL-play-drain", title: "Drain") + self.mockClient.playlistDetails["VL-play-drain"] = PlaylistDetail( + playlist: playlist, + tracks: [ + TestFixtures.makeSong(id: "1", title: "Love Song"), + TestFixtures.makeSong(id: "2", title: "Something Else"), + ], + duration: nil + ) + self.mockClient.playlistContinuationTracks["VL-play-drain"] = [ + [TestFixtures.makeSong(id: "3", title: "Lovely Day")], + [TestFixtures.makeSong(id: "4", title: "Endless Love")], + ] + let vm = PlaylistDetailViewModel( + playlist: playlist, client: self.mockClient, likeStatusManager: self.likeStatusManager + ) + await vm.ensureLoaded() + + vm.setSearchQuery("love") + // Pre-drain snapshot: only the first page has been searched. + let snapshot = vm.displayedTracks + #expect(snapshot.map(\.videoId) == ["1"]) + let tapped = snapshot[0].rowIdentity + + // This is what playback waits on before building the queue. + await vm.loadAllRemaining() + + #expect(vm.hasMore == false) + // Queueing the snapshot instead would play one track, then append what the search excluded. + #expect(Set(vm.displayedTracks.map(\.videoId)) == ["1", "3", "4"]) + #expect(vm.displayedTracks.contains { $0.rowIdentity == tapped }) // start index relocatable + } + + @Test("Search drain is debounced rather than fired on every keystroke") + func searchDrainIsDebounced() async { + let playlist = TestFixtures.makePlaylist(id: "VL-debounce", title: "Debounce") + self.mockClient.playlistDetails["VL-debounce"] = PlaylistDetail( + playlist: playlist, tracks: TestFixtures.makeSongs(count: 100), duration: nil + ) + self.mockClient.playlistContinuationTracks["VL-debounce"] = [ + (100 ..< 110).map { TestFixtures.makeSong(id: "video-\($0)", title: "Song \($0)") }, + ] + let vm = PlaylistDetailViewModel( + playlist: playlist, client: self.mockClient, likeStatusManager: self.likeStatusManager + ) + await vm.ensureLoaded() + + for prefix in ["s", "so", "son", "song"] { + vm.setSearchQuery(prefix) + } + // Filtering is applied immediately; only the pagination drain waits. + #expect(vm.isFilteringOrSorting == true) + #expect(self.mockClient.getPlaylistContinuationCallCount == 0) + + await self.waitUntil( + self.mockClient.getPlaylistContinuationCallCount >= 1, + description: "debounced drain eventually runs once" + ) + } + + @Test("Auto-load setting drains all pages on open") + func autoLoadOnOpenDrains() async { + let manager = SettingsManager.shared + let original = manager.autoLoadFullPlaylistOnOpen + defer { manager.autoLoadFullPlaylistOnOpen = original } + manager.autoLoadFullPlaylistOnOpen = true + + let playlist = Playlist( + id: "VL-test-playlist", title: "Large Playlist", description: nil, + thumbnailURL: URL(string: "https://example.com/playlist.jpg"), + trackCount: 125, author: Artist.inline(name: "Test User", namespace: "playlist-author") + ) + self.mockClient.playlistDetails[playlist.id] = PlaylistDetail( + playlist: playlist, tracks: TestFixtures.makeSongs(count: 100), duration: nil + ) + self.mockClient.playlistContinuationTracks[playlist.id] = [ + (100 ..< 115).map { TestFixtures.makeSong(id: "video-\($0)", title: "Song \($0)") }, + (115 ..< 125).map { TestFixtures.makeSong(id: "video-\($0)", title: "Song \($0)") }, + ] + + await self.viewModel.ensureLoaded() + await self.waitUntil( + self.mockClient.getPlaylistContinuationCallCount >= 2, + description: "auto-load drains both continuations" + ) + #expect(self.viewModel.hasMore == false) + #expect(self.viewModel.playlistDetail?.tracks.count == 125) + } + + @Test("No auto-load on open when setting is off") + func noAutoLoadWhenOff() async { + let manager = SettingsManager.shared + let original = manager.autoLoadFullPlaylistOnOpen + defer { manager.autoLoadFullPlaylistOnOpen = original } + manager.autoLoadFullPlaylistOnOpen = false + + let playlist = Playlist( + id: "VL-test-playlist", title: "Large Playlist", description: nil, + thumbnailURL: URL(string: "https://example.com/playlist.jpg"), + trackCount: 125, author: Artist.inline(name: "Test User", namespace: "playlist-author") + ) + self.mockClient.playlistDetails[playlist.id] = PlaylistDetail( + playlist: playlist, tracks: TestFixtures.makeSongs(count: 100), duration: nil + ) + self.mockClient.playlistContinuationTracks[playlist.id] = [ + (100 ..< 125).map { TestFixtures.makeSong(id: "video-\($0)", title: "Song \($0)") }, + ] + + await self.viewModel.ensureLoaded() + try? await Task.sleep(for: .milliseconds(150)) + #expect(self.mockClient.getPlaylistContinuationCallCount == 0) + #expect(self.viewModel.hasMore == true) + } + + @Test("Sorting a still-paging playlist drains all pages") + func sortTriggersDrainWhenHasMore() async { + let playlist = Playlist( + id: "VL-test-playlist", title: "Large Playlist", description: nil, + thumbnailURL: URL(string: "https://example.com/playlist.jpg"), + trackCount: 125, author: Artist.inline(name: "Test User", namespace: "playlist-author") + ) + self.mockClient.playlistDetails[playlist.id] = PlaylistDetail( + playlist: playlist, tracks: TestFixtures.makeSongs(count: 100), duration: nil + ) + self.mockClient.playlistContinuationTracks[playlist.id] = [ + (100 ..< 125).map { TestFixtures.makeSong(id: "video-\($0)", title: "Song \($0)") }, + ] + + await self.viewModel.load() + #expect(self.mockClient.getPlaylistContinuationCallCount == 0) + #expect(self.viewModel.hasMore == true) + + self.viewModel.setSortOrder(PlaylistSortOrder(key: .title, ascending: true)) + await self.waitUntil( + self.viewModel.hasMore == false, + description: "sort triggers a full drain" + ) + #expect(self.mockClient.getPlaylistContinuationCallCount >= 1) + #expect(self.viewModel.playlistDetail?.tracks.count == 125) + } + @Test("Large playlist load keeps delayed continuation lazy") func largePlaylistLoadKeepsDelayedContinuationLazy() async { let playlist = Playlist( diff --git a/Tests/KasetTests/PlaylistTrackListPresenterTests.swift b/Tests/KasetTests/PlaylistTrackListPresenterTests.swift new file mode 100644 index 00000000..8f5ef9c1 --- /dev/null +++ b/Tests/KasetTests/PlaylistTrackListPresenterTests.swift @@ -0,0 +1,290 @@ +import Foundation +import Testing +@testable import Kaset + +@Suite(.tags(.viewModel)) +struct PlaylistTrackListPresenterTests { + private func makeSong( + title: String, + artist: String, + videoId: String, + duration: TimeInterval? = nil, + albumTitle: String? = nil + ) -> Song { + Song( + id: videoId, + title: title, + artists: artist.isEmpty ? [] : [Artist.inline(name: artist, namespace: "test")], + album: albumTitle.map { + Album(id: "al-\($0)", title: $0, artists: nil, thumbnailURL: nil, year: nil, trackCount: nil) + }, + duration: duration, + thumbnailURL: nil, + videoId: videoId + ) + } + + // MARK: - Model + + @Test("Default sort order is original ascending") + func defaultSortOrder() { + let order = PlaylistSortOrder.default + #expect(order.key == .original) + #expect(order.ascending == true) + } + + @Test("All sort keys expose a non-empty display name") + func sortKeyDisplayNames() { + for key in PlaylistSortKey.allCases { + #expect(!key.displayName.isEmpty) + } + } + + // MARK: - Sorting + + @Test("Original order returns tracks unchanged") + func originalOrderUnchanged() { + let songs = [ + self.makeSong(title: "Zulu", artist: "B", videoId: "1"), + self.makeSong(title: "Alpha", artist: "A", videoId: "2"), + ] + let result = PlaylistTrackListPresenter.displayedTracks( + from: songs, sortOrder: .default, searchQuery: "" + ) + #expect(result.map(\.videoId) == ["1", "2"]) + } + + @Test("Title ascending sorts case- and diacritic-insensitively") + func titleAscending() { + let songs = [ + self.makeSong(title: "banana", artist: "x", videoId: "1"), + self.makeSong(title: "Ápple", artist: "x", videoId: "2"), + self.makeSong(title: "Cherry", artist: "x", videoId: "3"), + ] + let result = PlaylistTrackListPresenter.displayedTracks( + from: songs, + sortOrder: PlaylistSortOrder(key: .title, ascending: true), + searchQuery: "" + ) + #expect(result.map(\.videoId) == ["2", "1", "3"]) + } + + @Test("Title descending reverses the comparable order") + func titleDescending() { + let songs = [ + self.makeSong(title: "banana", artist: "x", videoId: "1"), + self.makeSong(title: "Ápple", artist: "x", videoId: "2"), + self.makeSong(title: "Cherry", artist: "x", videoId: "3"), + ] + let result = PlaylistTrackListPresenter.displayedTracks( + from: songs, + sortOrder: PlaylistSortOrder(key: .title, ascending: false), + searchQuery: "" + ) + #expect(result.map(\.videoId) == ["3", "1", "2"]) + } + + @Test("Duration sorts nils last in both directions") + func durationNilsLast() { + let songs = [ + self.makeSong(title: "a", artist: "x", videoId: "1", duration: 200), + self.makeSong(title: "b", artist: "x", videoId: "2", duration: nil), + self.makeSong(title: "c", artist: "x", videoId: "3", duration: 100), + ] + let asc = PlaylistTrackListPresenter.displayedTracks( + from: songs, sortOrder: PlaylistSortOrder(key: .duration, ascending: true), searchQuery: "" + ) + #expect(asc.map(\.videoId) == ["3", "1", "2"]) + let desc = PlaylistTrackListPresenter.displayedTracks( + from: songs, sortOrder: PlaylistSortOrder(key: .duration, ascending: false), searchQuery: "" + ) + #expect(desc.map(\.videoId) == ["1", "3", "2"]) + } + + @Test("Album sorts nils last") + func albumNilsLast() { + let songs = [ + self.makeSong(title: "a", artist: "x", videoId: "1", albumTitle: "Bravo"), + self.makeSong(title: "b", artist: "x", videoId: "2", albumTitle: nil), + self.makeSong(title: "c", artist: "x", videoId: "3", albumTitle: "Alpha"), + ] + let asc = PlaylistTrackListPresenter.displayedTracks( + from: songs, sortOrder: PlaylistSortOrder(key: .album, ascending: true), searchQuery: "" + ) + #expect(asc.map(\.videoId) == ["3", "1", "2"]) + } + + @Test("Sort is stable on ties") + func stableOnTies() { + let songs = [ + self.makeSong(title: "same", artist: "x", videoId: "1"), + self.makeSong(title: "same", artist: "x", videoId: "2"), + self.makeSong(title: "same", artist: "x", videoId: "3"), + ] + let result = PlaylistTrackListPresenter.displayedTracks( + from: songs, sortOrder: PlaylistSortOrder(key: .title, ascending: true), searchQuery: "" + ) + #expect(result.map(\.videoId) == ["1", "2", "3"]) + } + + // MARK: - Search + + @Test("Search matches title or artist, diacritic-insensitive") + func searchMatches() { + let songs = [ + self.makeSong(title: "Hello World", artist: "Adele", videoId: "1"), + self.makeSong(title: "Something", artist: "Beyoncé", videoId: "2"), + self.makeSong(title: "Other", artist: "Nobody", videoId: "3"), + ] + let byTitle = PlaylistTrackListPresenter.displayedTracks( + from: songs, sortOrder: .default, searchQuery: "hello" + ) + #expect(byTitle.map(\.videoId) == ["1"]) + let byArtist = PlaylistTrackListPresenter.displayedTracks( + from: songs, sortOrder: .default, searchQuery: "beyonce" + ) + #expect(byArtist.map(\.videoId) == ["2"]) + } + + @Test("Empty and whitespace query returns all tracks") + func emptyQueryReturnsAll() { + let songs = [ + self.makeSong(title: "a", artist: "x", videoId: "1"), + self.makeSong(title: "b", artist: "y", videoId: "2"), + ] + #expect( + PlaylistTrackListPresenter.displayedTracks(from: songs, sortOrder: .default, searchQuery: " ").count == 2 + ) + } + + @Test("No matches returns empty") + func noMatches() { + let songs = [self.makeSong(title: "a", artist: "x", videoId: "1")] + #expect( + PlaylistTrackListPresenter.displayedTracks(from: songs, sortOrder: .default, searchQuery: "zzz").isEmpty + ) + } + + @Test("Filter then sort: search narrows, sort orders the survivors") + func filterThenSort() { + let songs = [ + self.makeSong(title: "rock ballad", artist: "x", videoId: "1"), + self.makeSong(title: "pop anthem", artist: "x", videoId: "2"), + self.makeSong(title: "rock anthem", artist: "x", videoId: "3"), + ] + let result = PlaylistTrackListPresenter.displayedTracks( + from: songs, + sortOrder: PlaylistSortOrder(key: .title, ascending: true), + searchQuery: "rock" + ) + #expect(result.map(\.videoId) == ["3", "1"]) + } + + @Test("Search matches album title") + func searchMatchesAlbum() { + let songs = [ + self.makeSong(title: "a", artist: "x", videoId: "1", albumTitle: "Kind of Blue"), + self.makeSong(title: "b", artist: "y", videoId: "2", albumTitle: "Blue Train"), + self.makeSong(title: "c", artist: "z", videoId: "3", albumTitle: "Giant Steps"), + ] + let result = PlaylistTrackListPresenter.displayedTracks( + from: songs, + sortOrder: .default, + searchQuery: "blue" + ) + #expect(result.map(\.videoId) == ["1", "2"]) + } + + @Test("Multi-artist sort uses the joined artist display, not just the first artist") + func multiArtistSortUsesJoinedDisplay() { + let collab = Song( + id: "1", + title: "collab", + artists: [ + Artist.inline(name: "Zed", namespace: "test"), + Artist.inline(name: "Abe", namespace: "test"), + ], + album: nil, + duration: nil, + thumbnailURL: nil, + videoId: "1" + ) + let solo = self.makeSong(title: "solo", artist: "Mona", videoId: "2") + let result = PlaylistTrackListPresenter.displayedTracks( + from: [collab, solo], + sortOrder: PlaylistSortOrder(key: .artist, ascending: true), + searchQuery: "" + ) + // "Mona" sorts before "Zed, Abe" — the key is the joined display string. + #expect(result.map(\.videoId) == ["2", "1"]) + } + + @Test("Sorting is a permutation — every track survives, in every direction", arguments: [true, false]) + func sortPreservesEveryTrack(ascending: Bool) { + let songs = [ + self.makeSong(title: "b", artist: "z", videoId: "1", duration: 100, albumTitle: "X"), + self.makeSong(title: "a", artist: "y", videoId: "2", duration: nil, albumTitle: nil), + self.makeSong(title: "c", artist: "", videoId: "3", duration: 50, albumTitle: "Y"), + self.makeSong(title: "a", artist: "y", videoId: "4", duration: 100, albumTitle: "X"), + ] + for key in PlaylistSortKey.allCases { + let result = PlaylistTrackListPresenter.displayedTracks( + from: songs, + sortOrder: PlaylistSortOrder(key: key, ascending: ascending), + searchQuery: "" + ) + // The header Play button queues this list, so a sort that drops or duplicates a + // track would silently shorten or corrupt the play queue. + #expect(result.count == songs.count, "\(key) changed the track count") + #expect(Set(result.map(\.videoId)) == Set(songs.map(\.videoId)), "\(key) lost or duplicated a track") + } + } + + @Test("Filtering never reorders the survivors relative to the active sort") + func filterPreservesSortedOrder() { + let songs = [ + self.makeSong(title: "love song", artist: "Zed", videoId: "1"), + self.makeSong(title: "other", artist: "Abe", videoId: "2"), + self.makeSong(title: "love ballad", artist: "Mona", videoId: "3"), + ] + let order = PlaylistSortOrder(key: .artist, ascending: true) + let sorted = PlaylistTrackListPresenter.displayedTracks( + from: songs, sortOrder: order, searchQuery: "" + ) + let filtered = PlaylistTrackListPresenter.displayedTracks( + from: songs, sortOrder: order, searchQuery: "love" + ) + let expected = sorted.map(\.videoId).filter { filtered.map(\.videoId).contains($0) } + #expect(filtered.map(\.videoId) == expected) + #expect(filtered.map(\.videoId) == ["3", "1"]) // Mona before Zed + } + + // MARK: - Row identity + + @Test("Row identity prefers the per-occurrence playlist id so duplicates stay distinct") + func rowIdentityDistinguishesDuplicates() { + var first = self.makeSong(title: "same", artist: "x", videoId: "dup") + var second = self.makeSong(title: "same", artist: "x", videoId: "dup") + first.playlistSetVideoId = "set-a" + second.playlistSetVideoId = "set-b" + + #expect(first.rowIdentity != second.rowIdentity) + #expect(self.makeSong(title: "a", artist: "x", videoId: "vid").rowIdentity == "video:vid") + } + + @Test("Row identity survives a blank set id and never collides across namespaces") + func rowIdentityGuardsBlankAndNamespaces() { + // Duplicate ForEach ids break SwiftUI rendering outright. + var blankA = self.makeSong(title: "a", artist: "x", videoId: "v1") + var blankB = self.makeSong(title: "b", artist: "y", videoId: "v2") + blankA.playlistSetVideoId = "" + blankB.playlistSetVideoId = "" + #expect(blankA.rowIdentity != blankB.rowIdentity) + + // A set id equal to another track's video id must not collide either. + var setTrack = self.makeSong(title: "c", artist: "z", videoId: "other") + setTrack.playlistSetVideoId = "shared" + let videoTrack = self.makeSong(title: "d", artist: "w", videoId: "shared") + #expect(setTrack.rowIdentity != videoTrack.rowIdentity) + } +} diff --git a/docs/adr/0033-client-side-playlist-sort-and-search.md b/docs/adr/0033-client-side-playlist-sort-and-search.md new file mode 100644 index 00000000..16db8eab --- /dev/null +++ b/docs/adr/0033-client-side-playlist-sort-and-search.md @@ -0,0 +1,90 @@ +# ADR-0033: Client-Side Playlist Sort and Search + +## Status + +Accepted + +## Context + +The playlist detail page needed sort (title / artist / duration / album) and +search. Both look like server-side features, and neither can be. + +YouTube Music's playlist browse response has no server-side sort for playlist +contents — only library-level listings accept an `order` param. The nearer trap +is that a sort applied to the first page does not survive pagination: the +continuation tokens carry no sort state, so page two returns default order and +the list silently interleaves two orderings. Verified with `api-explorer`; see +`docs/api-discovery.md`. + +That forces both features client-side, which in turn forces a decision about +partial data. A playlist is paged, so a sort or search over "what happens to be +loaded" is not a smaller version of the right answer — it is a different answer, +and it changes as pages arrive. + +Three further questions followed: + +1. **Where does the control live?** ADR-adjacent history matters here: PR #369 + put a sort control in the header action row and was withdrawn because + `headerButtons` uses `ViewThatFits`, which re-measures both candidates on + every layout pass when it shares an `HStack` with a flexible sibling. The + header is inside the track `ScrollView`, so scrolling drove continuous + re-measure — janky scroll and lagging hover (issue #375). +2. **What is a row's identity?** A sortable list invalidates position-as-identity. +3. **What does playback follow?** The visible order, or the playlist's own? + +## Decision + +**Sort and filter are a pure function over the fully-drained track set; the +displayed list is the single source for both rendering and playback.** + +- `PlaylistTrackListPresenter` is a stateless enum: filter, then sort, no + networking. Sort keys are decorated once per track — comparing `artistsDisplay` + directly would re-join the artist array O(n log n) times — and the original + index is the tie-break, so ordering is stable. +- Activating a sort or search drains the remaining pages (single-flight, + coalescing with any in-flight drain) so the ordering covers every track. + Typing debounces that drain; filtering itself applies immediately. +- `displayedTracks` is **cached**, not computed. SwiftUI re-evaluates the detail + body on every observed change — each paged append, every `loadingState` + transition — and a computed property re-ran the full filter and sort each time. + Invalidation hangs off `playlistDetail`'s `didSet` so no load path can forget it. +- The controls live in the **toolbar**, outside the `ScrollView`. This is option 3 + of the four the reporter offered in #375, and it removes the `ViewThatFits` + interaction rather than working around it. The sort control names the active key + and direction, so the order is readable without opening the menu. +- Row identity is per-occurrence (`Song.rowIdentity`: namespaced set id, falling + back to video id), shared with `PlaylistPlaybackActions` so display and playback + agree on what "the same track" means. +- **Playback follows the displayed list.** Header Play / Play Next / Add to Queue + and row taps all queue `displayedTracks`. When a sort or search is active and + pages are still arriving, playback waits for the drain before building the queue. + The existing play-immediately-and-top-up path appends from the raw playlist, + which would replay a search's excluded tracks and abandon the sort partway down + the queue. Unsorted, unfiltered playback keeps that path unchanged. + +Sort and search are view state, not persisted, and reset per presentation. + +## Consequences + +**Easier.** Ordering rules are testable without a view or a network client. +Sorting cannot desynchronize from playback, because there is one list. The +toolbar placement leaves `headerButtons` alone, so #375's jank cannot recur +through this feature. + +**Harder.** Sorting or searching a large playlist costs a full drain — many +continuation requests — before the answer is even correct. A setting +(`autoLoadFullPlaylistOnOpen`, off by default) lets users pay that cost on open +instead. Pressing Play with a sort active on a still-paging playlist now waits +rather than starting immediately; the drain banner reports progress, and the +list is not trustworthy before it finishes anyway. + +**Watch for.** Invalidating from `didSet` means one full re-sort per mutation +rather than per frame. Paged appends are one mutation per page, which is the +intent, but a path that mutates `playlistDetail` in a tight loop would re-sort +each time; Liked Music's live-sync insert/remove is the one to watch if batches +ever grow. + +**Not addressed.** The row number renumbers under sort. `Song` carries no +track-number field — the column has always been a positional ordinal — so +preserving album track numbers would mean parsing a field the API may not +expose. Deferred. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4fc7ee57..dd332ec1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -67,3 +67,4 @@ What becomes easier or more difficult because of this change? | [0030](0030-account-scoped-favorites.md) | Account-Scoped Favorites Persistence | Accepted | | [0031](0031-saved-album-library-reconciliation.md) | Saved-Album Library Identity and Reconciliation | Accepted | | [0032](0032-youtube-ask-gemini.md) | Watch-Scoped YouTube Ask Gemini | Accepted; fixed WEB profile enabled in production | +| [0033](0033-client-side-playlist-sort-and-search.md) | Client-Side Playlist Sort and Search | Accepted | diff --git a/docs/api-discovery.md b/docs/api-discovery.md index 6f97aaf6..4953c1d8 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -902,6 +902,23 @@ let body = ["playlistId": "RDCLAK5uy_l2pHac-aawJYLcesgTf67gaKU-B9ekk1o"] --- +#### Playlist Sort Order (client-side only) + +A playlist's `musicPlaylistShelfRenderer` header carries a `sortFilterSubMenuRenderer` with six orderings — Top voted, Default ordering, Newest first, Oldest first, Title, Artist — each a `browseEndpoint` with a `2gg…` param: + +| Ordering | `params` | +|----------|----------| +| Top voted | `2ggECAIQBA%3D%3D` | +| Default ordering | `2ggA` | +| Newest first | `2ggECAIQAw%3D%3D` | +| Oldest first | `2ggECAEQAw%3D%3D` | +| Title | `2ggECAEQBQ%3D%3D` | +| Artist | `2ggECAEQBg%3D%3D` | + +**These params only reorder the returned window — they cannot be paginated.** Sending a sort param correctly sorts page 1, but the continuation token for page 2 is structurally identical with or without the param (decoding its protobuf shows only an opaque per-page cursor and no sort field). Fetching page 2 — with or without the param — returns the **default-order** next slice, and the page-2 set is identical between sorted and unsorted requests. Verified against a 111-track public playlist with `WEB_REMIX`. + +Consequence: Kaset does **not** send these params. Playlist sort (and search) run client-side in `PlaylistTrackListPresenter` over the fully-drained track set (`loadAllRemaining()`), which is the only way to get a correct order across a playlist larger than one page. + #### Playlist Management All playlist management endpoints require authentication (HTTP 401 without auth). The app exposes these through `YTMusicClientProtocol` so context menus and view models can be tested with mocks.