feat(music): show full play queue with history, YouTube Music style

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.
This commit is contained in:
edde746
2026-07-06 11:30:23 +02:00
parent fcdef343eb
commit 214a7c13dc
36 changed files with 181 additions and 236 deletions
-1
View File
@@ -975,7 +975,6 @@
"nowPlaying": "Сега се възпроизвежда",
"playingFrom": "Възпроизвеждане от ${title}",
"queue": "Опашка",
"upNext": "Следва",
"clearQueue": "Изчисти опашката",
"lyrics": "Текст",
"noLyrics": "Няма наличен текст",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -974,7 +974,6 @@
"nowPlaying": "再生中",
"playingFrom": "${title} から再生",
"queue": "再生キュー",
"upNext": "次はこちら",
"clearQueue": "キューをクリア",
"lyrics": "歌詞",
"noLyrics": "歌詞がありません",
-1
View File
@@ -974,7 +974,6 @@
"nowPlaying": "재생 중",
"playingFrom": "${title}에서 재생 중",
"queue": "재생 대기열",
"upNext": "다음 항목",
"clearQueue": "대기열 비우기",
"lyrics": "가사",
"noLyrics": "가사 없음",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -977,7 +977,6 @@
"nowPlaying": "Сейчас играет",
"playingFrom": "Воспроизведение из ${title}",
"queue": "Очередь",
"upNext": "Далее",
"clearQueue": "Очистить очередь",
"lyrics": "Текст песни",
"noLyrics": "Текст песни недоступен",
+1 -1
View File
@@ -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
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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 => '';
@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' => '',
'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}',
+1 -3
View File
@@ -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}',
+1 -5
View File
@@ -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',
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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 => '';
@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' => '',
'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}',
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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}',
+1 -3
View File
@@ -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 => '';
@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' => '',
'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}',
+1 -3
View File
@@ -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}',
-1
View File
@@ -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",
-1
View File
@@ -974,7 +974,6 @@
"nowPlaying": "正在播放",
"playingFrom": "来自 ${title}",
"queue": "播放队列",
"upNext": "即将播放",
"clearQueue": "清空队列",
"lyrics": "歌词",
"noLyrics": "暂无歌词",
+139 -156
View File
@@ -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<QueueList> createState() => _QueueListState();
}
class _QueueListState extends State<QueueList> {
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<MusicPlaybackService>();
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 <MediaItem>[];
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 = <String, int>{};
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<MusicPlaybackService, bool>((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,
);
},
);
}
}
+14 -2
View File
@@ -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<TrackRow> with ContextMenuTapMixin<TrackRow>,
final colorScheme = Theme.of(context).colorScheme;
// Both selects run unconditionally every build (provider contract).
final isCurrent = context.select<MusicPlaybackService, bool>((s) => s.currentTrack?.id == widget.item.id);
final isCurrentById = context.select<MusicPlaybackService, bool>((s) => s.currentTrack?.id == widget.item.id);
final serviceIsPlaying = context.select<MusicPlaybackService, bool>((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<TrackRow> with ContextMenuTapMixin<TrackRow>,
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)
+11 -11
View File
@@ -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<MediaItem> tracks;
final List<int> 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<MediaItem> get queue => tracks;
@override
int get currentIndex => 0;
int get currentIndex => 1;
@override
Future<void> 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]);
});
}