From 214a7c13dccaa003221987773fe99d599d0acba5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:30:23 +0200 Subject: [PATCH] feat(music): show full play queue with history, YouTube Music style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queue sheet/panel now renders the whole playback order — dimmed played tracks above the current one — instead of only up-next. Opens scrolled to the current track and follows advancement while parked on it. --- lib/i18n/bg.i18n.json | 1 - lib/i18n/da.i18n.json | 1 - lib/i18n/de.i18n.json | 1 - lib/i18n/en.i18n.json | 1 - lib/i18n/es.i18n.json | 1 - lib/i18n/fr.i18n.json | 1 - lib/i18n/it.i18n.json | 1 - lib/i18n/ja.i18n.json | 1 - lib/i18n/ko.i18n.json | 1 - lib/i18n/nb.i18n.json | 1 - lib/i18n/nl.i18n.json | 1 - lib/i18n/pl.i18n.json | 1 - lib/i18n/pt.i18n.json | 1 - lib/i18n/ru.i18n.json | 1 - lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_bg.g.dart | 4 +- lib/i18n/strings_da.g.dart | 4 +- lib/i18n/strings_de.g.dart | 4 +- lib/i18n/strings_en.g.dart | 6 +- lib/i18n/strings_es.g.dart | 4 +- lib/i18n/strings_fr.g.dart | 4 +- lib/i18n/strings_it.g.dart | 4 +- lib/i18n/strings_ja.g.dart | 4 +- lib/i18n/strings_ko.g.dart | 4 +- lib/i18n/strings_nb.g.dart | 4 +- lib/i18n/strings_nl.g.dart | 4 +- lib/i18n/strings_pl.g.dart | 4 +- lib/i18n/strings_pt.g.dart | 4 +- lib/i18n/strings_ru.g.dart | 4 +- lib/i18n/strings_sv.g.dart | 4 +- lib/i18n/strings_zh.g.dart | 4 +- lib/i18n/sv.i18n.json | 1 - lib/i18n/zh.i18n.json | 1 - lib/screens/music/queue_sheet.dart | 295 +++++++++++------------ lib/widgets/music/track_row.dart | 16 +- test/screens/music/queue_sheet_test.dart | 22 +- 36 files changed, 181 insertions(+), 236 deletions(-) diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 6268e4af..b3099d5c 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "Сега се възпроизвежда", "playingFrom": "Възпроизвеждане от ${title}", "queue": "Опашка", - "upNext": "Следва", "clearQueue": "Изчисти опашката", "lyrics": "Текст", "noLyrics": "Няма наличен текст", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index b5acc6af..358f1e8a 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "Afspiller nu", "playingFrom": "Afspiller fra ${title}", "queue": "Kø", - "upNext": "Næste i køen", "clearQueue": "Ryd kø", "lyrics": "Sangtekst", "noLyrics": "Ingen sangtekst tilgængelig", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 5a3abdd6..0a71f5f2 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "Wird wiedergegeben", "playingFrom": "Wiedergabe aus ${title}", "queue": "Warteschlange", - "upNext": "Als Nächstes", "clearQueue": "Warteschlange leeren", "lyrics": "Songtext", "noLyrics": "Kein Songtext verfügbar", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index b84dbd93..d444967d 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -987,7 +987,6 @@ "nowPlaying": "Now Playing", "playingFrom": "Playing from ${title}", "queue": "Queue", - "upNext": "Up next", "clearQueue": "Clear queue", "lyrics": "Lyrics", "noLyrics": "No lyrics available", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index e9562b29..1f2177d7 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "Reproduciendo ahora", "playingFrom": "Reproduciendo desde ${title}", "queue": "Cola", - "upNext": "A continuación", "clearQueue": "Vaciar la cola", "lyrics": "Letra", "noLyrics": "No hay letra disponible", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 6fd7ffd3..a0c7588e 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "Lecture en cours", "playingFrom": "Lecture depuis ${title}", "queue": "File d'attente", - "upNext": "À suivre", "clearQueue": "Vider la file d'attente", "lyrics": "Paroles", "noLyrics": "Aucune parole disponible", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index cc2b9c85..808d2c35 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "In riproduzione", "playingFrom": "Riproduzione da ${title}", "queue": "Coda", - "upNext": "Da riprodurre", "clearQueue": "Svuota la coda", "lyrics": "Testo", "noLyrics": "Nessun testo disponibile", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 77cadf3c..9bdc4c79 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -974,7 +974,6 @@ "nowPlaying": "再生中", "playingFrom": "${title} から再生", "queue": "再生キュー", - "upNext": "次はこちら", "clearQueue": "キューをクリア", "lyrics": "歌詞", "noLyrics": "歌詞がありません", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 949192c2..efd390e8 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -974,7 +974,6 @@ "nowPlaying": "재생 중", "playingFrom": "${title}에서 재생 중", "queue": "재생 대기열", - "upNext": "다음 항목", "clearQueue": "대기열 비우기", "lyrics": "가사", "noLyrics": "가사 없음", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 8742d744..b4d1f475 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "Spilles nå", "playingFrom": "Spiller fra ${title}", "queue": "Kø", - "upNext": "Neste i køen", "clearQueue": "Tøm kø", "lyrics": "Sangtekst", "noLyrics": "Ingen sangtekst tilgjengelig", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index c5242268..d5791529 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "Nu afspelen", "playingFrom": "Afspelen vanaf ${title}", "queue": "Wachtrij", - "upNext": "Volgende nummers", "clearQueue": "Wachtrij wissen", "lyrics": "Songtekst", "noLyrics": "Geen songtekst beschikbaar", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 1fda4e9b..82f5670c 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -977,7 +977,6 @@ "nowPlaying": "Teraz odtwarzane", "playingFrom": "Odtwarzanie z ${title}", "queue": "Kolejka", - "upNext": "Następne", "clearQueue": "Wyczyść kolejkę", "lyrics": "Tekst", "noLyrics": "Brak dostępnego tekstu", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 67066ced..6567b16e 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "Reproduzindo agora", "playingFrom": "Reproduzindo de ${title}", "queue": "Fila", - "upNext": "A seguir", "clearQueue": "Limpar fila", "lyrics": "Letra", "noLyrics": "Nenhuma letra disponível", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index c1decc42..612332ea 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -977,7 +977,6 @@ "nowPlaying": "Сейчас играет", "playingFrom": "Воспроизведение из ${title}", "queue": "Очередь", - "upNext": "Далее", "clearQueue": "Очистить очередь", "lyrics": "Текст песни", "noLyrics": "Текст песни недоступен", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index d22cc91b..e166a58e 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 21376 (1336 per locale) +/// Strings: 21360 (1335 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 902b53c3..4b5aa30d 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicBg extends TranslationsMusicEn { @override String get nowPlaying => 'Сега се възпроизвежда'; @override String playingFrom({required Object title}) => 'Възпроизвеждане от ${title}'; @override String get queue => 'Опашка'; - @override String get upNext => 'Следва'; @override String get clearQueue => 'Изчисти опашката'; @override String get lyrics => 'Текст'; @override String get noLyrics => 'Няма наличен текст'; @@ -2892,7 +2891,6 @@ extension on TranslationsBg { 'music.nowPlaying' => 'Сега се възпроизвежда', 'music.playingFrom' => ({required Object title}) => 'Възпроизвеждане от ${title}', 'music.queue' => 'Опашка', - 'music.upNext' => 'Следва', 'music.clearQueue' => 'Изчисти опашката', 'music.lyrics' => 'Текст', 'music.noLyrics' => 'Няма наличен текст', @@ -3022,9 +3020,9 @@ extension on TranslationsBg { 'downloads.noSyncRules' => 'Няма правила за синхронизация', 'downloads.manageSyncRule' => 'Управление на синхронизацията', 'downloads.editEpisodeCount' => 'Брой епизоди', + 'downloads.editSyncFilter' => 'Филтър за синхронизация', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Филтър за синхронизация', 'downloads.syncAllItems' => 'Синхронизират се всички елементи', 'downloads.syncUnwatchedItems' => 'Синхронизират се негледаните елементи', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Сървър: ${server} • ${status}', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 2e9e6c97..4d82808e 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicDa extends TranslationsMusicEn { @override String get nowPlaying => 'Afspiller nu'; @override String playingFrom({required Object title}) => 'Afspiller fra ${title}'; @override String get queue => 'Kø'; - @override String get upNext => 'Næste i køen'; @override String get clearQueue => 'Ryd kø'; @override String get lyrics => 'Sangtekst'; @override String get noLyrics => 'Ingen sangtekst tilgængelig'; @@ -2892,7 +2891,6 @@ extension on TranslationsDa { 'music.nowPlaying' => 'Afspiller nu', 'music.playingFrom' => ({required Object title}) => 'Afspiller fra ${title}', 'music.queue' => 'Kø', - 'music.upNext' => 'Næste i køen', 'music.clearQueue' => 'Ryd kø', 'music.lyrics' => 'Sangtekst', 'music.noLyrics' => 'Ingen sangtekst tilgængelig', @@ -3022,9 +3020,9 @@ extension on TranslationsDa { 'downloads.noSyncRules' => 'Ingen synkroniseringsregler', 'downloads.manageSyncRule' => 'Administrer synkronisering', 'downloads.editEpisodeCount' => 'Antal episoder', + 'downloads.editSyncFilter' => 'Synkroniseringsfilter', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Synkroniseringsfilter', 'downloads.syncAllItems' => 'Synkroniserer alle elementer', 'downloads.syncUnwatchedItems' => 'Synkroniserer usete elementer', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 7db7d652..511ed3b8 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicDe extends TranslationsMusicEn { @override String get nowPlaying => 'Wird wiedergegeben'; @override String playingFrom({required Object title}) => 'Wiedergabe aus ${title}'; @override String get queue => 'Warteschlange'; - @override String get upNext => 'Als Nächstes'; @override String get clearQueue => 'Warteschlange leeren'; @override String get lyrics => 'Songtext'; @override String get noLyrics => 'Kein Songtext verfügbar'; @@ -2892,7 +2891,6 @@ extension on TranslationsDe { 'music.nowPlaying' => 'Wird wiedergegeben', 'music.playingFrom' => ({required Object title}) => 'Wiedergabe aus ${title}', 'music.queue' => 'Warteschlange', - 'music.upNext' => 'Als Nächstes', 'music.clearQueue' => 'Warteschlange leeren', 'music.lyrics' => 'Songtext', 'music.noLyrics' => 'Kein Songtext verfügbar', @@ -3022,9 +3020,9 @@ extension on TranslationsDe { 'downloads.noSyncRules' => 'Keine Sync-Regeln', 'downloads.manageSyncRule' => 'Synchronisierung verwalten', 'downloads.editEpisodeCount' => 'Episodenanzahl', + 'downloads.editSyncFilter' => 'Synchronisierungsfilter', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Synchronisierungsfilter', 'downloads.syncAllItems' => 'Alle Einträge synchronisieren', 'downloads.syncUnwatchedItems' => 'Ungesehene Einträge synchronisieren', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 9936cde4..bbb5c1c3 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2905,9 +2905,6 @@ class TranslationsMusicEn { /// en: 'Queue' String get queue => 'Queue'; - /// en: 'Up next' - String get upNext => 'Up next'; - /// en: 'Clear queue' String get clearQueue => 'Clear queue'; @@ -5623,7 +5620,6 @@ extension on Translations { 'music.nowPlaying' => 'Now Playing', 'music.playingFrom' => ({required Object title}) => 'Playing from ${title}', 'music.queue' => 'Queue', - 'music.upNext' => 'Up next', 'music.clearQueue' => 'Clear queue', 'music.lyrics' => 'Lyrics', 'music.noLyrics' => 'No lyrics available', @@ -5741,9 +5737,9 @@ extension on Translations { 'downloads.customAmount' => 'Custom amount...', 'downloads.includeSpecials' => 'Include Specials', 'downloads.howManyEpisodes' => 'How many episodes?', + 'downloads.itemsQueued' => ({required Object count}) => '${count} items queued for download', _ => null, } ?? switch (path) { - 'downloads.itemsQueued' => ({required Object count}) => '${count} items queued for download', 'downloads.keepSynced' => 'Keep synced', 'downloads.downloadOnce' => 'Download once', 'downloads.keepNUnwatched' => ({required Object count}) => 'Keep ${count} unwatched', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index db39b78c..08c4a661 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicEs extends TranslationsMusicEn { @override String get nowPlaying => 'Reproduciendo ahora'; @override String playingFrom({required Object title}) => 'Reproduciendo desde ${title}'; @override String get queue => 'Cola'; - @override String get upNext => 'A continuación'; @override String get clearQueue => 'Vaciar la cola'; @override String get lyrics => 'Letra'; @override String get noLyrics => 'No hay letra disponible'; @@ -2892,7 +2891,6 @@ extension on TranslationsEs { 'music.nowPlaying' => 'Reproduciendo ahora', 'music.playingFrom' => ({required Object title}) => 'Reproduciendo desde ${title}', 'music.queue' => 'Cola', - 'music.upNext' => 'A continuación', 'music.clearQueue' => 'Vaciar la cola', 'music.lyrics' => 'Letra', 'music.noLyrics' => 'No hay letra disponible', @@ -3022,9 +3020,9 @@ extension on TranslationsEs { 'downloads.noSyncRules' => 'Sin reglas de sincronización', 'downloads.manageSyncRule' => 'Gestionar sincronización', 'downloads.editEpisodeCount' => 'Número de episodios', + 'downloads.editSyncFilter' => 'Filtro de sincronización', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Filtro de sincronización', 'downloads.syncAllItems' => 'Sincronizando todos los elementos', 'downloads.syncUnwatchedItems' => 'Sincronizando elementos no vistos', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Servidor: ${server} • ${status}', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index dab0d544..43adec54 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicFr extends TranslationsMusicEn { @override String get nowPlaying => 'Lecture en cours'; @override String playingFrom({required Object title}) => 'Lecture depuis ${title}'; @override String get queue => 'File d\'attente'; - @override String get upNext => 'À suivre'; @override String get clearQueue => 'Vider la file d\'attente'; @override String get lyrics => 'Paroles'; @override String get noLyrics => 'Aucune parole disponible'; @@ -2892,7 +2891,6 @@ extension on TranslationsFr { 'music.nowPlaying' => 'Lecture en cours', 'music.playingFrom' => ({required Object title}) => 'Lecture depuis ${title}', 'music.queue' => 'File d\'attente', - 'music.upNext' => 'À suivre', 'music.clearQueue' => 'Vider la file d\'attente', 'music.lyrics' => 'Paroles', 'music.noLyrics' => 'Aucune parole disponible', @@ -3022,9 +3020,9 @@ extension on TranslationsFr { 'downloads.noSyncRules' => 'Aucune règle de synchronisation', 'downloads.manageSyncRule' => 'Gérer la synchronisation', 'downloads.editEpisodeCount' => 'Nombre d’épisodes', + 'downloads.editSyncFilter' => 'Filtre de synchronisation', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Filtre de synchronisation', 'downloads.syncAllItems' => 'Synchronisation de tous les éléments', 'downloads.syncUnwatchedItems' => 'Synchronisation des éléments non vus', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Serveur : ${server} • ${status}', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index bc589826..9a36baef 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicIt extends TranslationsMusicEn { @override String get nowPlaying => 'In riproduzione'; @override String playingFrom({required Object title}) => 'Riproduzione da ${title}'; @override String get queue => 'Coda'; - @override String get upNext => 'Da riprodurre'; @override String get clearQueue => 'Svuota la coda'; @override String get lyrics => 'Testo'; @override String get noLyrics => 'Nessun testo disponibile'; @@ -2892,7 +2891,6 @@ extension on TranslationsIt { 'music.nowPlaying' => 'In riproduzione', 'music.playingFrom' => ({required Object title}) => 'Riproduzione da ${title}', 'music.queue' => 'Coda', - 'music.upNext' => 'Da riprodurre', 'music.clearQueue' => 'Svuota la coda', 'music.lyrics' => 'Testo', 'music.noLyrics' => 'Nessun testo disponibile', @@ -3022,9 +3020,9 @@ extension on TranslationsIt { 'downloads.noSyncRules' => 'Nessuna regola di sincronizzazione', 'downloads.manageSyncRule' => 'Gestisci sincronizzazione', 'downloads.editEpisodeCount' => 'Numero di episodi', + 'downloads.editSyncFilter' => 'Filtro di sincronizzazione', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Filtro di sincronizzazione', 'downloads.syncAllItems' => 'Sincronizzazione di tutti gli elementi', 'downloads.syncUnwatchedItems' => 'Sincronizzazione degli elementi non visti', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index cade7722..45ea642d 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -1222,7 +1222,6 @@ class _TranslationsMusicJa extends TranslationsMusicEn { @override String get nowPlaying => '再生中'; @override String playingFrom({required Object title}) => '${title} から再生'; @override String get queue => '再生キュー'; - @override String get upNext => '次はこちら'; @override String get clearQueue => 'キューをクリア'; @override String get lyrics => '歌詞'; @override String get noLyrics => '歌詞がありません'; @@ -2891,7 +2890,6 @@ extension on TranslationsJa { 'music.nowPlaying' => '再生中', 'music.playingFrom' => ({required Object title}) => '${title} から再生', 'music.queue' => '再生キュー', - 'music.upNext' => '次はこちら', 'music.clearQueue' => 'キューをクリア', 'music.lyrics' => '歌詞', 'music.noLyrics' => '歌詞がありません', @@ -3021,9 +3019,9 @@ extension on TranslationsJa { 'downloads.noSyncRules' => '同期ルールなし', 'downloads.manageSyncRule' => '同期を管理', 'downloads.editEpisodeCount' => 'エピソード数', + 'downloads.editSyncFilter' => '同期フィルター', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => '同期フィルター', 'downloads.syncAllItems' => 'すべてのアイテムを同期中', 'downloads.syncUnwatchedItems' => '未視聴のアイテムを同期中', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'サーバー: ${server} • ${status}', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 2b4e0ad7..d5954883 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -1222,7 +1222,6 @@ class _TranslationsMusicKo extends TranslationsMusicEn { @override String get nowPlaying => '재생 중'; @override String playingFrom({required Object title}) => '${title}에서 재생 중'; @override String get queue => '재생 대기열'; - @override String get upNext => '다음 항목'; @override String get clearQueue => '대기열 비우기'; @override String get lyrics => '가사'; @override String get noLyrics => '가사 없음'; @@ -2891,7 +2890,6 @@ extension on TranslationsKo { 'music.nowPlaying' => '재생 중', 'music.playingFrom' => ({required Object title}) => '${title}에서 재생 중', 'music.queue' => '재생 대기열', - 'music.upNext' => '다음 항목', 'music.clearQueue' => '대기열 비우기', 'music.lyrics' => '가사', 'music.noLyrics' => '가사 없음', @@ -3021,9 +3019,9 @@ extension on TranslationsKo { 'downloads.noSyncRules' => '동기화 규칙 없음', 'downloads.manageSyncRule' => '동기화 관리', 'downloads.editEpisodeCount' => '에피소드 수', + 'downloads.editSyncFilter' => '동기화 필터', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => '동기화 필터', 'downloads.syncAllItems' => '모든 항목 동기화 중', 'downloads.syncUnwatchedItems' => '시청하지 않은 항목 동기화 중', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '서버: ${server} • ${status}', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 9e7eb0fc..9e24344e 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicNb extends TranslationsMusicEn { @override String get nowPlaying => 'Spilles nå'; @override String playingFrom({required Object title}) => 'Spiller fra ${title}'; @override String get queue => 'Kø'; - @override String get upNext => 'Neste i køen'; @override String get clearQueue => 'Tøm kø'; @override String get lyrics => 'Sangtekst'; @override String get noLyrics => 'Ingen sangtekst tilgjengelig'; @@ -2892,7 +2891,6 @@ extension on TranslationsNb { 'music.nowPlaying' => 'Spilles nå', 'music.playingFrom' => ({required Object title}) => 'Spiller fra ${title}', 'music.queue' => 'Kø', - 'music.upNext' => 'Neste i køen', 'music.clearQueue' => 'Tøm kø', 'music.lyrics' => 'Sangtekst', 'music.noLyrics' => 'Ingen sangtekst tilgjengelig', @@ -3022,9 +3020,9 @@ extension on TranslationsNb { 'downloads.noSyncRules' => 'Ingen synkroniseringsregler', 'downloads.manageSyncRule' => 'Administrer synkronisering', 'downloads.editEpisodeCount' => 'Antall episoder', + 'downloads.editSyncFilter' => 'Synkroniseringsfilter', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Synkroniseringsfilter', 'downloads.syncAllItems' => 'Synkroniserer alle elementer', 'downloads.syncUnwatchedItems' => 'Synkroniserer usette elementer', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 7b9d0fb1..14754ea6 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicNl extends TranslationsMusicEn { @override String get nowPlaying => 'Nu afspelen'; @override String playingFrom({required Object title}) => 'Afspelen vanaf ${title}'; @override String get queue => 'Wachtrij'; - @override String get upNext => 'Volgende nummers'; @override String get clearQueue => 'Wachtrij wissen'; @override String get lyrics => 'Songtekst'; @override String get noLyrics => 'Geen songtekst beschikbaar'; @@ -2892,7 +2891,6 @@ extension on TranslationsNl { 'music.nowPlaying' => 'Nu afspelen', 'music.playingFrom' => ({required Object title}) => 'Afspelen vanaf ${title}', 'music.queue' => 'Wachtrij', - 'music.upNext' => 'Volgende nummers', 'music.clearQueue' => 'Wachtrij wissen', 'music.lyrics' => 'Songtekst', 'music.noLyrics' => 'Geen songtekst beschikbaar', @@ -3022,9 +3020,9 @@ extension on TranslationsNl { 'downloads.noSyncRules' => 'Geen synchronisatieregels', 'downloads.manageSyncRule' => 'Synchronisatie beheren', 'downloads.editEpisodeCount' => 'Aantal afleveringen', + 'downloads.editSyncFilter' => 'Synchronisatiefilter', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Synchronisatiefilter', 'downloads.syncAllItems' => 'Alle items synchroniseren', 'downloads.syncUnwatchedItems' => 'Ongekeken items synchroniseren', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 819f5e87..b08a5db9 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -1225,7 +1225,6 @@ class _TranslationsMusicPl extends TranslationsMusicEn { @override String get nowPlaying => 'Teraz odtwarzane'; @override String playingFrom({required Object title}) => 'Odtwarzanie z ${title}'; @override String get queue => 'Kolejka'; - @override String get upNext => 'Następne'; @override String get clearQueue => 'Wyczyść kolejkę'; @override String get lyrics => 'Tekst'; @override String get noLyrics => 'Brak dostępnego tekstu'; @@ -2894,7 +2893,6 @@ extension on TranslationsPl { 'music.nowPlaying' => 'Teraz odtwarzane', 'music.playingFrom' => ({required Object title}) => 'Odtwarzanie z ${title}', 'music.queue' => 'Kolejka', - 'music.upNext' => 'Następne', 'music.clearQueue' => 'Wyczyść kolejkę', 'music.lyrics' => 'Tekst', 'music.noLyrics' => 'Brak dostępnego tekstu', @@ -3024,9 +3022,9 @@ extension on TranslationsPl { 'downloads.noSyncRules' => 'Brak reguł synchronizacji', 'downloads.manageSyncRule' => 'Zarządzaj synchronizacją', 'downloads.editEpisodeCount' => 'Liczba odcinków', + 'downloads.editSyncFilter' => 'Filtr synchronizacji', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Filtr synchronizacji', 'downloads.syncAllItems' => 'Synchronizuję wszystkie elementy', 'downloads.syncUnwatchedItems' => 'Synchronizuję nieobejrzane elementy', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Serwer: ${server} • ${status}', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index a04692b7..7a57ed07 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicPt extends TranslationsMusicEn { @override String get nowPlaying => 'Reproduzindo agora'; @override String playingFrom({required Object title}) => 'Reproduzindo de ${title}'; @override String get queue => 'Fila'; - @override String get upNext => 'A seguir'; @override String get clearQueue => 'Limpar fila'; @override String get lyrics => 'Letra'; @override String get noLyrics => 'Nenhuma letra disponível'; @@ -2892,7 +2891,6 @@ extension on TranslationsPt { 'music.nowPlaying' => 'Reproduzindo agora', 'music.playingFrom' => ({required Object title}) => 'Reproduzindo de ${title}', 'music.queue' => 'Fila', - 'music.upNext' => 'A seguir', 'music.clearQueue' => 'Limpar fila', 'music.lyrics' => 'Letra', 'music.noLyrics' => 'Nenhuma letra disponível', @@ -3022,9 +3020,9 @@ extension on TranslationsPt { 'downloads.noSyncRules' => 'Nenhuma regra de sincronização', 'downloads.manageSyncRule' => 'Gerenciar sincronização', 'downloads.editEpisodeCount' => 'Número de episódios', + 'downloads.editSyncFilter' => 'Filtro de sincronização', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Filtro de sincronização', 'downloads.syncAllItems' => 'Sincronizando todos os itens', 'downloads.syncUnwatchedItems' => 'Sincronizando itens não vistos', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Servidor: ${server} • ${status}', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index da101bb0..87018dc2 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -1225,7 +1225,6 @@ class _TranslationsMusicRu extends TranslationsMusicEn { @override String get nowPlaying => 'Сейчас играет'; @override String playingFrom({required Object title}) => 'Воспроизведение из ${title}'; @override String get queue => 'Очередь'; - @override String get upNext => 'Далее'; @override String get clearQueue => 'Очистить очередь'; @override String get lyrics => 'Текст песни'; @override String get noLyrics => 'Текст песни недоступен'; @@ -2894,7 +2893,6 @@ extension on TranslationsRu { 'music.nowPlaying' => 'Сейчас играет', 'music.playingFrom' => ({required Object title}) => 'Воспроизведение из ${title}', 'music.queue' => 'Очередь', - 'music.upNext' => 'Далее', 'music.clearQueue' => 'Очистить очередь', 'music.lyrics' => 'Текст песни', 'music.noLyrics' => 'Текст песни недоступен', @@ -3024,9 +3022,9 @@ extension on TranslationsRu { 'downloads.noSyncRules' => 'Нет правил синхронизации', 'downloads.manageSyncRule' => 'Управление синхронизацией', 'downloads.editEpisodeCount' => 'Количество эпизодов', + 'downloads.editSyncFilter' => 'Фильтр синхронизации', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Фильтр синхронизации', 'downloads.syncAllItems' => 'Синхронизация всех элементов', 'downloads.syncUnwatchedItems' => 'Синхронизация непросмотренных элементов', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Сервер: ${server} • ${status}', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index ea54c16e..5969d9fd 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -1223,7 +1223,6 @@ class _TranslationsMusicSv extends TranslationsMusicEn { @override String get nowPlaying => 'Spelas nu'; @override String playingFrom({required Object title}) => 'Spelar från ${title}'; @override String get queue => 'Kö'; - @override String get upNext => 'Näst på tur'; @override String get clearQueue => 'Rensa kön'; @override String get lyrics => 'Låttext'; @override String get noLyrics => 'Ingen låttext tillgänglig'; @@ -2892,7 +2891,6 @@ extension on TranslationsSv { 'music.nowPlaying' => 'Spelas nu', 'music.playingFrom' => ({required Object title}) => 'Spelar från ${title}', 'music.queue' => 'Kö', - 'music.upNext' => 'Näst på tur', 'music.clearQueue' => 'Rensa kön', 'music.lyrics' => 'Låttext', 'music.noLyrics' => 'Ingen låttext tillgänglig', @@ -3022,9 +3020,9 @@ extension on TranslationsSv { 'downloads.noSyncRules' => 'Inga synkregler', 'downloads.manageSyncRule' => 'Hantera synkronisering', 'downloads.editEpisodeCount' => 'Antal avsnitt', + 'downloads.editSyncFilter' => 'Synkroniseringsfilter', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => 'Synkroniseringsfilter', 'downloads.syncAllItems' => 'Synkroniserar alla objekt', 'downloads.syncUnwatchedItems' => 'Synkroniserar osedda objekt', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 123b412b..2392675a 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -1222,7 +1222,6 @@ class _TranslationsMusicZh extends TranslationsMusicEn { @override String get nowPlaying => '正在播放'; @override String playingFrom({required Object title}) => '来自 ${title}'; @override String get queue => '播放队列'; - @override String get upNext => '即将播放'; @override String get clearQueue => '清空队列'; @override String get lyrics => '歌词'; @override String get noLyrics => '暂无歌词'; @@ -2891,7 +2890,6 @@ extension on TranslationsZh { 'music.nowPlaying' => '正在播放', 'music.playingFrom' => ({required Object title}) => '来自 ${title}', 'music.queue' => '播放队列', - 'music.upNext' => '即将播放', 'music.clearQueue' => '清空队列', 'music.lyrics' => '歌词', 'music.noLyrics' => '暂无歌词', @@ -3021,9 +3019,9 @@ extension on TranslationsZh { 'downloads.noSyncRules' => '没有同步规则', 'downloads.manageSyncRule' => '管理同步', 'downloads.editEpisodeCount' => '剧集数量', + 'downloads.editSyncFilter' => '同步筛选', _ => null, } ?? switch (path) { - 'downloads.editSyncFilter' => '同步筛选', 'downloads.syncAllItems' => '同步所有项目', 'downloads.syncUnwatchedItems' => '同步未观看项目', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '服务器: ${server} • ${status}', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index aa26d141..10c8fcfd 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -975,7 +975,6 @@ "nowPlaying": "Spelas nu", "playingFrom": "Spelar från ${title}", "queue": "Kö", - "upNext": "Näst på tur", "clearQueue": "Rensa kön", "lyrics": "Låttext", "noLyrics": "Ingen låttext tillgänglig", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 922fb562..fecf8069 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -974,7 +974,6 @@ "nowPlaying": "正在播放", "playingFrom": "来自 ${title}", "queue": "播放队列", - "upNext": "即将播放", "clearQueue": "清空队列", "lyrics": "歌词", "noLyrics": "暂无歌词", diff --git a/lib/screens/music/queue_sheet.dart b/lib/screens/music/queue_sheet.dart index 0892476f..1fbbf914 100644 --- a/lib/screens/music/queue_sheet.dart +++ b/lib/screens/music/queue_sheet.dart @@ -4,20 +4,15 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; -import '../../media/ids.dart'; -import '../../media/media_item.dart'; import '../../services/music/music_playback_service.dart'; import '../../theme/mono_tokens.dart'; -import '../../utils/media_image_helper.dart'; import '../../utils/platform_detector.dart'; -import '../../utils/provider_extensions.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/bottom_sheet_header.dart'; -import '../../widgets/music/equalizer_icon.dart'; import '../../widgets/music/repeat_mode.dart'; import '../../widgets/music/track_row.dart'; -import '../../widgets/optimized_media_image.dart'; import '../../widgets/overlay_sheet.dart'; /// Open the play-queue sheet. The caller's screen must have an @@ -81,23 +76,95 @@ class QueueSheet extends StatelessWidget { ], ), ), - const Flexible(child: QueueList()), + const Flexible(child: QueueList(autofocusCurrent: true)), ], ); } } -/// The queue body: pinned current-track row, "Up next" label, and the -/// upcoming tracks as [TrackRow]s. Reused verbatim by the desktop -/// now-playing layout as an inline panel. +/// The queue body: the full play queue in playback order — history, the +/// current track (equalizer row, dimmed rows above are already played), +/// then upcoming — YouTube-Music style. Opens scrolled so the current track +/// sits at the top, and follows track changes while the user is near the +/// playback position. Reused verbatim by the desktop now-playing layout as +/// an inline panel. /// /// Touch: long-press drag to reorder (delayed drag listener — TrackRow's /// context menu is disabled here so the gesture arena stays clean), swipe to /// remove, tap to jump. D-pad: rows are focusable, SELECT jumps, RIGHT /// reaches a dedicated remove button; reordering is intentionally not -/// offered on TV. -class QueueList extends StatelessWidget { - const QueueList({super.key}); +/// offered on TV. With [autofocusCurrent] (the sheet) initial focus lands on +/// the current track's row instead of the first history row. +class QueueList extends StatefulWidget { + /// Land initial d-pad/keyboard focus on the current track's row, + /// overriding the overlay host's focus-first-descendant default. The + /// inline desktop panel keeps this off so building the now-playing screen + /// never steals focus. + final bool autofocusCurrent; + + const QueueList({super.key, this.autofocusCurrent = false}); + + @override + State createState() => _QueueListState(); +} + +class _QueueListState extends State { + ScrollController? _scrollController; + final _currentRowFocusNode = FocusNode(debugLabel: 'QueueCurrentRowFocus'); + int _lastCurrentIndex = -1; + + /// Content offset that puts the row at [index] at the top of the viewport + /// (its top gap scrolled away, the list's own padding still visible). + /// Rows are fixed-extent, so this is exact — the shared + /// measure-the-first-item scroll helper can't be used because only the + /// first row lacks a leading gap. + double _rowOffset(int index, double gap) => index * (TrackRow.height + gap); + + @override + void initState() { + super.initState(); + if (widget.autofocusCurrent) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Timer.run(() { + if (!mounted || !InputModeTracker.isKeyboardMode(context)) return; + // Schedule after the overlay host's _autoFocus second callback so + // we override its focus-first-descendant default. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_currentRowFocusNode.context != null) _currentRowFocusNode.requestFocus(); + }); + }); + }); + } + } + + @override + void dispose() { + _scrollController?.dispose(); + _currentRowFocusNode.dispose(); + super.dispose(); + } + + /// Keep the current track at the top while playback advances, but only + /// when the list is still parked where the previous track was anchored — + /// once the user scrolls away to browse, moving rows under their pointer + /// invites mis-taps on remove/jump. + void _followCurrentTrack(int currentIndex, double gap) { + if (currentIndex == _lastCurrentIndex) return; + final fromIndex = _lastCurrentIndex; + _lastCurrentIndex = currentIndex; + // First build: the initial scroll offset already targets the row. + if (fromIndex < 0 || currentIndex < 0) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + final controller = _scrollController; + if (!mounted || controller == null || !controller.hasClients) return; + final position = controller.position; + final anchor = _rowOffset(fromIndex, gap).clamp(0.0, position.maxScrollExtent); + if ((anchor - position.pixels).abs() > (TrackRow.height + gap) * 1.5) return; + final target = _rowOffset(currentIndex, gap).clamp(0.0, position.maxScrollExtent); + unawaited(controller.animateTo(target, duration: const Duration(milliseconds: 250), curve: Curves.easeOut)); + }); + } @override Widget build(BuildContext context) { @@ -105,161 +172,77 @@ class QueueList extends StatelessWidget { final service = context.watch(); final queue = service.queue; final currentIndex = service.currentIndex; - final current = service.currentTrack; - final upcomingStart = currentIndex + 1; - final upcoming = currentIndex >= 0 && upcomingStart <= queue.length - ? queue.sublist(upcomingStart) - : const []; + + if (queue.isEmpty) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), + child: Text(t.messages.noItemsAvailable, style: TextStyle(fontSize: 13, color: tk.textMuted)), + ); + } + + _scrollController ??= ScrollController(initialScrollOffset: _rowOffset(currentIndex, tk.groupGap)); + _followCurrentTrack(currentIndex, tk.groupGap); // Stable per-item keys that survive reorders; the same track can appear // more than once, so disambiguate repeats by occurrence. final seen = {}; final itemKeys = [ - for (final item in upcoming) '${item.globalKey}#${seen.update(item.globalKey, (v) => v + 1, ifAbsent: () => 0)}', + for (final item in queue) '${item.globalKey}#${seen.update(item.globalKey, (v) => v + 1, ifAbsent: () => 0)}', ]; final allowTouchEditing = !PlatformDetector.isTV(); - return Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, - children: [ - if (current != null) - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), - child: _CurrentTrackRow(track: current), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: Text( - t.music.upNext, - style: Theme.of(context).textTheme.labelLarge?.copyWith(color: tk.textMuted, fontWeight: .w600), - ), - ), - Flexible( - child: upcoming.isEmpty - ? Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), - child: Text(t.messages.noItemsAvailable, style: TextStyle(fontSize: 13, color: tk.textMuted)), - ) - : ReorderableListView.builder( - shrinkWrap: true, - buildDefaultDragHandles: false, - padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), - itemCount: upcoming.length, - // onReorderItem already adjusts newIndex for the removal. - onReorderItem: (oldIndex, newIndex) { - if (oldIndex == newIndex) return; - service.reorder(upcomingStart + oldIndex, upcomingStart + newIndex); - }, - itemBuilder: (context, index) { - final item = upcoming[index]; - final queueIndex = upcomingStart + index; + return ReorderableListView.builder( + scrollController: _scrollController, + shrinkWrap: true, + buildDefaultDragHandles: false, + padding: const EdgeInsets.all(12), + itemCount: queue.length, + // onReorderItem already adjusts newIndex for the removal. + onReorderItem: (oldIndex, newIndex) { + if (oldIndex == newIndex) return; + service.reorder(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final item = queue[index]; + final isCurrent = index == currentIndex; - Widget row = TrackRow( - item: item, - showArtist: true, - isFirst: index == 0, - isLast: index == upcoming.length - 1, - enableContextMenu: false, - trailingIcon: Symbols.close_rounded, - onTrailingTap: () => service.removeAt(queueIndex), - onTap: () => unawaited(service.jumpTo(queueIndex)), - ); + Widget row = TrackRow( + item: item, + showArtist: true, + isFirst: index == 0, + isLast: index == queue.length - 1, + enableContextMenu: false, + isCurrent: isCurrent, + dimmed: index < currentIndex, + focusNode: isCurrent ? _currentRowFocusNode : null, + trailingIcon: Symbols.close_rounded, + onTrailingTap: () => service.removeAt(index), + onTap: () => unawaited(service.jumpTo(index)), + ); - if (allowTouchEditing) { - row = ReorderableDelayedDragStartListener(index: index, child: row); - row = Dismissible( - key: ValueKey('dismiss:${itemKeys[index]}'), - direction: DismissDirection.endToStart, - background: Container( - alignment: .centerRight, - padding: const EdgeInsets.only(right: 20), - color: Colors.red, - child: const AppIcon(Symbols.delete_rounded, fill: 1, color: Colors.white, size: 20), - ), - onDismissed: (_) => service.removeAt(queueIndex), - child: row, - ); - } + if (allowTouchEditing) { + row = ReorderableDelayedDragStartListener(index: index, child: row); + row = Dismissible( + key: ValueKey('dismiss:${itemKeys[index]}'), + direction: DismissDirection.endToStart, + background: Container( + alignment: .centerRight, + padding: const EdgeInsets.only(right: 20), + color: Colors.red, + child: const AppIcon(Symbols.delete_rounded, fill: 1, color: Colors.white, size: 20), + ), + onDismissed: (_) => service.removeAt(index), + child: row, + ); + } - return Padding( - key: ValueKey(itemKeys[index]), - padding: EdgeInsets.only(top: index == 0 ? 0 : tk.groupGap), - child: row, - ); - }, - ), - ), - ], - ); - } -} - -/// Pinned "now playing" row at the top of the queue: square art, title + -/// artist, and the shared equalizer indicator. -class _CurrentTrackRow extends StatelessWidget { - final MediaItem track; - - const _CurrentTrackRow({required this.track}); - - @override - Widget build(BuildContext context) { - final tk = tokens(context); - final colorScheme = Theme.of(context).colorScheme; - final isPlaying = context.select((s) => s.isPlaying); - final client = context.tryGetMediaClientWithFallback(serverIdOrNull(track.serverId)); - final artist = track.trackArtistTitle; - - return Material( - color: tk.surface, - borderRadius: BorderRadius.circular(tk.radiusLg), - clipBehavior: Clip.antiAlias, - child: Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(tk.radiusSm), - child: OptimizedMediaImage( - client: client, - imagePath: track.thumbPath, - imageType: ImageType.square, - width: 44, - height: 44, - fallbackIcon: Symbols.music_note_rounded, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - mainAxisAlignment: .center, - crossAxisAlignment: .start, - children: [ - Text( - track.title ?? '', - maxLines: 1, - overflow: .ellipsis, - style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text), - ), - if (artist != null && artist.isNotEmpty) - Text( - artist, - maxLines: 1, - overflow: .ellipsis, - style: TextStyle(fontSize: 12, color: tk.textMuted), - ), - ], - ), - ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(right: 8), - child: EqualizerIcon(animate: isPlaying, color: colorScheme.primary), - ), - ], - ), - ), + return Padding( + key: ValueKey(itemKeys[index]), + padding: EdgeInsets.only(top: index == 0 ? 0 : tk.groupGap), + child: row, + ); + }, ); } } diff --git a/lib/widgets/music/track_row.dart b/lib/widgets/music/track_row.dart index 4038cac1..9245f805 100644 --- a/lib/widgets/music/track_row.dart +++ b/lib/widgets/music/track_row.dart @@ -68,6 +68,15 @@ class TrackRow extends StatefulWidget { /// opt out so the recognizers don't fight in the gesture arena. final bool enableContextMenu; + /// Override the service-derived "is this the current track" check. Hosts + /// that can render the same track at several queue positions (the queue + /// sheet) pass the exact positional match so only the playing entry gets + /// the equalizer; null keeps the by-id fallback for album/track lists. + final bool? isCurrent; + + /// Mute the title — the queue sheet marks already-played entries this way. + final bool dimmed; + /// Show a compact download-status indicator (muted [DownloadStatusIcon]) /// between the duration and the trailing ⋮ when the track has a download /// record. Off by default — surfaces without download affordances (queue @@ -90,6 +99,8 @@ class TrackRow extends StatefulWidget { this.trailingIcon, this.onTrailingTap, this.enableContextMenu = true, + this.isCurrent, + this.dimmed = false, this.showDownloadStatus = false, }); @@ -177,8 +188,9 @@ class _TrackRowState extends State with ContextMenuTapMixin, final colorScheme = Theme.of(context).colorScheme; // Both selects run unconditionally every build (provider contract). - final isCurrent = context.select((s) => s.currentTrack?.id == widget.item.id); + final isCurrentById = context.select((s) => s.currentTrack?.id == widget.item.id); final serviceIsPlaying = context.select((s) => s.isPlaying); + final isCurrent = widget.isCurrent ?? isCurrentById; final showFocus = _hasFocus && InputModeTracker.isKeyboardMode(context); final radii = BorderRadius.vertical( @@ -239,7 +251,7 @@ class _TrackRowState extends State with ContextMenuTapMixin, style: TextStyle( fontSize: 14, fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400, - color: isCurrent ? tk.text : null, + color: isCurrent ? tk.text : (widget.dimmed ? tk.textMuted : null), ), ), if (subtitle != null) diff --git a/test/screens/music/queue_sheet_test.dart b/test/screens/music/queue_sheet_test.dart index 4badc31f..4c4b9c7a 100644 --- a/test/screens/music/queue_sheet_test.dart +++ b/test/screens/music/queue_sheet_test.dart @@ -30,7 +30,8 @@ MediaItem _track(String id, String title) => MediaItem( serverName: 'Server', ); -/// Fixed-state fake queue: three tracks, playing the first. +/// Fixed-state fake queue: three tracks, playing the middle one — so the +/// sheet renders history, current, and upcoming rows at once. class _FakeQueueService extends StubMusicPlaybackService { final List tracks; final List jumps = []; @@ -41,7 +42,7 @@ class _FakeQueueService extends StubMusicPlaybackService { bool get isAvailable => true; @override - MediaItem? get currentTrack => tracks.first; + MediaItem? get currentTrack => tracks[1]; @override MusicPlaybackStatus get status => MusicPlaybackStatus.playing; @@ -50,7 +51,7 @@ class _FakeQueueService extends StubMusicPlaybackService { List get queue => tracks; @override - int get currentIndex => 0; + int get currentIndex => 1; @override Future jumpTo(int index) async { @@ -87,7 +88,7 @@ void main() { ); } - testWidgets('renders header, pinned current track, and upcoming rows', (tester) async { + testWidgets('renders header and the full queue including history', (tester) async { final service = _FakeQueueService([_track('t1', 'Alpha'), _track('t2', 'Beta'), _track('t3', 'Gamma')]); await tester.pumpWidget(wrap(service)); @@ -100,17 +101,14 @@ void main() { expect(find.text(t.music.queue), findsOneWidget); expect(find.text(t.music.trackCount(n: 3)), findsOneWidget); - // Pinned current row (not a TrackRow) + "Up next" label. + // The whole queue renders as TrackRows: played, current, and upcoming. + expect(find.byType(TrackRow), findsNWidgets(3)); expect(find.text('Alpha'), findsOneWidget); - expect(find.text(t.music.upNext), findsOneWidget); - - // Upcoming tracks render as TrackRows. - expect(find.byType(TrackRow), findsNWidgets(2)); expect(find.text('Beta'), findsOneWidget); expect(find.text('Gamma'), findsOneWidget); }); - testWidgets('tapping an upcoming row jumps to its queue index', (tester) async { + testWidgets('tapping a played or upcoming row jumps to its queue index', (tester) async { final service = _FakeQueueService([_track('t1', 'Alpha'), _track('t2', 'Beta'), _track('t3', 'Gamma')]); await tester.pumpWidget(wrap(service)); @@ -121,7 +119,9 @@ void main() { await tester.tap(find.text('Gamma')); await tester.pump(); + await tester.tap(find.text('Alpha')); + await tester.pump(); - expect(service.jumps, [2]); + expect(service.jumps, [2, 0]); }); }