From 45525a4f140feb584b6e43dd50ee2717bd1056b3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 1 Apr 2026 06:32:14 +0200 Subject: [PATCH] feat: live TV favorite channel ordering close #780 --- lib/i18n/da.i18n.json | 3 +- lib/i18n/de.i18n.json | 3 +- lib/i18n/en.i18n.json | 3 +- lib/i18n/es.i18n.json | 3 +- lib/i18n/fr.i18n.json | 3 +- lib/i18n/it.i18n.json | 3 +- lib/i18n/ja.i18n.json | 3 +- lib/i18n/ko.i18n.json | 3 +- lib/i18n/nb.i18n.json | 3 +- lib/i18n/nl.i18n.json | 3 +- lib/i18n/pl.i18n.json | 3 +- lib/i18n/pt.i18n.json | 3 +- lib/i18n/ru.i18n.json | 3 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_da.g.dart | 2 + lib/i18n/strings_de.g.dart | 2 + lib/i18n/strings_en.g.dart | 4 + lib/i18n/strings_es.g.dart | 2 + lib/i18n/strings_fr.g.dart | 2 + lib/i18n/strings_it.g.dart | 2 + lib/i18n/strings_ja.g.dart | 2 + lib/i18n/strings_ko.g.dart | 2 + lib/i18n/strings_nb.g.dart | 2 + lib/i18n/strings_nl.g.dart | 2 + lib/i18n/strings_pl.g.dart | 2 + lib/i18n/strings_pt.g.dart | 2 + lib/i18n/strings_ru.g.dart | 2 + lib/i18n/strings_sv.g.dart | 2 + lib/i18n/strings_zh.g.dart | 2 + lib/i18n/sv.i18n.json | 3 +- lib/i18n/zh.i18n.json | 3 +- lib/screens/livetv/live_tv_screen.dart | 47 ++- .../livetv/reorder_favorites_sheet.dart | 346 ++++++++++++++++++ 33 files changed, 454 insertions(+), 20 deletions(-) create mode 100644 lib/screens/livetv/reorder_favorites_sheet.dart diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 70ee4b53..70ffe2ad 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Sen aften", "whatsOn": "Hvad der kører", "watchChannel": "Se kanal", - "favorites": "Favoritter" + "favorites": "Favoritter", + "reorderFavorites": "Omarranger favoritter" }, "collections": { "title": "Samlinger", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 1767db76..e5d3f89f 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Spätnacht", "whatsOn": "Jetzt im TV", "watchChannel": "Kanal ansehen", - "favorites": "Favoriten" + "favorites": "Favoriten", + "reorderFavorites": "Favoriten sortieren" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 006865dc..6116793c 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Late Night", "whatsOn": "What's On", "watchChannel": "Watch Channel", - "favorites": "Favorites" + "favorites": "Favorites", + "reorderFavorites": "Reorder Favorites" }, "collections": { "title": "Collections", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index a69ca9ec..77990b06 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Trasnoche", "whatsOn": "En emisión", "watchChannel": "Ver canal", - "favorites": "Favoritos" + "favorites": "Favoritos", + "reorderFavorites": "Reordenar favoritos" }, "collections": { "title": "Colecciones", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index ea251f60..be842091 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Nuit tardive", "whatsOn": "En ce moment", "watchChannel": "Regarder la chaîne", - "favorites": "Favoris" + "favorites": "Favoris", + "reorderFavorites": "Réorganiser les favoris" }, "collections": { "title": "Collections", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 57dbb91a..d06e7dcb 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Notte tarda", "whatsOn": "In onda ora", "watchChannel": "Guarda canale", - "favorites": "Preferiti" + "favorites": "Preferiti", + "reorderFavorites": "Riordina preferiti" }, "downloads": { "title": "Download", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index b00a74be..769ddd0d 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -581,7 +581,8 @@ "lateNight": "深夜", "whatsOn": "放送中", "watchChannel": "チャンネルを視聴", - "favorites": "お気に入り" + "favorites": "お気に入り", + "reorderFavorites": "お気に入りを並べ替え" }, "collections": { "title": "コレクション", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index ffa6907d..2e422322 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -581,7 +581,8 @@ "lateNight": "심야 방송", "whatsOn": "지금 방송 중", "watchChannel": "채널 시청", - "favorites": "즐겨찾기" + "favorites": "즐겨찾기", + "reorderFavorites": "즐겨찾기 순서 변경" }, "collections": { "title": "컬렉션", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index eab3dc4b..eebc7bcf 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Sen kveld", "whatsOn": "Hva går nå", "watchChannel": "Se kanal", - "favorites": "Favoritter" + "favorites": "Favoritter", + "reorderFavorites": "Endre rekkefølge på favoritter" }, "collections": { "title": "Samlinger", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 4eca6910..2a507a27 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Late avond", "whatsOn": "Nu op TV", "watchChannel": "Kanaal bekijken", - "favorites": "Favorieten" + "favorites": "Favorieten", + "reorderFavorites": "Favorieten herordenen" }, "downloads": { "title": "Downloads", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index f0dfd004..d9ebd639 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Późna noc", "whatsOn": "Co leci", "watchChannel": "Oglądaj kanał", - "favorites": "Ulubione" + "favorites": "Ulubione", + "reorderFavorites": "Zmień kolejność ulubionych" }, "collections": { "title": "Kolekcje", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index d72e5c66..888de0e7 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Madrugada", "whatsOn": "O que Está Passando", "watchChannel": "Assistir Canal", - "favorites": "Favoritos" + "favorites": "Favoritos", + "reorderFavorites": "Reordenar favoritos" }, "collections": { "title": "Coleções", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 2d765abb..cbca5378 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Поздний вечер", "whatsOn": "Что идёт", "watchChannel": "Смотреть канал", - "favorites": "Избранное" + "favorites": "Избранное", + "reorderFavorites": "Изменить порядок избранного" }, "collections": { "title": "Коллекции", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index db840703..d5ad5c48 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 15 -/// Strings: 12300 (820 per locale) +/// Strings: 12315 (821 per locale) /// -/// Built on 2026-03-31 at 11:48 UTC +/// Built on 2026-04-01 at 03:58 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 5ffa78a0..56d1e938 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvDa implements TranslationsLiveTvEn { @override String get whatsOn => 'Hvad der kører'; @override String get watchChannel => 'Se kanal'; @override String get favorites => 'Favoritter'; + @override String get reorderFavorites => 'Omarranger favoritter'; } // Path: collections @@ -1839,6 +1840,7 @@ extension on TranslationsDa { 'liveTv.whatsOn' => 'Hvad der kører', 'liveTv.watchChannel' => 'Se kanal', 'liveTv.favorites' => 'Favoritter', + 'liveTv.reorderFavorites' => 'Omarranger favoritter', 'collections.title' => 'Samlinger', 'collections.collection' => 'Samling', 'collections.empty' => 'Samlingen er tom', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index edd0acd5..0f5b4e98 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvDe implements TranslationsLiveTvEn { @override String get whatsOn => 'Jetzt im TV'; @override String get watchChannel => 'Kanal ansehen'; @override String get favorites => 'Favoriten'; + @override String get reorderFavorites => 'Favoriten sortieren'; } // Path: downloads @@ -1839,6 +1840,7 @@ extension on TranslationsDe { 'liveTv.whatsOn' => 'Jetzt im TV', 'liveTv.watchChannel' => 'Kanal ansehen', 'liveTv.favorites' => 'Favoriten', + 'liveTv.reorderFavorites' => 'Favoriten sortieren', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Verwalten', 'downloads.tvShows' => 'Serien', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 9ca358b6..15f9b835 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1786,6 +1786,9 @@ class TranslationsLiveTvEn { /// en: 'Favorites' String get favorites => 'Favorites'; + + /// en: 'Reorder Favorites' + String get reorderFavorites => 'Reorder Favorites'; } // Path: collections @@ -3486,6 +3489,7 @@ extension on Translations { 'liveTv.whatsOn' => 'What\'s On', 'liveTv.watchChannel' => 'Watch Channel', 'liveTv.favorites' => 'Favorites', + 'liveTv.reorderFavorites' => 'Reorder Favorites', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'Collection is empty', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index b16948f0..d0d66aea 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvEs implements TranslationsLiveTvEn { @override String get whatsOn => 'En emisión'; @override String get watchChannel => 'Ver canal'; @override String get favorites => 'Favoritos'; + @override String get reorderFavorites => 'Reordenar favoritos'; } // Path: collections @@ -1839,6 +1840,7 @@ extension on TranslationsEs { 'liveTv.whatsOn' => 'En emisión', 'liveTv.watchChannel' => 'Ver canal', 'liveTv.favorites' => 'Favoritos', + 'liveTv.reorderFavorites' => 'Reordenar favoritos', 'collections.title' => 'Colecciones', 'collections.collection' => 'Colección', 'collections.empty' => 'La colección está vacía', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 88d910f1..482f72a2 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvFr implements TranslationsLiveTvEn { @override String get whatsOn => 'En ce moment'; @override String get watchChannel => 'Regarder la chaîne'; @override String get favorites => 'Favoris'; + @override String get reorderFavorites => 'Réorganiser les favoris'; } // Path: collections @@ -1839,6 +1840,7 @@ extension on TranslationsFr { 'liveTv.whatsOn' => 'En ce moment', 'liveTv.watchChannel' => 'Regarder la chaîne', 'liveTv.favorites' => 'Favoris', + 'liveTv.reorderFavorites' => 'Réorganiser les favoris', 'collections.title' => 'Collections', 'collections.collection' => 'Collection', 'collections.empty' => 'La collection est vide', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 77da9716..59edb959 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvIt implements TranslationsLiveTvEn { @override String get whatsOn => 'In onda ora'; @override String get watchChannel => 'Guarda canale'; @override String get favorites => 'Preferiti'; + @override String get reorderFavorites => 'Riordina preferiti'; } // Path: downloads @@ -1839,6 +1840,7 @@ extension on TranslationsIt { 'liveTv.whatsOn' => 'In onda ora', 'liveTv.watchChannel' => 'Guarda canale', 'liveTv.favorites' => 'Preferiti', + 'liveTv.reorderFavorites' => 'Riordina preferiti', 'downloads.title' => 'Download', 'downloads.manage' => 'Gestisci', 'downloads.tvShows' => 'Serie TV', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 95254e0a..00941dc3 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvJa implements TranslationsLiveTvEn { @override String get whatsOn => '放送中'; @override String get watchChannel => 'チャンネルを視聴'; @override String get favorites => 'お気に入り'; + @override String get reorderFavorites => 'お気に入りを並べ替え'; } // Path: collections @@ -1839,6 +1840,7 @@ extension on TranslationsJa { 'liveTv.whatsOn' => '放送中', 'liveTv.watchChannel' => 'チャンネルを視聴', 'liveTv.favorites' => 'お気に入り', + 'liveTv.reorderFavorites' => 'お気に入りを並べ替え', 'collections.title' => 'コレクション', 'collections.collection' => 'コレクション', 'collections.empty' => 'コレクションは空です', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 37282f77..f94607fc 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvKo implements TranslationsLiveTvEn { @override String get whatsOn => '지금 방송 중'; @override String get watchChannel => '채널 시청'; @override String get favorites => '즐겨찾기'; + @override String get reorderFavorites => '즐겨찾기 순서 변경'; } // Path: collections @@ -1839,6 +1840,7 @@ extension on TranslationsKo { 'liveTv.whatsOn' => '지금 방송 중', 'liveTv.watchChannel' => '채널 시청', 'liveTv.favorites' => '즐겨찾기', + 'liveTv.reorderFavorites' => '즐겨찾기 순서 변경', 'collections.title' => '컬렉션', 'collections.collection' => '컬렉션', 'collections.empty' => '컬렉션이 비어 있습니다', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 77c2af7e..beb25dd6 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvNb implements TranslationsLiveTvEn { @override String get whatsOn => 'Hva går nå'; @override String get watchChannel => 'Se kanal'; @override String get favorites => 'Favoritter'; + @override String get reorderFavorites => 'Endre rekkefølge på favoritter'; } // Path: collections @@ -1839,6 +1840,7 @@ extension on TranslationsNb { 'liveTv.whatsOn' => 'Hva går nå', 'liveTv.watchChannel' => 'Se kanal', 'liveTv.favorites' => 'Favoritter', + 'liveTv.reorderFavorites' => 'Endre rekkefølge på favoritter', 'collections.title' => 'Samlinger', 'collections.collection' => 'Samling', 'collections.empty' => 'Samlingen er tom', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 9bc44588..998d7fe8 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvNl implements TranslationsLiveTvEn { @override String get whatsOn => 'Nu op TV'; @override String get watchChannel => 'Kanaal bekijken'; @override String get favorites => 'Favorieten'; + @override String get reorderFavorites => 'Favorieten herordenen'; } // Path: downloads @@ -1839,6 +1840,7 @@ extension on TranslationsNl { 'liveTv.whatsOn' => 'Nu op TV', 'liveTv.watchChannel' => 'Kanaal bekijken', 'liveTv.favorites' => 'Favorieten', + 'liveTv.reorderFavorites' => 'Favorieten herordenen', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Beheren', 'downloads.tvShows' => 'Series', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index dd936718..d821c609 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvPl implements TranslationsLiveTvEn { @override String get whatsOn => 'Co leci'; @override String get watchChannel => 'Oglądaj kanał'; @override String get favorites => 'Ulubione'; + @override String get reorderFavorites => 'Zmień kolejność ulubionych'; } // Path: collections @@ -1839,6 +1840,7 @@ extension on TranslationsPl { 'liveTv.whatsOn' => 'Co leci', 'liveTv.watchChannel' => 'Oglądaj kanał', 'liveTv.favorites' => 'Ulubione', + 'liveTv.reorderFavorites' => 'Zmień kolejność ulubionych', 'collections.title' => 'Kolekcje', 'collections.collection' => 'Kolekcja', 'collections.empty' => 'Kolekcja jest pusta', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 4e73104f..e3b899ea 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvPt implements TranslationsLiveTvEn { @override String get whatsOn => 'O que Está Passando'; @override String get watchChannel => 'Assistir Canal'; @override String get favorites => 'Favoritos'; + @override String get reorderFavorites => 'Reordenar favoritos'; } // Path: collections @@ -1839,6 +1840,7 @@ extension on TranslationsPt { 'liveTv.whatsOn' => 'O que Está Passando', 'liveTv.watchChannel' => 'Assistir Canal', 'liveTv.favorites' => 'Favoritos', + 'liveTv.reorderFavorites' => 'Reordenar favoritos', 'collections.title' => 'Coleções', 'collections.collection' => 'Coleção', 'collections.empty' => 'A coleção está vazia', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 569a4bef..fb1991bf 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvRu implements TranslationsLiveTvEn { @override String get whatsOn => 'Что идёт'; @override String get watchChannel => 'Смотреть канал'; @override String get favorites => 'Избранное'; + @override String get reorderFavorites => 'Изменить порядок избранного'; } // Path: collections @@ -1839,6 +1840,7 @@ extension on TranslationsRu { 'liveTv.whatsOn' => 'Что идёт', 'liveTv.watchChannel' => 'Смотреть канал', 'liveTv.favorites' => 'Избранное', + 'liveTv.reorderFavorites' => 'Изменить порядок избранного', 'collections.title' => 'Коллекции', 'collections.collection' => 'Коллекция', 'collections.empty' => 'Коллекция пуста', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index e8e2a728..9536f070 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvSv implements TranslationsLiveTvEn { @override String get whatsOn => 'På TV nu'; @override String get watchChannel => 'Titta på kanal'; @override String get favorites => 'Favoriter'; + @override String get reorderFavorites => 'Ordna om favoriter'; } // Path: downloads @@ -1839,6 +1840,7 @@ extension on TranslationsSv { 'liveTv.whatsOn' => 'På TV nu', 'liveTv.watchChannel' => 'Titta på kanal', 'liveTv.favorites' => 'Favoriter', + 'liveTv.reorderFavorites' => 'Ordna om favoriter', 'downloads.title' => 'Nedladdningar', 'downloads.manage' => 'Hantera', 'downloads.tvShows' => 'TV-serier', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 6ba92cfb..aebb1e7d 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -814,6 +814,7 @@ class _TranslationsLiveTvZh implements TranslationsLiveTvEn { @override String get whatsOn => '正在播出'; @override String get watchChannel => '观看频道'; @override String get favorites => '收藏'; + @override String get reorderFavorites => '重新排序收藏'; } // Path: downloads @@ -1839,6 +1840,7 @@ extension on TranslationsZh { 'liveTv.whatsOn' => '正在播出', 'liveTv.watchChannel' => '观看频道', 'liveTv.favorites' => '收藏', + 'liveTv.reorderFavorites' => '重新排序收藏', 'downloads.title' => '下载', 'downloads.manage' => '管理', 'downloads.tvShows' => '电视剧', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 6e43f6cb..bd74ce30 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -581,7 +581,8 @@ "lateNight": "Sen kväll", "whatsOn": "På TV nu", "watchChannel": "Titta på kanal", - "favorites": "Favoriter" + "favorites": "Favoriter", + "reorderFavorites": "Ordna om favoriter" }, "downloads": { "title": "Nedladdningar", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 80b5c381..65c07442 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -581,7 +581,8 @@ "lateNight": "深夜", "whatsOn": "正在播出", "watchChannel": "观看频道", - "favorites": "收藏" + "favorites": "收藏", + "reorderFavorites": "重新排序收藏" }, "downloads": { "title": "下载", diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index bdc12da4..9f539bdf 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -14,6 +14,8 @@ import '../../utils/app_logger.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/overlay_sheet.dart'; +import 'reorder_favorites_sheet.dart'; import 'tabs/guide_tab.dart'; import 'tabs/whats_on_tab.dart'; @@ -50,7 +52,11 @@ class _LiveTvScreenState extends State List get _filteredChannels { if (!_showFavoritesOnly || _favoriteChannelIds.isEmpty) return _channels; - return _channels.where((c) => _favoriteChannelIds.contains(c.key)).toList(); + final channelMap = {for (final c in _channels) c.key: c}; + return [ + for (final fav in _favoriteChannels) + if (channelMap.containsKey(fav.id)) channelMap[fav.id]!, + ]; } @override @@ -233,7 +239,6 @@ class _LiveTvScreenState extends State } void _toggleFavorite(LiveTvChannel channel) { - final multiServer = context.read(); final source = _favoriteSourceByServer[channel.serverId] ?? ''; setState(() { @@ -246,7 +251,37 @@ class _LiveTvScreenState extends State } }); - // Persist to server (fire-and-forget, optimistic UI) + _persistFavorites(); + } + + void _showReorderFavorites() { + final channelMap = {for (final c in _channels) c.key: c}; + + OverlaySheetController.showAdaptive( + context, + builder: (sheetContext) => ReorderFavoritesSheet( + favorites: List.from(_favoriteChannels), + channelMap: channelMap, + onReorder: (reordered) { + setState(() { + _favoriteChannels = reordered; + _favoriteChannelIds = reordered.map((f) => f.id).toSet(); + }); + _persistFavorites(); + }, + onRemove: (removed) { + setState(() { + _favoriteChannels = _favoriteChannels.where((f) => f.id != removed.id).toList(); + _favoriteChannelIds = Set.from(_favoriteChannelIds)..remove(removed.id); + }); + _persistFavorites(); + }, + ), + ); + } + + void _persistFavorites() { + final multiServer = context.read(); for (final serverInfo in multiServer.liveTvServers) { final client = multiServer.getClientForServer(serverInfo.serverId); if (client != null) { @@ -322,6 +357,12 @@ class _LiveTvScreenState extends State tooltip: t.liveTv.favorites, onPressed: _toggleFavoritesFilter, ), + if (_showFavoritesOnly && _favoriteChannels.length > 1) + FocusableAction( + icon: Symbols.swap_vert_rounded, + tooltip: t.liveTv.reorderFavorites, + onPressed: _showReorderFavorites, + ), FocusableAction( icon: Symbols.refresh_rounded, tooltip: t.liveTv.reloadGuide, diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart new file mode 100644 index 00000000..a7e85854 --- /dev/null +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../focus/dpad_navigator.dart'; +import '../../focus/focus_theme.dart'; +import '../../focus/input_mode_tracker.dart'; +import '../../focus/key_event_utils.dart'; +import '../../i18n/strings.g.dart'; +import '../../models/livetv_channel.dart'; +import '../../providers/multi_server_provider.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/bottom_sheet_header.dart'; +import '../../widgets/overlay_sheet.dart'; +import '../../widgets/plex_optimized_image.dart'; + +class ReorderFavoritesSheet extends StatefulWidget { + final List favorites; + final Map channelMap; + final void Function(List) onReorder; + final void Function(FavoriteChannel) onRemove; + + const ReorderFavoritesSheet({ + super.key, + required this.favorites, + required this.channelMap, + required this.onReorder, + required this.onRemove, + }); + + @override + State createState() => _ReorderFavoritesSheetState(); +} + +class _ReorderFavoritesSheetState extends State { + late List _tempFavorites; + + // Keyboard navigation state + int _focusedIndex = 0; + int _focusedColumn = 0; // 0 = row, 1 = remove button + int? _movingIndex; + int? _originalIndex; + List? _originalOrder; + final FocusNode _listFocusNode = FocusNode(); + final ScrollController _scrollController = ScrollController(); + bool _backKeyDownSeen = false; + + @override + void initState() { + super.initState(); + _tempFavorites = List.from(widget.favorites); + } + + @override + void dispose() { + _listFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _ensureFocusedVisible() { + if (!_scrollController.hasClients) return; + + const double itemHeight = 72.0; + const double listTopPadding = 8.0; + final double targetTop = listTopPadding + (_focusedIndex * itemHeight); + final double targetBottom = targetTop + itemHeight; + + final double viewportTop = _scrollController.offset; + final double viewportHeight = _scrollController.position.viewportDimension; + final double viewportBottom = viewportTop + viewportHeight; + + if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; + + final double destination = (targetTop - viewportHeight * 0.25).clamp( + 0.0, + _scrollController.position.maxScrollExtent, + ); + + _scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); + } + + KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { + final key = event.logicalKey; + + if (key.isBackKey) { + if (event is KeyDownEvent) { + _backKeyDownSeen = true; + } else if (event is KeyUpEvent && !_backKeyDownSeen) { + return KeyEventResult.handled; + } + if (event is KeyUpEvent) { + _backKeyDownSeen = false; + } + } + + final backResult = handleBackKeyAction(event, () { + if (_movingIndex != null) { + setState(() { + if (_originalOrder != null) { + _tempFavorites = List.from(_originalOrder!); + } + _focusedIndex = _originalIndex ?? 0; + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + } else { + OverlaySheetController.popAdaptive(context); + } + }); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + + if (!event.isActionable) return KeyEventResult.ignored; + + if (_movingIndex != null) { + if (key.isUpKey && _movingIndex! > 0) { + setState(() { + final item = _tempFavorites.removeAt(_movingIndex!); + _tempFavorites.insert(_movingIndex! - 1, item); + _movingIndex = _movingIndex! - 1; + _focusedIndex = _movingIndex!; + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isDownKey && _movingIndex! < _tempFavorites.length - 1) { + setState(() { + final item = _tempFavorites.removeAt(_movingIndex!); + _tempFavorites.insert(_movingIndex! + 1, item); + _movingIndex = _movingIndex! + 1; + _focusedIndex = _movingIndex!; + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + widget.onReorder(_tempFavorites); + setState(() { + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + return KeyEventResult.handled; + } + } else { + if (key.isUpKey && _focusedIndex > 0) { + setState(() { + _focusedIndex--; + _focusedColumn = 0; + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isDownKey && _focusedIndex < _tempFavorites.length - 1) { + setState(() { + _focusedIndex++; + _focusedColumn = 0; + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isLeftKey && _focusedColumn > 0) { + setState(() => _focusedColumn--); + return KeyEventResult.handled; + } + if (key.isRightKey && _focusedColumn < 1) { + setState(() => _focusedColumn++); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + if (_focusedColumn == 0) { + setState(() { + _movingIndex = _focusedIndex; + _originalIndex = _focusedIndex; + _originalOrder = List.from(_tempFavorites); + }); + } else if (_focusedColumn == 1) { + _removeItem(_focusedIndex); + } + return KeyEventResult.handled; + } + } + + if (key.isDpadDirection) { + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _onReorder(int oldIndex, int newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _tempFavorites.removeAt(oldIndex); + _tempFavorites.insert(newIndex, item); + }); + widget.onReorder(_tempFavorites); + } + + void _removeItem(int index) { + final removed = _tempFavorites[index]; + setState(() { + _tempFavorites.removeAt(index); + if (_focusedIndex >= _tempFavorites.length) { + _focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length); + } + }); + widget.onRemove(removed); + + if (_tempFavorites.isEmpty) { + OverlaySheetController.popAdaptive(context); + } + } + + @override + Widget build(BuildContext context) { + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + BottomSheetHeader( + title: t.liveTv.reorderFavorites, + icon: Symbols.swap_vert_rounded, + ), + Expanded( + child: Focus( + focusNode: _listFocusNode, + autofocus: isKeyboardMode, + onKeyEvent: _handleKeyEvent, + child: ReorderableListView.builder( + scrollController: _scrollController, + onReorder: _onReorder, + itemCount: _tempFavorites.length, + padding: const EdgeInsets.symmetric(vertical: 8), + buildDefaultDragHandles: false, + itemBuilder: (context, index) { + final fav = _tempFavorites[index]; + final channel = widget.channelMap[fav.id]; + final isFocused = isKeyboardMode && index == _focusedIndex; + final isMoving = index == _movingIndex; + + return _buildFavoriteTile( + key: ValueKey(fav.id), + fav: fav, + channel: channel, + index: index, + isFocused: isFocused, + isMoving: isMoving, + focusedColumn: isFocused ? _focusedColumn : null, + ); + }, + ), + ), + ), + ], + ); + } + + Widget _buildFavoriteTile({ + required Key key, + required FavoriteChannel fav, + required LiveTvChannel? channel, + required int index, + required bool isFocused, + required bool isMoving, + int? focusedColumn, + }) { + final colorScheme = Theme.of(context).colorScheme; + final multiServer = context.read(); + final client = multiServer.getClientForServer(channel?.serverId ?? ''); + + Color? tileColor; + if (isMoving) { + tileColor = colorScheme.primaryContainer; + } else if (isFocused && focusedColumn == 0) { + tileColor = colorScheme.surfaceContainerHighest; + } + + final isRemoveButtonFocused = isFocused && focusedColumn == 1; + final displayName = channel?.displayName ?? fav.title ?? fav.id; + final channelNumber = channel?.number ?? fav.vcn; + + return Container( + key: key, + decoration: BoxDecoration(color: tileColor), + child: ListTile( + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ReorderableDragStartListener( + index: index, + child: AppIcon( + isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded, + fill: 1, + color: isMoving ? colorScheme.primary : IconTheme.of(context).color?.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 40, + height: 40, + child: channel?.thumb != null && client != null + ? PlexOptimizedImage.thumb( + client: client, + imagePath: channel!.thumb, + width: 40, + height: 40, + fit: BoxFit.contain, + ) + : Center( + child: AppIcon(Symbols.live_tv_rounded, fill: 1, color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + title: Text(displayName, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: channelNumber != null + ? Text( + channelNumber, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + ), + ) + : null, + trailing: Container( + decoration: FocusTheme.focusBackgroundDecoration( + isFocused: isRemoveButtonFocused, + borderRadius: 20, + ), + child: IconButton( + icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20), + onPressed: () => _removeItem(index), + ), + ), + ), + ); + } +}