From 339a767304d516c525518ab654626c4ed89c815a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 2 Apr 2026 00:33:25 +0200 Subject: [PATCH] refactor: M3 settings screen redesign --- lib/i18n/da.i18n.json | 10 +- lib/i18n/de.i18n.json | 10 +- lib/i18n/en.i18n.json | 10 +- lib/i18n/es.i18n.json | 10 +- lib/i18n/fr.i18n.json | 10 +- lib/i18n/it.i18n.json | 10 +- lib/i18n/ja.i18n.json | 10 +- lib/i18n/ko.i18n.json | 10 +- lib/i18n/nb.i18n.json | 10 +- lib/i18n/nl.i18n.json | 10 +- lib/i18n/pl.i18n.json | 10 +- lib/i18n/pt.i18n.json | 10 +- lib/i18n/ru.i18n.json | 10 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_da.g.dart | 20 +- lib/i18n/strings_de.g.dart | 20 +- lib/i18n/strings_en.g.dart | 36 +- lib/i18n/strings_es.g.dart | 20 +- lib/i18n/strings_fr.g.dart | 20 +- lib/i18n/strings_it.g.dart | 20 +- lib/i18n/strings_ja.g.dart | 20 +- lib/i18n/strings_ko.g.dart | 20 +- lib/i18n/strings_nb.g.dart | 20 +- lib/i18n/strings_nl.g.dart | 20 +- lib/i18n/strings_pl.g.dart | 20 +- lib/i18n/strings_pt.g.dart | 20 +- lib/i18n/strings_ru.g.dart | 20 +- lib/i18n/strings_sv.g.dart | 20 +- lib/i18n/strings_zh.g.dart | 20 +- lib/i18n/sv.i18n.json | 10 +- lib/i18n/zh.i18n.json | 10 +- .../settings/appearance_settings_screen.dart | 396 +++ .../settings/keyboard_shortcuts_screen.dart | 133 + .../settings/playback_settings_screen.dart | 553 +++++ lib/screens/settings/settings_screen.dart | 2186 +++-------------- lib/screens/settings/settings_utils.dart | 290 +++ lib/widgets/settings_section.dart | 73 + 37 files changed, 2145 insertions(+), 1956 deletions(-) create mode 100644 lib/screens/settings/appearance_settings_screen.dart create mode 100644 lib/screens/settings/keyboard_shortcuts_screen.dart create mode 100644 lib/screens/settings/playback_settings_screen.dart create mode 100644 lib/screens/settings/settings_utils.dart create mode 100644 lib/widgets/settings_section.dart diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 65f15111..6253f20b 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Vis navigationsbarlabels", "showNavBarLabelsDescription": "Vis tekstlabels under navigationsbarikoner", "liveTvDefaultFavorites": "Standard til favoritkanaler", - "liveTvDefaultFavoritesDescription": "Vis kun favoritkanaler ved åbning af Live TV" + "liveTvDefaultFavoritesDescription": "Vis kun favoritkanaler ved åbning af Live TV", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Søg film, serier, musik...", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index f534e071..dc21bc40 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Navigationsleisten-Beschriftungen anzeigen", "showNavBarLabelsDescription": "Textbeschriftungen unter den Symbolen der Navigationsleiste anzeigen", "liveTvDefaultFavorites": "Standardmäßig Favoritenkanäle", - "liveTvDefaultFavoritesDescription": "Beim Öffnen von Live TV nur Favoritenkanäle anzeigen" + "liveTvDefaultFavoritesDescription": "Beim Öffnen von Live TV nur Favoritenkanäle anzeigen", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Filme, Serien, Musik suchen...", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 0a0cbb00..0f2e1f21 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Show Navigation Bar Labels", "showNavBarLabelsDescription": "Display text labels under navigation bar icons", "liveTvDefaultFavorites": "Default to Favorite Channels", - "liveTvDefaultFavoritesDescription": "Show only favorite channels when opening Live TV" + "liveTvDefaultFavoritesDescription": "Show only favorite channels when opening Live TV", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Search movies, shows, music...", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 8d584c19..41fe910f 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Mostrar etiquetas de la barra de navegación", "showNavBarLabelsDescription": "Mostrar etiquetas de texto bajo los iconos de la barra de navegación", "liveTvDefaultFavorites": "Canales favoritos por defecto", - "liveTvDefaultFavoritesDescription": "Mostrar solo canales favoritos al abrir TV en vivo" + "liveTvDefaultFavoritesDescription": "Mostrar solo canales favoritos al abrir TV en vivo", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Buscar películas, series, música...", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 7ff7453b..e2a0dc5d 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Afficher les libellés de la barre de navigation", "showNavBarLabelsDescription": "Afficher les libellés sous les icônes de la barre de navigation", "liveTvDefaultFavorites": "Chaînes favorites par défaut", - "liveTvDefaultFavoritesDescription": "Afficher uniquement les chaînes favorites à l'ouverture de la TV en direct" + "liveTvDefaultFavoritesDescription": "Afficher uniquement les chaînes favorites à l'ouverture de la TV en direct", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Rechercher des films, des séries, de la musique...", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index f4c6ba7d..029dda92 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Mostra etichette barra di navigazione", "showNavBarLabelsDescription": "Mostra le etichette sotto le icone della barra di navigazione", "liveTvDefaultFavorites": "Canali preferiti predefiniti", - "liveTvDefaultFavoritesDescription": "Mostra solo i canali preferiti all'apertura della TV dal vivo" + "liveTvDefaultFavoritesDescription": "Mostra solo i canali preferiti all'apertura della TV dal vivo", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Cerca film. spettacoli, musica...", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index b280be95..622c59a3 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "ナビゲーションバーラベルを表示", "showNavBarLabelsDescription": "ナビゲーションバーアイコンの下にテキストラベルを表示", "liveTvDefaultFavorites": "お気に入りチャンネルをデフォルトに", - "liveTvDefaultFavoritesDescription": "ライブTV を開いたときにお気に入りチャンネルのみ表示" + "liveTvDefaultFavoritesDescription": "ライブTV を開いたときにお気に入りチャンネルのみ表示", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "映画、番組、音楽を検索...", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index f14b8a28..2e0bd725 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "내비게이션 바 라벨 표시", "showNavBarLabelsDescription": "내비게이션 바 아이콘 아래에 텍스트 라벨을 표시합니다", "liveTvDefaultFavorites": "즐겨찾기 채널 기본 설정", - "liveTvDefaultFavoritesDescription": "라이브 TV를 열 때 즐겨찾기 채널만 표시" + "liveTvDefaultFavoritesDescription": "라이브 TV를 열 때 즐겨찾기 채널만 표시", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "영화, 시리즈, 음악 등을 검색하세요...", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index a790ad52..f674df57 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Vis navigasjonsfeltlabeler", "showNavBarLabelsDescription": "Vis tekstlabeler under navigasjonsfeltikoner", "liveTvDefaultFavorites": "Standard til favorittkanaler", - "liveTvDefaultFavoritesDescription": "Vis kun favorittkanaler når du åpner Live TV" + "liveTvDefaultFavoritesDescription": "Vis kun favorittkanaler når du åpner Live TV", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Søk i filmer, serier, musikk...", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 54624080..ae73c5a1 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Navigatiebalk labels tonen", "showNavBarLabelsDescription": "Tekstlabels onder de pictogrammen van de navigatiebalk weergeven", "liveTvDefaultFavorites": "Standaard favoriete zenders", - "liveTvDefaultFavoritesDescription": "Toon alleen favoriete zenders bij het openen van Live TV" + "liveTvDefaultFavoritesDescription": "Toon alleen favoriete zenders bij het openen van Live TV", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Zoek films, series, muziek...", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index a6e5bce9..648e151d 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Pokaż etykiety paska nawigacji", "showNavBarLabelsDescription": "Wyświetl tekstowe etykiety pod ikonami paska nawigacji", "liveTvDefaultFavorites": "Domyślnie ulubione kanały", - "liveTvDefaultFavoritesDescription": "Pokaż tylko ulubione kanały po otwarciu telewizji na żywo" + "liveTvDefaultFavoritesDescription": "Pokaż tylko ulubione kanały po otwarciu telewizji na żywo", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Szukaj filmów, seriali, muzyki...", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 30c6d553..258d37e4 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Mostrar Rótulos da Barra de Navegação", "showNavBarLabelsDescription": "Exibir rótulos de texto sob os ícones da barra de navegação", "liveTvDefaultFavorites": "Canais favoritos por padrão", - "liveTvDefaultFavoritesDescription": "Mostrar apenas canais favoritos ao abrir TV ao vivo" + "liveTvDefaultFavoritesDescription": "Mostrar apenas canais favoritos ao abrir TV ao vivo", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Buscar filmes, séries, músicas...", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index c89896fe..994c61be 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Показывать подписи панели навигации", "showNavBarLabelsDescription": "Отображать текстовые подписи под иконками панели навигации", "liveTvDefaultFavorites": "Избранные каналы по умолчанию", - "liveTvDefaultFavoritesDescription": "Показывать только избранные каналы при открытии ТВ" + "liveTvDefaultFavoritesDescription": "Показывать только избранные каналы при открытии ТВ", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Поиск фильмов, сериалов, музыки...", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 30fc0d33..d0d00af9 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: 12465 (831 per locale) +/// Strings: 12585 (839 per locale) /// -/// Built on 2026-04-01 at 15:18 UTC +/// Built on 2026-04-01 at 22:20 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 e847496b..bc2afebc 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsDa implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Vis tekstlabels under navigationsbarikoner'; @override String get liveTvDefaultFavorites => 'Standard til favoritkanaler'; @override String get liveTvDefaultFavoritesDescription => 'Vis kun favoritkanaler ved åbning af Live TV'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsDa { 'settings.showNavBarLabelsDescription' => 'Vis tekstlabels under navigationsbarikoner', 'settings.liveTvDefaultFavorites' => 'Standard til favoritkanaler', 'settings.liveTvDefaultFavoritesDescription' => 'Vis kun favoritkanaler ved åbning af Live TV', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Søg film, serier, musik...', 'search.tryDifferentTerm' => 'Prøv en anden søgning', 'search.searchYourMedia' => 'Søg i dine medier', @@ -1832,6 +1848,8 @@ extension on TranslationsDa { 'licenses.relatedPackages' => 'Relaterede pakker', 'licenses.license' => 'Licens', 'licenses.licenseNumber' => ({required Object number}) => 'Licens ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} licenser', 'navigation.libraries' => 'Biblioteker', 'navigation.downloads' => 'Downloads', @@ -1840,8 +1858,6 @@ extension on TranslationsDa { 'liveTv.guide' => 'Guide', 'liveTv.noChannels' => 'Ingen kanaler tilgængelige', 'liveTv.noDvr' => 'Ingen DVR konfigureret på nogen server', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Ingen programdata tilgængelig', 'liveTv.live' => 'LIVE', 'liveTv.reloadGuide' => 'Genindlæs guide', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 84cc5912..c1e4bffb 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Textbeschriftungen unter den Symbolen der Navigationsleiste anzeigen'; @override String get liveTvDefaultFavorites => 'Standardmäßig Favoritenkanäle'; @override String get liveTvDefaultFavoritesDescription => 'Beim Öffnen von Live TV nur Favoritenkanäle anzeigen'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsDe { 'settings.showNavBarLabelsDescription' => 'Textbeschriftungen unter den Symbolen der Navigationsleiste anzeigen', 'settings.liveTvDefaultFavorites' => 'Standardmäßig Favoritenkanäle', 'settings.liveTvDefaultFavoritesDescription' => 'Beim Öffnen von Live TV nur Favoritenkanäle anzeigen', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Filme, Serien, Musik suchen...', 'search.tryDifferentTerm' => 'Anderen Suchbegriff versuchen', 'search.searchYourMedia' => 'In den eigenen Medien suchen', @@ -1832,6 +1848,8 @@ extension on TranslationsDe { 'licenses.relatedPackages' => 'Verwandte Pakete', 'licenses.license' => 'Lizenz', 'licenses.licenseNumber' => ({required Object number}) => 'Lizenz ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} Lizenzen', 'navigation.libraries' => 'Medien', 'navigation.downloads' => 'Downloads', @@ -1840,8 +1858,6 @@ extension on TranslationsDe { 'liveTv.guide' => 'Programmführer', 'liveTv.noChannels' => 'Keine Kanäle verfügbar', 'liveTv.noDvr' => 'Kein DVR auf einem Server konfiguriert', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Keine Programmdaten verfügbar', 'liveTv.live' => 'LIVE', 'liveTv.reloadGuide' => 'Programmführer neu laden', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 66792997..d7b764d3 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -765,6 +765,30 @@ class TranslationsSettingsEn { /// en: 'Show only favorite channels when opening Live TV' String get liveTvDefaultFavoritesDescription => 'Show only favorite channels when opening Live TV'; + + /// en: 'Display' + String get display => 'Display'; + + /// en: 'Home Screen' + String get homeScreen => 'Home Screen'; + + /// en: 'Navigation' + String get navigation => 'Navigation'; + + /// en: 'Content' + String get content => 'Content'; + + /// en: 'Player' + String get player => 'Player'; + + /// en: 'Subtitles & Configuration' + String get subtitlesAndConfig => 'Subtitles & Configuration'; + + /// en: 'Seek & Timing' + String get seekAndTiming => 'Seek & Timing'; + + /// en: 'Behavior' + String get behavior => 'Behavior'; } // Path: search @@ -3208,6 +3232,14 @@ extension on Translations { 'settings.showNavBarLabelsDescription' => 'Display text labels under navigation bar icons', 'settings.liveTvDefaultFavorites' => 'Default to Favorite Channels', 'settings.liveTvDefaultFavoritesDescription' => 'Show only favorite channels when opening Live TV', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Search movies, shows, music...', 'search.tryDifferentTerm' => 'Try a different search term', 'search.searchYourMedia' => 'Search your media', @@ -3501,6 +3533,8 @@ extension on Translations { 'licenses.relatedPackages' => 'Related Packages', 'licenses.license' => 'License', 'licenses.licenseNumber' => ({required Object number}) => 'License ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} licenses', 'navigation.libraries' => 'Libraries', 'navigation.downloads' => 'Downloads', @@ -3509,8 +3543,6 @@ extension on Translations { 'liveTv.guide' => 'Guide', 'liveTv.noChannels' => 'No channels available', 'liveTv.noDvr' => 'No DVR configured on any server', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'No program data available', 'liveTv.live' => 'LIVE', 'liveTv.reloadGuide' => 'Reload Guide', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index a287ea8b..d138e676 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Mostrar etiquetas de texto bajo los iconos de la barra de navegación'; @override String get liveTvDefaultFavorites => 'Canales favoritos por defecto'; @override String get liveTvDefaultFavoritesDescription => 'Mostrar solo canales favoritos al abrir TV en vivo'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsEs { 'settings.showNavBarLabelsDescription' => 'Mostrar etiquetas de texto bajo los iconos de la barra de navegación', 'settings.liveTvDefaultFavorites' => 'Canales favoritos por defecto', 'settings.liveTvDefaultFavoritesDescription' => 'Mostrar solo canales favoritos al abrir TV en vivo', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Buscar películas, series, música...', 'search.tryDifferentTerm' => 'Prueba con un término de búsqueda diferente', 'search.searchYourMedia' => 'Busca en tu contenido', @@ -1832,6 +1848,8 @@ extension on TranslationsEs { 'licenses.relatedPackages' => 'Paquetes relacionados', 'licenses.license' => 'Licencia', 'licenses.licenseNumber' => ({required Object number}) => 'Licencia ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} licencias', 'navigation.libraries' => 'Medios', 'navigation.downloads' => 'Descargas', @@ -1840,8 +1858,6 @@ extension on TranslationsEs { 'liveTv.guide' => 'Guía', 'liveTv.noChannels' => 'No hay canales disponibles', 'liveTv.noDvr' => 'No hay DVR configurado en ningún servidor', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'No hay datos de programación disponibles', 'liveTv.live' => 'EN VIVO', 'liveTv.reloadGuide' => 'Recargar guía', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index b79554c8..9b6b6f8d 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Afficher les libellés sous les icônes de la barre de navigation'; @override String get liveTvDefaultFavorites => 'Chaînes favorites par défaut'; @override String get liveTvDefaultFavoritesDescription => 'Afficher uniquement les chaînes favorites à l\'ouverture de la TV en direct'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsFr { 'settings.showNavBarLabelsDescription' => 'Afficher les libellés sous les icônes de la barre de navigation', 'settings.liveTvDefaultFavorites' => 'Chaînes favorites par défaut', 'settings.liveTvDefaultFavoritesDescription' => 'Afficher uniquement les chaînes favorites à l\'ouverture de la TV en direct', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Rechercher des films, des séries, de la musique...', 'search.tryDifferentTerm' => 'Essayez un autre terme de recherche', 'search.searchYourMedia' => 'Rechercher dans vos médias', @@ -1832,6 +1848,8 @@ extension on TranslationsFr { 'licenses.relatedPackages' => 'Package associés', 'licenses.license' => 'Licence', 'licenses.licenseNumber' => ({required Object number}) => 'Licence ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} licences', 'navigation.libraries' => 'Medias', 'navigation.downloads' => 'Téléch.', @@ -1840,8 +1858,6 @@ extension on TranslationsFr { 'liveTv.guide' => 'Guide', 'liveTv.noChannels' => 'Aucune chaîne disponible', 'liveTv.noDvr' => 'Aucun DVR configuré sur les serveurs', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Aucune donnée de programme disponible', 'liveTv.live' => 'EN DIRECT', 'liveTv.reloadGuide' => 'Recharger le guide', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 8a8c74dd..65c8393c 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Mostra le etichette sotto le icone della barra di navigazione'; @override String get liveTvDefaultFavorites => 'Canali preferiti predefiniti'; @override String get liveTvDefaultFavoritesDescription => 'Mostra solo i canali preferiti all\'apertura della TV dal vivo'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsIt { 'settings.showNavBarLabelsDescription' => 'Mostra le etichette sotto le icone della barra di navigazione', 'settings.liveTvDefaultFavorites' => 'Canali preferiti predefiniti', 'settings.liveTvDefaultFavoritesDescription' => 'Mostra solo i canali preferiti all\'apertura della TV dal vivo', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Cerca film. spettacoli, musica...', 'search.tryDifferentTerm' => 'Prova altri termini di ricerca', 'search.searchYourMedia' => 'Cerca nei tuoi media', @@ -1832,6 +1848,8 @@ extension on TranslationsIt { 'licenses.relatedPackages' => 'Pacchetti correlati', 'licenses.license' => 'Licenza', 'licenses.licenseNumber' => ({required Object number}) => 'Licenza ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} licenze', 'navigation.libraries' => 'Librerie', 'navigation.downloads' => 'Download', @@ -1840,8 +1858,6 @@ extension on TranslationsIt { 'liveTv.guide' => 'Guida', 'liveTv.noChannels' => 'Nessun canale disponibile', 'liveTv.noDvr' => 'Nessun DVR configurato su nessun server', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Nessun dato di programma disponibile', 'liveTv.live' => 'IN DIRETTA', 'liveTv.reloadGuide' => 'Ricarica guida', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index aed3a549..5cab00ba 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsJa implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'ナビゲーションバーアイコンの下にテキストラベルを表示'; @override String get liveTvDefaultFavorites => 'お気に入りチャンネルをデフォルトに'; @override String get liveTvDefaultFavoritesDescription => 'ライブTV を開いたときにお気に入りチャンネルのみ表示'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsJa { 'settings.showNavBarLabelsDescription' => 'ナビゲーションバーアイコンの下にテキストラベルを表示', 'settings.liveTvDefaultFavorites' => 'お気に入りチャンネルをデフォルトに', 'settings.liveTvDefaultFavoritesDescription' => 'ライブTV を開いたときにお気に入りチャンネルのみ表示', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => '映画、番組、音楽を検索...', 'search.tryDifferentTerm' => '別の検索語をお試しください', 'search.searchYourMedia' => 'メディアを検索', @@ -1832,6 +1848,8 @@ extension on TranslationsJa { 'licenses.relatedPackages' => '関連パッケージ', 'licenses.license' => 'ライセンス', 'licenses.licenseNumber' => ({required Object number}) => 'ライセンス ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count}件のライセンス', 'navigation.libraries' => 'ライブラリ', 'navigation.downloads' => 'ダウンロード', @@ -1840,8 +1858,6 @@ extension on TranslationsJa { 'liveTv.guide' => '番組表', 'liveTv.noChannels' => 'チャンネルがありません', 'liveTv.noDvr' => 'どのサーバーにもDVRが設定されていません', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => '番組データがありません', 'liveTv.live' => 'ライブ', 'liveTv.reloadGuide' => '番組表を再読込', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 812e0d11..03981190 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => '내비게이션 바 아이콘 아래에 텍스트 라벨을 표시합니다'; @override String get liveTvDefaultFavorites => '즐겨찾기 채널 기본 설정'; @override String get liveTvDefaultFavoritesDescription => '라이브 TV를 열 때 즐겨찾기 채널만 표시'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsKo { 'settings.showNavBarLabelsDescription' => '내비게이션 바 아이콘 아래에 텍스트 라벨을 표시합니다', 'settings.liveTvDefaultFavorites' => '즐겨찾기 채널 기본 설정', 'settings.liveTvDefaultFavoritesDescription' => '라이브 TV를 열 때 즐겨찾기 채널만 표시', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => '영화, 시리즈, 음악 등을 검색하세요...', 'search.tryDifferentTerm' => '다른 검색어를 시도해 보세요', 'search.searchYourMedia' => '미디어 검색', @@ -1832,6 +1848,8 @@ extension on TranslationsKo { 'licenses.relatedPackages' => '관련 소프트웨어 패키지', 'licenses.license' => '라이선스', 'licenses.licenseNumber' => ({required Object number}) => '라이선스 ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} 개의 라이선스', 'navigation.libraries' => '미디어 라이브러리', 'navigation.downloads' => '다운로드', @@ -1840,8 +1858,6 @@ extension on TranslationsKo { 'liveTv.guide' => '편성표', 'liveTv.noChannels' => '사용 가능한 채널이 없습니다', 'liveTv.noDvr' => '서버에 DVR이 구성되어 있지 않습니다', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => '프로그램 데이터가 없습니다', 'liveTv.live' => '실시간', 'liveTv.reloadGuide' => '편성표 새로고침', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 412e49be..1cde300d 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsNb implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Vis tekstlabeler under navigasjonsfeltikoner'; @override String get liveTvDefaultFavorites => 'Standard til favorittkanaler'; @override String get liveTvDefaultFavoritesDescription => 'Vis kun favorittkanaler når du åpner Live TV'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsNb { 'settings.showNavBarLabelsDescription' => 'Vis tekstlabeler under navigasjonsfeltikoner', 'settings.liveTvDefaultFavorites' => 'Standard til favorittkanaler', 'settings.liveTvDefaultFavoritesDescription' => 'Vis kun favorittkanaler når du åpner Live TV', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Søk i filmer, serier, musikk...', 'search.tryDifferentTerm' => 'Prøv et annet søkeord', 'search.searchYourMedia' => 'Søk i mediene dine', @@ -1832,6 +1848,8 @@ extension on TranslationsNb { 'licenses.relatedPackages' => 'Relaterte pakker', 'licenses.license' => 'Lisens', 'licenses.licenseNumber' => ({required Object number}) => 'Lisens ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} lisenser', 'navigation.libraries' => 'Biblioteker', 'navigation.downloads' => 'Nedlastinger', @@ -1840,8 +1858,6 @@ extension on TranslationsNb { 'liveTv.guide' => 'Programguide', 'liveTv.noChannels' => 'Ingen kanaler tilgjengelig', 'liveTv.noDvr' => 'Ingen DVR konfigurert på noen server', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Ingen programdata tilgjengelig', 'liveTv.live' => 'DIREKTE', 'liveTv.reloadGuide' => 'Last inn programguide på nytt', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index a0ba79c2..aec0ee70 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Tekstlabels onder de pictogrammen van de navigatiebalk weergeven'; @override String get liveTvDefaultFavorites => 'Standaard favoriete zenders'; @override String get liveTvDefaultFavoritesDescription => 'Toon alleen favoriete zenders bij het openen van Live TV'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsNl { 'settings.showNavBarLabelsDescription' => 'Tekstlabels onder de pictogrammen van de navigatiebalk weergeven', 'settings.liveTvDefaultFavorites' => 'Standaard favoriete zenders', 'settings.liveTvDefaultFavoritesDescription' => 'Toon alleen favoriete zenders bij het openen van Live TV', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Zoek films, series, muziek...', 'search.tryDifferentTerm' => 'Probeer een andere zoekterm', 'search.searchYourMedia' => 'Zoek in je media', @@ -1832,6 +1848,8 @@ extension on TranslationsNl { 'licenses.relatedPackages' => 'Gerelateerde pakketten', 'licenses.license' => 'Licentie', 'licenses.licenseNumber' => ({required Object number}) => 'Licentie ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} licenties', 'navigation.libraries' => 'Media', 'navigation.downloads' => 'Downloads', @@ -1840,8 +1858,6 @@ extension on TranslationsNl { 'liveTv.guide' => 'Gids', 'liveTv.noChannels' => 'Geen zenders beschikbaar', 'liveTv.noDvr' => 'Geen DVR geconfigureerd op een server', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Geen programmagegevens beschikbaar', 'liveTv.live' => 'LIVE', 'liveTv.reloadGuide' => 'Gids herladen', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 126c68f1..04a3e4e7 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsPl implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Wyświetl tekstowe etykiety pod ikonami paska nawigacji'; @override String get liveTvDefaultFavorites => 'Domyślnie ulubione kanały'; @override String get liveTvDefaultFavoritesDescription => 'Pokaż tylko ulubione kanały po otwarciu telewizji na żywo'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsPl { 'settings.showNavBarLabelsDescription' => 'Wyświetl tekstowe etykiety pod ikonami paska nawigacji', 'settings.liveTvDefaultFavorites' => 'Domyślnie ulubione kanały', 'settings.liveTvDefaultFavoritesDescription' => 'Pokaż tylko ulubione kanały po otwarciu telewizji na żywo', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Szukaj filmów, seriali, muzyki...', 'search.tryDifferentTerm' => 'Spróbuj innego wyszukiwania', 'search.searchYourMedia' => 'Przeszukaj swoje media', @@ -1832,6 +1848,8 @@ extension on TranslationsPl { 'licenses.relatedPackages' => 'Powiązane pakiety', 'licenses.license' => 'Licencja', 'licenses.licenseNumber' => ({required Object number}) => 'Licencja ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} licencji', 'navigation.libraries' => 'Biblioteki', 'navigation.downloads' => 'Pobrania', @@ -1840,8 +1858,6 @@ extension on TranslationsPl { 'liveTv.guide' => 'Przewodnik', 'liveTv.noChannels' => 'Brak dostępnych kanałów', 'liveTv.noDvr' => 'Brak skonfigurowanego DVR na żadnym serwerze', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Brak danych o programach', 'liveTv.live' => 'NA ŻYWO', 'liveTv.reloadGuide' => 'Odśwież przewodnik', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 9ffeabbc..9dc66a9f 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsPt implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Exibir rótulos de texto sob os ícones da barra de navegação'; @override String get liveTvDefaultFavorites => 'Canais favoritos por padrão'; @override String get liveTvDefaultFavoritesDescription => 'Mostrar apenas canais favoritos ao abrir TV ao vivo'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsPt { 'settings.showNavBarLabelsDescription' => 'Exibir rótulos de texto sob os ícones da barra de navegação', 'settings.liveTvDefaultFavorites' => 'Canais favoritos por padrão', 'settings.liveTvDefaultFavoritesDescription' => 'Mostrar apenas canais favoritos ao abrir TV ao vivo', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Buscar filmes, séries, músicas...', 'search.tryDifferentTerm' => 'Tente um termo de busca diferente', 'search.searchYourMedia' => 'Buscar suas mídias', @@ -1832,6 +1848,8 @@ extension on TranslationsPt { 'licenses.relatedPackages' => 'Pacotes Relacionados', 'licenses.license' => 'Licença', 'licenses.licenseNumber' => ({required Object number}) => 'Licença ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} licenças', 'navigation.libraries' => 'Bibliotecas', 'navigation.downloads' => 'Downloads', @@ -1840,8 +1858,6 @@ extension on TranslationsPt { 'liveTv.guide' => 'Guia', 'liveTv.noChannels' => 'Nenhum canal disponível', 'liveTv.noDvr' => 'Nenhum DVR configurado em nenhum servidor', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Nenhum dado de programação disponível', 'liveTv.live' => 'AO VIVO', 'liveTv.reloadGuide' => 'Recarregar Guia', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index df2b3699..ee7a2a23 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsRu implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Отображать текстовые подписи под иконками панели навигации'; @override String get liveTvDefaultFavorites => 'Избранные каналы по умолчанию'; @override String get liveTvDefaultFavoritesDescription => 'Показывать только избранные каналы при открытии ТВ'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsRu { 'settings.showNavBarLabelsDescription' => 'Отображать текстовые подписи под иконками панели навигации', 'settings.liveTvDefaultFavorites' => 'Избранные каналы по умолчанию', 'settings.liveTvDefaultFavoritesDescription' => 'Показывать только избранные каналы при открытии ТВ', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Поиск фильмов, сериалов, музыки...', 'search.tryDifferentTerm' => 'Попробуйте другой запрос', 'search.searchYourMedia' => 'Поиск в вашей медиатеке', @@ -1832,6 +1848,8 @@ extension on TranslationsRu { 'licenses.relatedPackages' => 'Связанные пакеты', 'licenses.license' => 'Лицензия', 'licenses.licenseNumber' => ({required Object number}) => 'Лицензия ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} лицензий', 'navigation.libraries' => 'Библиотеки', 'navigation.downloads' => 'Загрузки', @@ -1840,8 +1858,6 @@ extension on TranslationsRu { 'liveTv.guide' => 'Программа', 'liveTv.noChannels' => 'Нет доступных каналов', 'liveTv.noDvr' => 'DVR не настроен ни на одном сервере', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Нет данных о программах', 'liveTv.live' => 'ЭФИР', 'liveTv.reloadGuide' => 'Перезагрузить программу', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 7ca6452b..6702383d 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => 'Visa textetiketter under navigeringsfältets ikoner'; @override String get liveTvDefaultFavorites => 'Standard till favoritkanaler'; @override String get liveTvDefaultFavoritesDescription => 'Visa bara favoritkanaler när du öppnar Live TV'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsSv { 'settings.showNavBarLabelsDescription' => 'Visa textetiketter under navigeringsfältets ikoner', 'settings.liveTvDefaultFavorites' => 'Standard till favoritkanaler', 'settings.liveTvDefaultFavoritesDescription' => 'Visa bara favoritkanaler när du öppnar Live TV', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => 'Sök filmer, serier, musik...', 'search.tryDifferentTerm' => 'Prova en annan sökterm', 'search.searchYourMedia' => 'Sök i dina media', @@ -1832,6 +1848,8 @@ extension on TranslationsSv { 'licenses.relatedPackages' => 'Relaterade paket', 'licenses.license' => 'Licens', 'licenses.licenseNumber' => ({required Object number}) => 'Licens ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} licenser', 'navigation.libraries' => 'Bibliotek', 'navigation.downloads' => 'Nerladdat', @@ -1840,8 +1858,6 @@ extension on TranslationsSv { 'liveTv.guide' => 'Programguide', 'liveTv.noChannels' => 'Inga kanaler tillgängliga', 'liveTv.noDvr' => 'Ingen DVR konfigurerad på någon server', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => 'Ingen programdata tillgänglig', 'liveTv.live' => 'LIVE', 'liveTv.reloadGuide' => 'Ladda om programguide', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index f52d25a0..01cdfc7c 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -340,6 +340,14 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn { @override String get showNavBarLabelsDescription => '在导航栏图标下方显示文字标签'; @override String get liveTvDefaultFavorites => '默认显示收藏频道'; @override String get liveTvDefaultFavoritesDescription => '打开直播电视时仅显示收藏频道'; + @override String get display => 'Display'; + @override String get homeScreen => 'Home Screen'; + @override String get navigation => 'Navigation'; + @override String get content => 'Content'; + @override String get player => 'Player'; + @override String get subtitlesAndConfig => 'Subtitles & Configuration'; + @override String get seekAndTiming => 'Seek & Timing'; + @override String get behavior => 'Behavior'; } // Path: search @@ -1539,6 +1547,14 @@ extension on TranslationsZh { 'settings.showNavBarLabelsDescription' => '在导航栏图标下方显示文字标签', 'settings.liveTvDefaultFavorites' => '默认显示收藏频道', 'settings.liveTvDefaultFavoritesDescription' => '打开直播电视时仅显示收藏频道', + 'settings.display' => 'Display', + 'settings.homeScreen' => 'Home Screen', + 'settings.navigation' => 'Navigation', + 'settings.content' => 'Content', + 'settings.player' => 'Player', + 'settings.subtitlesAndConfig' => 'Subtitles & Configuration', + 'settings.seekAndTiming' => 'Seek & Timing', + 'settings.behavior' => 'Behavior', 'search.hint' => '搜索电影、系列、音乐...', 'search.tryDifferentTerm' => '尝试不同的搜索词', 'search.searchYourMedia' => '搜索媒体', @@ -1832,6 +1848,8 @@ extension on TranslationsZh { 'licenses.relatedPackages' => '相关软件包', 'licenses.license' => '许可证', 'licenses.licenseNumber' => ({required Object number}) => '许可证 ${number}', + _ => null, + } ?? switch (path) { 'licenses.licensesCount' => ({required Object count}) => '${count} 个许可证', 'navigation.libraries' => '媒体库', 'navigation.downloads' => '下载', @@ -1840,8 +1858,6 @@ extension on TranslationsZh { 'liveTv.guide' => '节目指南', 'liveTv.noChannels' => '没有可用的频道', 'liveTv.noDvr' => '没有服务器配置了DVR', - _ => null, - } ?? switch (path) { 'liveTv.noPrograms' => '没有可用的节目数据', 'liveTv.live' => '直播', 'liveTv.reloadGuide' => '重新加载节目指南', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index f1e5bdb7..81e36d85 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "Visa navigeringsfältets etiketter", "showNavBarLabelsDescription": "Visa textetiketter under navigeringsfältets ikoner", "liveTvDefaultFavorites": "Standard till favoritkanaler", - "liveTvDefaultFavoritesDescription": "Visa bara favoritkanaler när du öppnar Live TV" + "liveTvDefaultFavoritesDescription": "Visa bara favoritkanaler när du öppnar Live TV", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "Sök filmer, serier, musik...", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 893e9216..2c337daf 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -220,7 +220,15 @@ "showNavBarLabels": "显示导航栏标签", "showNavBarLabelsDescription": "在导航栏图标下方显示文字标签", "liveTvDefaultFavorites": "默认显示收藏频道", - "liveTvDefaultFavoritesDescription": "打开直播电视时仅显示收藏频道" + "liveTvDefaultFavoritesDescription": "打开直播电视时仅显示收藏频道", + "display": "Display", + "homeScreen": "Home Screen", + "navigation": "Navigation", + "content": "Content", + "player": "Player", + "subtitlesAndConfig": "Subtitles & Configuration", + "seekAndTiming": "Seek & Timing", + "behavior": "Behavior" }, "search": { "hint": "搜索电影、系列、音乐...", diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart new file mode 100644 index 00000000..900cc438 --- /dev/null +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../i18n/strings.g.dart'; +import '../../providers/settings_provider.dart'; +import '../../providers/theme_provider.dart'; +import '../../providers/user_profile_provider.dart'; +import '../../services/settings_service.dart' as settings; +import '../../utils/platform_detector.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/settings_section.dart'; +import 'settings_utils.dart'; + +class AppearanceSettingsScreen extends StatefulWidget { + const AppearanceSettingsScreen({super.key}); + + @override + State createState() => _AppearanceSettingsScreenState(); +} + +class _AppearanceSettingsScreenState extends State { + late settings.SettingsService _settingsService; + bool _isLoading = true; + bool _requireProfileSelectionOnOpen = false; + bool _confirmExitOnBack = true; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + _settingsService = await settings.SettingsService.getInstance(); + if (!mounted) return; + setState(() { + _requireProfileSelectionOnOpen = _settingsService.getRequireProfileSelectionOnOpen(); + _confirmExitOnBack = _settingsService.getConfirmExitOnBack(); + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return FocusedScrollScaffold( + title: Text(t.settings.appearance), + slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], + ); + } + + return FocusedScrollScaffold( + title: Text(t.settings.appearance), + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + // --- Display --- + SettingsSectionHeader(t.settings.display), + _buildThemeSelector(), + _buildLanguageSelector(), + _buildDensitySelector(), + _buildViewModeSelector(), + _buildEpisodePosterModeSelector(), + + // --- Home Screen --- + SettingsSectionHeader(t.settings.homeScreen), + _buildShowHeroSection(), + _buildUseGlobalHubs(), + _buildShowServerNameOnHubs(), + + // --- Navigation --- + SettingsSectionHeader(t.settings.navigation), + if (PlatformDetector.shouldUseSideNavigation(context)) _buildAlwaysKeepSidebarOpen(), + if (!PlatformDetector.shouldUseSideNavigation(context)) _buildShowNavBarLabels(), + _buildShowUnwatchedCount(), + + // --- Content --- + SettingsSectionHeader(t.settings.content), + _buildLiveTvDefaultFavorites(), + _buildHideSpoilers(), + _buildRequireProfileSelection(), + if (PlatformDetector.isTV()) _buildConfirmExitOnBack(), + const SizedBox(height: 24), + ]), + ), + ], + ); + } + + // --- Display section --- + + Widget _buildThemeSelector() { + return Consumer( + builder: (context, themeProvider, child) { + return SegmentedSetting( + icon: themeProvider.themeModeIcon, + title: t.settings.theme, + segments: [ + ButtonSegment(value: settings.ThemeMode.system, label: Text(t.settings.systemTheme)), + ButtonSegment(value: settings.ThemeMode.light, label: Text(t.settings.lightTheme)), + ButtonSegment(value: settings.ThemeMode.dark, label: Text(t.settings.darkTheme)), + ButtonSegment(value: settings.ThemeMode.oled, label: Text(t.settings.oledTheme)), + ], + selected: themeProvider.themeMode, + onChanged: (value) => themeProvider.setThemeMode(value), + ); + }, + ); + } + + Widget _buildLanguageSelector() { + return ListTile( + leading: const AppIcon(Symbols.language_rounded, fill: 1), + title: Text(t.settings.language), + subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () async { + final value = await showSelectionDialog( + context: context, + title: t.settings.language, + options: AppLocale.values + .map((locale) => DialogOption(value: locale, title: _getLanguageDisplayName(locale))) + .toList(), + currentValue: LocaleSettings.currentLocale, + ); + if (value != null) { + await _settingsService.setAppLocale(value); + LocaleSettings.setLocale(value); + _restartApp(); + } + }, + ); + } + + Widget _buildDensitySelector() { + return Consumer( + builder: (context, settingsProvider, child) { + return SegmentedSetting( + icon: Symbols.grid_view_rounded, + title: t.settings.libraryDensity, + segments: [ + ButtonSegment(value: settings.LibraryDensity.compact, label: Text(t.settings.compact)), + ButtonSegment(value: settings.LibraryDensity.normal, label: Text(t.settings.normal)), + ButtonSegment(value: settings.LibraryDensity.comfortable, label: Text(t.settings.comfortable)), + ], + selected: settingsProvider.libraryDensity, + onChanged: (value) => settingsProvider.setLibraryDensity(value), + ); + }, + ); + } + + Widget _buildViewModeSelector() { + return Consumer( + builder: (context, settingsProvider, child) { + return SegmentedSetting( + icon: Symbols.view_list_rounded, + title: t.settings.viewMode, + segments: [ + ButtonSegment(value: settings.ViewMode.grid, label: Text(t.settings.gridView)), + ButtonSegment(value: settings.ViewMode.list, label: Text(t.settings.listView)), + ], + selected: settingsProvider.viewMode, + onChanged: (value) => settingsProvider.setViewMode(value), + ); + }, + ); + } + + Widget _buildEpisodePosterModeSelector() { + return Consumer( + builder: (context, settingsProvider, child) { + return SegmentedSetting( + icon: Symbols.image_rounded, + title: t.settings.episodePosterMode, + segments: [ + ButtonSegment(value: settings.EpisodePosterMode.seriesPoster, label: Text(t.settings.seriesPoster)), + ButtonSegment(value: settings.EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)), + ButtonSegment( + value: settings.EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)), + ], + selected: settingsProvider.episodePosterMode, + onChanged: (value) => settingsProvider.setEpisodePosterMode(value), + ); + }, + ); + } + + // --- Home Screen section --- + + Widget _buildShowHeroSection() { + return Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const AppIcon(Symbols.featured_play_list_rounded, fill: 1), + title: Text(t.settings.showHeroSection), + subtitle: Text(t.settings.showHeroSectionDescription), + value: settingsProvider.showHeroSection, + onChanged: (value) async { + await settingsProvider.setShowHeroSection(value); + }, + ); + }, + ); + } + + Widget _buildUseGlobalHubs() { + return Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const AppIcon(Symbols.home_rounded, fill: 1), + title: Text(t.settings.useGlobalHubs), + subtitle: Text(t.settings.useGlobalHubsDescription), + value: settingsProvider.useGlobalHubs, + onChanged: (value) async { + await settingsProvider.setUseGlobalHubs(value); + }, + ); + }, + ); + } + + Widget _buildShowServerNameOnHubs() { + return Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const AppIcon(Symbols.dns_rounded, fill: 1), + title: Text(t.settings.showServerNameOnHubs), + subtitle: Text(t.settings.showServerNameOnHubsDescription), + value: settingsProvider.showServerNameOnHubs, + onChanged: (value) async { + await settingsProvider.setShowServerNameOnHubs(value); + }, + ); + }, + ); + } + + // --- Navigation section --- + + Widget _buildAlwaysKeepSidebarOpen() { + return Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const AppIcon(Symbols.dock_to_left_rounded, fill: 1), + title: Text(t.settings.alwaysKeepSidebarOpen), + subtitle: Text(t.settings.alwaysKeepSidebarOpenDescription), + value: settingsProvider.alwaysKeepSidebarOpen, + onChanged: (value) async { + await settingsProvider.setAlwaysKeepSidebarOpen(value); + }, + ); + }, + ); + } + + Widget _buildShowNavBarLabels() { + return Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const AppIcon(Symbols.label_rounded, fill: 1), + title: Text(t.settings.showNavBarLabels), + subtitle: Text(t.settings.showNavBarLabelsDescription), + value: settingsProvider.showNavBarLabels, + onChanged: (value) async { + await settingsProvider.setShowNavBarLabels(value); + }, + ); + }, + ); + } + + Widget _buildShowUnwatchedCount() { + return Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const AppIcon(Symbols.counter_1_rounded, fill: 1), + title: Text(t.settings.showUnwatchedCount), + subtitle: Text(t.settings.showUnwatchedCountDescription), + value: settingsProvider.showUnwatchedCount, + onChanged: (value) async { + await settingsProvider.setShowUnwatchedCount(value); + }, + ); + }, + ); + } + + // --- Content section --- + + Widget _buildLiveTvDefaultFavorites() { + return Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const AppIcon(Symbols.star_rounded, fill: 1), + title: Text(t.settings.liveTvDefaultFavorites), + subtitle: Text(t.settings.liveTvDefaultFavoritesDescription), + value: settingsProvider.liveTvDefaultFavorites, + onChanged: (value) async { + await settingsProvider.setLiveTvDefaultFavorites(value); + }, + ); + }, + ); + } + + Widget _buildHideSpoilers() { + return Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const AppIcon(Symbols.visibility_off_rounded, fill: 1), + title: Text(t.settings.hideSpoilers), + subtitle: Text(t.settings.hideSpoilersDescription), + value: settingsProvider.hideSpoilers, + onChanged: (value) async { + await settingsProvider.setHideSpoilers(value); + }, + ); + }, + ); + } + + Widget _buildRequireProfileSelection() { + return Consumer( + builder: (context, userProfileProvider, child) { + if (!userProfileProvider.hasMultipleUsers) return const SizedBox.shrink(); + return SwitchListTile( + secondary: const AppIcon(Symbols.person_rounded, fill: 1), + title: Text(t.settings.requireProfileSelectionOnOpen), + subtitle: Text(t.settings.requireProfileSelectionOnOpenDescription), + value: _requireProfileSelectionOnOpen, + onChanged: (value) async { + setState(() => _requireProfileSelectionOnOpen = value); + await _settingsService.setRequireProfileSelectionOnOpen(value); + }, + ); + }, + ); + } + + Widget _buildConfirmExitOnBack() { + return SwitchListTile( + secondary: const AppIcon(Symbols.exit_to_app_rounded, fill: 1), + title: Text(t.settings.confirmExitOnBack), + subtitle: Text(t.settings.confirmExitOnBackDescription), + value: _confirmExitOnBack, + onChanged: (value) async { + setState(() => _confirmExitOnBack = value); + await _settingsService.setConfirmExitOnBack(value); + }, + ); + } + + // --- Helpers --- + + String _getLanguageDisplayName(AppLocale locale) { + switch (locale) { + case AppLocale.en: + return 'English'; + case AppLocale.sv: + return 'Svenska'; + case AppLocale.fr: + return 'Français'; + case AppLocale.it: + return 'Italiano'; + case AppLocale.nl: + return 'Nederlands'; + case AppLocale.de: + return 'Deutsch'; + case AppLocale.zh: + return '中文'; + case AppLocale.ko: + return '한국어'; + case AppLocale.es: + return 'Español'; + case AppLocale.pt: + return 'Português'; + case AppLocale.ja: + return '日本語'; + case AppLocale.ru: + return 'Русский'; + case AppLocale.pl: + return 'Polski'; + case AppLocale.da: + return 'Dansk'; + case AppLocale.nb: + return 'Norsk bokmål'; + } + } + + void _restartApp() { + Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); + } +} diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart new file mode 100644 index 00000000..784d8d4c --- /dev/null +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import '../../i18n/strings.g.dart'; +import '../../models/hotkey_model.dart'; +import '../../services/keyboard_shortcuts_service.dart'; +import '../../utils/snackbar_helper.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; +import 'hotkey_recorder_widget.dart'; + +class KeyboardShortcutsScreen extends StatefulWidget { + final KeyboardShortcutsService keyboardService; + + const KeyboardShortcutsScreen({super.key, required this.keyboardService}); + + @override + State createState() => _KeyboardShortcutsScreenState(); +} + +class _KeyboardShortcutsScreenState extends State { + Map _hotkeys = {}; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadHotkeys(); + } + + Future _loadHotkeys() async { + await widget.keyboardService.refreshFromStorage(); + if (!mounted) return; + setState(() { + _hotkeys = widget.keyboardService.hotkeys; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return FocusedScrollScaffold( + title: Text(t.settings.keyboardShortcuts), + actions: [ + TextButton( + onPressed: () async { + await widget.keyboardService.resetToDefaults(); + await _loadHotkeys(); + if (mounted) { + showSuccessSnackBar(this.context, t.settings.shortcutsReset); + } + }, + child: Text(t.common.reset), + ), + ], + slivers: _isLoading + ? [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))] + : [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final actions = _hotkeys.keys.toList(); + final action = actions[index]; + final hotkey = _hotkeys[action]!; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(widget.keyboardService.getActionDisplayName(action)), + subtitle: Text(action), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)), + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: Text( + widget.keyboardService.formatHotkey(hotkey), + style: const TextStyle(fontFamily: 'monospace'), + ), + ), + onTap: () => _editHotkey(action, hotkey), + ), + ); + }, childCount: _hotkeys.length), + ), + ), + ], + ); + } + + void _editHotkey(String action, HotKey currentHotkey) { + showDialog( + context: context, + builder: (BuildContext context) { + return HotKeyRecorderWidget( + actionName: widget.keyboardService.getActionDisplayName(action), + currentHotKey: currentHotkey, + onHotKeyRecorded: (newHotkey) async { + final navigator = Navigator.of(context); + + // Check for conflicts + final existingAction = widget.keyboardService.getActionForHotkey(newHotkey); + if (existingAction != null && existingAction != action) { + navigator.pop(); + showErrorSnackBar( + context, + t.settings.shortcutAlreadyAssigned(action: widget.keyboardService.getActionDisplayName(existingAction)), + ); + return; + } + + // Save the new hotkey + await widget.keyboardService.setHotkey(action, newHotkey); + + if (mounted) { + setState(() { + _hotkeys[action] = newHotkey; + }); + + navigator.pop(); + + showSuccessSnackBar( + this.context, + t.settings.shortcutUpdated(action: widget.keyboardService.getActionDisplayName(action)), + ); + } + }, + onCancel: () => Navigator.pop(context), + ); + }, + ); + } +} diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart new file mode 100644 index 00000000..dee0e8c5 --- /dev/null +++ b/lib/screens/settings/playback_settings_screen.dart @@ -0,0 +1,553 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../i18n/strings.g.dart'; +import '../../mpv/player/platform/player_android.dart'; +import '../../services/discord_rpc_service.dart'; +import '../../services/keyboard_shortcuts_service.dart'; +import '../../services/settings_service.dart' as settings; +import '../../utils/platform_detector.dart'; +import '../../utils/snackbar_helper.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/settings_section.dart'; +import 'external_player_screen.dart'; +import 'mpv_config_screen.dart'; +import 'settings_utils.dart'; +import 'subtitle_styling_screen.dart'; + +class PlaybackSettingsScreen extends StatefulWidget { + const PlaybackSettingsScreen({super.key}); + + @override + State createState() => _PlaybackSettingsScreenState(); +} + +class _PlaybackSettingsScreenState extends State { + late settings.SettingsService _settingsService; + KeyboardShortcutsService? _keyboardService; + bool _isLoading = true; + + bool _enableHardwareDecoding = true; + int _bufferSize = 0; + int _seekTimeSmall = 10; + int _seekTimeLarge = 30; + int _rewindOnResume = 0; + int _sleepTimerDuration = 30; + bool _rememberTrackSelections = true; + bool _clickVideoTogglesPlayback = false; + bool _autoSkipIntro = false; + bool _autoSkipCredits = false; + int _autoSkipDelay = 5; + String _introPattern = settings.SettingsService.defaultIntroPattern; + String _creditsPattern = settings.SettingsService.defaultCreditsPattern; + int _maxVolume = 100; + bool _enableDiscordRPC = false; + bool _autoPip = true; + bool _matchContentFrameRate = false; + bool _tunneledPlayback = true; + bool _useExoPlayer = true; + bool _useExternalPlayer = false; + String _selectedExternalPlayerName = ''; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + _settingsService = await settings.SettingsService.getInstance(); + if (KeyboardShortcutsService.isPlatformSupported()) { + _keyboardService = await KeyboardShortcutsService.getInstance(); + } + + if (!mounted) return; + setState(() { + _enableHardwareDecoding = _settingsService.getEnableHardwareDecoding(); + _bufferSize = _settingsService.getBufferSize(); + _seekTimeSmall = _settingsService.getSeekTimeSmall(); + _seekTimeLarge = _settingsService.getSeekTimeLarge(); + _rewindOnResume = _settingsService.getRewindOnResume(); + _sleepTimerDuration = _settingsService.getSleepTimerDuration(); + _rememberTrackSelections = _settingsService.getRememberTrackSelections(); + _clickVideoTogglesPlayback = _settingsService.getClickVideoTogglesPlayback(); + _autoSkipIntro = _settingsService.getAutoSkipIntro(); + _autoSkipCredits = _settingsService.getAutoSkipCredits(); + _autoSkipDelay = _settingsService.getAutoSkipDelay(); + _introPattern = _settingsService.getIntroPattern(); + _creditsPattern = _settingsService.getCreditsPattern(); + _maxVolume = _settingsService.getMaxVolume(); + _enableDiscordRPC = _settingsService.getEnableDiscordRPC(); + _autoPip = _settingsService.getAutoPip(); + _matchContentFrameRate = _settingsService.getMatchContentFrameRate(); + _tunneledPlayback = _settingsService.getTunneledPlayback(); + _useExoPlayer = _settingsService.getUseExoPlayer(); + _useExternalPlayer = _settingsService.getUseExternalPlayer(); + _selectedExternalPlayerName = _settingsService.getSelectedExternalPlayer().name; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return FocusedScrollScaffold( + title: Text(t.settings.videoPlayback), + slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], + ); + } + + final isMobile = PlatformDetector.isMobile(context); + + return FocusedScrollScaffold( + title: Text(t.settings.videoPlayback), + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + // --- Player --- + SettingsSectionHeader(t.settings.player), + if (Platform.isAndroid) _buildPlayerBackendSelector(), + _buildExternalPlayerTile(), + _buildHardwareDecoding(), + if ((Platform.isAndroid && !PlatformDetector.isTV()) || Platform.isIOS || Platform.isMacOS) _buildAutoPip(), + if (Platform.isAndroid) _buildMatchContentFrameRate(), + if (Platform.isAndroid && _useExoPlayer) _buildTunneledPlayback(), + _buildBufferSizeSelector(), + + // --- Subtitles & Config --- + SettingsSectionHeader(t.settings.subtitlesAndConfig), + _buildSubtitleStylingTile(), + if (!Platform.isAndroid || !_useExoPlayer) _buildMpvConfigTile(), + + // --- Seek & Timing --- + SettingsSectionHeader(t.settings.seekAndTiming), + _buildSmallSkipDuration(), + _buildLargeSkipDuration(), + _buildRewindOnResume(), + _buildDefaultSleepTimer(), + _buildMaxVolume(), + + // --- Behavior --- + SettingsSectionHeader(t.settings.behavior), + if (DiscordRPCService.isAvailable) _buildDiscordRPC(), + _buildRememberTrackSelections(), + if (!isMobile) _buildClickVideoTogglesPlayback(), + + // --- Auto-Skip --- + SettingsSectionHeader(t.settings.autoSkip), + _buildAutoSkipIntro(), + _buildAutoSkipCredits(), + _buildAutoSkipDelay(), + _buildIntroPattern(), + _buildCreditsPattern(), + const SizedBox(height: 24), + ]), + ), + ], + ); + } + + // --- Player section --- + + Widget _buildPlayerBackendSelector() { + return SegmentedSetting( + icon: Symbols.play_circle_rounded, + title: t.settings.playerBackend, + segments: [ + ButtonSegment(value: true, label: Text(t.settings.exoPlayer)), + ButtonSegment(value: false, label: Text(t.settings.mpv)), + ], + selected: _useExoPlayer, + onChanged: (value) async { + setState(() => _useExoPlayer = value); + await _settingsService.setUseExoPlayer(value); + }, + ); + } + + Widget _buildExternalPlayerTile() { + return ListTile( + leading: const AppIcon(Symbols.open_in_new_rounded, fill: 1), + title: Text(t.externalPlayer.title), + subtitle: Text(_useExternalPlayer ? _selectedExternalPlayerName : t.externalPlayer.off), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => const ExternalPlayerScreen())); + final s = await settings.SettingsService.getInstance(); + if (!mounted) return; + setState(() { + _useExternalPlayer = s.getUseExternalPlayer(); + _selectedExternalPlayerName = s.getSelectedExternalPlayer().name; + }); + }, + ); + } + + Widget _buildHardwareDecoding() { + return SwitchListTile( + secondary: const AppIcon(Symbols.hardware_rounded, fill: 1), + title: Text(t.settings.hardwareDecoding), + subtitle: Text(t.settings.hardwareDecodingDescription), + value: _enableHardwareDecoding, + onChanged: (value) async { + setState(() => _enableHardwareDecoding = value); + await _settingsService.setEnableHardwareDecoding(value); + }, + ); + } + + Widget _buildAutoPip() { + return SwitchListTile( + secondary: const AppIcon(Symbols.picture_in_picture_alt_rounded, fill: 1), + title: Text(t.settings.autoPip), + subtitle: Text(t.settings.autoPipDescription), + value: _autoPip, + onChanged: (value) async { + setState(() => _autoPip = value); + await _settingsService.setAutoPip(value); + }, + ); + } + + Widget _buildMatchContentFrameRate() { + return SwitchListTile( + secondary: const AppIcon(Symbols.display_settings_rounded, fill: 1), + title: Text(t.settings.matchContentFrameRate), + subtitle: Text(t.settings.matchContentFrameRateDescription), + value: _matchContentFrameRate, + onChanged: (value) async { + setState(() => _matchContentFrameRate = value); + await _settingsService.setMatchContentFrameRate(value); + }, + ); + } + + Widget _buildTunneledPlayback() { + return SwitchListTile( + secondary: const AppIcon(Symbols.tv_options_input_settings_rounded, fill: 1), + title: Text(t.settings.tunneledPlayback), + subtitle: Text(t.settings.tunneledPlaybackDescription), + value: _tunneledPlayback, + onChanged: (value) async { + setState(() => _tunneledPlayback = value); + await _settingsService.setTunneledPlayback(value); + }, + ); + } + + Widget _buildBufferSizeSelector() { + return ListTile( + leading: const AppIcon(Symbols.memory_rounded, fill: 1), + title: Text(t.settings.bufferSize), + subtitle: Text( + _bufferSize == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: _bufferSize.toString()), + ), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () async { + final bufferOptions = [0, 64, 128, 256, 512, 1024]; + final value = await showSelectionDialog( + context: context, + title: t.settings.bufferSize, + options: bufferOptions + .map((size) => DialogOption(value: size, title: size == 0 ? t.settings.bufferSizeAuto : '${size}MB')) + .toList(), + currentValue: _bufferSize, + ); + if (value != null) { + setState(() { + _bufferSize = value; + _settingsService.setBufferSize(value); + }); + if (Platform.isAndroid && value > 0) { + final heapMB = await PlayerAndroid.getHeapSize(); + if (heapMB > 0 && value > heapMB ~/ 4 && mounted) { + showAppSnackBar( + context, + t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString()), + ); + } + } + } + }, + ); + } + + // --- Subtitles & Config section --- + + Widget _buildSubtitleStylingTile() { + return ListTile( + leading: const AppIcon(Symbols.subtitles_rounded, fill: 1), + title: Text(t.settings.subtitleStyling), + subtitle: Text(t.settings.subtitleStylingDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const SubtitleStylingScreen())); + }, + ); + } + + Widget _buildMpvConfigTile() { + return ListTile( + leading: const AppIcon(Symbols.tune_rounded, fill: 1), + title: Text(t.mpvConfig.title), + subtitle: Text(t.mpvConfig.description), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const MpvConfigScreen())); + }, + ); + } + + // --- Seek & Timing section --- + + Widget _buildSmallSkipDuration() { + return ListTile( + leading: const AppIcon(Symbols.replay_10_rounded, fill: 1), + title: Text(t.settings.smallSkipDuration), + subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeSmall.toString())), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showNumericInputDialog( + context: context, + title: t.settings.smallSkipDuration, + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 120, + currentValue: _seekTimeSmall, + onSave: (value) async { + setState(() { + _seekTimeSmall = value; + _settingsService.setSeekTimeSmall(value); + }); + await _keyboardService?.refreshFromStorage(); + }, + ), + ); + } + + Widget _buildLargeSkipDuration() { + return ListTile( + leading: const AppIcon(Symbols.replay_30_rounded, fill: 1), + title: Text(t.settings.largeSkipDuration), + subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeLarge.toString())), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showNumericInputDialog( + context: context, + title: t.settings.largeSkipDuration, + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 120, + currentValue: _seekTimeLarge, + onSave: (value) async { + setState(() { + _seekTimeLarge = value; + _settingsService.setSeekTimeLarge(value); + }); + await _keyboardService?.refreshFromStorage(); + }, + ), + ); + } + + Widget _buildRewindOnResume() { + return ListTile( + leading: const AppIcon(Symbols.replay_rounded, fill: 1), + title: Text(t.settings.rewindOnResume), + subtitle: Text(t.settings.secondsUnit(seconds: _rewindOnResume.toString())), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showNumericInputDialog( + context: context, + title: t.settings.rewindOnResume, + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 0, + max: 10, + currentValue: _rewindOnResume, + onSave: (value) async { + setState(() { + _rewindOnResume = value; + _settingsService.setRewindOnResume(value); + }); + }, + ), + ); + } + + Widget _buildDefaultSleepTimer() { + return ListTile( + leading: const AppIcon(Symbols.bedtime_rounded, fill: 1), + title: Text(t.settings.defaultSleepTimer), + subtitle: Text(t.settings.minutesUnit(minutes: _sleepTimerDuration.toString())), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showNumericInputDialog( + context: context, + title: t.settings.defaultSleepTimer, + labelText: t.settings.minutesLabel, + suffixText: t.settings.minutesShort, + min: 5, + max: 240, + currentValue: _sleepTimerDuration, + onSave: (value) async { + setState(() => _sleepTimerDuration = value); + await _settingsService.setSleepTimerDuration(value); + }, + ), + ); + } + + Widget _buildMaxVolume() { + return ListTile( + leading: const AppIcon(Symbols.volume_up_rounded, fill: 1), + title: Text(t.settings.maxVolume), + subtitle: Text(t.settings.maxVolumePercent(percent: _maxVolume.toString())), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showNumericInputDialog( + context: context, + title: t.settings.maxVolume, + labelText: t.settings.maxVolumeDescription, + suffixText: '%', + min: 100, + max: 300, + currentValue: _maxVolume, + onSave: (value) async { + setState(() => _maxVolume = value); + await _settingsService.setMaxVolume(value); + }, + ), + ); + } + + // --- Behavior section --- + + Widget _buildDiscordRPC() { + return SwitchListTile( + secondary: const AppIcon(Symbols.chat_rounded, fill: 1), + title: Text(t.settings.discordRichPresence), + subtitle: Text(t.settings.discordRichPresenceDescription), + value: _enableDiscordRPC, + onChanged: (value) async { + setState(() => _enableDiscordRPC = value); + await _settingsService.setEnableDiscordRPC(value); + await DiscordRPCService.instance.setEnabled(value); + }, + ); + } + + Widget _buildRememberTrackSelections() { + return SwitchListTile( + secondary: const AppIcon(Symbols.bookmark_rounded, fill: 1), + title: Text(t.settings.rememberTrackSelections), + subtitle: Text(t.settings.rememberTrackSelectionsDescription), + value: _rememberTrackSelections, + onChanged: (value) async { + setState(() => _rememberTrackSelections = value); + await _settingsService.setRememberTrackSelections(value); + }, + ); + } + + Widget _buildClickVideoTogglesPlayback() { + return SwitchListTile( + secondary: const AppIcon(Symbols.play_pause_rounded, fill: 1), + title: Text(t.settings.clickVideoTogglesPlayback), + subtitle: Text(t.settings.clickVideoTogglesPlaybackDescription), + value: _clickVideoTogglesPlayback, + onChanged: (value) async { + setState(() => _clickVideoTogglesPlayback = value); + await _settingsService.setClickVideoTogglesPlayback(value); + }, + ); + } + + // --- Auto-Skip section --- + + Widget _buildAutoSkipIntro() { + return SwitchListTile( + secondary: const AppIcon(Symbols.fast_forward_rounded, fill: 1), + title: Text(t.settings.autoSkipIntro), + subtitle: Text(t.settings.autoSkipIntroDescription), + value: _autoSkipIntro, + onChanged: (value) async { + setState(() => _autoSkipIntro = value); + await _settingsService.setAutoSkipIntro(value); + }, + ); + } + + Widget _buildAutoSkipCredits() { + return SwitchListTile( + secondary: const AppIcon(Symbols.skip_next_rounded, fill: 1), + title: Text(t.settings.autoSkipCredits), + subtitle: Text(t.settings.autoSkipCreditsDescription), + value: _autoSkipCredits, + onChanged: (value) async { + setState(() => _autoSkipCredits = value); + await _settingsService.setAutoSkipCredits(value); + }, + ); + } + + Widget _buildAutoSkipDelay() { + return ListTile( + leading: const AppIcon(Symbols.timer_rounded, fill: 1), + title: Text(t.settings.autoSkipDelay), + subtitle: Text(t.settings.autoSkipDelayDescription(seconds: _autoSkipDelay.toString())), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showNumericInputDialog( + context: context, + title: t.settings.autoSkipDelay, + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 30, + currentValue: _autoSkipDelay, + onSave: (value) async { + setState(() => _autoSkipDelay = value); + await _settingsService.setAutoSkipDelay(value); + }, + ), + ); + } + + Widget _buildIntroPattern() { + return ListTile( + leading: const AppIcon(Symbols.match_case_rounded, fill: 1), + title: Text(t.settings.introPattern), + subtitle: Text(t.settings.introPatternDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showTextInputDialog( + context: context, + title: t.settings.introPattern, + currentValue: _introPattern, + defaultValue: settings.SettingsService.defaultIntroPattern, + onSave: (value) async { + setState(() => _introPattern = value); + await _settingsService.setIntroPattern(value); + }, + ), + ); + } + + Widget _buildCreditsPattern() { + return ListTile( + leading: const AppIcon(Symbols.match_case_rounded, fill: 1), + title: Text(t.settings.creditsPattern), + subtitle: Text(t.settings.creditsPatternDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => showTextInputDialog( + context: context, + title: t.settings.creditsPattern, + currentValue: _creditsPattern, + defaultValue: settings.SettingsService.defaultCreditsPattern, + onSave: (value) async { + setState(() => _creditsPattern = value); + await _settingsService.setCreditsPattern(value); + }, + ), + ); + } +} diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 62ab9d41..fdc61cf2 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -6,52 +6,30 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../models/hotkey_model.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../focus/dpad_navigator.dart'; import '../../focus/focus_memory_tracker.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; -import '../../utils/focus_utils.dart'; import '../main_screen.dart'; import '../../mixins/refreshable.dart'; -import '../../services/discord_rpc_service.dart'; import '../../services/download_storage_service.dart'; import '../../services/saf_storage_service.dart'; import '../../providers/settings_provider.dart'; import '../../providers/theme_provider.dart'; -import '../../providers/user_profile_provider.dart'; import '../../services/keyboard_shortcuts_service.dart'; -import '../../mpv/player/platform/player_android.dart'; import '../../services/settings_service.dart' as settings; import '../../services/update_service.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; -import '../../widgets/focusable_list_tile.dart'; -import '../../widgets/focused_scroll_scaffold.dart'; -import '../../widgets/overlay_sheet.dart'; -import '../../widgets/tv_number_spinner.dart'; -import 'hotkey_recorder_widget.dart'; -import '../../providers/companion_remote_provider.dart'; -import '../../screens/companion_remote/mobile_remote_screen.dart'; -import '../../widgets/companion_remote/remote_session_dialog.dart'; +import '../../widgets/settings_section.dart'; import 'about_screen.dart'; -import 'external_player_screen.dart'; +import 'appearance_settings_screen.dart'; +import 'keyboard_shortcuts_screen.dart'; import 'logs_screen.dart'; -import 'mpv_config_screen.dart'; -import 'subtitle_styling_screen.dart'; - -/// Helper class for option selection dialog items -class _DialogOption { - final T value; - final String title; - final String? subtitle; - - const _DialogOption({required this.value, required this.title, this.subtitle}); -} +import 'playback_settings_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -64,44 +42,9 @@ class _SettingsScreenState extends State with FocusableTab { late settings.SettingsService _settingsService; late final FocusMemoryTracker _focusTracker; - // Setting keys for focus tracking - static const _kTheme = 'theme'; - static const _kLanguage = 'language'; - static const _kLibraryDensity = 'library_density'; - static const _kViewMode = 'view_mode'; - static const _kEpisodePosterMode = 'episode_poster_mode'; - static const _kShowHeroSection = 'show_hero_section'; - static const _kUseGlobalHubs = 'use_global_hubs'; - static const _kShowServerNameOnHubs = 'show_server_name_on_hubs'; - static const _kAlwaysKeepSidebarOpen = 'always_keep_sidebar_open'; - static const _kShowUnwatchedCount = 'show_unwatched_count'; - static const _kHideSpoilers = 'hide_spoilers'; - static const _kShowNavBarLabels = 'show_nav_bar_labels'; - static const _kLiveTvDefaultFavorites = 'live_tv_default_favorites'; - static const _kRequireProfileSelectionOnOpen = 'require_profile_selection_on_open'; - static const _kConfirmExitOnBack = 'confirm_exit_on_back'; - static const _kPlayerBackend = 'player_backend'; - static const _kExternalPlayer = 'external_player'; - static const _kHardwareDecoding = 'hardware_decoding'; - static const _kAutoPip = 'auto_pip'; - static const _kMatchContentFrameRate = 'match_content_frame_rate'; - static const _kTunneledPlayback = 'tunneled_playback'; - static const _kBufferSize = 'buffer_size'; - static const _kSubtitleStyling = 'subtitle_styling'; - static const _kMpvConfig = 'mpv_config'; - static const _kSmallSkipDuration = 'small_skip_duration'; - static const _kLargeSkipDuration = 'large_skip_duration'; - static const _kRewindOnResume = 'rewind_on_resume'; - static const _kDefaultSleepTimer = 'default_sleep_timer'; - static const _kMaxVolume = 'max_volume'; - static const _kDiscordRichPresence = 'discord_rich_presence'; - static const _kRememberTrackSelections = 'remember_track_selections'; - static const _kClickVideoTogglesPlayback = 'click_video_toggles_playback'; - static const _kAutoSkipIntro = 'auto_skip_intro'; - static const _kAutoSkipCredits = 'auto_skip_credits'; - static const _kAutoSkipDelay = 'auto_skip_delay'; - static const _kIntroPattern = 'intro_pattern'; - static const _kCreditsPattern = 'credits_pattern'; + // Focus tracking keys + static const _kAppearance = 'appearance'; + static const _kPlayback = 'playback'; static const _kDownloadLocation = 'download_location'; static const _kDownloadOnWifiOnly = 'download_on_wifi_only'; static const _kVideoPlayerControls = 'video_player_controls'; @@ -114,37 +57,15 @@ class _SettingsScreenState extends State with FocusableTab { static const _kCheckForUpdates = 'check_for_updates'; static const _kAbout = 'about'; static const _kWatchTogetherRelay = 'watch_together_relay'; + KeyboardShortcutsService? _keyboardService; late final bool _keyboardShortcutsSupported = KeyboardShortcutsService.isPlatformSupported(); bool _isLoading = true; bool _crashReporting = true; bool _enableDebugLogging = false; - bool _enableHardwareDecoding = true; - int _bufferSize = 0; - int _seekTimeSmall = 10; - int _seekTimeLarge = 30; - int _rewindOnResume = 0; - int _sleepTimerDuration = 30; - bool _rememberTrackSelections = true; - bool _clickVideoTogglesPlayback = false; - bool _autoSkipIntro = false; - bool _autoSkipCredits = false; - int _autoSkipDelay = 5; - String _introPattern = settings.SettingsService.defaultIntroPattern; - String _creditsPattern = settings.SettingsService.defaultCreditsPattern; bool _downloadOnWifiOnly = false; bool _videoPlayerNavigationEnabled = false; - int _maxVolume = 100; - bool _enableDiscordRPC = false; - bool _autoPip = true; - bool _matchContentFrameRate = false; - bool _tunneledPlayback = true; - bool _useExoPlayer = true; // Android only: ExoPlayer vs MPV - bool _requireProfileSelectionOnOpen = false; - bool _useExternalPlayer = false; - bool _confirmExitOnBack = true; - String _selectedExternalPlayerName = ''; String? _customRelayUrl; // Update checking state @@ -173,16 +94,14 @@ class _SettingsScreenState extends State with FocusableTab { @override void focusActiveTabIfReady() { if (InputModeTracker.isKeyboardMode(context)) { - _focusTracker.restoreFocus(fallbackKey: _kTheme); + _focusTracker.restoreFocus(fallbackKey: _kAppearance); } } - /// Navigate focus to the sidebar void _navigateToSidebar() { MainScreenFocusScope.of(context)?.focusSidebar(); } - /// Handle key events for LEFT arrow → sidebar navigation KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowLeft) { _navigateToSidebar(); @@ -201,31 +120,8 @@ class _SettingsScreenState extends State with FocusableTab { setState(() { _crashReporting = _settingsService.getCrashReporting(); _enableDebugLogging = _settingsService.getEnableDebugLogging(); - _enableHardwareDecoding = _settingsService.getEnableHardwareDecoding(); - _bufferSize = _settingsService.getBufferSize(); - _seekTimeSmall = _settingsService.getSeekTimeSmall(); - _seekTimeLarge = _settingsService.getSeekTimeLarge(); - _rewindOnResume = _settingsService.getRewindOnResume(); - _sleepTimerDuration = _settingsService.getSleepTimerDuration(); - _rememberTrackSelections = _settingsService.getRememberTrackSelections(); - _clickVideoTogglesPlayback = _settingsService.getClickVideoTogglesPlayback(); - _autoSkipIntro = _settingsService.getAutoSkipIntro(); - _autoSkipCredits = _settingsService.getAutoSkipCredits(); - _autoSkipDelay = _settingsService.getAutoSkipDelay(); - _introPattern = _settingsService.getIntroPattern(); - _creditsPattern = _settingsService.getCreditsPattern(); _downloadOnWifiOnly = _settingsService.getDownloadOnWifiOnly(); _videoPlayerNavigationEnabled = _settingsService.getVideoPlayerNavigationEnabled(); - _maxVolume = _settingsService.getMaxVolume(); - _enableDiscordRPC = _settingsService.getEnableDiscordRPC(); - _autoPip = _settingsService.getAutoPip(); - _matchContentFrameRate = _settingsService.getMatchContentFrameRate(); - _tunneledPlayback = _settingsService.getTunneledPlayback(); - _useExoPlayer = _settingsService.getUseExoPlayer(); - _requireProfileSelectionOnOpen = _settingsService.getRequireProfileSelectionOnOpen(); - _useExternalPlayer = _settingsService.getUseExternalPlayer(); - _selectedExternalPlayerName = _settingsService.getSelectedExternalPlayer().name; - _confirmExitOnBack = _settingsService.getConfirmExitOnBack(); _customRelayUrl = _settingsService.getCustomRelayUrl(); _isLoading = false; }); @@ -244,26 +140,43 @@ class _SettingsScreenState extends State with FocusableTab { primary: false, slivers: [ ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)), - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - _buildAppearanceSection(), - const SizedBox(height: 24), - _buildVideoPlaybackSection(), - const SizedBox(height: 24), - _buildDownloadsSection(), - const SizedBox(height: 24), - if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection(), const SizedBox(height: 24)], - _buildCompanionRemoteSection(), - const SizedBox(height: 24), - _buildAdvancedSection(), - const SizedBox(height: 24), - if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection(), const SizedBox(height: 24)], - _buildAboutSection(), - const SizedBox(height: 24), - ]), - ), + SliverList( + delegate: SliverChildListDelegate([ + // --- Appearance (navigation tile) --- + _buildAppearanceTile(), + + // --- Playback (navigation tile) --- + _buildPlaybackTile(), + + // --- Downloads (inline) --- + _buildDownloadsSection(), + + // --- Keyboard Shortcuts (inline, conditional) --- + if (_keyboardShortcutsSupported) ...[ + _buildKeyboardShortcutsSection(), + ], + + // --- Advanced (inline) --- + _buildAdvancedSection(), + + // --- Updates (conditional) --- + if (UpdateService.isUpdateCheckEnabled) ...[ + _buildUpdateSection(), + ], + + // --- About --- + ListTile( + focusNode: _focusTracker.get(_kAbout), + leading: const AppIcon(Symbols.info_rounded, fill: 1), + title: Text(t.settings.about), + subtitle: Text(t.settings.aboutDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const AboutScreen())); + }, + ), + const SizedBox(height: 24), + ]), ), ], ), @@ -271,679 +184,34 @@ class _SettingsScreenState extends State with FocusableTab { ); } - Widget _buildAppearanceSection() { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.settings.appearance, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Consumer( - builder: (context, themeProvider, child) { - return Builder( - builder: (tileContext) => ListTile( - focusNode: _focusTracker.get(_kTheme), - leading: AppIcon(themeProvider.themeModeIcon, fill: 1), - title: Text(t.settings.theme), - subtitle: Text(themeProvider.themeModeDisplayName), - trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), - onTap: () async { - final value = await _showSettingsMenu( - tileContext: tileContext, - title: t.settings.theme, - options: [ - _DialogOption( - value: settings.ThemeMode.system, - title: t.settings.systemTheme, - subtitle: t.settings.systemThemeDescription, - ), - _DialogOption(value: settings.ThemeMode.light, title: t.settings.lightTheme), - _DialogOption(value: settings.ThemeMode.dark, title: t.settings.darkTheme), - _DialogOption( - value: settings.ThemeMode.oled, - title: t.settings.oledTheme, - subtitle: t.settings.oledThemeDescription, - ), - ], - currentValue: themeProvider.themeMode, - ); - if (value != null) { - themeProvider.setThemeMode(value); - } - }, - ), - ); - }, - ), - Builder( - builder: (tileContext) => ListTile( - focusNode: _focusTracker.get(_kLanguage), - leading: const AppIcon(Symbols.language_rounded, fill: 1), - title: Text(t.settings.language), - subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)), - trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), - onTap: () async { - final value = await _showSettingsMenu( - tileContext: tileContext, - title: t.settings.language, - options: AppLocale.values - .map((locale) => _DialogOption(value: locale, title: _getLanguageDisplayName(locale))) - .toList(), - currentValue: LocaleSettings.currentLocale, - ); - if (value != null) { - await _settingsService.setAppLocale(value); - LocaleSettings.setLocale(value); - _restartApp(); - } - }, - ), - ), - Consumer( - builder: (context, settingsProvider, child) { - return Builder( - builder: (tileContext) => ListTile( - focusNode: _focusTracker.get(_kLibraryDensity), - leading: const AppIcon(Symbols.grid_view_rounded, fill: 1), - title: Text(t.settings.libraryDensity), - subtitle: Text(settingsProvider.libraryDensityDisplayName), - trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), - onTap: () async { - final value = await _showSettingsMenu( - tileContext: tileContext, - title: t.settings.libraryDensity, - options: [ - _DialogOption( - value: settings.LibraryDensity.compact, - title: t.settings.compact, - subtitle: t.settings.compactDescription, - ), - _DialogOption( - value: settings.LibraryDensity.normal, - title: t.settings.normal, - subtitle: t.settings.normalDescription, - ), - _DialogOption( - value: settings.LibraryDensity.comfortable, - title: t.settings.comfortable, - subtitle: t.settings.comfortableDescription, - ), - ], - currentValue: settingsProvider.libraryDensity, - ); - if (value != null) { - settingsProvider.setLibraryDensity(value); - } - }, - ), - ); - }, - ), - Consumer( - builder: (context, settingsProvider, child) { - return Builder( - builder: (tileContext) => ListTile( - focusNode: _focusTracker.get(_kViewMode), - leading: const AppIcon(Symbols.view_list_rounded, fill: 1), - title: Text(t.settings.viewMode), - subtitle: Text( - settingsProvider.viewMode == settings.ViewMode.grid ? t.settings.gridView : t.settings.listView, - ), - trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), - onTap: () async { - final value = await _showSettingsMenu( - tileContext: tileContext, - title: t.settings.viewMode, - options: [ - _DialogOption( - value: settings.ViewMode.grid, - title: t.settings.gridView, - subtitle: t.settings.gridViewDescription, - ), - _DialogOption( - value: settings.ViewMode.list, - title: t.settings.listView, - subtitle: t.settings.listViewDescription, - ), - ], - currentValue: settingsProvider.viewMode, - ); - if (value != null) { - settingsProvider.setViewMode(value); - } - }, - ), - ); - }, - ), - Consumer( - builder: (context, settingsProvider, child) { - return Builder( - builder: (tileContext) => ListTile( - focusNode: _focusTracker.get(_kEpisodePosterMode), - leading: const AppIcon(Symbols.image_rounded, fill: 1), - title: Text(t.settings.episodePosterMode), - subtitle: Text(settingsProvider.episodePosterModeDisplayName), - trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), - onTap: () async { - final value = await _showSettingsMenu( - tileContext: tileContext, - title: t.settings.episodePosterMode, - options: [ - _DialogOption( - value: settings.EpisodePosterMode.seriesPoster, - title: t.settings.seriesPoster, - subtitle: t.settings.seriesPosterDescription, - ), - _DialogOption( - value: settings.EpisodePosterMode.seasonPoster, - title: t.settings.seasonPoster, - subtitle: t.settings.seasonPosterDescription, - ), - _DialogOption( - value: settings.EpisodePosterMode.episodeThumbnail, - title: t.settings.episodeThumbnail, - subtitle: t.settings.episodeThumbnailDescription, - ), - ], - currentValue: settingsProvider.episodePosterMode, - ); - if (value != null) { - settingsProvider.setEpisodePosterMode(value); - } - }, - ), - ); - }, - ), - Consumer( - builder: (context, settingsProvider, child) { - return SwitchListTile( - focusNode: _focusTracker.get(_kShowHeroSection), - secondary: const AppIcon(Symbols.featured_play_list_rounded, fill: 1), - title: Text(t.settings.showHeroSection), - subtitle: Text(t.settings.showHeroSectionDescription), - value: settingsProvider.showHeroSection, - onChanged: (value) async { - await settingsProvider.setShowHeroSection(value); - }, - ); - }, - ), - Consumer( - builder: (context, settingsProvider, child) { - return SwitchListTile( - focusNode: _focusTracker.get(_kUseGlobalHubs), - secondary: const AppIcon(Symbols.home_rounded, fill: 1), - title: Text(t.settings.useGlobalHubs), - subtitle: Text(t.settings.useGlobalHubsDescription), - value: settingsProvider.useGlobalHubs, - onChanged: (value) async { - await settingsProvider.setUseGlobalHubs(value); - }, - ); - }, - ), - Consumer( - builder: (context, settingsProvider, child) { - return SwitchListTile( - focusNode: _focusTracker.get(_kShowServerNameOnHubs), - secondary: const AppIcon(Symbols.dns_rounded, fill: 1), - title: Text(t.settings.showServerNameOnHubs), - subtitle: Text(t.settings.showServerNameOnHubsDescription), - value: settingsProvider.showServerNameOnHubs, - onChanged: (value) async { - await settingsProvider.setShowServerNameOnHubs(value); - }, - ); - }, - ), - if (PlatformDetector.shouldUseSideNavigation(context)) - Consumer( - builder: (context, settingsProvider, child) { - return SwitchListTile( - focusNode: _focusTracker.get(_kAlwaysKeepSidebarOpen), - secondary: const AppIcon(Symbols.dock_to_left_rounded, fill: 1), - title: Text(t.settings.alwaysKeepSidebarOpen), - subtitle: Text(t.settings.alwaysKeepSidebarOpenDescription), - value: settingsProvider.alwaysKeepSidebarOpen, - onChanged: (value) async { - await settingsProvider.setAlwaysKeepSidebarOpen(value); - }, - ); - }, - ), - if (!PlatformDetector.shouldUseSideNavigation(context)) - Consumer( - builder: (context, settingsProvider, child) { - return SwitchListTile( - focusNode: _focusTracker.get(_kShowNavBarLabels), - secondary: const AppIcon(Symbols.label_rounded, fill: 1), - title: Text(t.settings.showNavBarLabels), - subtitle: Text(t.settings.showNavBarLabelsDescription), - value: settingsProvider.showNavBarLabels, - onChanged: (value) async { - await settingsProvider.setShowNavBarLabels(value); - }, - ); - }, - ), - Consumer( - builder: (context, settingsProvider, child) { - return SwitchListTile( - focusNode: _focusTracker.get(_kLiveTvDefaultFavorites), - secondary: const AppIcon(Symbols.star_rounded, fill: 1), - title: Text(t.settings.liveTvDefaultFavorites), - subtitle: Text(t.settings.liveTvDefaultFavoritesDescription), - value: settingsProvider.liveTvDefaultFavorites, - onChanged: (value) async { - await settingsProvider.setLiveTvDefaultFavorites(value); - }, - ); - }, - ), - Consumer( - builder: (context, settingsProvider, child) { - return SwitchListTile( - focusNode: _focusTracker.get(_kShowUnwatchedCount), - secondary: const AppIcon(Symbols.counter_1_rounded, fill: 1), - title: Text(t.settings.showUnwatchedCount), - subtitle: Text(t.settings.showUnwatchedCountDescription), - value: settingsProvider.showUnwatchedCount, - onChanged: (value) async { - await settingsProvider.setShowUnwatchedCount(value); - }, - ); - }, - ), - Consumer( - builder: (context, settingsProvider, child) { - return SwitchListTile( - focusNode: _focusTracker.get(_kHideSpoilers), - secondary: const AppIcon(Symbols.visibility_off_rounded, fill: 1), - title: Text(t.settings.hideSpoilers), - subtitle: Text(t.settings.hideSpoilersDescription), - value: settingsProvider.hideSpoilers, - onChanged: (value) async { - await settingsProvider.setHideSpoilers(value); - }, - ); - }, - ), - Consumer( - builder: (context, userProfileProvider, child) { - if (!userProfileProvider.hasMultipleUsers) return const SizedBox.shrink(); - return SwitchListTile( - focusNode: _focusTracker.get(_kRequireProfileSelectionOnOpen), - secondary: const AppIcon(Symbols.person_rounded, fill: 1), - title: Text(t.settings.requireProfileSelectionOnOpen), - subtitle: Text(t.settings.requireProfileSelectionOnOpenDescription), - value: _requireProfileSelectionOnOpen, - onChanged: (value) async { - setState(() => _requireProfileSelectionOnOpen = value); - await _settingsService.setRequireProfileSelectionOnOpen(value); - }, - ); - }, - ), - if (PlatformDetector.isTV()) - SwitchListTile( - focusNode: _focusTracker.get(_kConfirmExitOnBack), - secondary: const AppIcon(Symbols.exit_to_app_rounded, fill: 1), - title: Text(t.settings.confirmExitOnBack), - subtitle: Text(t.settings.confirmExitOnBackDescription), - value: _confirmExitOnBack, - onChanged: (value) async { - setState(() => _confirmExitOnBack = value); - await _settingsService.setConfirmExitOnBack(value); - }, - ), - ], - ), + Widget _buildAppearanceTile() { + return Consumer2( + builder: (context, themeProvider, settingsProvider, child) { + final summary = '${themeProvider.themeModeDisplayName} · ${settingsProvider.libraryDensityDisplayName}'; + return ListTile( + focusNode: _focusTracker.get(_kAppearance), + leading: const AppIcon(Symbols.palette_rounded, fill: 1), + title: Text(t.settings.appearance), + subtitle: Text(summary), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const AppearanceSettingsScreen())); + }, + ); + }, ); } - Widget _buildVideoPlaybackSection() { - final isMobile = PlatformDetector.isMobile(context); - - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.settings.videoPlayback, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - if (Platform.isAndroid) - Builder( - builder: (tileContext) => ListTile( - focusNode: _focusTracker.get(_kPlayerBackend), - leading: const AppIcon(Symbols.play_circle_rounded, fill: 1), - title: Text(t.settings.playerBackend), - subtitle: Text(_useExoPlayer ? t.settings.exoPlayerDescription : t.settings.mpvDescription), - trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), - onTap: () async { - final value = await _showSettingsMenu( - tileContext: tileContext, - title: t.settings.playerBackend, - options: [ - _DialogOption( - value: true, - title: t.settings.exoPlayer, - subtitle: t.settings.exoPlayerDescription, - ), - _DialogOption(value: false, title: t.settings.mpv, subtitle: t.settings.mpvDescription), - ], - currentValue: _useExoPlayer, - ); - if (value != null) { - setState(() => _useExoPlayer = value); - await _settingsService.setUseExoPlayer(value); - } - }, - ), - ), - ListTile( - focusNode: _focusTracker.get(_kExternalPlayer), - leading: const AppIcon(Symbols.open_in_new_rounded, fill: 1), - title: Text(t.externalPlayer.title), - subtitle: Text(_useExternalPlayer ? _selectedExternalPlayerName : t.externalPlayer.off), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () async { - await Navigator.push(context, MaterialPageRoute(builder: (context) => const ExternalPlayerScreen())); - // Reload to reflect any changes - final s = await settings.SettingsService.getInstance(); - if (!mounted) return; - setState(() { - _useExternalPlayer = s.getUseExternalPlayer(); - _selectedExternalPlayerName = s.getSelectedExternalPlayer().name; - }); - }, - ), - SwitchListTile( - focusNode: _focusTracker.get(_kHardwareDecoding), - secondary: const AppIcon(Symbols.hardware_rounded, fill: 1), - title: Text(t.settings.hardwareDecoding), - subtitle: Text(t.settings.hardwareDecodingDescription), - value: _enableHardwareDecoding, - onChanged: (value) async { - setState(() { - _enableHardwareDecoding = value; - }); - await _settingsService.setEnableHardwareDecoding(value); - }, - ), - if ((Platform.isAndroid && !PlatformDetector.isTV()) || Platform.isIOS || Platform.isMacOS) - SwitchListTile( - focusNode: _focusTracker.get(_kAutoPip), - secondary: const AppIcon(Symbols.picture_in_picture_alt_rounded, fill: 1), - title: Text(t.settings.autoPip), - subtitle: Text(t.settings.autoPipDescription), - value: _autoPip, - onChanged: (value) async { - setState(() { - _autoPip = value; - }); - await _settingsService.setAutoPip(value); - }, - ), - if (Platform.isAndroid) - SwitchListTile( - focusNode: _focusTracker.get(_kMatchContentFrameRate), - secondary: const AppIcon(Symbols.display_settings_rounded, fill: 1), - title: Text(t.settings.matchContentFrameRate), - subtitle: Text(t.settings.matchContentFrameRateDescription), - value: _matchContentFrameRate, - onChanged: (value) async { - setState(() { - _matchContentFrameRate = value; - }); - await _settingsService.setMatchContentFrameRate(value); - }, - ), - if (Platform.isAndroid && _useExoPlayer) - SwitchListTile( - focusNode: _focusTracker.get(_kTunneledPlayback), - secondary: const AppIcon(Symbols.tv_options_input_settings_rounded, fill: 1), - title: Text(t.settings.tunneledPlayback), - subtitle: Text(t.settings.tunneledPlaybackDescription), - value: _tunneledPlayback, - onChanged: (value) async { - setState(() { - _tunneledPlayback = value; - }); - await _settingsService.setTunneledPlayback(value); - }, - ), - Builder( - builder: (tileContext) => ListTile( - focusNode: _focusTracker.get(_kBufferSize), - leading: const AppIcon(Symbols.memory_rounded, fill: 1), - title: Text(t.settings.bufferSize), - subtitle: Text( - _bufferSize == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: _bufferSize.toString()), - ), - trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), - onTap: () async { - final bufferOptions = [0, 64, 128, 256, 512, 1024]; - final value = await _showSettingsMenu( - tileContext: tileContext, - title: t.settings.bufferSize, - options: bufferOptions - .map( - (size) => - _DialogOption(value: size, title: size == 0 ? t.settings.bufferSizeAuto : '${size}MB'), - ) - .toList(), - currentValue: _bufferSize, - ); - if (value != null) { - setState(() { - _bufferSize = value; - _settingsService.setBufferSize(value); - }); - if (Platform.isAndroid && value > 0) { - final heapMB = await PlayerAndroid.getHeapSize(); - if (heapMB > 0 && value > heapMB ~/ 4 && mounted) { - showAppSnackBar( - context, - t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString()), - ); - } - } - } - }, - ), - ), - ListTile( - focusNode: _focusTracker.get(_kSubtitleStyling), - leading: const AppIcon(Symbols.subtitles_rounded, fill: 1), - title: Text(t.settings.subtitleStyling), - subtitle: Text(t.settings.subtitleStylingDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const SubtitleStylingScreen())); - }, - ), - // MPV Config is only available when using MPV player backend - if (!Platform.isAndroid || !_useExoPlayer) - ListTile( - focusNode: _focusTracker.get(_kMpvConfig), - leading: const AppIcon(Symbols.tune_rounded, fill: 1), - title: Text(t.mpvConfig.title), - subtitle: Text(t.mpvConfig.description), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const MpvConfigScreen())); - }, - ), - ListTile( - focusNode: _focusTracker.get(_kSmallSkipDuration), - leading: const AppIcon(Symbols.replay_10_rounded, fill: 1), - title: Text(t.settings.smallSkipDuration), - subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeSmall.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showSeekTimeSmallDialog(), - ), - ListTile( - focusNode: _focusTracker.get(_kLargeSkipDuration), - leading: const AppIcon(Symbols.replay_30_rounded, fill: 1), - title: Text(t.settings.largeSkipDuration), - subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeLarge.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showSeekTimeLargeDialog(), - ), - ListTile( - focusNode: _focusTracker.get(_kRewindOnResume), - leading: const AppIcon(Symbols.replay_rounded, fill: 1), - title: Text(t.settings.rewindOnResume), - subtitle: Text(t.settings.secondsUnit(seconds: _rewindOnResume.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showRewindOnResumeDialog(), - ), - ListTile( - focusNode: _focusTracker.get(_kDefaultSleepTimer), - leading: const AppIcon(Symbols.bedtime_rounded, fill: 1), - title: Text(t.settings.defaultSleepTimer), - subtitle: Text(t.settings.minutesUnit(minutes: _sleepTimerDuration.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showSleepTimerDurationDialog(), - ), - ListTile( - focusNode: _focusTracker.get(_kMaxVolume), - leading: const AppIcon(Symbols.volume_up_rounded, fill: 1), - title: Text(t.settings.maxVolume), - subtitle: Text(t.settings.maxVolumePercent(percent: _maxVolume.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showMaxVolumeDialog(), - ), - if (DiscordRPCService.isAvailable) - SwitchListTile( - focusNode: _focusTracker.get(_kDiscordRichPresence), - secondary: const AppIcon(Symbols.chat_rounded, fill: 1), - title: Text(t.settings.discordRichPresence), - subtitle: Text(t.settings.discordRichPresenceDescription), - value: _enableDiscordRPC, - onChanged: (value) async { - setState(() => _enableDiscordRPC = value); - await _settingsService.setEnableDiscordRPC(value); - await DiscordRPCService.instance.setEnabled(value); - }, - ), - SwitchListTile( - focusNode: _focusTracker.get(_kRememberTrackSelections), - secondary: const AppIcon(Symbols.bookmark_rounded, fill: 1), - title: Text(t.settings.rememberTrackSelections), - subtitle: Text(t.settings.rememberTrackSelectionsDescription), - value: _rememberTrackSelections, - onChanged: (value) async { - setState(() { - _rememberTrackSelections = value; - }); - await _settingsService.setRememberTrackSelections(value); - }, - ), - if (!isMobile) - SwitchListTile( - focusNode: _focusTracker.get(_kClickVideoTogglesPlayback), - secondary: const AppIcon(Symbols.play_pause_rounded, fill: 1), - title: Text(t.settings.clickVideoTogglesPlayback), - subtitle: Text(t.settings.clickVideoTogglesPlaybackDescription), - value: _clickVideoTogglesPlayback, - onChanged: (value) async { - setState(() { - _clickVideoTogglesPlayback = value; - }); - await _settingsService.setClickVideoTogglesPlayback(value); - }, - ), - const Divider(), - Padding( - padding: const EdgeInsets.only(left: 16, top: 8, right: 16), - child: Text( - t.settings.autoSkip, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - SwitchListTile( - focusNode: _focusTracker.get(_kAutoSkipIntro), - secondary: const AppIcon(Symbols.fast_forward_rounded, fill: 1), - title: Text(t.settings.autoSkipIntro), - subtitle: Text(t.settings.autoSkipIntroDescription), - value: _autoSkipIntro, - onChanged: (value) async { - setState(() { - _autoSkipIntro = value; - }); - await _settingsService.setAutoSkipIntro(value); - }, - ), - SwitchListTile( - focusNode: _focusTracker.get(_kAutoSkipCredits), - secondary: const AppIcon(Symbols.skip_next_rounded, fill: 1), - title: Text(t.settings.autoSkipCredits), - subtitle: Text(t.settings.autoSkipCreditsDescription), - value: _autoSkipCredits, - onChanged: (value) async { - setState(() { - _autoSkipCredits = value; - }); - await _settingsService.setAutoSkipCredits(value); - }, - ), - ListTile( - focusNode: _focusTracker.get(_kAutoSkipDelay), - leading: const AppIcon(Symbols.timer_rounded, fill: 1), - title: Text(t.settings.autoSkipDelay), - subtitle: Text(t.settings.autoSkipDelayDescription(seconds: _autoSkipDelay.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showAutoSkipDelayDialog(), - ), - ListTile( - focusNode: _focusTracker.get(_kIntroPattern), - leading: const AppIcon(Symbols.match_case_rounded, fill: 1), - title: Text(t.settings.introPattern), - subtitle: Text(t.settings.introPatternDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showTextInputDialog( - title: t.settings.introPattern, - currentValue: _introPattern, - defaultValue: settings.SettingsService.defaultIntroPattern, - onSave: (value) async { - setState(() => _introPattern = value); - await _settingsService.setIntroPattern(value); - }, - ), - ), - ListTile( - focusNode: _focusTracker.get(_kCreditsPattern), - leading: const AppIcon(Symbols.match_case_rounded, fill: 1), - title: Text(t.settings.creditsPattern), - subtitle: Text(t.settings.creditsPatternDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showTextInputDialog( - title: t.settings.creditsPattern, - currentValue: _creditsPattern, - defaultValue: settings.SettingsService.defaultCreditsPattern, - onSave: (value) async { - setState(() => _creditsPattern = value); - await _settingsService.setCreditsPattern(value); - }, - ), - ), - ], - ), + Widget _buildPlaybackTile() { + return ListTile( + focusNode: _focusTracker.get(_kPlayback), + leading: const AppIcon(Symbols.play_circle_rounded, fill: 1), + title: Text(t.settings.videoPlayback), + subtitle: Text(t.settings.subtitleStylingDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const PlaybackSettingsScreen())); + }, ); } @@ -951,50 +219,215 @@ class _SettingsScreenState extends State with FocusableTab { final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.settings.downloads, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - // Download location picker - not available on iOS - if (!Platform.isIOS) - FutureBuilder( - future: storageService.getCurrentDownloadPathDisplay(), - builder: (context, snapshot) { - final currentPath = snapshot.data ?? '...'; - - return ListTile( - focusNode: _focusTracker.get(_kDownloadLocation), - leading: const AppIcon(Symbols.folder_rounded, fill: 1), - title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault), - subtitle: Text(currentPath, maxLines: 2, overflow: TextOverflow.ellipsis), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showDownloadLocationDialog(), - ); - }, - ), - SwitchListTile( - focusNode: _focusTracker.get(_kDownloadOnWifiOnly), - secondary: const AppIcon(Symbols.wifi_rounded, fill: 1), - title: Text(t.settings.downloadOnWifiOnly), - subtitle: Text(t.settings.downloadOnWifiOnlyDescription), - value: _downloadOnWifiOnly, - onChanged: (value) async { - setState(() => _downloadOnWifiOnly = value); - await _settingsService.setDownloadOnWifiOnly(value); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSectionHeader(t.settings.downloads), + if (!Platform.isIOS) + FutureBuilder( + future: storageService.getCurrentDownloadPathDisplay(), + builder: (context, snapshot) { + final currentPath = snapshot.data ?? '...'; + return ListTile( + focusNode: _focusTracker.get(_kDownloadLocation), + leading: const AppIcon(Symbols.folder_rounded, fill: 1), + title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault), + subtitle: Text(currentPath, maxLines: 2, overflow: TextOverflow.ellipsis), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => _showDownloadLocationDialog(), + ); }, ), - ], - ), + SwitchListTile( + focusNode: _focusTracker.get(_kDownloadOnWifiOnly), + secondary: const AppIcon(Symbols.wifi_rounded, fill: 1), + title: Text(t.settings.downloadOnWifiOnly), + subtitle: Text(t.settings.downloadOnWifiOnlyDescription), + value: _downloadOnWifiOnly, + onChanged: (value) async { + setState(() => _downloadOnWifiOnly = value); + await _settingsService.setDownloadOnWifiOnly(value); + }, + ), + ], ); } + Widget _buildKeyboardShortcutsSection() { + if (_keyboardService == null) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSectionHeader(t.settings.keyboardShortcuts), + ListTile( + focusNode: _focusTracker.get(_kVideoPlayerControls), + leading: const AppIcon(Symbols.keyboard_rounded, fill: 1), + title: Text(t.settings.videoPlayerControls), + subtitle: Text(t.settings.keyboardShortcutsDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => KeyboardShortcutsScreen(keyboardService: _keyboardService!)), + ); + }, + ), + SwitchListTile( + focusNode: _focusTracker.get(_kVideoPlayerNavigation), + secondary: const AppIcon(Symbols.gamepad_rounded, fill: 1), + title: Text(t.settings.videoPlayerNavigation), + subtitle: Text(t.settings.videoPlayerNavigationDescription), + value: _videoPlayerNavigationEnabled, + onChanged: (value) async { + setState(() => _videoPlayerNavigationEnabled = value); + await _settingsService.setVideoPlayerNavigationEnabled(value); + }, + ), + ], + ); + } + + + + Widget _buildAdvancedSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSectionHeader(t.settings.advanced), + ListTile( + focusNode: _focusTracker.get(_kWatchTogetherRelay), + leading: const AppIcon(Symbols.dns_rounded, fill: 1), + title: Text(t.settings.watchTogetherRelay), + subtitle: Text(t.settings.watchTogetherRelayDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => _showRelayUrlDialog(), + ), + SwitchListTile( + focusNode: _focusTracker.get(_kCrashReporting), + secondary: const AppIcon(Symbols.monitoring_rounded, fill: 1), + title: Text(t.settings.crashReporting), + subtitle: Text(t.settings.crashReportingDescription), + value: _crashReporting, + onChanged: (value) async { + setState(() => _crashReporting = value); + await _settingsService.setCrashReporting(value); + }, + ), + SwitchListTile( + focusNode: _focusTracker.get(_kDebugLogging), + secondary: const AppIcon(Symbols.bug_report_rounded, fill: 1), + title: Text(t.settings.debugLogging), + subtitle: Text(t.settings.debugLoggingDescription), + value: _enableDebugLogging, + onChanged: (value) async { + setState(() => _enableDebugLogging = value); + await _settingsService.setEnableDebugLogging(value); + }, + ), + ListTile( + focusNode: _focusTracker.get(_kViewLogs), + leading: const AppIcon(Symbols.article_rounded, fill: 1), + title: Text(t.settings.viewLogs), + subtitle: Text(t.settings.viewLogsDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); + }, + ), + ListTile( + focusNode: _focusTracker.get(_kClearCache), + leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1), + title: Text(t.settings.clearCache), + subtitle: Text(t.settings.clearCacheDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => _showClearCacheDialog(), + ), + ListTile( + focusNode: _focusTracker.get(_kResetSettings), + leading: const AppIcon(Symbols.restore_rounded, fill: 1), + title: Text(t.settings.resetSettings), + subtitle: Text(t.settings.resetSettingsDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => _showResetSettingsDialog(), + ), + if (kDebugMode) + ListTile( + leading: const AppIcon(Symbols.error_rounded, fill: 1), + title: const Text('Test Sentry'), + subtitle: const Text('Send a test error'), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + throw Exception("Example exception"); + }, + ), + if (kDebugMode) + ListTile( + leading: const AppIcon(Symbols.timer_rounded, fill: 1), + title: const Text('Test ANR'), + subtitle: const Text('Block the main thread for 10 seconds'), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + showSnackBar(context, 'Blocking main thread...'); + final end = DateTime.now().add(const Duration(seconds: 10)); + while (DateTime.now().isBefore(end)) {} + }, + ), + ], + ); + } + + Widget _buildUpdateSection() { + if (UpdateService.useNativeUpdater) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSectionHeader(t.settings.updates), + ListTile( + focusNode: _focusTracker.get(_kCheckForUpdates), + leading: const AppIcon(Symbols.system_update_rounded, fill: 1), + title: Text(t.settings.checkForUpdates), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => UpdateService.checkForUpdatesNative(inBackground: false), + ), + ], + ); + } + + final hasUpdate = _updateInfo != null && _updateInfo!['hasUpdate'] == true; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSectionHeader(t.settings.updates), + ListTile( + focusNode: _focusTracker.get(_kCheckForUpdates), + leading: AppIcon( + hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded, + fill: 1, + color: hasUpdate ? Colors.orange : null, + ), + title: Text(hasUpdate ? t.settings.updateAvailable : t.settings.checkForUpdates), + subtitle: hasUpdate ? Text(t.update.versionAvailable(version: _updateInfo!['latestVersion'])) : null, + trailing: _isCheckingForUpdate + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + : const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: _isCheckingForUpdate + ? null + : () { + if (hasUpdate) { + _showUpdateDialog(); + } else { + _checkForUpdates(); + } + }, + ), + ], + ); + } + + // --- Dialogs --- + Future _showDownloadLocationDialog() async { final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); @@ -1048,7 +481,6 @@ class _SettingsScreenState extends State with FocusableTab { String pathType = 'file'; if (Platform.isAndroid) { - // Use SAF on Android final safService = SafStorageService.instance; selectedPath = await safService.pickDirectory(); if (selectedPath != null) { @@ -1060,13 +492,11 @@ class _SettingsScreenState extends State with FocusableTab { return; } } else { - // Use file_picker on desktop final result = await FilePicker.platform.getDirectoryPath(dialogTitle: t.settings.selectFolder); selectedPath = result; } if (selectedPath != null) { - // Validate the path is writable (for non-SAF paths) if (pathType == 'file') { final dir = Directory(selectedPath); final isWritable = await DownloadStorageService.instance.isDirectoryWritable(dir); @@ -1078,7 +508,6 @@ class _SettingsScreenState extends State with FocusableTab { } } - // Save the setting await _settingsService.setCustomDownloadPath(selectedPath, type: pathType); await DownloadStorageService.instance.refreshCustomPath(); @@ -1106,600 +535,6 @@ class _SettingsScreenState extends State with FocusableTab { } } - Widget _buildKeyboardShortcutsSection() { - if (_keyboardService == null) return const SizedBox.shrink(); - - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.settings.keyboardShortcuts, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ListTile( - focusNode: _focusTracker.get(_kVideoPlayerControls), - leading: const AppIcon(Symbols.keyboard_rounded, fill: 1), - title: Text(t.settings.videoPlayerControls), - subtitle: Text(t.settings.keyboardShortcutsDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showKeyboardShortcutsDialog(), - ), - SwitchListTile( - focusNode: _focusTracker.get(_kVideoPlayerNavigation), - secondary: const AppIcon(Symbols.gamepad_rounded, fill: 1), - title: Text(t.settings.videoPlayerNavigation), - subtitle: Text(t.settings.videoPlayerNavigationDescription), - value: _videoPlayerNavigationEnabled, - onChanged: (value) async { - setState(() { - _videoPlayerNavigationEnabled = value; - }); - await _settingsService.setVideoPlayerNavigationEnabled(value); - }, - ), - ], - ), - ); - } - - Widget _buildCompanionRemoteSection() { - return Consumer( - builder: (context, companionRemote, child) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.companionRemote.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - if (PlatformDetector.shouldActAsRemoteHost(context)) - ListTile( - leading: const AppIcon(Symbols.phone_android_rounded, fill: 1), - title: Text(t.companionRemote.hostRemoteSession), - subtitle: companionRemote.isConnected - ? Text(t.companionRemote.connectedTo(name: companionRemote.connectedDevice?.name ?? '')) - : Text(t.companionRemote.controlThisDevice), - trailing: companionRemote.isConnected - ? const AppIcon(Symbols.check_circle_rounded, fill: 1, color: Colors.green) - : const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => RemoteSessionDialog.show(context), - ) - else - ListTile( - leading: const AppIcon(Symbols.phone_android_rounded, fill: 1), - title: Text(t.companionRemote.remoteControl), - subtitle: companionRemote.isConnected - ? Text(t.companionRemote.connectedTo(name: companionRemote.connectedDevice?.name ?? '')) - : Text(t.companionRemote.controlDesktop), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen())); - }, - ), - ], - ), - ); - }, - ); - } - - Widget _buildAdvancedSection() { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.settings.advanced, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ListTile( - focusNode: _focusTracker.get(_kWatchTogetherRelay), - leading: const AppIcon(Symbols.dns_rounded, fill: 1), - title: Text(t.settings.watchTogetherRelay), - subtitle: Text(t.settings.watchTogetherRelayDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showRelayUrlDialog(), - ), - SwitchListTile( - focusNode: _focusTracker.get(_kCrashReporting), - secondary: const AppIcon(Symbols.monitoring_rounded, fill: 1), - title: Text(t.settings.crashReporting), - subtitle: Text(t.settings.crashReportingDescription), - value: _crashReporting, - onChanged: (value) async { - setState(() { - _crashReporting = value; - }); - await _settingsService.setCrashReporting(value); - }, - ), - SwitchListTile( - focusNode: _focusTracker.get(_kDebugLogging), - secondary: const AppIcon(Symbols.bug_report_rounded, fill: 1), - title: Text(t.settings.debugLogging), - subtitle: Text(t.settings.debugLoggingDescription), - value: _enableDebugLogging, - onChanged: (value) async { - setState(() { - _enableDebugLogging = value; - }); - await _settingsService.setEnableDebugLogging(value); - }, - ), - ListTile( - focusNode: _focusTracker.get(_kViewLogs), - leading: const AppIcon(Symbols.article_rounded, fill: 1), - title: Text(t.settings.viewLogs), - subtitle: Text(t.settings.viewLogsDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); - }, - ), - ListTile( - focusNode: _focusTracker.get(_kClearCache), - leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1), - title: Text(t.settings.clearCache), - subtitle: Text(t.settings.clearCacheDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showClearCacheDialog(), - ), - ListTile( - focusNode: _focusTracker.get(_kResetSettings), - leading: const AppIcon(Symbols.restore_rounded, fill: 1), - title: Text(t.settings.resetSettings), - subtitle: Text(t.settings.resetSettingsDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showResetSettingsDialog(), - ), - if (kDebugMode) - ListTile( - leading: const AppIcon(Symbols.error_rounded, fill: 1), - title: const Text('Test Sentry'), - subtitle: const Text('Send a test error'), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - throw Exception("Example exception"); - }, - ), - if (kDebugMode) - ListTile( - leading: const AppIcon(Symbols.timer_rounded, fill: 1), - title: const Text('Test ANR'), - subtitle: const Text('Block the main thread for 10 seconds'), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - showSnackBar(context, 'Blocking main thread...'); - final end = DateTime.now().add(const Duration(seconds: 10)); - while (DateTime.now().isBefore(end)) {} - }, - ), - ], - ), - ); - } - - Widget _buildUpdateSection() { - // Native updater: simple tile that triggers Sparkle/WinSparkle native dialog - if (UpdateService.useNativeUpdater) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.settings.updates, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ListTile( - focusNode: _focusTracker.get(_kCheckForUpdates), - leading: const AppIcon(Symbols.system_update_rounded, fill: 1), - title: Text(t.settings.checkForUpdates), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => UpdateService.checkForUpdatesNative(inBackground: false), - ), - ], - ), - ); - } - - final hasUpdate = _updateInfo != null && _updateInfo!['hasUpdate'] == true; - - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.settings.updates, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ListTile( - focusNode: _focusTracker.get(_kCheckForUpdates), - leading: AppIcon( - hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded, - fill: 1, - color: hasUpdate ? Colors.orange : null, - ), - title: Text(hasUpdate ? t.settings.updateAvailable : t.settings.checkForUpdates), - subtitle: hasUpdate ? Text(t.update.versionAvailable(version: _updateInfo!['latestVersion'])) : null, - trailing: _isCheckingForUpdate - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) - : const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: _isCheckingForUpdate - ? null - : () { - if (hasUpdate) { - _showUpdateDialog(); - } else { - _checkForUpdates(); - } - }, - ), - ], - ), - ); - } - - Widget _buildAboutSection() { - return Card( - child: ListTile( - focusNode: _focusTracker.get(_kAbout), - leading: const AppIcon(Symbols.info_rounded, fill: 1), - title: Text(t.settings.about), - subtitle: Text(t.settings.aboutDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const AboutScreen())); - }, - ), - ); - } - - /// Shows a platform-adaptive settings menu: popup dropdown on desktop/TV, - /// bottom sheet on mobile. - Future _showSettingsMenu({ - required BuildContext tileContext, - required String title, - required List<_DialogOption> options, - required T currentValue, - }) { - final useBottomSheet = Platform.isIOS || Platform.isAndroid; - final focusFirstItem = InputModeTracker.isKeyboardMode(context); - - if (useBottomSheet) { - return _showSettingsMenuSheet( - title: title, - options: options, - currentValue: currentValue, - focusFirstItem: focusFirstItem, - ); - } else { - return _showSettingsMenuPopup( - tileContext: tileContext, - options: options, - currentValue: currentValue, - focusFirstItem: focusFirstItem, - ); - } - } - - /// Mobile path: bottom sheet via OverlaySheetController.showAdaptive - Future _showSettingsMenuSheet({ - required String title, - required List<_DialogOption> options, - required T currentValue, - required bool focusFirstItem, - }) { - return OverlaySheetController.showAdaptive( - context, - showDragHandle: true, - builder: (context) => _SettingsMenuSheet( - title: title, - options: options, - currentValue: currentValue, - focusFirstItem: focusFirstItem, - ), - ); - } - - /// Desktop/TV path: positioned popup dropdown - Future _showSettingsMenuPopup({ - required BuildContext tileContext, - required List<_DialogOption> options, - required T currentValue, - required bool focusFirstItem, - }) { - final renderBox = tileContext.findRenderObject() as RenderBox; - final position = renderBox.localToGlobal(Offset.zero); - final tileSize = renderBox.size; - - return showDialog( - context: context, - barrierColor: Colors.transparent, - builder: (context) => _SettingsMenuPopup( - position: Offset(position.dx + tileSize.width, position.dy + tileSize.height), - options: options, - currentValue: currentValue, - focusFirstItem: focusFirstItem, - ), - ); - } - - /// Generic numeric input dialog to avoid duplication across settings. - /// On TV, uses a spinner widget with +/- buttons for D-pad navigation. - /// On other platforms, uses a TextField with focus management. - void _showNumericInputDialog({ - required String title, - required String labelText, - required String suffixText, - required int min, - required int max, - required int currentValue, - required Future Function(int value) onSave, - }) { - final useDpadControls = InputModeTracker.isKeyboardMode(context); - - if (useDpadControls) { - _showNumericInputDialogTV( - title: title, - suffixText: suffixText, - min: min, - max: max, - currentValue: currentValue, - onSave: onSave, - ); - } else { - _showNumericInputDialogStandard( - title: title, - labelText: labelText, - suffixText: suffixText, - min: min, - max: max, - currentValue: currentValue, - onSave: onSave, - ); - } - } - - /// TV-specific numeric input dialog with spinner widget. - void _showNumericInputDialogTV({ - required String title, - required String suffixText, - required int min, - required int max, - required int currentValue, - required Future Function(int value) onSave, - }) { - int spinnerValue = currentValue; - final saveFocusNode = FocusNode(); - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(title), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TvNumberSpinner( - value: spinnerValue, - min: min, - max: max, - suffix: suffixText, - autofocus: true, - onChanged: (value) { - setDialogState(() { - spinnerValue = value; - }); - }, - onConfirm: () => saveFocusNode.requestFocus(), - onCancel: () => Navigator.pop(dialogContext), - ), - const SizedBox(height: 8), - Text( - t.settings.durationHint(min: min, max: max), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( - focusNode: saveFocusNode, - onPressed: () async { - await onSave(spinnerValue); - if (dialogContext.mounted) { - Navigator.pop(dialogContext); - } - }, - child: Text(t.common.save), - ), - ], - ); - }, - ); - }, - ).then((_) => saveFocusNode.dispose()); - } - - /// Standard numeric input dialog with TextField for non-TV platforms. - void _showNumericInputDialogStandard({ - required String title, - required String labelText, - required String suffixText, - required int min, - required int max, - required int currentValue, - required Future Function(int value) onSave, - }) { - final controller = TextEditingController(text: currentValue.toString()); - String? errorText; - final saveFocusNode = FocusNode(); - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: labelText, - hintText: t.settings.durationHint(min: min, max: max), - errorText: errorText, - suffixText: suffixText, - ), - autofocus: true, - textInputAction: TextInputAction.done, - onEditingComplete: () { - // Move focus to Save button when keyboard checkmark is pressed - saveFocusNode.requestFocus(); - }, - onChanged: (value) { - final parsed = int.tryParse(value); - setDialogState(() { - if (parsed == null) { - errorText = t.settings.validationErrorEnterNumber; - } else if (parsed < min || parsed > max) { - errorText = t.settings.validationErrorDuration(min: min, max: max, unit: labelText.toLowerCase()); - } else { - errorText = null; - } - }); - }, - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( - focusNode: saveFocusNode, - onPressed: () async { - final parsed = int.tryParse(controller.text); - if (parsed != null && parsed >= min && parsed <= max) { - await onSave(parsed); - if (dialogContext.mounted) { - Navigator.pop(dialogContext); - } - } - }, - child: Text(t.common.save), - ), - ], - ); - }, - ); - }, - ).then((_) { - // Clean up focus node when dialog is dismissed - saveFocusNode.dispose(); - }); - } - - void _showSeekTimeSmallDialog() { - _showNumericInputDialog( - title: t.settings.smallSkipDuration, - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 120, - currentValue: _seekTimeSmall, - onSave: (value) async { - setState(() { - _seekTimeSmall = value; - _settingsService.setSeekTimeSmall(value); - }); - await _keyboardService?.refreshFromStorage(); - }, - ); - } - - void _showSeekTimeLargeDialog() { - _showNumericInputDialog( - title: t.settings.largeSkipDuration, - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 120, - currentValue: _seekTimeLarge, - onSave: (value) async { - setState(() { - _seekTimeLarge = value; - _settingsService.setSeekTimeLarge(value); - }); - await _keyboardService?.refreshFromStorage(); - }, - ); - } - - void _showRewindOnResumeDialog() { - _showNumericInputDialog( - title: t.settings.rewindOnResume, - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 0, - max: 10, - currentValue: _rewindOnResume, - onSave: (value) async { - setState(() { - _rewindOnResume = value; - _settingsService.setRewindOnResume(value); - }); - }, - ); - } - - void _showSleepTimerDurationDialog() { - _showNumericInputDialog( - title: t.settings.defaultSleepTimer, - labelText: t.settings.minutesLabel, - suffixText: t.settings.minutesShort, - min: 5, - max: 240, - currentValue: _sleepTimerDuration, - onSave: (value) async { - setState(() => _sleepTimerDuration = value); - await _settingsService.setSleepTimerDuration(value); - }, - ); - } - - void _showAutoSkipDelayDialog() { - _showNumericInputDialog( - title: t.settings.autoSkipDelay, - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 30, - currentValue: _autoSkipDelay, - onSave: (value) async { - setState(() => _autoSkipDelay = value); - await _settingsService.setAutoSkipDelay(value); - }, - ); - } - void _showRelayUrlDialog() { final controller = TextEditingController(text: _customRelayUrl ?? ''); final saveFocusNode = FocusNode(); @@ -1746,93 +581,6 @@ class _SettingsScreenState extends State with FocusableTab { ).then((_) => saveFocusNode.dispose()); } - void _showTextInputDialog({ - required String title, - required String currentValue, - required String defaultValue, - required Future Function(String value) onSave, - }) { - final controller = TextEditingController(text: currentValue); - String? errorText; - final saveFocusNode = FocusNode(); - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - decoration: InputDecoration( - labelText: 'Regex', - errorText: errorText, - ), - autofocus: true, - textInputAction: TextInputAction.done, - onEditingComplete: () => saveFocusNode.requestFocus(), - onChanged: (value) { - setDialogState(() { - try { - RegExp(value, caseSensitive: false); - errorText = null; - } catch (_) { - errorText = t.settings.invalidRegex; - } - }); - }, - ), - actions: [ - TextButton( - onPressed: () { - controller.text = defaultValue; - setDialogState(() => errorText = null); - }, - child: Text(t.settings.resetToDefault), - ), - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( - focusNode: saveFocusNode, - onPressed: () async { - if (errorText != null) return; - await onSave(controller.text); - if (dialogContext.mounted) Navigator.pop(dialogContext); - }, - child: Text(t.common.save), - ), - ], - ); - }, - ); - }, - ).then((_) => saveFocusNode.dispose()); - } - - void _showMaxVolumeDialog() { - _showNumericInputDialog( - title: t.settings.maxVolume, - labelText: t.settings.maxVolumeDescription, - suffixText: '%', - min: 100, - max: 300, - currentValue: _maxVolume, - onSave: (value) async { - setState(() => _maxVolume = value); - await _settingsService.setMaxVolume(value); - }, - ); - } - - void _showKeyboardShortcutsDialog() { - if (_keyboardService == null) return; - - Navigator.push( - context, - MaterialPageRoute(builder: (context) => _KeyboardShortcutsScreen(keyboardService: _keyboardService!)), - ); - } - void _showClearCacheDialog() { showDialog( context: context, @@ -1887,50 +635,8 @@ class _SettingsScreenState extends State with FocusableTab { ); } - String _getLanguageDisplayName(AppLocale locale) { - switch (locale) { - case AppLocale.en: - return 'English'; - case AppLocale.sv: - return 'Svenska'; - case AppLocale.fr: - return 'Français'; - case AppLocale.it: - return 'Italiano'; - case AppLocale.nl: - return 'Nederlands'; - case AppLocale.de: - return 'Deutsch'; - case AppLocale.zh: - return '中文'; - case AppLocale.ko: - return '한국어'; - case AppLocale.es: - return 'Español'; - case AppLocale.pt: - return 'Português'; - case AppLocale.ja: - return '日本語'; - case AppLocale.ru: - return 'Русский'; - case AppLocale.pl: - return 'Polski'; - case AppLocale.da: - return 'Dansk'; - case AppLocale.nb: - return 'Norsk bokmål'; - } - } - - void _restartApp() { - // Navigate to the root and remove all previous routes - Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); - } - Future _checkForUpdates() async { - setState(() { - _isCheckingForUpdate = true; - }); + setState(() => _isCheckingForUpdate = true); try { final updateInfo = await UpdateService.checkForUpdates(); @@ -1942,16 +648,12 @@ class _SettingsScreenState extends State with FocusableTab { }); if (updateInfo == null || updateInfo['hasUpdate'] != true) { - // Show "no updates" message showAppSnackBar(context, t.update.latestVersion); } } } catch (e) { if (mounted) { - setState(() { - _isCheckingForUpdate = false; - }); - + setState(() => _isCheckingForUpdate = false); showErrorSnackBar(context, t.update.checkFailed); } } @@ -1998,337 +700,3 @@ class _SettingsScreenState extends State with FocusableTab { ); } } - -/// Bottom sheet content for settings menu (mobile) -class _SettingsMenuSheet extends StatefulWidget { - final String title; - final List<_DialogOption> options; - final T currentValue; - final bool focusFirstItem; - - const _SettingsMenuSheet({ - required this.title, - required this.options, - required this.currentValue, - this.focusFirstItem = false, - }); - - @override - State<_SettingsMenuSheet> createState() => _SettingsMenuSheetState(); -} - -class _SettingsMenuSheetState extends State<_SettingsMenuSheet> { - late final FocusNode _initialFocusNode; - - @override - void initState() { - super.initState(); - _initialFocusNode = FocusNode(debugLabel: 'SettingsMenuSheetInitialFocus'); - } - - @override - void dispose() { - _initialFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - widget.title, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Flexible( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...widget.options.asMap().entries.map((entry) { - final index = entry.key; - final option = entry.value; - final isSelected = widget.currentValue == option.value; - return FocusableListTile( - focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, - dense: true, - leading: AppIcon( - isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(option.title), - subtitle: option.subtitle != null ? Text(option.subtitle!) : null, - onTap: () => OverlaySheetController.closeAdaptive(context, option.value), - ); - }), - ], - ), - ), - ), - ], - ); - } -} - -/// Positioned popup menu for settings menu (desktop/TV) -class _SettingsMenuPopup extends StatefulWidget { - final Offset position; - final List<_DialogOption> options; - final T currentValue; - final bool focusFirstItem; - - const _SettingsMenuPopup({ - required this.position, - required this.options, - required this.currentValue, - this.focusFirstItem = false, - }); - - @override - State<_SettingsMenuPopup> createState() => _SettingsMenuPopupState(); -} - -class _SettingsMenuPopupState extends State<_SettingsMenuPopup> { - late final FocusNode _initialFocusNode; - - @override - void initState() { - super.initState(); - _initialFocusNode = FocusNode(debugLabel: 'SettingsMenuPopupInitialFocus'); - if (widget.focusFirstItem) { - FocusUtils.requestFocusAfterBuild(this, _initialFocusNode); - } - } - - @override - void dispose() { - _initialFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final screenSize = MediaQuery.of(context).size; - const menuWidth = 280.0; - const edgePadding = 8.0; - - // Right-align: position.dx is the tile's right edge - final left = (widget.position.dx - menuWidth).clamp(edgePadding, screenSize.width - menuWidth - edgePadding); - - final estimatedHeight = widget.options.length * 56.0 + 16; - final spaceBelow = screenSize.height - widget.position.dy - edgePadding; - final spaceAbove = widget.position.dy - edgePadding; - - final double top; - final double maxHeight; - if (estimatedHeight <= spaceBelow) { - top = widget.position.dy; - maxHeight = spaceBelow; - } else if (spaceAbove > spaceBelow) { - final menuHeight = estimatedHeight.clamp(0.0, spaceAbove); - top = widget.position.dy - menuHeight; - maxHeight = menuHeight; - } else { - top = widget.position.dy; - maxHeight = spaceBelow; - } - - return FocusScope( - autofocus: false, - child: Focus( - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (node, event) { - if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { - return KeyEventResult.handled; - } - if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - onTap: () => Navigator.pop(context), - behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), - ), - ), - Positioned( - left: left, - top: top, - child: Material( - elevation: 8, - color: Color.alphaBlend( - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08), - Theme.of(context).colorScheme.surface, - ), - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth, maxHeight: maxHeight), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: widget.options.asMap().entries.map((entry) { - final index = entry.key; - final option = entry.value; - final isSelected = widget.currentValue == option.value; - return FocusableListTile( - focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, - dense: true, - leading: AppIcon( - isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(option.title), - subtitle: option.subtitle != null ? Text(option.subtitle!) : null, - onTap: () => Navigator.pop(context, option.value), - ); - }).toList(), - ), - ), - ), - ), - ), - ], - ), - ), - ); - } -} - -class _KeyboardShortcutsScreen extends StatefulWidget { - final KeyboardShortcutsService keyboardService; - - const _KeyboardShortcutsScreen({required this.keyboardService}); - - @override - State<_KeyboardShortcutsScreen> createState() => _KeyboardShortcutsScreenState(); -} - -class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { - Map _hotkeys = {}; - bool _isLoading = true; - - @override - void initState() { - super.initState(); - _loadHotkeys(); - } - - Future _loadHotkeys() async { - await widget.keyboardService.refreshFromStorage(); - if (!mounted) return; - setState(() { - _hotkeys = widget.keyboardService.hotkeys; - _isLoading = false; - }); - } - - @override - Widget build(BuildContext context) { - return FocusedScrollScaffold( - title: Text(t.settings.keyboardShortcuts), - actions: [ - TextButton( - onPressed: () async { - await widget.keyboardService.resetToDefaults(); - await _loadHotkeys(); - if (mounted) { - showSuccessSnackBar(this.context, t.settings.shortcutsReset); - } - }, - child: Text(t.common.reset), - ), - ], - slivers: _isLoading - ? [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))] - : [ - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final actions = _hotkeys.keys.toList(); - final action = actions[index]; - final hotkey = _hotkeys[action]!; - - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text(widget.keyboardService.getActionDisplayName(action)), - subtitle: Text(action), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)), - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - child: Text( - widget.keyboardService.formatHotkey(hotkey), - style: const TextStyle(fontFamily: 'monospace'), - ), - ), - onTap: () => _editHotkey(action, hotkey), - ), - ); - }, childCount: _hotkeys.length), - ), - ), - ], - ); - } - - void _editHotkey(String action, HotKey currentHotkey) { - showDialog( - context: context, - builder: (BuildContext context) { - return HotKeyRecorderWidget( - actionName: widget.keyboardService.getActionDisplayName(action), - currentHotKey: currentHotkey, - onHotKeyRecorded: (newHotkey) async { - final navigator = Navigator.of(context); - - // Check for conflicts - final existingAction = widget.keyboardService.getActionForHotkey(newHotkey); - if (existingAction != null && existingAction != action) { - navigator.pop(); - showErrorSnackBar( - context, - t.settings.shortcutAlreadyAssigned(action: widget.keyboardService.getActionDisplayName(existingAction)), - ); - return; - } - - // Save the new hotkey - await widget.keyboardService.setHotkey(action, newHotkey); - - if (mounted) { - // Update UI directly instead of reloading from storage - setState(() { - _hotkeys[action] = newHotkey; - }); - - navigator.pop(); - - showSuccessSnackBar( - this.context, - t.settings.shortcutUpdated(action: widget.keyboardService.getActionDisplayName(action)), - ); - } - }, - onCancel: () => Navigator.pop(context), - ); - }, - ); - } -} diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart new file mode 100644 index 00000000..2d80b156 --- /dev/null +++ b/lib/screens/settings/settings_utils.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; + +import '../../focus/input_mode_tracker.dart'; +import '../../i18n/strings.g.dart'; +import '../../widgets/tv_number_spinner.dart'; + +/// Model for option selection dialogs. +class DialogOption { + final T value; + final String title; + final String? subtitle; + + const DialogOption({required this.value, required this.title, this.subtitle}); +} + +/// Shows an M3-conformant AlertDialog with RadioListTiles for multi-option selection. +/// Used for settings with 5+ options (language, buffer size, etc.). +Future showSelectionDialog({ + required BuildContext context, + required String title, + required List> options, + required T currentValue, +}) { + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + contentPadding: const EdgeInsets.only(top: 12, bottom: 24), + content: SingleChildScrollView( + child: RadioGroup( + groupValue: currentValue, + onChanged: (value) => Navigator.pop(dialogContext, value), + child: Column( + mainAxisSize: MainAxisSize.min, + children: options + .map((option) => RadioListTile( + title: Text(option.title), + subtitle: option.subtitle != null ? Text(option.subtitle!) : null, + value: option.value, + )) + .toList(), + ), + ), + ), + ), + ); +} + +/// Generic numeric input dialog. +/// On TV/keyboard mode, uses a spinner widget with +/- buttons for D-pad navigation. +/// On other platforms, uses a TextField with focus management. +void showNumericInputDialog({ + required BuildContext context, + required String title, + required String labelText, + required String suffixText, + required int min, + required int max, + required int currentValue, + required Future Function(int value) onSave, +}) { + final useDpadControls = InputModeTracker.isKeyboardMode(context); + + if (useDpadControls) { + _showNumericInputDialogTV( + context: context, + title: title, + suffixText: suffixText, + min: min, + max: max, + currentValue: currentValue, + onSave: onSave, + ); + } else { + _showNumericInputDialogStandard( + context: context, + title: title, + labelText: labelText, + suffixText: suffixText, + min: min, + max: max, + currentValue: currentValue, + onSave: onSave, + ); + } +} + +void _showNumericInputDialogTV({ + required BuildContext context, + required String title, + required String suffixText, + required int min, + required int max, + required int currentValue, + required Future Function(int value) onSave, +}) { + int spinnerValue = currentValue; + final saveFocusNode = FocusNode(); + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TvNumberSpinner( + value: spinnerValue, + min: min, + max: max, + suffix: suffixText, + autofocus: true, + onChanged: (value) { + setDialogState(() { + spinnerValue = value; + }); + }, + onConfirm: () => saveFocusNode.requestFocus(), + onCancel: () => Navigator.pop(dialogContext), + ), + const SizedBox(height: 8), + Text( + t.settings.durationHint(min: min, max: max), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), + TextButton( + focusNode: saveFocusNode, + onPressed: () async { + await onSave(spinnerValue); + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + }, + child: Text(t.common.save), + ), + ], + ); + }, + ); + }, + ).then((_) => saveFocusNode.dispose()); +} + +void _showNumericInputDialogStandard({ + required BuildContext context, + required String title, + required String labelText, + required String suffixText, + required int min, + required int max, + required int currentValue, + required Future Function(int value) onSave, +}) { + final controller = TextEditingController(text: currentValue.toString()); + String? errorText; + final saveFocusNode = FocusNode(); + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: labelText, + hintText: t.settings.durationHint(min: min, max: max), + errorText: errorText, + suffixText: suffixText, + ), + autofocus: true, + textInputAction: TextInputAction.done, + onEditingComplete: () { + saveFocusNode.requestFocus(); + }, + onChanged: (value) { + final parsed = int.tryParse(value); + setDialogState(() { + if (parsed == null) { + errorText = t.settings.validationErrorEnterNumber; + } else if (parsed < min || parsed > max) { + errorText = t.settings.validationErrorDuration(min: min, max: max, unit: labelText.toLowerCase()); + } else { + errorText = null; + } + }); + }, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), + TextButton( + focusNode: saveFocusNode, + onPressed: () async { + final parsed = int.tryParse(controller.text); + if (parsed != null && parsed >= min && parsed <= max) { + await onSave(parsed); + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + } + }, + child: Text(t.common.save), + ), + ], + ); + }, + ); + }, + ).then((_) { + saveFocusNode.dispose(); + }); +} + +/// Shows a text input dialog with regex validation and reset-to-default support. +void showTextInputDialog({ + required BuildContext context, + required String title, + required String currentValue, + required String defaultValue, + required Future Function(String value) onSave, +}) { + final controller = TextEditingController(text: currentValue); + String? errorText; + final saveFocusNode = FocusNode(); + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + decoration: InputDecoration( + labelText: 'Regex', + errorText: errorText, + ), + autofocus: true, + textInputAction: TextInputAction.done, + onEditingComplete: () => saveFocusNode.requestFocus(), + onChanged: (value) { + setDialogState(() { + try { + RegExp(value, caseSensitive: false); + errorText = null; + } catch (_) { + errorText = t.settings.invalidRegex; + } + }); + }, + ), + actions: [ + TextButton( + onPressed: () { + controller.text = defaultValue; + setDialogState(() => errorText = null); + }, + child: Text(t.settings.resetToDefault), + ), + TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), + TextButton( + focusNode: saveFocusNode, + onPressed: () async { + if (errorText != null) return; + await onSave(controller.text); + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + child: Text(t.common.save), + ), + ], + ); + }, + ); + }, + ).then((_) => saveFocusNode.dispose()); +} diff --git a/lib/widgets/settings_section.dart b/lib/widgets/settings_section.dart new file mode 100644 index 00000000..099c59d3 --- /dev/null +++ b/lib/widgets/settings_section.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import '../theme/mono_tokens.dart'; +import 'app_icon.dart'; + +class SettingsSectionHeader extends StatelessWidget { + final String title; + + const SettingsSectionHeader(this.title, {super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: tokens(context).textMuted, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +/// A setting with a label + icon row and a full-width SegmentedButton below. +/// Used for settings with 2-4 short options. +class SegmentedSetting extends StatelessWidget { + final IconData icon; + final String title; + final List> segments; + final T selected; + final ValueChanged onChanged; + + const SegmentedSetting({ + super.key, + required this.icon, + required this.title, + required this.segments, + required this.selected, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AppIcon(icon, fill: 1), + const SizedBox(width: 16), + Text(title, style: Theme.of(context).textTheme.bodyLarge), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: segments, + selected: {selected}, + onSelectionChanged: (Set newSelection) { + onChanged(newSelection.first); + }, + showSelectedIcon: false, + ), + ), + ], + ), + ); + } +}