From de94c49e6e2c4f11090746fcb2b2df6670d554ee Mon Sep 17 00:00:00 2001 From: shinya Date: Mon, 10 Aug 2026 20:56:49 +0800 Subject: [PATCH 1/3] feat(playlist): client-side sort & search on the detail page Add a toolbar sort menu (original / title / artist / duration / album, with ascending/descending toggle) and an inline search field to the playlist detail page. Sorting and searching run client-side over the fully-loaded track set via a pure PlaylistTrackListPresenter; the first sort/search interaction drains all remaining pages through the existing single-flight loadAllRemaining(). Server sort params exist but cannot be paginated, so they are not used (documented in api-discovery.md). Add a default-off "Load all songs when opening a playlist" setting that eagerly drains every page on open. Sort/search state is ephemeral per open and reset on close (the persistent Liked Music view model is reused, so reset explicitly). Localized in all 17 shipped locales. Extracted the artist-formatting and sort/search helpers into PlaylistDetailView extensions to stay within file/type length limits. Co-Authored-By: Claude Opus 4.8 --- Sources/Kaset/Models/PlaylistSortOrder.swift | 36 + Sources/Kaset/Resources/Localizable.xcstrings | 954 ++++++++++++++++++ .../Resources/ar.lproj/Localizable.strings | 9 + .../Resources/de.lproj/Localizable.strings | 9 + .../Resources/en.lproj/Localizable.strings | 9 + .../Resources/es.lproj/Localizable.strings | 9 + .../Resources/fr.lproj/Localizable.strings | 9 + .../Resources/id.lproj/Localizable.strings | 9 + .../Resources/it.lproj/Localizable.strings | 9 + .../Resources/ko.lproj/Localizable.strings | 9 + .../Resources/nl.lproj/Localizable.strings | 9 + .../Resources/pl.lproj/Localizable.strings | 9 + .../Resources/pt.lproj/Localizable.strings | 9 + .../Resources/ru.lproj/Localizable.strings | 9 + .../Resources/sv.lproj/Localizable.strings | 9 + .../Resources/tr.lproj/Localizable.strings | 9 + .../Resources/uk.lproj/Localizable.strings | 9 + .../zh-Hans.lproj/Localizable.strings | 9 + .../zh-Hant.lproj/Localizable.strings | 9 + .../Playlist/PlaylistTrackListPresenter.swift | 123 +++ Sources/Kaset/Services/SettingsManager.swift | 9 + .../ViewModels/PlaylistDetailViewModel.swift | 55 + Sources/Kaset/Views/MusicSettingsView.swift | 14 + .../PlaylistDetailView+ArtistFormatting.swift | 73 ++ .../Views/PlaylistDetailView+SortSearch.swift | 83 ++ Sources/Kaset/Views/PlaylistDetailView.swift | 122 +-- .../PlaylistAutoLoadSettingTests.swift | 20 + .../PlaylistDetailViewModelTests.swift | 130 +++ .../PlaylistTrackListPresenterTests.swift | 182 ++++ docs/api-discovery.md | 17 + 30 files changed, 1896 insertions(+), 75 deletions(-) create mode 100644 Sources/Kaset/Models/PlaylistSortOrder.swift create mode 100644 Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift create mode 100644 Sources/Kaset/Views/PlaylistDetailView+ArtistFormatting.swift create mode 100644 Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift create mode 100644 Tests/KasetTests/PlaylistAutoLoadSettingTests.swift create mode 100644 Tests/KasetTests/PlaylistTrackListPresenterTests.swift diff --git a/Sources/Kaset/Models/PlaylistSortOrder.swift b/Sources/Kaset/Models/PlaylistSortOrder.swift new file mode 100644 index 000000000..8fff76215 --- /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/Resources/Localizable.xcstrings b/Sources/Kaset/Resources/Localizable.xcstrings index 9743a91b6..1f3f4ad0e 100644 --- a/Sources/Kaset/Resources/Localizable.xcstrings +++ b/Sources/Kaset/Resources/Localizable.xcstrings @@ -56666,6 +56666,960 @@ } } } + }, + "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 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 aktiviert, werden beim Öffnen einer großen Playlist alle Seiten sofort geladen, was viele Anfragen verursachen kann." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "When on, opening a large playlist fetches every page up front, which can make many requests." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "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": "Lorsque cette option est activée, 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 aktif, membuka playlist besar akan mengambil semua halaman di awal, yang dapat menghasilkan banyak permintaan." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "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": "Indien ingeschakeld haalt het openen van een grote afspeellijst meteen alle pagina’s op, wat veel verzoeken kan veroorzaken." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Gdy włączone, otwarcie dużej playlisty od razu pobiera wszystkie strony, co może generować wiele żądań." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "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 på hämtar en stor spellista alla sidor direkt, vilket kan ge många förfrågningar." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "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": "開啟後,打開大型播放清單會一次擷取全部分頁,可能產生大量請求。" + } + } + } + }, + "When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front.": { + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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 9ba5fc701..3486afcc2 100644 --- a/Sources/Kaset/Resources/ar.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ar.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 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." = "عند الإيقاف، تُحمَّل الأغاني صفحةً تلو الأخرى أثناء التمرير. عند التفعيل، يؤدي فتح قائمة تشغيل كبيرة إلى جلب كل الصفحات مسبقًا."; diff --git a/Sources/Kaset/Resources/de.lproj/Localizable.strings b/Sources/Kaset/Resources/de.lproj/Localizable.strings index 52a032fa1..b88378f66 100644 --- a/Sources/Kaset/Resources/de.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/de.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Wenn aktiviert, werden beim Öffnen einer großen Playlist alle Seiten sofort geladen, was viele Anfragen verursachen kann."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "Wenn aus, werden Titel beim Scrollen seitenweise geladen. Wenn an, werden beim Öffnen einer großen Playlist alle Seiten sofort geladen."; diff --git a/Sources/Kaset/Resources/en.lproj/Localizable.strings b/Sources/Kaset/Resources/en.lproj/Localizable.strings index af2f1cbb5..3c4afa7c1 100644 --- a/Sources/Kaset/Resources/en.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/en.lproj/Localizable.strings @@ -35,3 +35,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "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." = "When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front."; diff --git a/Sources/Kaset/Resources/es.lproj/Localizable.strings b/Sources/Kaset/Resources/es.lproj/Localizable.strings index 38e87e5e6..4ea93c816 100644 --- a/Sources/Kaset/Resources/es.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/es.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Si está activado, abrir una lista grande carga todas las páginas de inmediato, lo que puede generar muchas solicitudes."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; diff --git a/Sources/Kaset/Resources/fr.lproj/Localizable.strings b/Sources/Kaset/Resources/fr.lproj/Localizable.strings index 49ff25e8a..7dd5ad9aa 100644 --- a/Sources/Kaset/Resources/fr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/fr.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Lorsque cette option est activée, l’ouverture d’une grande playlist charge toutes les pages d’emblée, ce qui peut générer de nombreuses requêtes."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; diff --git a/Sources/Kaset/Resources/id.lproj/Localizable.strings b/Sources/Kaset/Resources/id.lproj/Localizable.strings index 0e1585c7b..64acb9aa9 100644 --- a/Sources/Kaset/Resources/id.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/id.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Jika aktif, membuka playlist besar akan mengambil semua halaman di awal, yang dapat menghasilkan banyak permintaan."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "Jika nonaktif, lagu dimuat per halaman saat Anda menggulir. Jika aktif, membuka playlist besar akan mengambil semua halaman di awal."; diff --git a/Sources/Kaset/Resources/it.lproj/Localizable.strings b/Sources/Kaset/Resources/it.lproj/Localizable.strings index 074d1e538..4de5998e8 100644 --- a/Sources/Kaset/Resources/it.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/it.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Se attivo, l’apertura di una playlist grande carica subito tutte le pagine, generando potenzialmente molte richieste."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; diff --git a/Sources/Kaset/Resources/ko.lproj/Localizable.strings b/Sources/Kaset/Resources/ko.lproj/Localizable.strings index 5c43e0a7f..b5f56c64c 100644 --- a/Sources/Kaset/Resources/ko.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ko.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 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." = "끄면 스크롤할 때 노래가 페이지 단위로 로드됩니다. 켜면 큰 재생목록을 열 때 모든 페이지를 미리 가져옵니다."; diff --git a/Sources/Kaset/Resources/nl.lproj/Localizable.strings b/Sources/Kaset/Resources/nl.lproj/Localizable.strings index 97c3270e8..47c392faf 100644 --- a/Sources/Kaset/Resources/nl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/nl.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Indien ingeschakeld haalt het openen van een grote afspeellijst meteen alle pagina’s op, wat veel verzoeken kan veroorzaken."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "Uitgeschakeld laden nummers per pagina terwijl je scrolt. Ingeschakeld haalt het openen van een grote afspeellijst meteen alle pagina’s op."; diff --git a/Sources/Kaset/Resources/pl.lproj/Localizable.strings b/Sources/Kaset/Resources/pl.lproj/Localizable.strings index 6378e36e1..3afad6912 100644 --- a/Sources/Kaset/Resources/pl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pl.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Gdy włączone, otwarcie dużej playlisty od razu pobiera wszystkie strony, co może generować wiele żądań."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "Gdy wyłączone, utwory ładują się stronami podczas przewijania. Gdy włączone, otwarcie dużej playlisty od razu pobiera wszystkie strony."; diff --git a/Sources/Kaset/Resources/pt.lproj/Localizable.strings b/Sources/Kaset/Resources/pt.lproj/Localizable.strings index 5545e2773..3a7a1b7de 100644 --- a/Sources/Kaset/Resources/pt.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pt.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Quando ativado, abrir uma playlist grande busca todas as páginas de uma vez, o que pode gerar muitas solicitações."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; diff --git a/Sources/Kaset/Resources/ru.lproj/Localizable.strings b/Sources/Kaset/Resources/ru.lproj/Localizable.strings index 6d623cbb9..8f0a0cb70 100644 --- a/Sources/Kaset/Resources/ru.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ru.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 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." = "Когда выключено, песни загружаются постранично при прокрутке. Когда включено, открытие большого плейлиста сразу загружает все страницы."; diff --git a/Sources/Kaset/Resources/sv.lproj/Localizable.strings b/Sources/Kaset/Resources/sv.lproj/Localizable.strings index 9c7df9c6c..97a4c98f8 100644 --- a/Sources/Kaset/Resources/sv.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/sv.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "När det är på hämtar en stor spellista alla sidor direkt, vilket kan ge många förfrågningar."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; diff --git a/Sources/Kaset/Resources/tr.lproj/Localizable.strings b/Sources/Kaset/Resources/tr.lproj/Localizable.strings index 9efd38471..b780408b1 100644 --- a/Sources/Kaset/Resources/tr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/tr.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Açıkken, büyük bir çalma listesini açmak tüm sayfaları önceden getirir; bu çok sayıda istek oluşturabilir."; +"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; diff --git a/Sources/Kaset/Resources/uk.lproj/Localizable.strings b/Sources/Kaset/Resources/uk.lproj/Localizable.strings index 256f95221..25f1693cc 100644 --- a/Sources/Kaset/Resources/uk.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/uk.lproj/Localizable.strings @@ -566,3 +566,12 @@ "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 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." = "Коли вимкнено, пісні завантажуються посторінково під час прокручування. Коли ввімкнено, відкриття великого плейлиста одразу завантажує всі сторінки."; diff --git a/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings index 658771864..2505390be 100644 --- a/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings @@ -564,3 +564,12 @@ "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 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." = "关闭时,滚动时按页加载歌曲。开启时,打开大型播放列表会一次性获取全部分页。"; diff --git a/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings b/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings index a54805cf3..c65ba0460 100644 --- a/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings @@ -564,3 +564,12 @@ "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 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." = "關閉時,捲動時逐頁載入歌曲。開啟時,打開大型播放清單會一次擷取全部分頁。"; diff --git a/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift b/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift new file mode 100644 index 000000000..dd4b35677 --- /dev/null +++ b/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift @@ -0,0 +1,123 @@ +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 + } + 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 + + private static func sort(_ tracks: [Song], order: PlaylistSortOrder) -> [Song] { + guard order.key != .original else { return tracks } + + // Decorate with the original index so ties — and the whole comparison — stay stable. + let indexed = Array(tracks.enumerated()) + let sorted = indexed.sorted { lhs, rhs in + Self.less(lhs.element, rhs.element, order: order, lhsIndex: lhs.offset, rhsIndex: rhs.offset) + } + return sorted.map(\.element) + } + + private static func less( + _ lhs: Song, _ rhs: Song, order: PlaylistSortOrder, lhsIndex: Int, rhsIndex: Int + ) -> Bool { + switch self.compareKeys(lhs, rhs, key: order.key) { + case .orderedSame: + lhsIndex < rhsIndex // 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: Song, _ rhs: Song, key: PlaylistSortKey) -> KeyComparison { + switch key { + case .original: + .orderedSame + case .title: + self.compareStrings(lhs.title, rhs.title) + case .artist: + self.compareStrings( + lhs.artistsDisplay.isEmpty ? nil : lhs.artistsDisplay, + rhs.artistsDisplay.isEmpty ? nil : rhs.artistsDisplay + ) + case .album: + self.compareStrings(lhs.album?.title, rhs.album?.title) + case .duration: + self.compareDurations(lhs.duration, rhs.duration) + } + } + + 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 05e7667c0..b40ed397f 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 05bb668b1..a3548792b 100644 --- a/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift +++ b/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift @@ -71,6 +71,12 @@ final class PlaylistDetailViewModel { /// 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 = "" + private let playlist: Playlist /// The API client (exposed for add to library action). let client: any YTMusicClientProtocol @@ -130,6 +136,49 @@ final class PlaylistDetailViewModel { self.playlist.id } + /// The tracks to display, after applying the current search filter and sort order. + var displayedTracks: [Song] { + PlaylistTrackListPresenter.displayedTracks( + from: self.playlistDetail?.tracks ?? [], + sortOrder: self.sortOrder, + searchQuery: self.searchQuery + ) + } + + /// 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) { + self.sortOrder = order + self.drainAllIfNeededForDisplay() + } + + /// Updates the search query and, when the playlist is still paging, drains every + /// remaining page so the search covers the complete track set. + func setSearchQuery(_ query: String) { + self.searchQuery = query + self.drainAllIfNeededForDisplay() + } + + /// Resets sort and search back to the default (server order, no filter). + func resetSortAndSearch() { + self.sortOrder = .default + self.searchQuery = "" + } + + /// Kicks a full drain (single-flight; coalesces with any in-flight drain) when a + /// sort/search is active and more 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.loadTask?.cancel() self.fullLoadTask?.cancel() @@ -187,6 +236,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 aed8feca4..09f4b683a 100644 --- a/Sources/Kaset/Views/MusicSettingsView.swift +++ b/Sources/Kaset/Views/MusicSettingsView.swift @@ -86,6 +86,20 @@ 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 + ) + .help(String(localized: "When on, opening a large playlist fetches every page up front, which can make many requests.")) + } header: { + Text(String(localized: "Playlists")) + } footer: { + 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.")) + } } .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 000000000..08ce4c740 --- /dev/null +++ b/Sources/Kaset/Views/PlaylistDetailView+ArtistFormatting.swift @@ -0,0 +1,73 @@ +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 + } + + 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+SortSearch.swift b/Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift new file mode 100644 index 000000000..d929ef74c --- /dev/null +++ b/Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift @@ -0,0 +1,83 @@ +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 + } + + /// 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: { + Image(systemName: "arrow.up.arrow.down") + } + .help(String(localized: "Sort songs")) + } + + @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)) + } + } + + /// Inline search field that filters the track list locally. + @ViewBuilder + var searchField: some View { + let query = Binding( + get: { self.viewModel.searchQuery }, + set: { self.viewModel.setSearchQuery($0) } + ) + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField(String(localized: "Search in Playlist"), text: query) + .textFieldStyle(.plain) + if !self.viewModel.searchQuery.isEmpty { + Button { + self.viewModel.setSearchQuery("") + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(8) + .compatGlass(in: .capsule) + } +} diff --git a/Sources/Kaset/Views/PlaylistDetailView.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index 2b0f64e91..dfd994341 100644 --- a/Sources/Kaset/Views/PlaylistDetailView.swift +++ b/Sources/Kaset/Views/PlaylistDetailView.swift @@ -87,6 +87,13 @@ struct PlaylistDetailView: View { ) .navigationTitle(self.playlist.title) .toolbarBackgroundVisibility(.hidden, for: .automatic) + .toolbar { + if self.viewModel.playlistDetail != nil { + ToolbarItem(placement: .automatic) { + self.sortMenu + } + } + } .safeAreaInset(edge: .bottom, spacing: 0) { if case .error = self.viewModel.loadingState { } else { @@ -101,6 +108,11 @@ struct PlaylistDetailView: View { .refreshable { await self.viewModel.refresh() } + .onDisappear { + // Sort/search are ephemeral per open. Some detail view models are reused + // (e.g. the persistent Liked Music model), so reset explicitly on close. + 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 { @@ -139,6 +151,8 @@ struct PlaylistDetailView: View { Divider() + self.searchField + // Tracks let fallbackAlbum = Album( id: detail.id, @@ -148,10 +162,17 @@ 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 + if displayed.isEmpty, self.hasActiveSearch { + 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) } @@ -262,8 +283,13 @@ struct PlaylistDetailView: View { fallbackAlbum: fallbackAlbum ) .onAppear { - // Load more when reaching the last few items - if index >= tracks.count - 3, self.viewModel.hasMore { + // Positional paging only applies to the natural (unsorted, unfiltered) list. + // Once a sort or search is active, list position no longer maps to the load + // frontier, so completeness is driven by loadAllRemaining() instead. + if !self.viewModel.isFilteringOrSorting, + index >= tracks.count - 3, + self.viewModel.hasMore + { Task { await self.viewModel.loadMore() } } } @@ -276,8 +302,21 @@ struct PlaylistDetailView: View { } } - // Loading indicator for pagination - if self.viewModel.loadingState == .loadingMore { + // While a sort/search is active and pages are still draining, tell the user + // more matches may appear. + if self.viewModel.isFilteringOrSorting, self.viewModel.hasMore { + HStack(spacing: 8) { + Spacer() + ProgressView() + .controlSize(.small) + Text(String(localized: "Loading all songs…")) + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + } + .padding() + } else if self.viewModel.loadingState == .loadingMore { + // Loading indicator for scroll pagination HStack { Spacer() ProgressView() @@ -318,73 +357,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 diff --git a/Tests/KasetTests/PlaylistAutoLoadSettingTests.swift b/Tests/KasetTests/PlaylistAutoLoadSettingTests.swift new file mode 100644 index 000000000..665c21cfe --- /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 cd945e962..9b52c4924 100644 --- a/Tests/KasetTests/PlaylistDetailViewModelTests.swift +++ b/Tests/KasetTests/PlaylistDetailViewModelTests.swift @@ -396,6 +396,136 @@ 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("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 000000000..df8dc56ba --- /dev/null +++ b/Tests/KasetTests/PlaylistTrackListPresenterTests.swift @@ -0,0 +1,182 @@ +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"]) + } +} diff --git a/docs/api-discovery.md b/docs/api-discovery.md index 6f97aaf6d..4953c1d89 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. From a24ae66f58ec7e7940ffeaec06681643f4bf497b Mon Sep 17 00:00:00 2001 From: shinya Date: Mon, 10 Aug 2026 22:08:25 +0800 Subject: [PATCH 2/3] perf(playlist): cache the sorted track list and fix sort/search UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort and search on the playlist detail page had a few problems that only show up on large playlists. displayedTracks was a computed property, so SwiftUI re-ran the full filter and sort on every observed change — each paged append and every loadingState transition. Draining a large playlist meant tens of full re-sorts on the main actor, each rebuilding artistsDisplay inside the comparator O(n log n) times. It is now cached, invalidated from playlistDetail's didSet so no load path can miss it, and the comparator works off keys decorated once per track. The track ForEach keyed rows by position, which is not identity once the list reorders: SwiftUI recycled row-local state (hover, in-flight like) onto the wrong track. Rows now key off playlistSetVideoId, which is per-occurrence. Sort/search reset was wired to onDisappear, which also fires when the screen is pushed over. A trace confirmed it firing with an active sort right before the pushed destination appeared, silently dropping the sort on a round trip to an artist page. The reset now runs once per presentation, guarded by @State. Search moved from the scrolling content into the toolbar via .searchable, so it stays reachable and clearable deep into a long playlist and sits beside the sort control. Clicking the page body now resigns its focus — track rows are Buttons and don't take first responder on macOS, so focus had nowhere to go. The sort toolbar button now names the active key and direction instead of being a stateless icon, and playlist rows show the album, so sorting by album no longer orders on a field the row never displays. Album is searchable too. Typing debounces the full-playlist drain rather than starting a fresh round of continuation requests per keystroke, and a mid-drain search no longer claims "No Results" for a track that simply hasn't paged in yet. Drain progress moved above the list, where it is visible while rows are reordering underneath. Merges the auto-load setting's tooltip into its footer; the request cost is the reason to leave it off and belongs where it is always visible. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Kaset/Models/Song.swift | 7 + Sources/Kaset/Resources/Localizable.xcstrings | 142 +++--------------- .../Resources/ar.lproj/Localizable.strings | 3 +- .../Resources/de.lproj/Localizable.strings | 3 +- .../Resources/en.lproj/Localizable.strings | 3 +- .../Resources/es.lproj/Localizable.strings | 3 +- .../Resources/fr.lproj/Localizable.strings | 3 +- .../Resources/id.lproj/Localizable.strings | 3 +- .../Resources/it.lproj/Localizable.strings | 3 +- .../Resources/ko.lproj/Localizable.strings | 3 +- .../Resources/nl.lproj/Localizable.strings | 3 +- .../Resources/pl.lproj/Localizable.strings | 3 +- .../Resources/pt.lproj/Localizable.strings | 3 +- .../Resources/ru.lproj/Localizable.strings | 3 +- .../Resources/sv.lproj/Localizable.strings | 3 +- .../Resources/tr.lproj/Localizable.strings | 3 +- .../Resources/uk.lproj/Localizable.strings | 3 +- .../zh-Hans.lproj/Localizable.strings | 3 +- .../zh-Hant.lproj/Localizable.strings | 3 +- .../Playlist/PlaylistTrackListPresenter.swift | 65 +++++--- .../ViewModels/PlaylistDetailViewModel.swift | 69 ++++++--- Sources/Kaset/Views/MusicSettingsView.swift | 5 +- .../PlaylistDetailView+ArtistFormatting.swift | 14 ++ .../Views/PlaylistDetailView+SortSearch.swift | 78 ++++++---- Sources/Kaset/Views/PlaylistDetailView.swift | 74 +++++---- .../PlaylistDetailViewModelTests.swift | 52 +++++++ .../PlaylistTrackListPresenterTests.swift | 52 +++++++ 27 files changed, 356 insertions(+), 253 deletions(-) diff --git a/Sources/Kaset/Models/Song.swift b/Sources/Kaset/Models/Song.swift index a1835d85f..e28b3df70 100644 --- a/Sources/Kaset/Models/Song.swift +++ b/Sources/Kaset/Models/Song.swift @@ -154,6 +154,13 @@ struct Song: Identifiable, Codable, Hashable { self.artists.map(\.name).joined(separator: ", ") } + /// Stable `ForEach` identity for a track row. Position can't serve as identity in a + /// sortable list — the row keeps its index while its content changes, so SwiftUI + /// recycles row-local state (hover, in-flight like) onto the wrong track. + var rowIdentity: String { + self.playlistSetVideoId ?? self.id + } + /// 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 1f3f4ad0e..723be2106 100644 --- a/Sources/Kaset/Resources/Localizable.xcstrings +++ b/Sources/Kaset/Resources/Localizable.xcstrings @@ -57409,214 +57409,108 @@ } } }, - "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.": { "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "عند التفعيل، يؤدي فتح قائمة تشغيل كبيرة إلى جلب كل الصفحات مسبقًا، ما قد يُنشئ طلبات كثيرة." + "value": "عند الإيقاف، تُحمَّل الأغاني صفحةً تلو الأخرى أثناء التمرير. عند التفعيل، يؤدي فتح قائمة تشغيل كبيرة إلى جلب كل الصفحات مسبقًا، ما قد يُنشئ طلبات كثيرة." } }, "de": { "stringUnit": { "state": "translated", - "value": "Wenn aktiviert, werden beim Öffnen einer großen Playlist alle Seiten sofort geladen, was viele Anfragen verursachen kann." + "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 on, opening a large playlist fetches every page up front, which can make many requests." + "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á activado, abrir una lista grande carga todas las páginas de inmediato, lo que puede generar muchas solicitudes." + "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": "Lorsque cette option est activée, l’ouverture d’une grande playlist charge toutes les pages d’emblée, ce qui peut générer de nombreuses requêtes." + "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 aktif, membuka playlist besar akan mengambil semua halaman di awal, yang dapat menghasilkan banyak permintaan." + "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 attivo, l’apertura di una playlist grande carica subito tutte le pagine, generando potenzialmente molte richieste." + "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": "켜면 큰 재생목록을 열 때 모든 페이지를 미리 가져오므로 요청이 많아질 수 있습니다." + "value": "끄면 스크롤할 때 노래가 페이지 단위로 로드됩니다. 켜면 큰 재생목록을 열 때 모든 페이지를 미리 가져오므로 요청이 많아질 수 있습니다." } }, "nl": { "stringUnit": { "state": "translated", - "value": "Indien ingeschakeld haalt het openen van een grote afspeellijst meteen alle pagina’s op, wat veel verzoeken kan veroorzaken." + "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 włączone, otwarcie dużej playlisty od razu pobiera wszystkie strony, co może generować wiele żądań." + "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 ativado, abrir uma playlist grande busca todas as páginas de uma vez, o que pode gerar muitas solicitações." + "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": "Когда включено, открытие большого плейлиста сразу загружает все страницы, что может создать много запросов." + "value": "Когда выключено, песни загружаются постранично при прокрутке. Когда включено, открытие большого плейлиста сразу загружает все страницы, что может создать много запросов." } }, "sv": { "stringUnit": { "state": "translated", - "value": "När det är på hämtar en stor spellista alla sidor direkt, vilket kan ge många förfrågningar." + "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": "Açıkken, büyük bir çalma listesini açmak tüm sayfaları önceden getirir; bu çok sayıda istek oluşturabilir." + "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": "Коли ввімкнено, відкриття великого плейлиста одразу завантажує всі сторінки, що може створити багато запитів." + "value": "Коли вимкнено, пісні завантажуються посторінково під час прокручування. Коли ввімкнено, відкриття великого плейлиста одразу завантажує всі сторінки, що може створити багато запитів." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "开启后,打开大型播放列表会一次性获取全部分页,可能产生大量请求。" + "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.": { - "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." - } - }, - "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." - } - }, - "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." - } - }, - "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." - } - }, - "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." - } - }, - "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." - } - }, - "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." - } - }, - "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." - } - }, - "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." - } - }, - "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." - } - }, - "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." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Коли вимкнено, пісні завантажуються посторінково під час прокручування. Коли ввімкнено, відкриття великого плейлиста одразу завантажує всі сторінки." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "关闭时,滚动时按页加载歌曲。开启时,打开大型播放列表会一次性获取全部分页。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "關閉時,捲動時逐頁載入歌曲。開啟時,打開大型播放清單會一次擷取全部分頁。" + "value": "關閉時,捲動時逐頁載入歌曲。開啟時,打開大型播放清單會一次擷取全部分頁,可能產生大量請求。" } } } diff --git a/Sources/Kaset/Resources/ar.lproj/Localizable.strings b/Sources/Kaset/Resources/ar.lproj/Localizable.strings index 3486afcc2..262b8f048 100644 --- a/Sources/Kaset/Resources/ar.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ar.lproj/Localizable.strings @@ -573,5 +573,4 @@ "Search in Playlist" = "البحث في قائمة التشغيل"; "Loading all songs…" = "جارٍ تحميل كل الأغاني…"; "Load All Songs When Opening a Playlist" = "تحميل كل الأغاني عند فتح قائمة التشغيل"; -"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." = "عند الإيقاف، تُحمَّل الأغاني صفحةً تلو الأخرى أثناء التمرير. عند التفعيل، يؤدي فتح قائمة تشغيل كبيرة إلى جلب كل الصفحات مسبقًا."; +"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 b88378f66..1bffa2f95 100644 --- a/Sources/Kaset/Resources/de.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/de.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Wenn aktiviert, werden beim Öffnen einer großen Playlist alle Seiten sofort geladen, was viele Anfragen verursachen kann."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "Wenn aus, werden Titel beim Scrollen seitenweise geladen. Wenn an, werden beim Öffnen einer großen Playlist alle Seiten sofort geladen."; +"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 3c4afa7c1..6d7b74b0f 100644 --- a/Sources/Kaset/Resources/en.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/en.lproj/Localizable.strings @@ -42,5 +42,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "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." = "When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front."; +"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 4ea93c816..09b074a87 100644 --- a/Sources/Kaset/Resources/es.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/es.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Si está activado, abrir una lista grande carga todas las páginas de inmediato, lo que puede generar muchas solicitudes."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; +"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 7dd5ad9aa..fab102f78 100644 --- a/Sources/Kaset/Resources/fr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/fr.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Lorsque cette option est activée, l’ouverture d’une grande playlist charge toutes les pages d’emblée, ce qui peut générer de nombreuses requêtes."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; +"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 64acb9aa9..0447dd0cc 100644 --- a/Sources/Kaset/Resources/id.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/id.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Jika aktif, membuka playlist besar akan mengambil semua halaman di awal, yang dapat menghasilkan banyak permintaan."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "Jika nonaktif, lagu dimuat per halaman saat Anda menggulir. Jika aktif, membuka playlist besar akan mengambil semua halaman di awal."; +"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 4de5998e8..92009c8c9 100644 --- a/Sources/Kaset/Resources/it.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/it.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Se attivo, l’apertura di una playlist grande carica subito tutte le pagine, generando potenzialmente molte richieste."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; +"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 b5f56c64c..3e4487e9b 100644 --- a/Sources/Kaset/Resources/ko.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ko.lproj/Localizable.strings @@ -573,5 +573,4 @@ "Search in Playlist" = "재생목록에서 검색"; "Loading all songs…" = "모든 노래 불러오는 중…"; "Load All Songs When Opening a Playlist" = "재생목록을 열 때 모든 노래 불러오기"; -"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." = "끄면 스크롤할 때 노래가 페이지 단위로 로드됩니다. 켜면 큰 재생목록을 열 때 모든 페이지를 미리 가져옵니다."; +"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 47c392faf..8ac38687d 100644 --- a/Sources/Kaset/Resources/nl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/nl.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Indien ingeschakeld haalt het openen van een grote afspeellijst meteen alle pagina’s op, wat veel verzoeken kan veroorzaken."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "Uitgeschakeld laden nummers per pagina terwijl je scrolt. Ingeschakeld haalt het openen van een grote afspeellijst meteen alle pagina’s op."; +"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 3afad6912..6032d9a8b 100644 --- a/Sources/Kaset/Resources/pl.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pl.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Gdy włączone, otwarcie dużej playlisty od razu pobiera wszystkie strony, co może generować wiele żądań."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "Gdy wyłączone, utwory ładują się stronami podczas przewijania. Gdy włączone, otwarcie dużej playlisty od razu pobiera wszystkie strony."; +"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 3a7a1b7de..166ea5df6 100644 --- a/Sources/Kaset/Resources/pt.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/pt.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Quando ativado, abrir uma playlist grande busca todas as páginas de uma vez, o que pode gerar muitas solicitações."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; +"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 8f0a0cb70..3ce9c66aa 100644 --- a/Sources/Kaset/Resources/ru.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/ru.lproj/Localizable.strings @@ -573,5 +573,4 @@ "Search in Playlist" = "Поиск в плейлисте"; "Loading all songs…" = "Загрузка всех песен…"; "Load All Songs When Opening a Playlist" = "Загружать все песни при открытии плейлиста"; -"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." = "Когда выключено, песни загружаются постранично при прокрутке. Когда включено, открытие большого плейлиста сразу загружает все страницы."; +"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 97a4c98f8..1eac552db 100644 --- a/Sources/Kaset/Resources/sv.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/sv.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "När det är på hämtar en stor spellista alla sidor direkt, vilket kan ge många förfrågningar."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; +"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 b780408b1..f3275b88c 100644 --- a/Sources/Kaset/Resources/tr.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/tr.lproj/Localizable.strings @@ -573,5 +573,4 @@ "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 on, opening a large playlist fetches every page up front, which can make many requests." = "Açıkken, büyük bir çalma listesini açmak tüm sayfaları önceden getirir; bu çok sayıda istek oluşturabilir."; -"When off, songs load a page at a time as you scroll. When on, opening a large playlist fetches every page up front." = "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."; +"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 25f1693cc..d4a3af516 100644 --- a/Sources/Kaset/Resources/uk.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/uk.lproj/Localizable.strings @@ -573,5 +573,4 @@ "Search in Playlist" = "Пошук у плейлисті"; "Loading all songs…" = "Завантаження всіх пісень…"; "Load All Songs When Opening a Playlist" = "Завантажувати всі пісні під час відкриття плейлиста"; -"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." = "Коли вимкнено, пісні завантажуються посторінково під час прокручування. Коли ввімкнено, відкриття великого плейлиста одразу завантажує всі сторінки."; +"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 2505390be..7d1340e09 100644 --- a/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings @@ -571,5 +571,4 @@ "Search in Playlist" = "在播放列表中搜索"; "Loading all songs…" = "正在加载全部歌曲…"; "Load All Songs When Opening a Playlist" = "打开播放列表时加载全部歌曲"; -"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." = "关闭时,滚动时按页加载歌曲。开启时,打开大型播放列表会一次性获取全部分页。"; +"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 c65ba0460..6e599dd96 100644 --- a/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings @@ -571,5 +571,4 @@ "Search in Playlist" = "在播放清單中搜尋"; "Loading all songs…" = "正在載入全部歌曲…"; "Load All Songs When Opening a Playlist" = "開啟播放清單時載入全部歌曲"; -"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." = "關閉時,捲動時逐頁載入歌曲。開啟時,打開大型播放清單會一次擷取全部分頁。"; +"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/Playlist/PlaylistTrackListPresenter.swift b/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift index dd4b35677..8178d5d0f 100644 --- a/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift +++ b/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift @@ -27,6 +27,9 @@ enum PlaylistTrackListPresenter { 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) } } } @@ -40,23 +43,50 @@ enum PlaylistTrackListPresenter { // 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 } - // Decorate with the original index so ties — and the whole comparison — stay stable. - let indexed = Array(tracks.enumerated()) - let sorted = indexed.sorted { lhs, rhs in - Self.less(lhs.element, rhs.element, order: order, lhsIndex: lhs.offset, rhsIndex: rhs.offset) + 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 sorted.map(\.element) + + return decorated + .sorted { Self.less($0, $1, order: order) } + .map(\.song) } - private static func less( - _ lhs: Song, _ rhs: Song, order: PlaylistSortOrder, lhsIndex: Int, rhsIndex: Int - ) -> Bool { - switch self.compareKeys(lhs, rhs, key: order.key) { + 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: - lhsIndex < rhsIndex // stable tie-break, direction-independent + lhs.index < rhs.index // stable tie-break, direction-independent case .orderedAscending: order.ascending case .orderedDescending: @@ -76,21 +106,14 @@ enum PlaylistTrackListPresenter { case rhsMissing } - private static func compareKeys(_ lhs: Song, _ rhs: Song, key: PlaylistSortKey) -> KeyComparison { + private static func compareKeys(_ lhs: Decorated, _ rhs: Decorated, key: PlaylistSortKey) -> KeyComparison { switch key { case .original: .orderedSame - case .title: - self.compareStrings(lhs.title, rhs.title) - case .artist: - self.compareStrings( - lhs.artistsDisplay.isEmpty ? nil : lhs.artistsDisplay, - rhs.artistsDisplay.isEmpty ? nil : rhs.artistsDisplay - ) - case .album: - self.compareStrings(lhs.album?.title, rhs.album?.title) case .duration: - self.compareDurations(lhs.duration, rhs.duration) + Self.compareDurations(lhs.duration, rhs.duration) + case .title, .artist, .album: + Self.compareStrings(lhs.stringKey, rhs.stringKey) } } diff --git a/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift b/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift index a3548792b..13b6481e1 100644 --- a/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift +++ b/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift @@ -65,8 +65,13 @@ 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 @@ -77,6 +82,16 @@ final class PlaylistDetailViewModel { /// 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 @@ -136,15 +151,6 @@ final class PlaylistDetailViewModel { self.playlist.id } - /// The tracks to display, after applying the current search filter and sort order. - var displayedTracks: [Song] { - PlaylistTrackListPresenter.displayedTracks( - from: self.playlistDetail?.tracks ?? [], - sortOrder: self.sortOrder, - searchQuery: self.searchQuery - ) - } - /// Whether a non-default sort or a non-empty search is currently active. var isFilteringOrSorting: Bool { self.sortOrder.key != .original @@ -154,32 +160,61 @@ final class PlaylistDetailViewModel { /// 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 and, when the playlist is still paging, drains every - /// remaining page so the search covers the complete track set. + /// 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.drainAllIfNeededForDisplay() + 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 full drain (single-flight; coalesces with any in-flight drain) when a - /// sort/search is active and more pages remain, so client-side ordering covers - /// every track rather than just the loaded window. + /// 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() diff --git a/Sources/Kaset/Views/MusicSettingsView.swift b/Sources/Kaset/Views/MusicSettingsView.swift index 09f4b683a..8b3d489ec 100644 --- a/Sources/Kaset/Views/MusicSettingsView.swift +++ b/Sources/Kaset/Views/MusicSettingsView.swift @@ -94,11 +94,12 @@ struct MusicSettingsView: View { String(localized: "Load All Songs When Opening a Playlist"), isOn: self.$settings.autoLoadFullPlaylistOnOpen ) - .help(String(localized: "When on, opening a large playlist fetches every page up front, which can make many requests.")) } header: { Text(String(localized: "Playlists")) } footer: { - 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.")) + // 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) diff --git a/Sources/Kaset/Views/PlaylistDetailView+ArtistFormatting.swift b/Sources/Kaset/Views/PlaylistDetailView+ArtistFormatting.swift index 08ce4c740..07dcbf7ad 100644 --- a/Sources/Kaset/Views/PlaylistDetailView+ArtistFormatting.swift +++ b/Sources/Kaset/Views/PlaylistDetailView+ArtistFormatting.swift @@ -22,6 +22,20 @@ extension PlaylistDetailView { 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] = [] diff --git a/Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift b/Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift index d929ef74c..b9e27fee6 100644 --- a/Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift +++ b/Sources/Kaset/Views/PlaylistDetailView+SortSearch.swift @@ -9,6 +9,40 @@ extension PlaylistDetailView { !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 { @@ -20,11 +54,27 @@ extension PlaylistDetailView { } } } label: { - Image(systemName: "arrow.up.arrow.down") + 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 { @@ -54,30 +104,4 @@ extension PlaylistDetailView { self.viewModel.setSortOrder(PlaylistSortOrder(key: key, ascending: true)) } } - - /// Inline search field that filters the track list locally. - @ViewBuilder - var searchField: some View { - let query = Binding( - get: { self.viewModel.searchQuery }, - set: { self.viewModel.setSearchQuery($0) } - ) - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField(String(localized: "Search in Playlist"), text: query) - .textFieldStyle(.plain) - if !self.viewModel.searchQuery.isEmpty { - Button { - self.viewModel.setSearchQuery("") - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .padding(8) - .compatGlass(in: .capsule) - } } diff --git a/Sources/Kaset/Views/PlaylistDetailView.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index dfd994341..177637ba1 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 { @@ -94,6 +98,14 @@ struct PlaylistDetailView: View { } } } + // 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 { @@ -108,9 +120,13 @@ struct PlaylistDetailView: View { .refreshable { await self.viewModel.refresh() } - .onDisappear { - // Sort/search are ephemeral per open. Some detail view models are reused - // (e.g. the persistent Liked Music model), so reset explicitly on close. + .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 @@ -151,8 +167,6 @@ struct PlaylistDetailView: View { Divider() - self.searchField - // Tracks let fallbackAlbum = Album( id: detail.id, @@ -163,10 +177,20 @@ struct PlaylistDetailView: View { trackCount: detail.trackCount ?? detail.tracks.count ) let displayed = self.viewModel.displayedTracks + let isDraining = self.viewModel.isFilteringOrSorting && self.viewModel.hasMore + + if isDraining { + self.drainProgressBanner + } + if displayed.isEmpty, self.hasActiveSearch { - ContentUnavailableView.search(text: self.viewModel.searchQuery) - .frame(maxWidth: .infinity) - .padding(.top, 24) + // 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, @@ -175,6 +199,14 @@ struct PlaylistDetailView: View { } } .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 @@ -277,15 +309,14 @@ 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 { - // Positional paging only applies to the natural (unsorted, unfiltered) list. - // Once a sort or search is active, list position no longer maps to the load - // frontier, so completeness is driven by loadAllRemaining() instead. + // 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 @@ -302,21 +333,8 @@ struct PlaylistDetailView: View { } } - // While a sort/search is active and pages are still draining, tell the user - // more matches may appear. - if self.viewModel.isFilteringOrSorting, self.viewModel.hasMore { - HStack(spacing: 8) { - Spacer() - ProgressView() - .controlSize(.small) - Text(String(localized: "Loading all songs…")) - .font(.footnote) - .foregroundStyle(.secondary) - Spacer() - } - .padding() - } else if self.viewModel.loadingState == .loadingMore { - // Loading indicator for scroll pagination + // Scroll pagination only; the sort/search drain reports via `drainProgressBanner`. + if self.viewModel.loadingState == .loadingMore { HStack { Spacer() ProgressView() @@ -336,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( diff --git a/Tests/KasetTests/PlaylistDetailViewModelTests.swift b/Tests/KasetTests/PlaylistDetailViewModelTests.swift index 9b52c4924..a8eb0cc57 100644 --- a/Tests/KasetTests/PlaylistDetailViewModelTests.swift +++ b/Tests/KasetTests/PlaylistDetailViewModelTests.swift @@ -445,6 +445,58 @@ struct PlaylistDetailViewModelTests { #expect(vm.isFilteringOrSorting == false) } + @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("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 diff --git a/Tests/KasetTests/PlaylistTrackListPresenterTests.swift b/Tests/KasetTests/PlaylistTrackListPresenterTests.swift index df8dc56ba..5ef08111b 100644 --- a/Tests/KasetTests/PlaylistTrackListPresenterTests.swift +++ b/Tests/KasetTests/PlaylistTrackListPresenterTests.swift @@ -179,4 +179,56 @@ struct PlaylistTrackListPresenterTests { ) #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"]) + } + + // 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 == "vid") + } } From c9c36283efd500bf0a6869302e7ab7e27df9ab41 Mon Sep 17 00:00:00 2001 From: shinya Date: Mon, 10 Aug 2026 23:12:59 +0800 Subject: [PATCH 3/3] fix(playlist): queue what the list shows, and unify occurrence identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playback disagreed with the screen. The header Play / Play Next / Add to Queue actions built their queue from the raw track list while a row tap used the displayed one, so sorting a playlist and pressing Play played the original order, and searching then pressing Play played the whole playlist. All four now queue the displayed list. That exposed a second problem. The deferred-load path plays the visible snapshot immediately and tops the queue up once pagination finishes, appending from the raw playlist — which would replay the tracks a search had excluded and abandon the sort partway down the queue. With a sort or search active, playback now waits for the drain (already running, with progress on screen) and queues exactly the displayed list, relocating the tapped track by identity. Unsorted, unfiltered playback keeps the play-immediately path unchanged. Song.rowIdentity was a weaker re-implementation of the occurrence identity PlaylistPlaybackActions already computed: it lacked the empty-string guard, so a blank playlistSetVideoId collapsed every such row onto one ForEach id; it keyed off `id` where the established helper used `videoId`; and it dropped the set:/video: namespacing that keeps a set id from colliding with another track's video id. Both now share one implementation. Adds ADR-0033 for the client-side sort/search decision, the toolbar placement that answers issue #375, and the playback contract above. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Kaset/Models/Song.swift | 11 ++- .../Player/PlaylistPlaybackActions.swift | 11 +-- .../Playlist/PlaylistTrackListPresenter.swift | 6 +- .../PlaylistDetailView+HeaderActions.swift | 6 +- Sources/Kaset/Views/PlaylistDetailView.swift | 31 ++++++- .../PlaylistDetailViewModelTests.swift | 61 +++++++++++++ .../PlaylistTrackListPresenterTests.swift | 58 +++++++++++- ...33-client-side-playlist-sort-and-search.md | 90 +++++++++++++++++++ docs/adr/README.md | 1 + 9 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 docs/adr/0033-client-side-playlist-sort-and-search.md diff --git a/Sources/Kaset/Models/Song.swift b/Sources/Kaset/Models/Song.swift index e28b3df70..b306ac691 100644 --- a/Sources/Kaset/Models/Song.swift +++ b/Sources/Kaset/Models/Song.swift @@ -154,11 +154,14 @@ struct Song: Identifiable, Codable, Hashable { self.artists.map(\.name).joined(separator: ", ") } - /// Stable `ForEach` identity for a track row. Position can't serve as identity in a - /// sortable list — the row keeps its index while its content changes, so SwiftUI - /// recycles row-local state (hover, in-flight like) onto the wrong track. + /// 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 { - self.playlistSetVideoId ?? self.id + if let setVideoId = self.playlistSetVideoId, !setVideoId.isEmpty { + return "set:\(setVideoId)" + } + return "video:\(self.videoId)" } /// Formatted duration string (e.g., "3:45"). diff --git a/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift b/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift index 6b98c99aa..5a260b462 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 index 8178d5d0f..3f72e120f 100644 --- a/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift +++ b/Sources/Kaset/Services/Playlist/PlaylistTrackListPresenter.swift @@ -84,7 +84,7 @@ enum PlaylistTrackListPresenter { } private static func less(_ lhs: Decorated, _ rhs: Decorated, order: PlaylistSortOrder) -> Bool { - switch Self.compareKeys(lhs, rhs, key: order.key) { + switch self.compareKeys(lhs, rhs, key: order.key) { case .orderedSame: lhs.index < rhs.index // stable tie-break, direction-independent case .orderedAscending: @@ -111,9 +111,9 @@ enum PlaylistTrackListPresenter { case .original: .orderedSame case .duration: - Self.compareDurations(lhs.duration, rhs.duration) + self.compareDurations(lhs.duration, rhs.duration) case .title, .artist, .album: - Self.compareStrings(lhs.stringKey, rhs.stringKey) + self.compareStrings(lhs.stringKey, rhs.stringKey) } } diff --git a/Sources/Kaset/Views/PlaylistDetailView+HeaderActions.swift b/Sources/Kaset/Views/PlaylistDetailView+HeaderActions.swift index 62c11efe3..eec991eb4 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.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index 177637ba1..d16f38b85 100644 --- a/Sources/Kaset/Views/PlaylistDetailView.swift +++ b/Sources/Kaset/Views/PlaylistDetailView.swift @@ -520,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 ) @@ -539,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/PlaylistDetailViewModelTests.swift b/Tests/KasetTests/PlaylistDetailViewModelTests.swift index a8eb0cc57..4bbb96084 100644 --- a/Tests/KasetTests/PlaylistDetailViewModelTests.swift +++ b/Tests/KasetTests/PlaylistDetailViewModelTests.swift @@ -445,6 +445,32 @@ struct PlaylistDetailViewModelTests { #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") @@ -470,6 +496,41 @@ struct PlaylistDetailViewModelTests { #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") diff --git a/Tests/KasetTests/PlaylistTrackListPresenterTests.swift b/Tests/KasetTests/PlaylistTrackListPresenterTests.swift index 5ef08111b..8f5ef9c12 100644 --- a/Tests/KasetTests/PlaylistTrackListPresenterTests.swift +++ b/Tests/KasetTests/PlaylistTrackListPresenterTests.swift @@ -219,6 +219,46 @@ struct PlaylistTrackListPresenterTests { #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") @@ -229,6 +269,22 @@ struct PlaylistTrackListPresenterTests { second.playlistSetVideoId = "set-b" #expect(first.rowIdentity != second.rowIdentity) - #expect(self.makeSong(title: "a", artist: "x", videoId: "vid").rowIdentity == "vid") + #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 000000000..16db8eabb --- /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 4fc7ee573..dd332ec1d 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 |