From 5f49dddb4ddd18ac5cc63ba5e757dd922e830638 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:32:16 +0200 Subject: [PATCH] feat(ui): M3E restyle for settings, auth, and profile screens --- lib/focus/card_focus_scope.dart | 8 +- lib/focus/focus_theme.dart | 14 +- lib/focus/focusable_wrapper.dart | 12 +- lib/i18n/bg.i18n.json | 2 +- lib/i18n/da.i18n.json | 2 +- lib/i18n/de.i18n.json | 2 +- lib/i18n/en.i18n.json | 2 +- lib/i18n/es.i18n.json | 2 +- lib/i18n/fr.i18n.json | 2 +- lib/i18n/it.i18n.json | 2 +- lib/i18n/ja.i18n.json | 2 +- lib/i18n/ko.i18n.json | 2 +- lib/i18n/nb.i18n.json | 2 +- lib/i18n/nl.i18n.json | 2 +- lib/i18n/pl.i18n.json | 2 +- lib/i18n/pt.i18n.json | 2 +- lib/i18n/ru.i18n.json | 2 +- lib/i18n/strings_bg.g.dart | 4 +- lib/i18n/strings_da.g.dart | 4 +- lib/i18n/strings_de.g.dart | 4 +- lib/i18n/strings_en.g.dart | 6 +- lib/i18n/strings_es.g.dart | 4 +- lib/i18n/strings_fr.g.dart | 4 +- lib/i18n/strings_it.g.dart | 4 +- lib/i18n/strings_ja.g.dart | 4 +- lib/i18n/strings_ko.g.dart | 4 +- lib/i18n/strings_nb.g.dart | 4 +- lib/i18n/strings_nl.g.dart | 4 +- lib/i18n/strings_pl.g.dart | 4 +- lib/i18n/strings_pt.g.dart | 4 +- lib/i18n/strings_ru.g.dart | 4 +- lib/i18n/strings_sv.g.dart | 4 +- lib/i18n/strings_zh.g.dart | 4 +- lib/i18n/sv.i18n.json | 2 +- lib/i18n/zh.i18n.json | 2 +- .../profile/borrow_connection_screen.dart | 20 +- .../profile/profile_detail_screen.dart | 78 +-- lib/screens/profile/profile_name_field.dart | 9 +- .../profile/profile_switch_screen.dart | 18 +- lib/screens/settings/about_screen.dart | 24 +- .../settings/add_connection_screen.dart | 57 ++- lib/screens/settings/add_jellyfin_screen.dart | 24 +- .../settings/appearance_settings_screen.dart | 367 +++++++------- .../settings/external_player_screen.dart | 60 ++- .../settings/keyboard_shortcuts_screen.dart | 49 +- lib/screens/settings/mpv_config_screen.dart | 89 ++-- .../settings/playback_settings_screen.dart | 463 +++++++++--------- lib/screens/settings/settings_screen.dart | 73 ++- .../settings/subtitle_styling_screen.dart | 224 +++++---- .../tracker_account_settings_body.dart | 76 +-- .../tracker_library_filter_screen.dart | 96 ++-- .../settings/trackers_settings_screen.dart | 6 +- lib/theme/mono_motion.dart | 27 + lib/theme/mono_theme.dart | 12 + lib/theme/mono_tokens.dart | 47 +- lib/widgets/expressive_button_group.dart | 239 +++++++++ lib/widgets/settings_section.dart | 79 ++- test/widgets/player_queue_spoilers_test.dart | 4 + test/widgets/side_navigation_rail_test.dart | 4 + test/widgets/track_sheet_test.dart | 4 + test/widgets/video_controls_test.dart | 4 + test/widgets/video_settings_sheet_test.dart | 4 + 62 files changed, 1415 insertions(+), 874 deletions(-) create mode 100644 lib/theme/mono_motion.dart create mode 100644 lib/widgets/expressive_button_group.dart diff --git a/lib/focus/card_focus_scope.dart b/lib/focus/card_focus_scope.dart index 02236d40..ffb247c2 100644 --- a/lib/focus/card_focus_scope.dart +++ b/lib/focus/card_focus_scope.dart @@ -37,12 +37,17 @@ class CardFocusScope extends InheritedWidget { class CardFocusBorder extends StatelessWidget { const CardFocusBorder({ super.key, - required this.borderRadius, + this.borderRadius = FocusTheme.defaultBorderRadius, + this.borderRadii, this.strokeAlign = BorderSide.strokeAlignOutside, required this.child, }); final double borderRadius; + + /// Per-corner radii; overrides [borderRadius] when set (M3E grouped cards). + final BorderRadius? borderRadii; + final double strokeAlign; final Widget child; @@ -58,6 +63,7 @@ class CardFocusBorder extends StatelessWidget { context, isFocused: showFocus, borderRadius: borderRadius, + radii: borderRadii, borderStrokeAlign: strokeAlign, ), child: child, diff --git a/lib/focus/focus_theme.dart b/lib/focus/focus_theme.dart index 318ba84d..2f6612da 100644 --- a/lib/focus/focus_theme.dart +++ b/lib/focus/focus_theme.dart @@ -24,17 +24,20 @@ class FocusTheme { return Theme.of(context).extension()?.fast ?? const Duration(milliseconds: 150); } + /// [radii] overrides [borderRadius] when per-corner radii are needed + /// (M3E grouped cards: large outer / small inner corners). static BoxDecoration focusDecoration( BuildContext context, { required bool isFocused, double borderRadius = defaultBorderRadius, + BorderRadius? radii, double borderStrokeAlign = BorderSide.strokeAlignInside, Color? color, }) { final focusColor = color ?? getFocusBorderColor(context); return BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), + borderRadius: radii ?? BorderRadius.circular(borderRadius), border: Border.all( color: isFocused ? focusColor : Colors.transparent, width: focusBorderWidth, @@ -65,9 +68,14 @@ class FocusTheme { /// Build focus decoration with background color instead of border. /// Useful for video controls where it should match the native hover style. - static BoxDecoration focusBackgroundDecoration({required bool isFocused, double borderRadius = defaultBorderRadius}) { + /// [radii] overrides [borderRadius] when per-corner radii are needed. + static BoxDecoration focusBackgroundDecoration({ + required bool isFocused, + double borderRadius = defaultBorderRadius, + BorderRadius? radii, + }) { return BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), + borderRadius: radii ?? BorderRadius.circular(borderRadius), color: isFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, ); } diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 047222aa..50bdb8a5 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -68,6 +68,10 @@ class FocusableWrapper extends StatefulWidget { /// Border radius for the focus indicator. final double borderRadius; + /// Per-corner radii for the focus indicator; overrides [borderRadius] when + /// set (M3E grouped cards: large outer / small inner corners). + final BorderRadius? borderRadii; + /// Whether to scroll the widget into view when focused. final bool autoScroll; @@ -138,6 +142,7 @@ class FocusableWrapper extends StatefulWidget { this.autofocus = false, this.focusNode, this.borderRadius = FocusTheme.defaultBorderRadius, + this.borderRadii, this.autoScroll = true, this.scrollAlignment = 0.5, this.useComfortableZone = false, @@ -487,11 +492,16 @@ class _FocusableWrapperState extends State with SingleTickerPr card = CardFocusScope(showFocus: showFocus, child: widget.child); } else { final focusDecoration = widget.useBackgroundFocus - ? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius) + ? FocusTheme.focusBackgroundDecoration( + isFocused: showFocus, + borderRadius: widget.borderRadius, + radii: widget.borderRadii, + ) : FocusTheme.focusDecoration( context, isFocused: showFocus, borderRadius: widget.borderRadius, + radii: widget.borderRadii, color: widget.focusColor, ); card = AnimatedContainer( diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 4ef05832..b3dd8cb1 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Скривай спойлери за негледани епизоди", "hideSpoilersDescription": "Замазвай миниатюри и описания за негледани епизоди", "playerBackend": "Енджин на плеъра", - "exoPlayer": "ExoPlayer (препоръчително)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Хардуерно декодиране", "hardwareDecodingDescription": "Използвай хардуерно ускорение, когато е налично", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 35037507..f4b44c0f 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Skjul spoilere for usete episoder", "hideSpoilersDescription": "Slør miniaturebilleder og beskrivelser for usete episoder", "playerBackend": "Afspillerbackend", - "exoPlayer": "ExoPlayer (Anbefalet)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Hardwaredekodning", "hardwareDecodingDescription": "Brug hardwareacceleration når tilgængelig", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 70cbcfda..38c4813f 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Spoiler für nicht gesehene Episoden verbergen", "hideSpoilersDescription": "Vorschaubilder und Beschreibungen ungesehener Episoden verwischen", "playerBackend": "Player-Backend", - "exoPlayer": "ExoPlayer (Empfohlen)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Hardware-Decodierung", "hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 6630d384..5035b862 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -148,7 +148,7 @@ "hideSpoilers": "Hide Spoilers for Unwatched Episodes", "hideSpoilersDescription": "Blur thumbnails and descriptions for unwatched episodes", "playerBackend": "Player Backend", - "exoPlayer": "ExoPlayer (Recommended)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Hardware Decoding", "hardwareDecodingDescription": "Use hardware acceleration when available", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 5ecca4a1..bedc2e15 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Ocultar spoilers de episodios no vistos", "hideSpoilersDescription": "Desenfocar miniaturas y descripciones de episodios no vistos", "playerBackend": "Reproductor", - "exoPlayer": "ExoPlayer (Recomendado)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Decodificación por Hardware", "hardwareDecodingDescription": "Usar aceleración por hardware cuando esté disponible", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4e776879..0622e943 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Masquer les spoilers des épisodes non vus", "hideSpoilersDescription": "Flouter les miniatures et descriptions des épisodes non vus", "playerBackend": "Moteur de lecture", - "exoPlayer": "ExoPlayer (Recommandé)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Décodage matériel", "hardwareDecodingDescription": "Utilisez l'accélération matérielle lorsqu'elle est disponible.", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index a4378d03..3d26c94b 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Nascondi spoiler per episodi non visti", "hideSpoilersDescription": "Sfoca miniature e descrizioni degli episodi non visti", "playerBackend": "Motore di riproduzione", - "exoPlayer": "ExoPlayer (Consigliato)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Decodifica Hardware", "hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 58cbd99f..90e77b7a 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "未視聴エピソードのネタバレを非表示", "hideSpoilersDescription": "未視聴エピソードのサムネイルと説明をぼかします", "playerBackend": "プレーヤーバックエンド", - "exoPlayer": "ExoPlayer(推奨)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "ハードウェアデコード", "hardwareDecodingDescription": "利用可能な場合にハードウェアアクセラレーションを使用", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 0a27e6a6..a25418f7 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "미시청 에피소드 스포일러 숨기기", "hideSpoilersDescription": "시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리", "playerBackend": "플레이어 백엔드", - "exoPlayer": "ExoPlayer (권장)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "하드웨어 디코딩", "hardwareDecodingDescription": "가능한 경우 하드웨어 가속을 사용합니다", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index f4766fe7..59d8a382 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Skjul spoilere for usette episoder", "hideSpoilersDescription": "Slør miniatyrbilder og beskrivelser for usette episoder", "playerBackend": "Spillermotor", - "exoPlayer": "ExoPlayer (Anbefalt)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Maskinvaredekoding", "hardwareDecodingDescription": "Bruk maskinvareakselerasjon når tilgjengelig", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index f07149fa..76f2f453 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Spoilers voor ongekeken afleveringen verbergen", "hideSpoilersDescription": "Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen", "playerBackend": "Speler backend", - "exoPlayer": "ExoPlayer (Aanbevolen)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Hardware decodering", "hardwareDecodingDescription": "Gebruik hardware versnelling indien beschikbaar", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 199928ce..d6254574 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Ukryj spoilery nieobejrzanych odcinków", "hideSpoilersDescription": "Rozmywaj miniatury i opisy nieobejrzanych odcinków", "playerBackend": "Backend odtwarzacza", - "exoPlayer": "ExoPlayer (Zalecany)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Dekodowanie sprzętowe", "hardwareDecodingDescription": "Użyj akceleracji sprzętowej, gdy dostępna", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 25f05f81..167eac86 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Ocultar Spoilers de Episódios Não Assistidos", "hideSpoilersDescription": "Desfocar miniaturas e descrições de episódios não vistos", "playerBackend": "Backend do Player", - "exoPlayer": "ExoPlayer (Recomendado)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Decodificação por Hardware", "hardwareDecodingDescription": "Usar aceleração por hardware quando disponível", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 5a2d9b52..08bfff34 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Скрыть спойлеры непросмотренных эпизодов", "hideSpoilersDescription": "Размывать миниатюры и описания непросмотренных серий", "playerBackend": "Бэкенд плеера", - "exoPlayer": "ExoPlayer (Рекомендуется)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Аппаратное декодирование", "hardwareDecodingDescription": "Использовать аппаратное ускорение, когда доступно", diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 9737a47d..bb6c1327 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsBg extends TranslationsSettingsEn { @override String get hideSpoilers => 'Скривай спойлери за негледани епизоди'; @override String get hideSpoilersDescription => 'Замазвай миниатюри и описания за негледани епизоди'; @override String get playerBackend => 'Енджин на плеъра'; - @override String get exoPlayer => 'ExoPlayer (препоръчително)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Хардуерно декодиране'; @override String get hardwareDecodingDescription => 'Използвай хардуерно ускорение, когато е налично'; @@ -2062,7 +2062,7 @@ extension on TranslationsBg { 'settings.hideSpoilers' => 'Скривай спойлери за негледани епизоди', 'settings.hideSpoilersDescription' => 'Замазвай миниатюри и описания за негледани епизоди', 'settings.playerBackend' => 'Енджин на плеъра', - 'settings.exoPlayer' => 'ExoPlayer (препоръчително)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Хардуерно декодиране', 'settings.hardwareDecodingDescription' => 'Използвай хардуерно ускорение, когато е налично', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 4b366202..e0dc31d9 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsDa extends TranslationsSettingsEn { @override String get hideSpoilers => 'Skjul spoilere for usete episoder'; @override String get hideSpoilersDescription => 'Slør miniaturebilleder og beskrivelser for usete episoder'; @override String get playerBackend => 'Afspillerbackend'; - @override String get exoPlayer => 'ExoPlayer (Anbefalet)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Hardwaredekodning'; @override String get hardwareDecodingDescription => 'Brug hardwareacceleration når tilgængelig'; @@ -2062,7 +2062,7 @@ extension on TranslationsDa { 'settings.hideSpoilers' => 'Skjul spoilere for usete episoder', 'settings.hideSpoilersDescription' => 'Slør miniaturebilleder og beskrivelser for usete episoder', 'settings.playerBackend' => 'Afspillerbackend', - 'settings.exoPlayer' => 'ExoPlayer (Anbefalet)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Hardwaredekodning', 'settings.hardwareDecodingDescription' => 'Brug hardwareacceleration når tilgængelig', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index bb90c998..51a98ebb 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsDe extends TranslationsSettingsEn { @override String get hideSpoilers => 'Spoiler für nicht gesehene Episoden verbergen'; @override String get hideSpoilersDescription => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen'; @override String get playerBackend => 'Player-Backend'; - @override String get exoPlayer => 'ExoPlayer (Empfohlen)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Hardware-Decodierung'; @override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, sofern verfügbar'; @@ -2062,7 +2062,7 @@ extension on TranslationsDe { 'settings.hideSpoilers' => 'Spoiler für nicht gesehene Episoden verbergen', 'settings.hideSpoilersDescription' => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen', 'settings.playerBackend' => 'Player-Backend', - 'settings.exoPlayer' => 'ExoPlayer (Empfohlen)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Hardware-Decodierung', 'settings.hardwareDecodingDescription' => 'Hardwarebeschleunigung verwenden, sofern verfügbar', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 69535866..d6f75078 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -555,8 +555,8 @@ class TranslationsSettingsEn { /// en: 'Player Backend' String get playerBackend => 'Player Backend'; - /// en: 'ExoPlayer (Recommended)' - String get exoPlayer => 'ExoPlayer (Recommended)'; + /// en: 'ExoPlayer' + String get exoPlayer => 'ExoPlayer'; /// en: 'mpv' String get mpv => 'mpv'; @@ -4663,7 +4663,7 @@ extension on Translations { 'settings.hideSpoilers' => 'Hide Spoilers for Unwatched Episodes', 'settings.hideSpoilersDescription' => 'Blur thumbnails and descriptions for unwatched episodes', 'settings.playerBackend' => 'Player Backend', - 'settings.exoPlayer' => 'ExoPlayer (Recommended)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Hardware Decoding', 'settings.hardwareDecodingDescription' => 'Use hardware acceleration when available', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index c5c7dad2..aa5cdb88 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsEs extends TranslationsSettingsEn { @override String get hideSpoilers => 'Ocultar spoilers de episodios no vistos'; @override String get hideSpoilersDescription => 'Desenfocar miniaturas y descripciones de episodios no vistos'; @override String get playerBackend => 'Reproductor'; - @override String get exoPlayer => 'ExoPlayer (Recomendado)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Decodificación por Hardware'; @override String get hardwareDecodingDescription => 'Usar aceleración por hardware cuando esté disponible'; @@ -2062,7 +2062,7 @@ extension on TranslationsEs { 'settings.hideSpoilers' => 'Ocultar spoilers de episodios no vistos', 'settings.hideSpoilersDescription' => 'Desenfocar miniaturas y descripciones de episodios no vistos', 'settings.playerBackend' => 'Reproductor', - 'settings.exoPlayer' => 'ExoPlayer (Recomendado)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Decodificación por Hardware', 'settings.hardwareDecodingDescription' => 'Usar aceleración por hardware cuando esté disponible', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 1ca8b96a..94fe5bb4 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsFr extends TranslationsSettingsEn { @override String get hideSpoilers => 'Masquer les spoilers des épisodes non vus'; @override String get hideSpoilersDescription => 'Flouter les miniatures et descriptions des épisodes non vus'; @override String get playerBackend => 'Moteur de lecture'; - @override String get exoPlayer => 'ExoPlayer (Recommandé)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Décodage matériel'; @override String get hardwareDecodingDescription => 'Utilisez l\'accélération matérielle lorsqu\'elle est disponible.'; @@ -2062,7 +2062,7 @@ extension on TranslationsFr { 'settings.hideSpoilers' => 'Masquer les spoilers des épisodes non vus', 'settings.hideSpoilersDescription' => 'Flouter les miniatures et descriptions des épisodes non vus', 'settings.playerBackend' => 'Moteur de lecture', - 'settings.exoPlayer' => 'ExoPlayer (Recommandé)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Décodage matériel', 'settings.hardwareDecodingDescription' => 'Utilisez l\'accélération matérielle lorsqu\'elle est disponible.', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index ad09090b..2656636a 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsIt extends TranslationsSettingsEn { @override String get hideSpoilers => 'Nascondi spoiler per episodi non visti'; @override String get hideSpoilersDescription => 'Sfoca miniature e descrizioni degli episodi non visti'; @override String get playerBackend => 'Motore di riproduzione'; - @override String get exoPlayer => 'ExoPlayer (Consigliato)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Decodifica Hardware'; @override String get hardwareDecodingDescription => 'Utilizza l\'accelerazione hardware quando disponibile'; @@ -2062,7 +2062,7 @@ extension on TranslationsIt { 'settings.hideSpoilers' => 'Nascondi spoiler per episodi non visti', 'settings.hideSpoilersDescription' => 'Sfoca miniature e descrizioni degli episodi non visti', 'settings.playerBackend' => 'Motore di riproduzione', - 'settings.exoPlayer' => 'ExoPlayer (Consigliato)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Decodifica Hardware', 'settings.hardwareDecodingDescription' => 'Utilizza l\'accelerazione hardware quando disponibile', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 88c9ce1c..0191078b 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsJa extends TranslationsSettingsEn { @override String get hideSpoilers => '未視聴エピソードのネタバレを非表示'; @override String get hideSpoilersDescription => '未視聴エピソードのサムネイルと説明をぼかします'; @override String get playerBackend => 'プレーヤーバックエンド'; - @override String get exoPlayer => 'ExoPlayer(推奨)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'ハードウェアデコード'; @override String get hardwareDecodingDescription => '利用可能な場合にハードウェアアクセラレーションを使用'; @@ -2062,7 +2062,7 @@ extension on TranslationsJa { 'settings.hideSpoilers' => '未視聴エピソードのネタバレを非表示', 'settings.hideSpoilersDescription' => '未視聴エピソードのサムネイルと説明をぼかします', 'settings.playerBackend' => 'プレーヤーバックエンド', - 'settings.exoPlayer' => 'ExoPlayer(推奨)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'ハードウェアデコード', 'settings.hardwareDecodingDescription' => '利用可能な場合にハードウェアアクセラレーションを使用', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index b598535a..d5fd42fd 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsKo extends TranslationsSettingsEn { @override String get hideSpoilers => '미시청 에피소드 스포일러 숨기기'; @override String get hideSpoilersDescription => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리'; @override String get playerBackend => '플레이어 백엔드'; - @override String get exoPlayer => 'ExoPlayer (권장)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => '하드웨어 디코딩'; @override String get hardwareDecodingDescription => '가능한 경우 하드웨어 가속을 사용합니다'; @@ -2062,7 +2062,7 @@ extension on TranslationsKo { 'settings.hideSpoilers' => '미시청 에피소드 스포일러 숨기기', 'settings.hideSpoilersDescription' => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리', 'settings.playerBackend' => '플레이어 백엔드', - 'settings.exoPlayer' => 'ExoPlayer (권장)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => '하드웨어 디코딩', 'settings.hardwareDecodingDescription' => '가능한 경우 하드웨어 가속을 사용합니다', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index ffdb5a7e..ab2bfe3e 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsNb extends TranslationsSettingsEn { @override String get hideSpoilers => 'Skjul spoilere for usette episoder'; @override String get hideSpoilersDescription => 'Slør miniatyrbilder og beskrivelser for usette episoder'; @override String get playerBackend => 'Spillermotor'; - @override String get exoPlayer => 'ExoPlayer (Anbefalt)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Maskinvaredekoding'; @override String get hardwareDecodingDescription => 'Bruk maskinvareakselerasjon når tilgjengelig'; @@ -2062,7 +2062,7 @@ extension on TranslationsNb { 'settings.hideSpoilers' => 'Skjul spoilere for usette episoder', 'settings.hideSpoilersDescription' => 'Slør miniatyrbilder og beskrivelser for usette episoder', 'settings.playerBackend' => 'Spillermotor', - 'settings.exoPlayer' => 'ExoPlayer (Anbefalt)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Maskinvaredekoding', 'settings.hardwareDecodingDescription' => 'Bruk maskinvareakselerasjon når tilgjengelig', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index c6bd6b00..17838108 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsNl extends TranslationsSettingsEn { @override String get hideSpoilers => 'Spoilers voor ongekeken afleveringen verbergen'; @override String get hideSpoilersDescription => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen'; @override String get playerBackend => 'Speler backend'; - @override String get exoPlayer => 'ExoPlayer (Aanbevolen)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Hardware decodering'; @override String get hardwareDecodingDescription => 'Gebruik hardware versnelling indien beschikbaar'; @@ -2062,7 +2062,7 @@ extension on TranslationsNl { 'settings.hideSpoilers' => 'Spoilers voor ongekeken afleveringen verbergen', 'settings.hideSpoilersDescription' => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen', 'settings.playerBackend' => 'Speler backend', - 'settings.exoPlayer' => 'ExoPlayer (Aanbevolen)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Hardware decodering', 'settings.hardwareDecodingDescription' => 'Gebruik hardware versnelling indien beschikbaar', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index a3b5099c..971e7efc 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsPl extends TranslationsSettingsEn { @override String get hideSpoilers => 'Ukryj spoilery nieobejrzanych odcinków'; @override String get hideSpoilersDescription => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków'; @override String get playerBackend => 'Backend odtwarzacza'; - @override String get exoPlayer => 'ExoPlayer (Zalecany)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Dekodowanie sprzętowe'; @override String get hardwareDecodingDescription => 'Użyj akceleracji sprzętowej, gdy dostępna'; @@ -2062,7 +2062,7 @@ extension on TranslationsPl { 'settings.hideSpoilers' => 'Ukryj spoilery nieobejrzanych odcinków', 'settings.hideSpoilersDescription' => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków', 'settings.playerBackend' => 'Backend odtwarzacza', - 'settings.exoPlayer' => 'ExoPlayer (Zalecany)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Dekodowanie sprzętowe', 'settings.hardwareDecodingDescription' => 'Użyj akceleracji sprzętowej, gdy dostępna', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 64714522..82a3f8e4 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsPt extends TranslationsSettingsEn { @override String get hideSpoilers => 'Ocultar Spoilers de Episódios Não Assistidos'; @override String get hideSpoilersDescription => 'Desfocar miniaturas e descrições de episódios não vistos'; @override String get playerBackend => 'Backend do Player'; - @override String get exoPlayer => 'ExoPlayer (Recomendado)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Decodificação por Hardware'; @override String get hardwareDecodingDescription => 'Usar aceleração por hardware quando disponível'; @@ -2062,7 +2062,7 @@ extension on TranslationsPt { 'settings.hideSpoilers' => 'Ocultar Spoilers de Episódios Não Assistidos', 'settings.hideSpoilersDescription' => 'Desfocar miniaturas e descrições de episódios não vistos', 'settings.playerBackend' => 'Backend do Player', - 'settings.exoPlayer' => 'ExoPlayer (Recomendado)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Decodificação por Hardware', 'settings.hardwareDecodingDescription' => 'Usar aceleração por hardware quando disponível', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 621cffe3..d5bc8493 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsRu extends TranslationsSettingsEn { @override String get hideSpoilers => 'Скрыть спойлеры непросмотренных эпизодов'; @override String get hideSpoilersDescription => 'Размывать миниатюры и описания непросмотренных серий'; @override String get playerBackend => 'Бэкенд плеера'; - @override String get exoPlayer => 'ExoPlayer (Рекомендуется)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Аппаратное декодирование'; @override String get hardwareDecodingDescription => 'Использовать аппаратное ускорение, когда доступно'; @@ -2062,7 +2062,7 @@ extension on TranslationsRu { 'settings.hideSpoilers' => 'Скрыть спойлеры непросмотренных эпизодов', 'settings.hideSpoilersDescription' => 'Размывать миниатюры и описания непросмотренных серий', 'settings.playerBackend' => 'Бэкенд плеера', - 'settings.exoPlayer' => 'ExoPlayer (Рекомендуется)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Аппаратное декодирование', 'settings.hardwareDecodingDescription' => 'Использовать аппаратное ускорение, когда доступно', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index cd50a40b..39aa2c02 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsSv extends TranslationsSettingsEn { @override String get hideSpoilers => 'Dölj spoilers för osedda avsnitt'; @override String get hideSpoilersDescription => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt'; @override String get playerBackend => 'Spelarmotor'; - @override String get exoPlayer => 'ExoPlayer (Rekommenderad)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => 'Hårdvaruavkodning'; @override String get hardwareDecodingDescription => 'Använd hårdvaruacceleration när tillgängligt'; @@ -2062,7 +2062,7 @@ extension on TranslationsSv { 'settings.hideSpoilers' => 'Dölj spoilers för osedda avsnitt', 'settings.hideSpoilersDescription' => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt', 'settings.playerBackend' => 'Spelarmotor', - 'settings.exoPlayer' => 'ExoPlayer (Rekommenderad)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => 'Hårdvaruavkodning', 'settings.hardwareDecodingDescription' => 'Använd hårdvaruacceleration när tillgängligt', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 13e9063b..1e9f500f 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -271,7 +271,7 @@ class _TranslationsSettingsZh extends TranslationsSettingsEn { @override String get hideSpoilers => '隐藏未看剧集的剧透内容'; @override String get hideSpoilersDescription => '模糊未观看剧集的缩略图和描述'; @override String get playerBackend => '播放器引擎'; - @override String get exoPlayer => 'ExoPlayer(推荐)'; + @override String get exoPlayer => 'ExoPlayer'; @override String get mpv => 'mpv'; @override String get hardwareDecoding => '硬件解码'; @override String get hardwareDecodingDescription => '如果可用,使用硬件加速'; @@ -2062,7 +2062,7 @@ extension on TranslationsZh { 'settings.hideSpoilers' => '隐藏未看剧集的剧透内容', 'settings.hideSpoilersDescription' => '模糊未观看剧集的缩略图和描述', 'settings.playerBackend' => '播放器引擎', - 'settings.exoPlayer' => 'ExoPlayer(推荐)', + 'settings.exoPlayer' => 'ExoPlayer', 'settings.mpv' => 'mpv', 'settings.hardwareDecoding' => '硬件解码', 'settings.hardwareDecodingDescription' => '如果可用,使用硬件加速', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index aa15165b..65e0dedf 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "Dölj spoilers för osedda avsnitt", "hideSpoilersDescription": "Sudda miniatyrbilder och beskrivningar för osedda avsnitt", "playerBackend": "Spelarmotor", - "exoPlayer": "ExoPlayer (Rekommenderad)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "Hårdvaruavkodning", "hardwareDecodingDescription": "Använd hårdvaruacceleration när tillgängligt", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 2e7881ee..e74741ae 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -142,7 +142,7 @@ "hideSpoilers": "隐藏未看剧集的剧透内容", "hideSpoilersDescription": "模糊未观看剧集的缩略图和描述", "playerBackend": "播放器引擎", - "exoPlayer": "ExoPlayer(推荐)", + "exoPlayer": "ExoPlayer", "mpv": "mpv", "hardwareDecoding": "硬件解码", "hardwareDecodingDescription": "如果可用,使用硬件加速", diff --git a/lib/screens/profile/borrow_connection_screen.dart b/lib/screens/profile/borrow_connection_screen.dart index 4748d73c..d9f08d0e 100644 --- a/lib/screens/profile/borrow_connection_screen.dart +++ b/lib/screens/profile/borrow_connection_screen.dart @@ -18,6 +18,7 @@ import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_merge.dart'; import '../../profiles/profile_registry.dart'; import '../../services/storage_service.dart'; +import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/app_icon.dart'; @@ -183,14 +184,24 @@ class _BorrowConnectionScreenState extends State { SliverList( delegate: SliverChildBuilderDelegate((context, index) { final cand = candidates[index]; + // M3E connected-group geometry: large outer corners, small + // inner corners, hairline gaps between tiles. + final tokensRef = tokens(context); + final tileRadii = BorderRadius.vertical( + top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), + bottom: Radius.circular(index == candidates.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), + ); return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0), child: FocusableWrapper( autofocus: index == 0, disableScale: true, + borderRadii: tileRadii, onSelect: _busy ? null : () => _borrow(cand), child: Card( - child: _BorrowTile(candidate: cand, onTap: () => _borrow(cand)), + shape: RoundedRectangleBorder(borderRadius: tileRadii), + clipBehavior: Clip.antiAlias, + child: _BorrowTile(candidate: cand, borderRadius: tileRadii, onTap: () => _borrow(cand)), ), ), ); @@ -348,16 +359,17 @@ class _BorrowCandidate { class _BorrowTile extends StatelessWidget { final _BorrowCandidate candidate; + final BorderRadius borderRadius; final VoidCallback onTap; - const _BorrowTile({required this.candidate, required this.onTap}); + const _BorrowTile({required this.candidate, required this.borderRadius, required this.onTap}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(12), + borderRadius: borderRadius, child: Padding( padding: const EdgeInsets.all(12), child: Row( diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index 2a97d1fa..8c418887 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -29,6 +29,7 @@ import '../../widgets/app_menu.dart'; import '../../widgets/backend_badge.dart'; import '../../widgets/focusable_popup_menu_button.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/settings_section.dart'; import '../../utils/dialogs.dart'; import '../settings/add_connection_screen.dart'; import '../settings/edit_jellyfin_connection_screen.dart'; @@ -439,51 +440,50 @@ class _ConnectionsListState extends State<_ConnectionsList> { ), ); } - return Column( + // The screen already pads its content; SettingsGroup supplies + // the M3E connected-group card geometry. + return SettingsGroup( + margin: EdgeInsets.zero, children: [ if (parentConn != null) - Card( - child: ListTile( - leading: BackendBadge(backend: parentConn.backend, size: 24), - title: Text(parentConn.displayLabel), - subtitle: Text(t.profiles.plexHomeAccount), - trailing: FocusablePopupMenuButton( - icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), - tooltip: t.profiles.manage, - onSelected: (value) { - if (value == 'sign_out') { - unawaited(widget.onSignOutParent(parentConn)); - } - }, - itemBuilder: (_) => [AppMenuItem(value: 'sign_out', label: t.profiles.signOut)], - ), + ListTile( + leading: BackendBadge(backend: parentConn.backend, size: 24), + title: Text(parentConn.displayLabel), + subtitle: Text(t.profiles.plexHomeAccount), + trailing: FocusablePopupMenuButton( + icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), + tooltip: t.profiles.manage, + onSelected: (value) { + if (value == 'sign_out') { + unawaited(widget.onSignOutParent(parentConn)); + } + }, + itemBuilder: (_) => [AppMenuItem(value: 'sign_out', label: t.profiles.signOut)], ), ), for (final pc in visiblePcs) if (byId[pc.connectionId] case final conn?) - Card( - child: ListTile( - leading: BackendBadge(backend: conn.backend, size: 24), - title: Text(conn.displayLabel), - subtitle: _ConnectionSubtitle.build(conn: conn, pc: pc, homeCache: homeCache, theme: theme), - trailing: FocusablePopupMenuButton( - icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), - tooltip: t.profiles.manage, - onSelected: (value) { - if (value == 'default') { - unawaited(pcRegistry.setDefault(profile.id, pc.connectionId)); - } else if (value == 'edit') { - unawaited(widget.onEdit(conn)); - } else if (value == 'remove') { - unawaited(widget.onRemove(pc, conn)); - } - }, - itemBuilder: (_) => [ - if (!pc.isDefault) AppMenuItem(value: 'default', label: t.profiles.makeDefault), - if (conn is JellyfinConnection) AppMenuItem(value: 'edit', label: t.common.edit), - AppMenuItem(value: 'remove', label: t.profiles.removeConnection), - ], - ), + ListTile( + leading: BackendBadge(backend: conn.backend, size: 24), + title: Text(conn.displayLabel), + subtitle: _ConnectionSubtitle.build(conn: conn, pc: pc, homeCache: homeCache, theme: theme), + trailing: FocusablePopupMenuButton( + icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), + tooltip: t.profiles.manage, + onSelected: (value) { + if (value == 'default') { + unawaited(pcRegistry.setDefault(profile.id, pc.connectionId)); + } else if (value == 'edit') { + unawaited(widget.onEdit(conn)); + } else if (value == 'remove') { + unawaited(widget.onRemove(pc, conn)); + } + }, + itemBuilder: (_) => [ + if (!pc.isDefault) AppMenuItem(value: 'default', label: t.profiles.makeDefault), + if (conn is JellyfinConnection) AppMenuItem(value: 'edit', label: t.common.edit), + AppMenuItem(value: 'remove', label: t.profiles.removeConnection), + ], ), ), ], diff --git a/lib/screens/profile/profile_name_field.dart b/lib/screens/profile/profile_name_field.dart index 54d4182b..04096d7d 100644 --- a/lib/screens/profile/profile_name_field.dart +++ b/lib/screens/profile/profile_name_field.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import '../../focus/focusable_text_field.dart'; -/// Bordered "Profile name" text field used by both the new-profile flow and -/// the profile-detail rename row. Optional [trailing] slot for an inline Save -/// button — pass `null` when the screen saves elsewhere (e.g. on Continue). +/// "Profile name" text field used by both the new-profile flow and the +/// profile-detail rename row; inherits the app-wide filled input style. +/// Optional [trailing] slot for an inline Save button — pass `null` when the +/// screen saves elsewhere (e.g. on Continue). class ProfileNameField extends StatelessWidget { const ProfileNameField({ super.key, @@ -38,7 +39,7 @@ class ProfileNameField extends StatelessWidget { focusNode: focusNode, autofocus: autofocus, textInputAction: TextInputAction.done, - decoration: InputDecoration(hintText: hintText, border: const OutlineInputBorder()), + decoration: InputDecoration(hintText: hintText), onChanged: (_) => onChanged?.call(), onNavigateUp: onNavigateUp ?? () => FocusScope.of(context).previousFocus(), onNavigateDown: onNavigateDown ?? () => FocusScope.of(context).nextFocus(), diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index cd9bea95..5370e0d2 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -20,6 +20,7 @@ import '../../profiles/profile_registry.dart'; import '../../profiles/profiles_view.dart'; import '../../services/app_exit_service.dart'; import '../../services/storage_service.dart'; +import '../../theme/mono_tokens.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/app_menu.dart'; import '../../widgets/backend_badge.dart'; @@ -223,6 +224,13 @@ class _ProfileSwitchScreenState extends State with MountedS delegate: SliverChildBuilderDelegate((context, index) { final profile = profiles[index]; final isActive = profile.id == activeId; + // M3E connected-group geometry: large outer corners, small inner + // corners, hairline gaps between tiles. + final tokensRef = tokens(context); + final tileRadii = BorderRadius.vertical( + top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), + bottom: Radius.circular(index == profiles.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), + ); final isFirstSelectable = autofocusFirst && index == 0; final profileFocusNode = _profileFocusNode(profile); final menuFocusNode = _profileMenuFocusNode(profile); @@ -247,17 +255,21 @@ class _ProfileSwitchScreenState extends State with MountedS } return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0), child: FocusableWrapper( autofocus: isFirstSelectable, focusNode: profileFocusNode, disableScale: true, + borderRadii: tileRadii, enableLongPress: hasMenu, onLongPress: hasMenu ? () => _openProfileMenu(profile) : null, onNavigateRight: hasMenu ? () => menuFocusNode.requestFocus() : null, onSelect: _switching || (isActive && !widget.requireSelection) ? null : () => _switchTo(profile), child: Card( + shape: RoundedRectangleBorder(borderRadius: tileRadii), + clipBehavior: Clip.antiAlias, child: _ProfileTile( + borderRadius: tileRadii, profile: profile, isActive: isActive && !widget.requireSelection, chips: _chipsFor(profile, view), @@ -356,6 +368,7 @@ class _ProfileSwitchScreenState extends State with MountedS class _ProfileTile extends StatelessWidget { final Profile profile; final bool isActive; + final BorderRadius borderRadius; final List<_ChipData> chips; final VoidCallback onTap; final VoidCallback? onManage; @@ -368,6 +381,7 @@ class _ProfileTile extends StatelessWidget { const _ProfileTile({ required this.profile, required this.isActive, + required this.borderRadius, required this.chips, required this.onTap, this.onManage, @@ -384,7 +398,7 @@ class _ProfileTile extends StatelessWidget { final hasMenu = onManage != null || onDelete != null || onSignOut != null; return InkWell( onTap: isActive ? null : onTap, - borderRadius: BorderRadius.circular(12), + borderRadius: borderRadius, child: Padding( padding: const EdgeInsets.all(12), child: Row( diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart index 09e9b3a4..25c2e95b 100644 --- a/lib/screens/settings/about_screen.dart +++ b/lib/screens/settings/about_screen.dart @@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/settings_section.dart'; import '../../i18n/strings.g.dart'; import 'licenses_screen.dart'; @@ -52,16 +53,19 @@ class AboutScreen extends StatelessWidget { const SizedBox(height: 40), // Open Source Licenses - Card( - child: ListTile( - leading: const AppIcon(Symbols.description_rounded, fill: 1), - title: Text(t.about.openSourceLicenses), - subtitle: Text(t.about.viewLicensesDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const LicensesScreen())); - }, - ), + SettingsGroup( + margin: EdgeInsets.zero, + children: [ + ListTile( + leading: const AppIcon(Symbols.description_rounded, fill: 1), + title: Text(t.about.openSourceLicenses), + subtitle: Text(t.about.viewLicensesDescription), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const LicensesScreen())); + }, + ), + ], ), const SizedBox(height: 24), diff --git a/lib/screens/settings/add_connection_screen.dart b/lib/screens/settings/add_connection_screen.dart index b897c170..9feeed0a 100644 --- a/lib/screens/settings/add_connection_screen.dart +++ b/lib/screens/settings/add_connection_screen.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../media/media_backend.dart'; +import '../../theme/mono_tokens.dart'; import '../../profiles/profile.dart'; import '../../widgets/backend_badge.dart'; import '../../widgets/focused_scroll_scaffold.dart'; @@ -45,7 +46,21 @@ class AddConnectionScreen extends StatelessWidget { : t.addServer.connectToJellyfinCardSubtitle, builder: (_) => AddJellyfinScreen(targetProfile: targetProfile), ), + if (scoped) + _BackendOption( + backend: null, + title: t.addServer.borrowFromAnotherProfile, + subtitle: t.addServer.borrowFromAnotherProfileSubtitle, + builder: (_) => BorrowConnectionScreen(targetProfile: targetProfile!), + ), ]; + final tokensRef = tokens(context); + // M3E connected-group geometry: large outer corners, small inner corners, + // hairline gaps. + BorderRadius radiiFor(int i) => BorderRadius.vertical( + top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), + bottom: Radius.circular(i == options.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), + ); return FocusedScrollScaffold( title: Text( scoped @@ -58,9 +73,12 @@ class AddConnectionScreen extends StatelessWidget { sliver: SliverList( delegate: SliverChildListDelegate([ for (var i = 0; i < options.length; i++) ...[ - if (i > 0) const SizedBox(height: 12), + if (i > 0) SizedBox(height: tokensRef.groupGap), _BackendCard( - leading: BackendBadge(backend: options[i].backend, size: 28), + borderRadius: radiiFor(i), + leading: options[i].backend != null + ? BackendBadge(backend: options[i].backend!, size: 28) + : const AppIcon(Symbols.share_rounded, fill: 1, size: 28), title: options[i].title, subtitle: options[i].subtitle, onTap: () async { @@ -71,23 +89,6 @@ class AddConnectionScreen extends StatelessWidget { }, ), ], - if (scoped) ...[ - const SizedBox(height: 12), - _BackendCard( - leading: const AppIcon(Symbols.share_rounded, fill: 1, size: 28), - title: t.addServer.borrowFromAnotherProfile, - subtitle: t.addServer.borrowFromAnotherProfileSubtitle, - onTap: () async { - final added = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: targetProfile!)), - ); - if (added == true && context.mounted) { - Navigator.of(context).pop(true); - } - }, - ), - ], ]), ), ), @@ -97,7 +98,8 @@ class AddConnectionScreen extends StatelessWidget { } class _BackendOption { - final MediaBackend backend; + /// Null for the borrow option (renders a share icon instead of a badge). + final MediaBackend? backend; final String title; final String subtitle; final WidgetBuilder builder; @@ -106,27 +108,34 @@ class _BackendOption { } class _BackendCard extends StatelessWidget { + final BorderRadius borderRadius; final Widget leading; final String title; final String subtitle; final VoidCallback onTap; - const _BackendCard({required this.leading, required this.title, required this.subtitle, required this.onTap}); + const _BackendCard({ + required this.borderRadius, + required this.leading, + required this.title, + required this.subtitle, + required this.onTap, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return FocusableWrapper( disableScale: true, - borderRadius: 12, + borderRadii: borderRadius, descendantsAreFocusable: false, onSelect: onTap, child: Material( color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), + borderRadius: borderRadius, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(12), + borderRadius: borderRadius, child: Padding( padding: const EdgeInsets.all(16), child: Row( diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index aba38fe6..57b8c410 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -24,6 +24,7 @@ import '../../services/jellyfin_auth_service.dart'; import '../../services/jellyfin_endpoint_discovery.dart'; import '../../services/jellyfin_lan_discovery_service.dart'; import '../../services/storage_service.dart'; +import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/focused_scroll_scaffold.dart'; @@ -587,7 +588,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(tokens(context).radiusMd), ), child: Row( children: [ @@ -649,13 +650,22 @@ class _AddJellyfinScreenState extends State with AsyncFormSta } if (_localServers.isEmpty) return const []; + final tokensRef = tokens(context); + // M3E connected-group geometry: large outer corners, small inner corners, + // hairline gaps between tiles. + BorderRadius radiiFor(int i) => BorderRadius.vertical( + top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), + bottom: Radius.circular(i == _localServers.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), + ); return [ const SizedBox(height: 16), Text(t.addServer.localServers, style: theme.textTheme.titleSmall), const SizedBox(height: 8), - for (final server in _localServers) ...[ + for (final (i, server) in _localServers.indexed) ...[ + if (i > 0) SizedBox(height: tokensRef.groupGap), _DiscoveredJellyfinServerTile( server: server, + borderRadius: radiiFor(i), focusNode: _discoveredServerFocusNodes[server.id], onNavigateUp: () { final index = _localServers.indexOf(server); @@ -675,8 +685,8 @@ class _AddJellyfinScreenState extends State with AsyncFormSta }, onTap: busy ? null : () => unawaited(_useDiscoveredServer(server)), ), - const SizedBox(height: 8), ], + const SizedBox(height: 8), ]; } @@ -746,6 +756,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta class _DiscoveredJellyfinServerTile extends StatelessWidget { final DiscoveredJellyfinServer server; + final BorderRadius borderRadius; final FocusNode? focusNode; final VoidCallback? onNavigateUp; final VoidCallback? onNavigateDown; @@ -753,6 +764,7 @@ class _DiscoveredJellyfinServerTile extends StatelessWidget { const _DiscoveredJellyfinServerTile({ required this.server, + required this.borderRadius, required this.focusNode, required this.onNavigateUp, required this.onNavigateDown, @@ -772,14 +784,14 @@ class _DiscoveredJellyfinServerTile extends StatelessWidget { onNavigateUp: onNavigateUp, onNavigateDown: onNavigateDown, child: CardFocusBorder( - borderRadius: 12, + borderRadii: borderRadius, strokeAlign: BorderSide.strokeAlignInside, child: Material( color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), + borderRadius: borderRadius, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(12), + borderRadius: borderRadius, child: Padding( padding: const EdgeInsets.all(12), child: Row( diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 298c3b45..4709988d 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -27,163 +27,197 @@ class AppearanceSettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { + // Watched at build level so the tile can be excluded with a plain `if` — + // a child that renders SizedBox.shrink() would corrupt the group corners. + final hasMultipleProfiles = context.watch().hasMultipleProfiles; return SettingsPage( title: Text(t.settings.appearance), children: [ - SettingsSectionHeader(t.settings.display), - _themeSelector(), - _languageSelector(context), - _densitySelector(), - _viewModeSelector(), - _episodePosterModeSelector(), - if (PlatformDetector.isTV()) - SettingSwitchTile( - pref: SettingsService.tvFullCardLayout, - icon: Symbols.image_rounded, - title: t.settings.tvFullCardLayout, - subtitle: t.settings.tvFullCardLayoutDescription, - ), - if (PlatformDetector.isTV()) - SettingSwitchTile( - pref: SettingsService.focusGlow, - icon: Symbols.lightbulb_rounded, - title: t.settings.focusGlow, - subtitle: t.settings.focusGlowDescription, - ), - if (Platform.isAndroid) _visualEffectsSelector(context), - SettingSwitchTile( - pref: SettingsService.showEpisodeNumberOnCards, - icon: Symbols.tag_rounded, - title: t.settings.showEpisodeNumberOnCards, - subtitle: t.settings.showEpisodeNumberOnCardsDescription, - ), - if (!PlatformDetector.isTV()) - SettingSwitchTile( - pref: SettingsService.showSeasonPostersOnTabs, - icon: Symbols.image_rounded, - title: t.settings.showSeasonPostersOnTabs, - subtitle: t.settings.showSeasonPostersOnTabsDescription, - ), - - SettingsSectionHeader(t.settings.homeScreen), - if (!PlatformDetector.isTV()) - SettingSwitchTile( - pref: SettingsService.showHeroSection, - icon: Symbols.featured_play_list_rounded, - title: t.settings.showHeroSection, - subtitle: t.settings.showHeroSectionDescription, - ), - _continueWatchingActionSelector(), - SettingSwitchTile( - pref: SettingsService.useGlobalHubs, - icon: Symbols.home_rounded, - title: t.settings.useGlobalHubs, - subtitle: t.settings.useGlobalHubsDescription, - ), - SettingSwitchTile( - pref: SettingsService.showServerNameOnHubs, - icon: Symbols.dns_rounded, - title: t.settings.showServerNameOnHubs, - subtitle: t.settings.showServerNameOnHubsDescription, + SettingsGroup( + title: t.settings.display, + children: [ + _themeSelector(), + _languageSelector(context), + _densitySelector(), + _viewModeSelector(), + _episodePosterModeSelector(), + if (PlatformDetector.isTV()) + SettingSwitchTile( + pref: SettingsService.tvFullCardLayout, + icon: Symbols.image_rounded, + title: t.settings.tvFullCardLayout, + subtitle: t.settings.tvFullCardLayoutDescription, + ), + if (PlatformDetector.isTV()) + SettingSwitchTile( + pref: SettingsService.focusGlow, + icon: Symbols.lightbulb_rounded, + title: t.settings.focusGlow, + subtitle: t.settings.focusGlowDescription, + ), + if (Platform.isAndroid) _visualEffectsSelector(context), + SettingSwitchTile( + pref: SettingsService.showEpisodeNumberOnCards, + icon: Symbols.tag_rounded, + title: t.settings.showEpisodeNumberOnCards, + subtitle: t.settings.showEpisodeNumberOnCardsDescription, + ), + if (!PlatformDetector.isTV()) + SettingSwitchTile( + pref: SettingsService.showSeasonPostersOnTabs, + icon: Symbols.image_rounded, + title: t.settings.showSeasonPostersOnTabs, + subtitle: t.settings.showSeasonPostersOnTabsDescription, + ), + ], ), - SettingsSectionHeader(t.settings.navigation), - _startupSectionSelector(), - if (Platform.isAndroid) - SettingSwitchTile( - pref: SettingsService.forceTvMode, - icon: Symbols.tv_rounded, - title: t.settings.forceTvMode, - subtitle: t.settings.forceTvModeDescription, - onAfterWrite: (value) { - TvDetectionService.setForceTVSync(value); - _restartApp(context); - }, - ), - if (PlatformDetector.shouldUseSideNavigation(context)) - SettingSwitchTile( - pref: SettingsService.alwaysKeepSidebarOpen, - icon: Symbols.dock_to_left_rounded, - title: t.settings.alwaysKeepSidebarOpen, - subtitle: t.settings.alwaysKeepSidebarOpenDescription, - ), - if (PlatformDetector.shouldUseSideNavigation(context)) - SettingSwitchTile( - pref: SettingsService.groupLibrariesByServer, - icon: Symbols.dns_rounded, - title: t.settings.groupLibrariesByServer, - subtitle: t.settings.groupLibrariesByServerDescription, - ), - if (!PlatformDetector.shouldUseSideNavigation(context)) - SettingSwitchTile( - pref: SettingsService.showNavBarLabels, - icon: Symbols.label_rounded, - title: t.settings.showNavBarLabels, - subtitle: t.settings.showNavBarLabelsDescription, - ), - SettingSwitchTile( - pref: SettingsService.showUnwatchedCount, - icon: Symbols.counter_1_rounded, - title: t.settings.showUnwatchedCount, - subtitle: t.settings.showUnwatchedCountDescription, + SettingsGroup( + title: t.settings.homeScreen, + children: [ + if (!PlatformDetector.isTV()) + SettingSwitchTile( + pref: SettingsService.showHeroSection, + icon: Symbols.featured_play_list_rounded, + title: t.settings.showHeroSection, + subtitle: t.settings.showHeroSectionDescription, + ), + _continueWatchingActionSelector(), + SettingSwitchTile( + pref: SettingsService.useGlobalHubs, + icon: Symbols.home_rounded, + title: t.settings.useGlobalHubs, + subtitle: t.settings.useGlobalHubsDescription, + ), + SettingSwitchTile( + pref: SettingsService.showServerNameOnHubs, + icon: Symbols.dns_rounded, + title: t.settings.showServerNameOnHubs, + subtitle: t.settings.showServerNameOnHubsDescription, + ), + ], ), - if (PlatformDetector.isDesktopOS()) ...[ - SettingsSectionHeader(t.settings.window), - SettingSwitchTile( - pref: SettingsService.startInFullscreen, - icon: Symbols.fullscreen_rounded, - title: t.settings.startInFullscreen, - subtitle: t.settings.startInFullscreenDescription, - ), - SettingSwitchTile( - pref: SettingsService.exitFullscreenOnPlayerClose, - icon: Symbols.fullscreen_exit_rounded, - title: t.settings.exitFullscreenOnPlayerClose, - subtitle: t.settings.exitFullscreenOnPlayerCloseDescription, - ), - ], + SettingsGroup( + title: t.settings.navigation, + children: [ + _startupSectionSelector(), + if (Platform.isAndroid) + SettingSwitchTile( + pref: SettingsService.forceTvMode, + icon: Symbols.tv_rounded, + title: t.settings.forceTvMode, + subtitle: t.settings.forceTvModeDescription, + onAfterWrite: (value) { + TvDetectionService.setForceTVSync(value); + _restartApp(context); + }, + ), + if (PlatformDetector.shouldUseSideNavigation(context)) + SettingSwitchTile( + pref: SettingsService.alwaysKeepSidebarOpen, + icon: Symbols.dock_to_left_rounded, + title: t.settings.alwaysKeepSidebarOpen, + subtitle: t.settings.alwaysKeepSidebarOpenDescription, + ), + if (PlatformDetector.shouldUseSideNavigation(context)) + SettingSwitchTile( + pref: SettingsService.groupLibrariesByServer, + icon: Symbols.dns_rounded, + title: t.settings.groupLibrariesByServer, + subtitle: t.settings.groupLibrariesByServerDescription, + ), + if (!PlatformDetector.shouldUseSideNavigation(context)) + SettingSwitchTile( + pref: SettingsService.showNavBarLabels, + icon: Symbols.label_rounded, + title: t.settings.showNavBarLabels, + subtitle: t.settings.showNavBarLabelsDescription, + ), + SettingSwitchTile( + pref: SettingsService.showUnwatchedCount, + icon: Symbols.counter_1_rounded, + title: t.settings.showUnwatchedCount, + subtitle: t.settings.showUnwatchedCountDescription, + ), + ], + ), - SettingsSectionHeader(t.settings.content), - SettingSwitchTile( - pref: SettingsService.liveTvDefaultFavorites, - icon: Symbols.star_rounded, - title: t.settings.liveTvDefaultFavorites, - subtitle: t.settings.liveTvDefaultFavoritesDescription, - ), - SettingSwitchTile( - pref: SettingsService.hideSpoilers, - icon: Symbols.visibility_off_rounded, - title: t.settings.hideSpoilers, - subtitle: t.settings.hideSpoilersDescription, - ), - _episodeActionSelector(), - _requireProfileSelection(), - SettingSwitchTile( - pref: SettingsService.autoHidePerformanceOverlay, - icon: Symbols.speed_rounded, - title: t.settings.autoHidePerformanceOverlay, - subtitle: t.settings.autoHidePerformanceOverlayDescription, + if (PlatformDetector.isDesktopOS()) + SettingsGroup( + title: t.settings.window, + children: [ + SettingSwitchTile( + pref: SettingsService.startInFullscreen, + icon: Symbols.fullscreen_rounded, + title: t.settings.startInFullscreen, + subtitle: t.settings.startInFullscreenDescription, + ), + SettingSwitchTile( + pref: SettingsService.exitFullscreenOnPlayerClose, + icon: Symbols.fullscreen_exit_rounded, + title: t.settings.exitFullscreenOnPlayerClose, + subtitle: t.settings.exitFullscreenOnPlayerCloseDescription, + ), + ], + ), + + SettingsGroup( + title: t.settings.content, + children: [ + SettingSwitchTile( + pref: SettingsService.liveTvDefaultFavorites, + icon: Symbols.star_rounded, + title: t.settings.liveTvDefaultFavorites, + subtitle: t.settings.liveTvDefaultFavoritesDescription, + ), + SettingSwitchTile( + pref: SettingsService.hideSpoilers, + icon: Symbols.visibility_off_rounded, + title: t.settings.hideSpoilers, + subtitle: t.settings.hideSpoilersDescription, + ), + _episodeActionSelector(), + if (hasMultipleProfiles) + SettingSwitchTile( + pref: SettingsService.requireProfileSelectionOnOpen, + icon: Symbols.person_rounded, + title: t.settings.requireProfileSelectionOnOpen, + subtitle: t.settings.requireProfileSelectionOnOpenDescription, + ), + SettingSwitchTile( + pref: SettingsService.autoHidePerformanceOverlay, + icon: Symbols.speed_rounded, + title: t.settings.autoHidePerformanceOverlay, + subtitle: t.settings.autoHidePerformanceOverlayDescription, + ), + ], ), const SizedBox(height: 24), ], ); } + String _themeModeLabel(settings.ThemeMode mode) => switch (mode) { + settings.ThemeMode.system => t.settings.systemTheme, + settings.ThemeMode.light => t.settings.lightTheme, + settings.ThemeMode.dark => t.settings.darkTheme, + settings.ThemeMode.oled => t.settings.oledTheme, + }; + + // Writes the pref directly; ThemeProvider listens to the pref's listenable + // and applies the change live. The Consumer only feeds the dynamic icon. Widget _themeSelector() { return Consumer( builder: (context, themeProvider, _) { - return SegmentedSetting( + return SettingSelectionTile( + pref: SettingsService.themeMode, 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: themeProvider.setThemeMode, + subtitleBuilder: _themeModeLabel, + options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: _themeModeLabel(m))).toList(), + decode: (v) => v, + encode: (v) => v, ); }, ); @@ -216,29 +250,44 @@ class AppearanceSettingsScreen extends StatelessWidget { ); } + // Same label-row-plus-control layout as SegmentedSetting so slider and + // button-group tiles read as one family inside a SettingsGroup. Widget _densitySelector() { return SettingValueBuilder( pref: SettingsService.libraryDensity, - builder: (_, density, _) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const AppIcon(Symbols.grid_view_rounded, fill: 1), - const SizedBox(width: 16), - Text(t.settings.compact, style: const TextStyle(fontSize: 12, color: Colors.grey)), - Expanded( - child: FocusableSlider( + builder: (context, density, _) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + const AppIcon(Symbols.grid_view_rounded, fill: 1), + const SizedBox(width: 16), + Text(t.settings.libraryDensity, style: theme.textTheme.bodyLarge), + ], + ), + const SizedBox(height: 12), + FocusableSlider( value: density.toDouble(), min: 1, max: 5, divisions: 4, onChanged: (v) => SettingsService.instance.write(SettingsService.libraryDensity, v.round()), ), - ), - Text(t.settings.comfortable, style: const TextStyle(fontSize: 12, color: Colors.grey)), - ], - ), - ), + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Text(t.settings.compact, style: theme.textTheme.bodySmall), + Text(t.settings.comfortable, style: theme.textTheme.bodySmall), + ], + ), + ], + ), + ); + }, ); } @@ -345,20 +394,6 @@ class AppearanceSettingsScreen extends StatelessWidget { }, ); - Widget _requireProfileSelection() { - return Consumer( - builder: (context, activeProvider, _) { - if (!activeProvider.hasMultipleProfiles) return const SizedBox.shrink(); - return SettingSwitchTile( - pref: SettingsService.requireProfileSelectionOnOpen, - icon: Symbols.person_rounded, - title: t.settings.requireProfileSelectionOnOpen, - subtitle: t.settings.requireProfileSelectionOnOpenDescription, - ); - }, - ); - } - String _getLanguageDisplayName(AppLocale locale) { switch (locale) { case AppLocale.en: diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index f246edfb..eb7e7061 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -11,6 +11,7 @@ import '../../i18n/strings.g.dart'; import '../../models/external_player_models.dart'; import '../../services/settings_service.dart'; import '../../utils/dialogs.dart'; +import '../../widgets/expressive_button_group.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_page.dart'; @@ -25,11 +26,15 @@ class ExternalPlayerScreen extends StatelessWidget { return SettingsPage( title: Text(t.externalPlayer.title), children: [ - SettingSwitchTile( - pref: SettingsService.useExternalPlayer, - icon: Symbols.open_in_new_rounded, - title: t.externalPlayer.useExternalPlayer, - subtitle: t.externalPlayer.useExternalPlayerDescription, + SettingsGroup( + children: [ + SettingSwitchTile( + pref: SettingsService.useExternalPlayer, + icon: Symbols.open_in_new_rounded, + title: t.externalPlayer.useExternalPlayer, + subtitle: t.externalPlayer.useExternalPlayerDescription, + ), + ], ), SettingsBuilder( prefs: [ @@ -44,14 +49,20 @@ class ExternalPlayerScreen extends StatelessWidget { final custom = svc.read(SettingsService.customExternalPlayers); return Column( children: [ - SettingsSectionHeader(t.externalPlayer.selectPlayer), - ...knownPlayers.map((p) => _PlayerTile(player: p, selectedId: selected.id)), - SettingsSectionHeader(t.externalPlayer.customPlayers), - ...custom.map((p) => _PlayerTile(player: p, selectedId: selected.id, isCustom: true)), - ListTile( - leading: const AppIcon(Symbols.add_rounded, fill: 1), - title: Text(t.externalPlayer.addCustomPlayer), - onTap: () => _showAddCustomPlayerDialog(context), + SettingsGroup( + title: t.externalPlayer.selectPlayer, + children: [for (final p in knownPlayers) _PlayerTile(player: p, selectedId: selected.id)], + ), + SettingsGroup( + title: t.externalPlayer.customPlayers, + children: [ + for (final p in custom) _PlayerTile(player: p, selectedId: selected.id, isCustom: true), + ListTile( + leading: const AppIcon(Symbols.add_rounded, fill: 1), + title: Text(t.externalPlayer.addCustomPlayer), + onTap: () => _showAddCustomPlayerDialog(context), + ), + ], ), ], ); @@ -197,19 +208,16 @@ class _AddCustomPlayerDialogState extends State<_AddCustomPlayerDialog> { onSubmitted: (_) => _valueFocusNode.requestFocus(), ), const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: SegmentedButton( - segments: [ - ButtonSegment( - value: CustomPlayerType.command, - label: Text(Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand), - ), - ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)), - ], - selected: {_selectedType}, - onSelectionChanged: (value) => setState(() => _selectedType = value.first), - ), + ExpressiveButtonGroup( + segments: [ + ButtonSegment( + value: CustomPlayerType.command, + label: Text(Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand), + ), + ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)), + ], + selected: _selectedType, + onChanged: (value) => setState(() => _selectedType = value), ), const SizedBox(height: 16), FocusableTextField( diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart index f696cffd..14bd157e 100644 --- a/lib/screens/settings/keyboard_shortcuts_screen.dart +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -7,7 +7,9 @@ import '../../services/shader_service.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; +import '../../theme/mono_tokens.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/settings_section.dart'; import 'hotkey_recorder_widget.dart'; class KeyboardShortcutsScreen extends StatelessWidget { @@ -39,33 +41,30 @@ class KeyboardShortcutsScreen extends StatelessWidget { ), ), ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final action = actions[index]; - final hotkey = hotkeys[action]!; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text(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( - keyboardService.formatHotkey(hotkey), - style: const TextStyle(fontFamily: 'monospace'), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 16), + child: SettingsGroup( + children: [ + for (final action in actions) + ListTile( + title: Text(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: BorderRadius.circular(tokens(context).radiusSm), + ), + child: Text( + keyboardService.formatHotkey(hotkeys[action]!), + style: const TextStyle(fontFamily: 'monospace'), + ), ), + onTap: () => _editHotkey(context, action, hotkeys[action]!), ), - onTap: () => _editHotkey(context, action, hotkey), - ), - ); - }, childCount: actions.length), + ], + ), ), ), ], diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index 0784eb49..597f95a6 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -17,6 +17,7 @@ import '../../widgets/app_menu.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focusable_popup_menu_button.dart'; import '../../widgets/settings_builder.dart'; +import '../../widgets/settings_section.dart'; class MpvConfigScreen extends StatefulWidget { const MpvConfigScreen({super.key}); @@ -193,59 +194,51 @@ class _MpvConfigScreenState extends State with SettingsEffectMi Widget _buildPresetsCard() { return SettingValueBuilder>( pref: SettingsService.mpvPresets, - builder: (context, presets, _) => Card( - child: Column( - crossAxisAlignment: .start, - children: [ + builder: (context, presets, _) => SettingsGroup( + title: t.mpvConfig.presets, + // The page already pads its slivers by 16. + margin: EdgeInsets.zero, + children: [ + ListTile( + focusNode: _savePresetFocusNode, + leading: const AppIcon(Symbols.save_rounded, fill: 1), + title: Text(t.mpvConfig.saveAsPreset), + enabled: _textController.text.trim().isNotEmpty, + onTap: _textController.text.trim().isNotEmpty ? _showSavePresetDialog : null, + ), + if (presets.isNotEmpty) + ...presets.map( + (preset) => ListTile( + leading: const AppIcon(Symbols.folder_rounded, fill: 1), + title: Text(preset.name), + trailing: FocusablePopupMenuButton( + icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), + onSelected: (value) { + if (value == 'load') { + _loadPreset(preset); + } else if (value == 'delete') { + _deletePreset(preset); + } + }, + itemBuilder: (context) => [ + AppMenuItem(value: 'load', label: t.mpvConfig.loadPreset), + AppMenuItem(value: 'delete', label: t.mpvConfig.deletePreset), + ], + ), + onTap: () => _loadPreset(preset), + ), + ) + else Padding( padding: const EdgeInsets.all(16), child: Text( - t.mpvConfig.presets, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: .bold), + t.mpvConfig.noPresets, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), - ListTile( - focusNode: _savePresetFocusNode, - leading: const AppIcon(Symbols.save_rounded, fill: 1), - title: Text(t.mpvConfig.saveAsPreset), - enabled: _textController.text.trim().isNotEmpty, - onTap: _textController.text.trim().isNotEmpty ? _showSavePresetDialog : null, - ), - if (presets.isNotEmpty) ...[ - const Divider(), - ...presets.map( - (preset) => ListTile( - leading: const AppIcon(Symbols.folder_rounded, fill: 1), - title: Text(preset.name), - trailing: FocusablePopupMenuButton( - icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), - onSelected: (value) { - if (value == 'load') { - _loadPreset(preset); - } else if (value == 'delete') { - _deletePreset(preset); - } - }, - itemBuilder: (context) => [ - AppMenuItem(value: 'load', label: t.mpvConfig.loadPreset), - AppMenuItem(value: 'delete', label: t.mpvConfig.deletePreset), - ], - ), - onTap: () => _loadPreset(preset), - ), - ), - ] else - Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), - child: Text( - t.mpvConfig.noPresets, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - ], - ), + ], ), ); } diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index 3e47ad5b..882a0830 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -46,172 +46,217 @@ class _PlaybackSettingsScreenState extends State { Widget build(BuildContext context) { final isMobile = PlatformDetector.isMobile(context); - return SettingsPage( - title: Text(t.settings.videoPlayback), - children: [ - SettingsSectionHeader(t.settings.player), - if (Platform.isAndroid) _playerBackendSelector(), - if (PlatformDetector.supportsExternalPlayers()) _externalPlayerTile(), - _hardwareDecodingTile(), - if (PlatformDetector.supportsPictureInPicture()) _autoPipTile(), - if (Platform.isAndroid) _matchContentFrameRateTile(), - if (Platform.isWindows) _matchRefreshRateTile(), - if (Platform.isWindows) _matchDynamicRangeTile(), - _displaySwitchDelayTile(), - _tunneledPlaybackTile(), - if (PlatformDetector.supportsAudioPassthrough()) _audioPassthroughTile(), - _dvConversionModeTile(), - _bufferSizeTile(), - _defaultQualityTile(), - - SettingsSectionHeader(t.settings.subtitlesAndConfig), - SettingNavigationTile( - icon: Symbols.subtitles_rounded, - title: t.settings.subtitleStyling, - subtitle: t.settings.subtitleStylingDescription, - destinationBuilder: (_) => const SubtitleStylingScreen(), - ), - _mpvConfigTile(), - - SettingsSectionHeader(t.settings.seekAndTiming), - SettingNumberTile( - pref: SettingsService.seekTimeSmall, - icon: Symbols.replay_10_rounded, - title: t.settings.smallSkipDuration, - subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 120, - onAfterWrite: (_) => _keyboardService?.refreshFromStorage(), - ), - SettingNumberTile( - pref: SettingsService.seekTimeLarge, - icon: Symbols.replay_30_rounded, - title: t.settings.largeSkipDuration, - subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 120, - onAfterWrite: (_) => _keyboardService?.refreshFromStorage(), - ), - SettingNumberTile( - pref: SettingsService.rewindOnResume, - icon: Symbols.replay_rounded, - title: t.settings.rewindOnResume, - subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 0, - max: 10, - ), - SettingNumberTile( - pref: SettingsService.sleepTimerDuration, - icon: Symbols.bedtime_rounded, - title: t.settings.defaultSleepTimer, - subtitleBuilder: (v) => t.settings.minutesUnit(minutes: v.toString()), - labelText: t.settings.minutesLabel, - suffixText: t.settings.minutesShort, - min: 5, - max: 240, - ), - SettingNumberTile( - pref: SettingsService.maxVolume, - icon: Symbols.volume_up_rounded, - title: t.settings.maxVolume, - subtitleBuilder: (v) => t.settings.maxVolumePercent(percent: v.toString()), - labelText: t.settings.maxVolumeDescription, - suffixText: '%', - min: 100, - max: 300, - ), - - SettingsSectionHeader(t.settings.behavior), - if (DiscordRPCService.isAvailable) - SettingSwitchTile( - pref: SettingsService.enableDiscordRPC, - icon: Symbols.chat_rounded, - title: t.settings.discordRichPresence, - subtitle: t.settings.discordRichPresenceDescription, - onAfterWrite: (v) => DiscordRPCService.instance.setEnabled(v), - ), - if (PlatformDetector.shouldActAsRemoteHost(context)) - SettingSwitchTile( - pref: SettingsService.enableCompanionRemoteServer, - icon: Symbols.phone_android_rounded, - title: t.settings.companionRemoteServer, - subtitle: t.settings.companionRemoteServerDescription, - onAfterWrite: (v) => applyCompanionRemoteServerSetting(context, v), - ), - SettingSwitchTile( - pref: SettingsService.rememberTrackSelections, - icon: Symbols.bookmark_rounded, - title: t.settings.rememberTrackSelections, - subtitle: t.settings.rememberTrackSelectionsDescription, - ), - SettingSwitchTile( - pref: SettingsService.showChapterMarkersOnTimeline, - icon: Symbols.bookmarks_rounded, - title: t.settings.showChapterMarkersOnTimeline, - subtitle: t.settings.showChapterMarkersOnTimelineDescription, - ), - if (!isMobile) - SettingSwitchTile( - pref: SettingsService.clickVideoTogglesPlayback, - icon: Symbols.play_pause_rounded, - title: t.settings.clickVideoTogglesPlayback, - subtitle: t.settings.clickVideoTogglesPlaybackDescription, - ), - - SettingsSectionHeader(t.settings.autoSkip), - SettingSwitchTile( - pref: SettingsService.autoSkipIntro, - icon: Symbols.fast_forward_rounded, - title: t.settings.autoSkipIntro, - subtitle: t.settings.autoSkipIntroDescription, - ), - SettingSwitchTile( - pref: SettingsService.autoSkipCredits, - icon: Symbols.skip_next_rounded, - title: t.settings.autoSkipCredits, - subtitle: t.settings.autoSkipCreditsDescription, - ), - SettingSwitchTile( - pref: SettingsService.forceSkipMarkerFallback, - icon: Symbols.tune_rounded, - title: t.settings.forceSkipMarkerFallback, - subtitle: t.settings.forceSkipMarkerFallbackDescription, - ), - SettingNumberTile( - pref: SettingsService.autoSkipDelay, - icon: Symbols.timer_rounded, - title: t.settings.autoSkipDelay, - subtitleBuilder: (v) => t.settings.autoSkipDelayDescription(seconds: v.toString()), - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 1, - max: 30, - ), - SettingRegexTile( - pref: SettingsService.introPattern, - icon: Symbols.match_case_rounded, - title: t.settings.introPattern, - subtitle: t.settings.introPatternDescription, - defaultValue: SettingsService.defaultIntroPattern, - ), - SettingRegexTile( - pref: SettingsService.creditsPattern, - icon: Symbols.match_case_rounded, - title: t.settings.creditsPattern, - subtitle: t.settings.creditsPatternDescription, - defaultValue: SettingsService.defaultCreditsPattern, - ), - const SizedBox(height: 24), + // Visibility of several Player tiles is pref-reactive; hoisted here so + // group children can use plain `if`s (a SizedBox.shrink() child would + // corrupt the SettingsGroup corner shapes). + return SettingsBuilder( + prefs: const [ + SettingsService.useExoPlayer, + SettingsService.matchRefreshRate, + SettingsService.matchDynamicRange, + SettingsService.matchContentFrameRate, ], + builder: (context) { + final svc = SettingsService.instance; + final exoActive = Platform.isAndroid && svc.read(SettingsService.useExoPlayer); + final showDisplaySwitchDelay = + PlatformDetector.isAppleTV() || + (Platform.isWindows && + (svc.read(SettingsService.matchRefreshRate) || svc.read(SettingsService.matchDynamicRange))) || + (Platform.isAndroid && svc.read(SettingsService.matchContentFrameRate)); + + return SettingsPage( + title: Text(t.settings.videoPlayback), + children: [ + SettingsGroup( + title: t.settings.player, + children: [ + if (Platform.isAndroid) _playerBackendSelector(), + if (PlatformDetector.supportsExternalPlayers()) _externalPlayerTile(), + _hardwareDecodingTile(), + if (PlatformDetector.supportsPictureInPicture()) _autoPipTile(), + if (Platform.isAndroid) _matchContentFrameRateTile(), + if (Platform.isWindows) _matchRefreshRateTile(), + if (Platform.isWindows) _matchDynamicRangeTile(), + if (showDisplaySwitchDelay) _displaySwitchDelayTile(), + if (exoActive) _tunneledPlaybackTile(), + if (PlatformDetector.supportsAudioPassthrough()) _audioPassthroughTile(), + if (exoActive) _dvConversionModeTile(), + _bufferSizeTile(), + _defaultQualityTile(), + ], + ), + + SettingsGroup( + title: t.settings.subtitlesAndConfig, + children: [ + SettingNavigationTile( + icon: Symbols.subtitles_rounded, + title: t.settings.subtitleStyling, + subtitle: t.settings.subtitleStylingDescription, + destinationBuilder: (_) => const SubtitleStylingScreen(), + ), + if (!exoActive) _mpvConfigTile(), + ], + ), + + _seekAndTimingGroup(), + _behaviorGroup(context, isMobile), + _autoSkipGroup(), + const SizedBox(height: 24), + ], + ); + }, ); } + Widget _seekAndTimingGroup() => SettingsGroup( + title: t.settings.seekAndTiming, + children: [ + SettingNumberTile( + pref: SettingsService.seekTimeSmall, + icon: Symbols.replay_10_rounded, + title: t.settings.smallSkipDuration, + subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 120, + onAfterWrite: (_) => _keyboardService?.refreshFromStorage(), + ), + SettingNumberTile( + pref: SettingsService.seekTimeLarge, + icon: Symbols.replay_30_rounded, + title: t.settings.largeSkipDuration, + subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 120, + onAfterWrite: (_) => _keyboardService?.refreshFromStorage(), + ), + SettingNumberTile( + pref: SettingsService.rewindOnResume, + icon: Symbols.replay_rounded, + title: t.settings.rewindOnResume, + subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 0, + max: 10, + ), + SettingNumberTile( + pref: SettingsService.sleepTimerDuration, + icon: Symbols.bedtime_rounded, + title: t.settings.defaultSleepTimer, + subtitleBuilder: (v) => t.settings.minutesUnit(minutes: v.toString()), + labelText: t.settings.minutesLabel, + suffixText: t.settings.minutesShort, + min: 5, + max: 240, + ), + SettingNumberTile( + pref: SettingsService.maxVolume, + icon: Symbols.volume_up_rounded, + title: t.settings.maxVolume, + subtitleBuilder: (v) => t.settings.maxVolumePercent(percent: v.toString()), + labelText: t.settings.maxVolumeDescription, + suffixText: '%', + min: 100, + max: 300, + ), + ], + ); + + Widget _behaviorGroup(BuildContext context, bool isMobile) => SettingsGroup( + title: t.settings.behavior, + children: [ + if (DiscordRPCService.isAvailable) + SettingSwitchTile( + pref: SettingsService.enableDiscordRPC, + icon: Symbols.chat_rounded, + title: t.settings.discordRichPresence, + subtitle: t.settings.discordRichPresenceDescription, + onAfterWrite: (v) => DiscordRPCService.instance.setEnabled(v), + ), + if (PlatformDetector.shouldActAsRemoteHost(context)) + SettingSwitchTile( + pref: SettingsService.enableCompanionRemoteServer, + icon: Symbols.phone_android_rounded, + title: t.settings.companionRemoteServer, + subtitle: t.settings.companionRemoteServerDescription, + onAfterWrite: (v) => applyCompanionRemoteServerSetting(context, v), + ), + SettingSwitchTile( + pref: SettingsService.rememberTrackSelections, + icon: Symbols.bookmark_rounded, + title: t.settings.rememberTrackSelections, + subtitle: t.settings.rememberTrackSelectionsDescription, + ), + SettingSwitchTile( + pref: SettingsService.showChapterMarkersOnTimeline, + icon: Symbols.bookmarks_rounded, + title: t.settings.showChapterMarkersOnTimeline, + subtitle: t.settings.showChapterMarkersOnTimelineDescription, + ), + if (!isMobile) + SettingSwitchTile( + pref: SettingsService.clickVideoTogglesPlayback, + icon: Symbols.play_pause_rounded, + title: t.settings.clickVideoTogglesPlayback, + subtitle: t.settings.clickVideoTogglesPlaybackDescription, + ), + ], + ); + + Widget _autoSkipGroup() => SettingsGroup( + title: t.settings.autoSkip, + children: [ + SettingSwitchTile( + pref: SettingsService.autoSkipIntro, + icon: Symbols.fast_forward_rounded, + title: t.settings.autoSkipIntro, + subtitle: t.settings.autoSkipIntroDescription, + ), + SettingSwitchTile( + pref: SettingsService.autoSkipCredits, + icon: Symbols.skip_next_rounded, + title: t.settings.autoSkipCredits, + subtitle: t.settings.autoSkipCreditsDescription, + ), + SettingSwitchTile( + pref: SettingsService.forceSkipMarkerFallback, + icon: Symbols.tune_rounded, + title: t.settings.forceSkipMarkerFallback, + subtitle: t.settings.forceSkipMarkerFallbackDescription, + ), + SettingNumberTile( + pref: SettingsService.autoSkipDelay, + icon: Symbols.timer_rounded, + title: t.settings.autoSkipDelay, + subtitleBuilder: (v) => t.settings.autoSkipDelayDescription(seconds: v.toString()), + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 1, + max: 30, + ), + SettingRegexTile( + pref: SettingsService.introPattern, + icon: Symbols.match_case_rounded, + title: t.settings.introPattern, + subtitle: t.settings.introPatternDescription, + defaultValue: SettingsService.defaultIntroPattern, + ), + SettingRegexTile( + pref: SettingsService.creditsPattern, + icon: Symbols.match_case_rounded, + title: t.settings.creditsPattern, + subtitle: t.settings.creditsPatternDescription, + defaultValue: SettingsService.defaultCreditsPattern, + ), + ], + ); + Widget _playerBackendSelector() => SettingSegmentedTile( pref: SettingsService.useExoPlayer, icon: Symbols.play_circle_rounded, @@ -283,62 +328,36 @@ class _PlaybackSettingsScreenState extends State { subtitle: t.settings.audioPassthroughDescription, ); - Widget _displaySwitchDelayTile() => SettingsBuilder( - prefs: const [ - SettingsService.matchRefreshRate, - SettingsService.matchDynamicRange, - SettingsService.matchContentFrameRate, - ], - builder: (context) { - final svc = SettingsService.instance; - final shouldShow = - PlatformDetector.isAppleTV() || - (Platform.isWindows && - (svc.read(SettingsService.matchRefreshRate) || svc.read(SettingsService.matchDynamicRange))) || - (Platform.isAndroid && svc.read(SettingsService.matchContentFrameRate)); - if (!shouldShow) return const SizedBox.shrink(); - return SettingNumberTile( - pref: SettingsService.displaySwitchDelay, - icon: Symbols.timer_rounded, - title: t.settings.displaySwitchDelay, - subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), - labelText: t.settings.secondsLabel, - suffixText: t.settings.secondsShort, - min: 0, - max: 10, - ); - }, + // Visibility for this and the three tiles below is decided by the hoisted + // SettingsBuilder in build(). + Widget _displaySwitchDelayTile() => SettingNumberTile( + pref: SettingsService.displaySwitchDelay, + icon: Symbols.timer_rounded, + title: t.settings.displaySwitchDelay, + subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()), + labelText: t.settings.secondsLabel, + suffixText: t.settings.secondsShort, + min: 0, + max: 10, ); - Widget _tunneledPlaybackTile() => SettingValueBuilder( - pref: SettingsService.useExoPlayer, - builder: (_, useExo, _) { - if (!Platform.isAndroid || !useExo) return const SizedBox.shrink(); - return SettingSwitchTile( - pref: SettingsService.tunneledPlayback, - icon: Symbols.tv_options_input_settings_rounded, - title: t.settings.tunneledPlayback, - subtitle: t.settings.tunneledPlaybackDescription, - ); - }, + Widget _tunneledPlaybackTile() => SettingSwitchTile( + pref: SettingsService.tunneledPlayback, + icon: Symbols.tv_options_input_settings_rounded, + title: t.settings.tunneledPlayback, + subtitle: t.settings.tunneledPlaybackDescription, ); - Widget _dvConversionModeTile() => SettingValueBuilder( - pref: SettingsService.useExoPlayer, - builder: (_, useExo, _) { - if (!Platform.isAndroid || !useExo) return const SizedBox.shrink(); - return SettingSelectionTile( - pref: SettingsService.dvConversionMode, - icon: Symbols.hdr_strong_rounded, - title: t.settings.dvConversionMode, - subtitleBuilder: (mode) => '${_dvConversionModeLabel(mode)} · ${t.settings.dvConversionModeDescription}', - options: DvConversionModePreference.values - .map((m) => DialogOption(value: m, title: _dvConversionModeLabel(m))) - .toList(), - decode: (m) => m, - encode: (m) => m, - ); - }, + Widget _dvConversionModeTile() => SettingSelectionTile( + pref: SettingsService.dvConversionMode, + icon: Symbols.hdr_strong_rounded, + title: t.settings.dvConversionMode, + subtitleBuilder: (mode) => '${_dvConversionModeLabel(mode)} · ${t.settings.dvConversionModeDescription}', + options: DvConversionModePreference.values + .map((m) => DialogOption(value: m, title: _dvConversionModeLabel(m))) + .toList(), + decode: (m) => m, + encode: (m) => m, ); String _dvConversionModeLabel(DvConversionModePreference mode) => switch (mode) { @@ -383,16 +402,10 @@ class _PlaybackSettingsScreenState extends State { encode: (p) => p, ); - Widget _mpvConfigTile() => SettingValueBuilder( - pref: SettingsService.useExoPlayer, - builder: (_, useExo, _) { - if (Platform.isAndroid && useExo) return const SizedBox.shrink(); - return SettingNavigationTile( - icon: Symbols.tune_rounded, - title: t.mpvConfig.title, - subtitle: t.mpvConfig.description, - destinationBuilder: (_) => const MpvConfigScreen(), - ); - }, + Widget _mpvConfigTile() => SettingNavigationTile( + icon: Symbols.tune_rounded, + title: t.mpvConfig.title, + subtitle: t.mpvConfig.description, + destinationBuilder: (_) => const MpvConfigScreen(), ); } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index e36158f2..c6f15812 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -144,18 +144,18 @@ class _SettingsScreenState extends State with FocusableTab, Moun ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)), SliverList( delegate: SliverChildListDelegate([ - if (DonationService.isEnabled) _buildDonateTile(), - - _buildAppearanceTile(), - - _buildPlaybackTile(), - - _buildTrackersTile(), + const SizedBox(height: 8), + SettingsGroup( + children: [ + if (DonationService.isEnabled) _buildDonateTile(), + _buildAppearanceTile(), + _buildPlaybackTile(), + _buildTrackersTile(), + ], + ), _buildConnectionsSection(), - _buildProfilesSection(), - if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(), if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()], @@ -166,12 +166,17 @@ class _SettingsScreenState extends State with FocusableTab, Moun if (!PlatformDetector.isTV()) _buildBackupSection(), - SettingNavigationTile( - focusNode: _focusTracker.get(_kAbout), - icon: Symbols.info_rounded, - title: t.settings.about, - subtitle: t.settings.aboutDescription, - destinationBuilder: (context) => const AboutScreen(), + const SizedBox(height: 24), + SettingsGroup( + children: [ + SettingNavigationTile( + focusNode: _focusTracker.get(_kAbout), + icon: Symbols.info_rounded, + title: t.settings.about, + subtitle: t.settings.aboutDescription, + destinationBuilder: (context) => const AboutScreen(), + ), + ], ), const SizedBox(height: 24), ]), @@ -253,10 +258,9 @@ class _SettingsScreenState extends State with FocusableTab, Moun ? t.connections.addConnectionSubtitleNoProfile : t.connections.addConnectionSubtitleScoped(displayName: active.displayName); - return Column( - crossAxisAlignment: .start, + return SettingsGroup( + title: t.connections.sectionTitle, children: [ - SettingsSectionHeader(t.connections.sectionTitle), // Connections are managed per-profile (via the Profiles section // and each profile's detail screen). The shortcut here just opens // the picker scoped to the active profile so users can add a Plex @@ -270,11 +274,12 @@ class _SettingsScreenState extends State with FocusableTab, Moun Navigator.push(context, MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: active))); }, ), + _buildProfilesTile(), ], ); } - Widget _buildProfilesSection() { + Widget _buildProfilesTile() { // ActiveProfileProvider already merges local rows with virtual Plex // Home profiles — counting only the local DB rows made every Plex Home // household read as a single profile here. `context.select` keeps @@ -302,10 +307,9 @@ class _SettingsScreenState extends State with FocusableTab, Moun final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); - return Column( - crossAxisAlignment: .start, + return SettingsGroup( + title: t.settings.downloads, children: [ - SettingsSectionHeader(t.settings.downloads), if (!Platform.isIOS) FutureBuilder( future: storageService.getCurrentDownloadPathDisplay(), @@ -342,10 +346,9 @@ class _SettingsScreenState extends State with FocusableTab, Moun Widget _buildKeyboardShortcutsSection() { if (_keyboardService == null) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: .start, + return SettingsGroup( + title: t.settings.keyboardShortcuts, children: [ - SettingsSectionHeader(t.settings.keyboardShortcuts), SettingNavigationTile( focusNode: _focusTracker.get(_kVideoPlayerControls), icon: Symbols.keyboard_rounded, @@ -370,10 +373,9 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Widget _buildAdvancedSection() { - return Column( - crossAxisAlignment: .start, + return SettingsGroup( + title: t.settings.advanced, children: [ - SettingsSectionHeader(t.settings.advanced), ListTile( focusNode: _focusTracker.get(_kWatchTogetherRelay), leading: const AppIcon(Symbols.dns_rounded, fill: 1), @@ -446,10 +448,9 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Widget _buildBackupSection() { - return Column( - crossAxisAlignment: .start, + return SettingsGroup( + title: t.settings.backup, children: [ - SettingsSectionHeader(t.settings.backup), ListTile( focusNode: _focusTracker.get(_kExportSettings), leading: const AppIcon(Symbols.upload_rounded, fill: 1), @@ -480,10 +481,9 @@ class _SettingsScreenState extends State with FocusableTab, Moun Widget _buildUpdateSection() { if (UpdateService.useNativeUpdater) { - return Column( - crossAxisAlignment: .start, + return SettingsGroup( + title: t.settings.updates, children: [ - SettingsSectionHeader(t.settings.updates), ListTile( focusNode: _focusTracker.get(_kCheckForUpdates), leading: const AppIcon(Symbols.system_update_rounded, fill: 1), @@ -498,10 +498,9 @@ class _SettingsScreenState extends State with FocusableTab, Moun final hasUpdate = _updateInfo != null && _updateInfo!['hasUpdate'] == true; - return Column( - crossAxisAlignment: .start, + return SettingsGroup( + title: t.settings.updates, children: [ - SettingsSectionHeader(t.settings.updates), ListTile( focusNode: _focusTracker.get(_kCheckForUpdates), leading: AppIcon( diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index 7bf6458d..e80057c1 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -45,116 +45,128 @@ class SubtitleStylingScreen extends StatelessWidget { return SettingsPage( title: Text(t.screens.subtitleStyling), children: [ - SettingsSectionHeader(t.subtitlingStyling.text), - SettingSelectionTile( - pref: SettingsService.subAssOverride, - icon: Symbols.subtitles_rounded, - title: t.subtitlingStyling.assOverride, - subtitleBuilder: _assOverrideLabel, - options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, - ), - // iOS/tvOS avfoundation VO: screen vs video-resolution basis. - if (Platform.isIOS) - SettingSelectionTile( - pref: SettingsService.subtitleRenderResolution, - icon: Symbols.aspect_ratio_rounded, - title: t.subtitlingStyling.renderResolution, - subtitleBuilder: _renderResolutionLabel, - options: const [ - SubtitleRenderResolution.screen, - SubtitleRenderResolution.video, - ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, - ), - // Android libass overlay: full or a fractional render scale (perf knob for - // render-bound low-end TVs; heavy/animated signs raster faster at < 1). - if (Platform.isAndroid) - SettingSelectionTile( - pref: SettingsService.subtitleRenderResolution, - icon: Symbols.aspect_ratio_rounded, - title: t.subtitlingStyling.renderResolution, - subtitleBuilder: _renderResolutionLabel, - options: const [ - SubtitleRenderResolution.screen, - SubtitleRenderResolution.threeQuarter, - SubtitleRenderResolution.half, - SubtitleRenderResolution.third, - SubtitleRenderResolution.quarter, - ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, - ), - SettingNumberTile( - pref: SettingsService.subtitleFontSize, - icon: Symbols.format_size_rounded, - title: t.subtitlingStyling.fontSize, - subtitleBuilder: (v) => '$v', - labelText: t.subtitlingStyling.fontSize, - suffixText: '', - min: 10, - max: 80, - ), - SettingColorTile( - pref: SettingsService.subtitleTextColor, - icon: Symbols.format_color_text_rounded, - title: t.subtitlingStyling.textColor, - ), - SettingNumberTile( - pref: SettingsService.subtitlePosition, - icon: Symbols.vertical_align_bottom_rounded, - title: t.subtitlingStyling.position, - subtitleBuilder: _formatPosition, - labelText: t.subtitlingStyling.position, - suffixText: '%', - min: 0, - max: 100, - ), - SettingSwitchTile( - pref: SettingsService.subtitleBold, - icon: Symbols.format_bold_rounded, - title: t.subtitlingStyling.bold, - ), - SettingSwitchTile( - pref: SettingsService.subtitleItalic, - icon: Symbols.format_italic_rounded, - title: t.subtitlingStyling.italic, + SettingsGroup( + title: t.subtitlingStyling.text, + children: [ + SettingSelectionTile( + pref: SettingsService.subAssOverride, + icon: Symbols.subtitles_rounded, + title: t.subtitlingStyling.assOverride, + subtitleBuilder: _assOverrideLabel, + options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(), + decode: (v) => v, + encode: (v) => v, + ), + // iOS/tvOS avfoundation VO: screen vs video-resolution basis. + if (Platform.isIOS) + SettingSelectionTile( + pref: SettingsService.subtitleRenderResolution, + icon: Symbols.aspect_ratio_rounded, + title: t.subtitlingStyling.renderResolution, + subtitleBuilder: _renderResolutionLabel, + options: const [ + SubtitleRenderResolution.screen, + SubtitleRenderResolution.video, + ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), + decode: (v) => v, + encode: (v) => v, + ), + // Android libass overlay: full or a fractional render scale (perf knob for + // render-bound low-end TVs; heavy/animated signs raster faster at < 1). + if (Platform.isAndroid) + SettingSelectionTile( + pref: SettingsService.subtitleRenderResolution, + icon: Symbols.aspect_ratio_rounded, + title: t.subtitlingStyling.renderResolution, + subtitleBuilder: _renderResolutionLabel, + options: const [ + SubtitleRenderResolution.screen, + SubtitleRenderResolution.threeQuarter, + SubtitleRenderResolution.half, + SubtitleRenderResolution.third, + SubtitleRenderResolution.quarter, + ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), + decode: (v) => v, + encode: (v) => v, + ), + SettingNumberTile( + pref: SettingsService.subtitleFontSize, + icon: Symbols.format_size_rounded, + title: t.subtitlingStyling.fontSize, + subtitleBuilder: (v) => '$v', + labelText: t.subtitlingStyling.fontSize, + suffixText: '', + min: 10, + max: 80, + ), + SettingColorTile( + pref: SettingsService.subtitleTextColor, + icon: Symbols.format_color_text_rounded, + title: t.subtitlingStyling.textColor, + ), + SettingNumberTile( + pref: SettingsService.subtitlePosition, + icon: Symbols.vertical_align_bottom_rounded, + title: t.subtitlingStyling.position, + subtitleBuilder: _formatPosition, + labelText: t.subtitlingStyling.position, + suffixText: '%', + min: 0, + max: 100, + ), + SettingSwitchTile( + pref: SettingsService.subtitleBold, + icon: Symbols.format_bold_rounded, + title: t.subtitlingStyling.bold, + ), + SettingSwitchTile( + pref: SettingsService.subtitleItalic, + icon: Symbols.format_italic_rounded, + title: t.subtitlingStyling.italic, + ), + ], ), - SettingsSectionHeader(t.subtitlingStyling.border), - SettingNumberTile( - pref: SettingsService.subtitleBorderSize, - icon: Symbols.border_style_rounded, - title: t.subtitlingStyling.borderSize, - subtitleBuilder: (v) => '$v', - labelText: t.subtitlingStyling.borderSize, - suffixText: '', - min: 0, - max: 5, - ), - SettingColorTile( - pref: SettingsService.subtitleBorderColor, - icon: Symbols.border_color_rounded, - title: t.subtitlingStyling.borderColor, + SettingsGroup( + title: t.subtitlingStyling.border, + children: [ + SettingNumberTile( + pref: SettingsService.subtitleBorderSize, + icon: Symbols.border_style_rounded, + title: t.subtitlingStyling.borderSize, + subtitleBuilder: (v) => '$v', + labelText: t.subtitlingStyling.borderSize, + suffixText: '', + min: 0, + max: 5, + ), + SettingColorTile( + pref: SettingsService.subtitleBorderColor, + icon: Symbols.border_color_rounded, + title: t.subtitlingStyling.borderColor, + ), + ], ), - SettingsSectionHeader(t.subtitlingStyling.background), - SettingNumberTile( - pref: SettingsService.subtitleBackgroundOpacity, - icon: Symbols.opacity_rounded, - title: t.subtitlingStyling.backgroundOpacity, - subtitleBuilder: (v) => '$v%', - labelText: t.subtitlingStyling.backgroundOpacity, - suffixText: '%', - min: 0, - max: 100, - ), - SettingColorTile( - pref: SettingsService.subtitleBackgroundColor, - icon: Symbols.format_color_fill_rounded, - title: t.subtitlingStyling.backgroundColor, + SettingsGroup( + title: t.subtitlingStyling.background, + children: [ + SettingNumberTile( + pref: SettingsService.subtitleBackgroundOpacity, + icon: Symbols.opacity_rounded, + title: t.subtitlingStyling.backgroundOpacity, + subtitleBuilder: (v) => '$v%', + labelText: t.subtitlingStyling.backgroundOpacity, + suffixText: '%', + min: 0, + max: 100, + ), + SettingColorTile( + pref: SettingsService.subtitleBackgroundColor, + icon: Symbols.format_color_fill_rounded, + title: t.subtitlingStyling.backgroundColor, + ), + ], ), const SizedBox(height: 24), ], diff --git a/lib/screens/settings/tracker_account_settings_body.dart b/lib/screens/settings/tracker_account_settings_body.dart index 93ba25ee..b228a73a 100644 --- a/lib/screens/settings/tracker_account_settings_body.dart +++ b/lib/screens/settings/tracker_account_settings_body.dart @@ -52,40 +52,52 @@ class TrackerAccountSettingsBody extends StatelessWidget { return SettingsPage( title: title, children: [ - ListTile( - leading: const AppIcon(Symbols.account_circle_rounded, fill: 1), - title: Text(accountTitle), - subtitle: accountSubtitle != null ? Text(accountSubtitle!) : null, + SettingsGroup( + children: [ + ListTile( + leading: const AppIcon(Symbols.account_circle_rounded, fill: 1), + title: Text(accountTitle), + subtitle: accountSubtitle != null ? Text(accountSubtitle!) : null, + ), + ], ), - SettingsSectionHeader(t.settings.behavior), - for (final toggle in toggles) - SettingSwitchTile( - pref: toggle.pref, - icon: toggle.icon, - title: toggle.title, - subtitle: toggle.subtitle, - onAfterWrite: toggle.onAfterWrite, - ), - SettingsBuilder( - prefs: [SettingsService.trackerFilterModePref(service), SettingsService.trackerFilterIdsPref(service)], - builder: (context) { - final settings = SettingsService.instance; - return ListTile( - leading: const AppIcon(Symbols.filter_list_rounded, fill: 1), - title: Text(t.trackers.libraryFilter.title), - subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(settings, service)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => TrackerLibraryFilterScreen(service: service))), - ); - }, + SettingsGroup( + title: t.settings.behavior, + children: [ + for (final toggle in toggles) + SettingSwitchTile( + pref: toggle.pref, + icon: toggle.icon, + title: toggle.title, + subtitle: toggle.subtitle, + onAfterWrite: toggle.onAfterWrite, + ), + SettingsBuilder( + prefs: [SettingsService.trackerFilterModePref(service), SettingsService.trackerFilterIdsPref(service)], + builder: (context) { + final settings = SettingsService.instance; + return ListTile( + leading: const AppIcon(Symbols.filter_list_rounded, fill: 1), + title: Text(t.trackers.libraryFilter.title), + subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(settings, service)), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => TrackerLibraryFilterScreen(service: service))), + ); + }, + ), + ], ), - const Divider(height: 32), - ListTile( - leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error), - title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)), - onTap: () => unawaited(Future.sync(onDisconnect)), + const SizedBox(height: 24), + SettingsGroup( + children: [ + ListTile( + leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error), + title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)), + onTap: () => unawaited(Future.sync(onDisconnect)), + ), + ], ), const SizedBox(height: 24), ], diff --git a/lib/screens/settings/tracker_library_filter_screen.dart b/lib/screens/settings/tracker_library_filter_screen.dart index 572c513c..bd08ba21 100644 --- a/lib/screens/settings/tracker_library_filter_screen.dart +++ b/lib/screens/settings/tracker_library_filter_screen.dart @@ -58,6 +58,22 @@ class TrackerLibraryFilterScreen extends StatelessWidget { final grouped = _groupByServer(libraries); final showServerHeaders = grouped.length > 1; + Widget libraryTile(MediaLibrary lib) => FocusableSwitchListTile( + key: ValueKey('tracker-library-filter-${lib.globalKey}'), + secondary: const AppIcon(Symbols.folder_rounded, fill: 1), + title: Text(lib.title), + value: selectedIds.contains(lib.globalKey), + onChanged: (v) async { + final next = Set.of(selectedIds); + if (v) { + next.add(lib.globalKey); + } else { + next.remove(lib.globalKey); + } + await settings.write(idsPref, next.toList()); + }, + ); + final children = [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), @@ -68,53 +84,55 @@ class TrackerLibraryFilterScreen extends StatelessWidget { style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), - SettingSegmentedTile( - pref: modePref, - icon: Symbols.filter_list_rounded, - title: t.trackers.libraryFilter.mode, - segments: [ - ButtonSegment( - value: TrackerLibraryFilterMode.blacklist, - label: Text(t.trackers.libraryFilter.modeBlacklist), - ), - ButtonSegment( - value: TrackerLibraryFilterMode.whitelist, - label: Text(t.trackers.libraryFilter.modeWhitelist), + SettingsGroup( + children: [ + SettingSegmentedTile( + pref: modePref, + icon: Symbols.filter_list_rounded, + title: t.trackers.libraryFilter.mode, + segments: [ + ButtonSegment( + value: TrackerLibraryFilterMode.blacklist, + label: Text(t.trackers.libraryFilter.modeBlacklist), + ), + ButtonSegment( + value: TrackerLibraryFilterMode.whitelist, + label: Text(t.trackers.libraryFilter.modeWhitelist), + ), + ], + decode: (v) => v, + encode: (v) => v, ), ], - decode: (v) => v, - encode: (v) => v, ), - SettingsSectionHeader(t.trackers.libraryFilter.libraries), ]; if (libraries.isEmpty) { - children.add(ListTile(title: Text(t.trackers.libraryFilter.noLibraries))); - } else { + children.add( + SettingsGroup( + title: t.trackers.libraryFilter.libraries, + children: [ListTile(title: Text(t.trackers.libraryFilter.noLibraries))], + ), + ); + } else if (showServerHeaders) { for (final entry in grouped.entries) { - if (showServerHeaders) { - children.add(SettingsSectionHeader(entry.value.first.serverName ?? entry.key)); - } - for (final lib in entry.value) { - children.add( - FocusableSwitchListTile( - key: ValueKey('tracker-library-filter-${lib.globalKey}'), - secondary: const AppIcon(Symbols.folder_rounded, fill: 1), - title: Text(lib.title), - value: selectedIds.contains(lib.globalKey), - onChanged: (v) async { - final next = Set.of(selectedIds); - if (v) { - next.add(lib.globalKey); - } else { - next.remove(lib.globalKey); - } - await settings.write(idsPref, next.toList()); - }, - ), - ); - } + children.add( + SettingsGroup( + title: entry.value.first.serverName ?? entry.key, + children: [for (final lib in entry.value) libraryTile(lib)], + ), + ); } + } else { + children.add( + SettingsGroup( + title: t.trackers.libraryFilter.libraries, + children: [ + for (final libs in grouped.values) + for (final lib in libs) libraryTile(lib), + ], + ), + ); } children.add(const SizedBox(height: 24)); diff --git a/lib/screens/settings/trackers_settings_screen.dart b/lib/screens/settings/trackers_settings_screen.dart index ce67e1aa..d40b1773 100644 --- a/lib/screens/settings/trackers_settings_screen.dart +++ b/lib/screens/settings/trackers_settings_screen.dart @@ -8,6 +8,7 @@ import '../../providers/trackers_provider.dart'; import '../../providers/trakt_account_provider.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/settings_section.dart'; import 'tracker_settings_screen.dart'; import 'trakt_settings_screen.dart'; @@ -32,10 +33,7 @@ class TrackersSettingsScreen extends StatelessWidget { ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), - _trakt(), - _mal(), - _anilist(), - _simkl(), + SettingsGroup(children: [_trakt(), _mal(), _anilist(), _simkl()]), const SizedBox(height: 24), ]), ), diff --git a/lib/theme/mono_motion.dart b/lib/theme/mono_motion.dart new file mode 100644 index 00000000..fff2d693 --- /dev/null +++ b/lib/theme/mono_motion.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import '../services/device_performance.dart'; +import 'mono_tokens.dart'; + +/// Motion vocabulary for the M3E-style components. All durations collapse to +/// [Duration.zero] on the reduced performance tier so low-end TVs get instant +/// snaps instead of animations. +class MonoMotion { + MonoMotion._(); + + /// M3 emphasized-decelerate. Monotonic in [0,1], so a single + /// AnimatedContainer can animate color and border radius together without + /// overshooting into out-of-gamut colors or negative radii. + static const Curve emphasized = Easing.emphasizedDecelerate; + + /// The app-wide standard curve. + static const Curve standard = Curves.easeOutCubic; + + /// Shape morphs (segment square→pill, group corner changes). + static Duration shape(BuildContext context) => DevicePerformance.reducedDuration(tokens(context).expressive); + + /// Fill/text color transitions. + static Duration fill(BuildContext context) => DevicePerformance.reducedDuration(tokens(context).normal); + + /// Press squish in/out. + static Duration press(BuildContext context) => DevicePerformance.reducedDuration(tokens(context).fast); +} diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index 2a8f0caf..e8bf9607 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -78,6 +78,10 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { // remove "Material feel" splashFactory: NoSplash.splashFactory, highlightColor: Colors.transparent, + // Explicit mono-derived tile highlights: ListTile's native focus/hover + // fill is the dpad focus visual inside M3E grouped-list cards. + focusColor: c.text.withValues(alpha: 0.12), + hoverColor: c.text.withValues(alpha: 0.05), dividerColor: c.outline, scaffoldBackgroundColor: c.bg, appBarTheme: AppBarTheme( @@ -109,6 +113,10 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { outlinedButtonTheme: OutlinedButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)), iconButtonTheme: IconButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)), sliderTheme: SliderThemeData( + // The mono scheme maps surfaceContainerHighest (the M3 default inactive + // track) to the same color as surface cards, which makes the inactive + // track invisible inside grouped-list items. + inactiveTrackColor: c.text.withValues(alpha: 0.12), trackHeight: 16, trackGap: 6, thumbSize: const WidgetStatePropertyAll(Size(4, 20)), @@ -154,10 +162,14 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { MonoTokens( radiusSm: 8, radiusMd: 12, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, space: 12, fast: const Duration(milliseconds: 120), normal: const Duration(milliseconds: 200), slow: const Duration(milliseconds: 300), + expressive: const Duration(milliseconds: 350), bg: c.bg, surface: c.surface, outline: c.outline, diff --git a/lib/theme/mono_tokens.dart b/lib/theme/mono_tokens.dart index b5a5d9ca..8b8d7422 100644 --- a/lib/theme/mono_tokens.dart +++ b/lib/theme/mono_tokens.dart @@ -5,12 +5,29 @@ MonoTokens tokens(BuildContext context) => Theme.of(context).extension { + /// Effectively-stadium radius for pill shapes; the renderer proportionally + /// clamps oversized RRect radii (same trick as FocusableButton). + static const double radiusFull = 100; + final double radiusSm; final double radiusMd; + + /// Outer corners of M3E grouped-list cards and connected button groups. + final double radiusLg; + + /// Inner corners between adjacent items of an M3E group. + final double radiusXs; + + /// Gap between adjacent items of an M3E group. + final double groupGap; + final double space; final Duration fast; final Duration normal; final Duration slow; + + /// M3E shape-morph duration (segment square→pill and friends). + final Duration expressive; final Color bg; final Color surface; final Color outline; @@ -21,10 +38,14 @@ class MonoTokens extends ThemeExtension { const MonoTokens({ required this.radiusSm, required this.radiusMd, + required this.radiusLg, + required this.radiusXs, + required this.groupGap, required this.space, required this.fast, required this.normal, required this.slow, + required this.expressive, required this.bg, required this.surface, required this.outline, @@ -37,10 +58,14 @@ class MonoTokens extends ThemeExtension { MonoTokens copyWith({ double? radiusSm, double? radiusMd, + double? radiusLg, + double? radiusXs, + double? groupGap, double? space, Duration? fast, Duration? normal, Duration? slow, + Duration? expressive, Color? bg, Color? surface, Color? outline, @@ -50,10 +75,14 @@ class MonoTokens extends ThemeExtension { }) => MonoTokens( radiusSm: radiusSm ?? this.radiusSm, radiusMd: radiusMd ?? this.radiusMd, + radiusLg: radiusLg ?? this.radiusLg, + radiusXs: radiusXs ?? this.radiusXs, + groupGap: groupGap ?? this.groupGap, space: space ?? this.space, fast: fast ?? this.fast, normal: normal ?? this.normal, slow: slow ?? this.slow, + expressive: expressive ?? this.expressive, bg: bg ?? this.bg, surface: surface ?? this.surface, outline: outline ?? this.outline, @@ -66,19 +95,19 @@ class MonoTokens extends ThemeExtension { ThemeExtension lerp(covariant MonoTokens? other, double t) { if (other == null) return this; Color lerpC(Color a, Color b) => Color.lerp(a, b, t)!; + Duration lerpD(Duration a, Duration b) => + Duration(milliseconds: lerpDouble(a.inMilliseconds.toDouble(), b.inMilliseconds.toDouble(), t)!.round()); return MonoTokens( radiusSm: lerpDouble(radiusSm, other.radiusSm, t)!, radiusMd: lerpDouble(radiusMd, other.radiusMd, t)!, + radiusLg: lerpDouble(radiusLg, other.radiusLg, t)!, + radiusXs: lerpDouble(radiusXs, other.radiusXs, t)!, + groupGap: lerpDouble(groupGap, other.groupGap, t)!, space: lerpDouble(space, other.space, t)!, - fast: Duration( - milliseconds: lerpDouble(fast.inMilliseconds.toDouble(), other.fast.inMilliseconds.toDouble(), t)!.round(), - ), - normal: Duration( - milliseconds: lerpDouble(normal.inMilliseconds.toDouble(), other.normal.inMilliseconds.toDouble(), t)!.round(), - ), - slow: Duration( - milliseconds: lerpDouble(slow.inMilliseconds.toDouble(), other.slow.inMilliseconds.toDouble(), t)!.round(), - ), + fast: lerpD(fast, other.fast), + normal: lerpD(normal, other.normal), + slow: lerpD(slow, other.slow), + expressive: lerpD(expressive, other.expressive), bg: lerpC(bg, other.bg), surface: lerpC(surface, other.surface), outline: lerpC(outline, other.outline), diff --git a/lib/widgets/expressive_button_group.dart b/lib/widgets/expressive_button_group.dart new file mode 100644 index 00000000..fc3a1a45 --- /dev/null +++ b/lib/widgets/expressive_button_group.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; + +import '../focus/input_mode_tracker.dart'; +import '../focus/key_event_utils.dart'; +import '../theme/mono_motion.dart'; +import '../theme/mono_tokens.dart'; +import '../utils/platform_detector.dart'; + +/// M3E connected button group: the selected segment is a filled pill, the +/// unselected segments are rounded squares, and the row's outermost corners +/// are fully rounded so the whole group reads as one pill. Segments morph +/// shape+fill on selection and squish (radius narrows) on press. +/// +/// Reuses [ButtonSegment] as a plain data holder (value/icon/label/enabled/ +/// tooltip) so [SegmentedButton] call sites port without changes. +/// +/// D-pad: LEFT/RIGHT rove within the group with the edges trapped (#1181), +/// SELECT commits, UP/DOWN exit via framework traversal. Focus visuals are +/// background fills gated by keyboard mode. No width/flex morphing of the +/// selected segment (deliberate simplification of the full M3E behavior). +class ExpressiveButtonGroup extends StatefulWidget { + final List> segments; + final T selected; + final ValueChanged onChanged; + + /// true: Row of Expanded — full-width equal segments (settings use). + /// false: intrinsic segment widths (dialogs / sheets). + final bool expandSegments; + final double minHeight; + final bool enabled; + + const ExpressiveButtonGroup({ + super.key, + required this.segments, + required this.selected, + required this.onChanged, + this.expandSegments = true, + this.minHeight = 40, + this.enabled = true, + }); + + @override + State> createState() => _ExpressiveButtonGroupState(); +} + +class _ExpressiveButtonGroupState extends State> { + late List _focusNodes; + late List _focusListeners; + late List _focusStates; + int? _pressedIndex; + + /// Segment whose press was just released; keeps the un-squish on the fast + /// press duration until the selection change re-triggers the shape morph. + int? _releasedIndex; + int? _hoveredIndex; + + @override + void initState() { + super.initState(); + _initNodes(); + } + + @override + void didUpdateWidget(ExpressiveButtonGroup oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.segments.length != widget.segments.length) { + _disposeNodes(); + _initNodes(); + _pressedIndex = null; + _releasedIndex = null; + _hoveredIndex = null; + } + if (oldWidget.selected != widget.selected) _releasedIndex = null; + } + + void _initNodes() { + _focusNodes = List.generate(widget.segments.length, (i) => FocusNode(debugLabel: 'ButtonGroup[$i]')); + _focusStates = List.generate(widget.segments.length, (i) => false); + _focusListeners = []; + for (var i = 0; i < _focusNodes.length; i++) { + final idx = i; + void listener() { + final hasFocus = _focusNodes[idx].hasFocus; + if (_focusStates[idx] != hasFocus) { + setState(() => _focusStates[idx] = hasFocus); + } + } + + _focusListeners.add(listener); + _focusNodes[i].addListener(listener); + } + } + + void _disposeNodes() { + for (var i = 0; i < _focusNodes.length; i++) { + _focusNodes[i].removeListener(_focusListeners[i]); + _focusNodes[i].dispose(); + } + } + + @override + void dispose() { + _disposeNodes(); + super.dispose(); + } + + void _commit(ButtonSegment segment) { + if (segment.value != widget.selected) widget.onChanged(segment.value); + } + + BorderRadius _radiiFor(int i, bool selected, bool pressed, MonoTokens t) { + if (selected) { + return BorderRadius.circular(pressed ? t.radiusLg : MonoTokens.radiusFull); + } + // The row-end corners stay fully rounded even while pressed so the group + // keeps its pill silhouette. + final inner = Radius.circular(pressed ? t.radiusXs : t.radiusSm); + const outer = Radius.circular(MonoTokens.radiusFull); + return BorderRadius.horizontal( + left: i == 0 ? outer : inner, + right: i == widget.segments.length - 1 ? outer : inner, + ); + } + + Color _fillFor(bool selected, bool focused, bool hovered, ColorScheme cs, MonoTokens t) { + if (selected) return focused ? Color.lerp(cs.primary, cs.surface, 0.25)! : cs.primary; + if (focused) return t.text.withValues(alpha: 0.18); + if (hovered) return t.text.withValues(alpha: 0.12); + return t.text.withValues(alpha: 0.08); + } + + @override + Widget build(BuildContext context) { + final t = tokens(context); + return Row( + mainAxisSize: widget.expandSegments ? MainAxisSize.max : MainAxisSize.min, + children: [ + for (var i = 0; i < widget.segments.length; i++) ...[ + if (i > 0) SizedBox(width: t.groupGap), + if (widget.expandSegments) Expanded(child: _buildSegment(context, i)) else _buildSegment(context, i), + ], + ], + ); + } + + Widget _buildSegment(BuildContext context, int i) { + final theme = Theme.of(context); + final t = tokens(context); + final segment = widget.segments[i]; + final enabled = widget.enabled && segment.enabled; + final selected = segment.value == widget.selected; + final focused = _focusStates[i] && InputModeTracker.isKeyboardMode(context); + final pressed = _pressedIndex == i; + // Press squish in/out runs on the fast duration; everything else (the + // selection morph) on the expressive shape duration. + final duration = (pressed || _releasedIndex == i) ? MonoMotion.press(context) : MonoMotion.shape(context); + + final foreground = selected ? theme.colorScheme.onPrimary : t.text; + final label = segment.label == null + ? null + : AnimatedDefaultTextStyle( + duration: MonoMotion.fill(context), + curve: MonoMotion.standard, + style: theme.textTheme.labelLarge!.copyWith( + color: foreground, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + ), + child: segment.label!, + ); + final icon = segment.icon == null + ? null + : IconTheme.merge( + data: IconThemeData(color: foreground, size: 18), + child: segment.icon!, + ); + + final content = icon != null && label != null + ? Row(mainAxisSize: MainAxisSize.min, children: [icon, const SizedBox(width: 8), label]) + : (label ?? icon); + + Widget child = AnimatedContainer( + duration: duration, + curve: MonoMotion.emphasized, + constraints: BoxConstraints(minHeight: widget.minHeight), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + color: _fillFor(selected, focused, _hoveredIndex == i, theme.colorScheme, t), + borderRadius: _radiiFor(i, selected, pressed, t), + ), + // Labels never wrap: a group where one segment breaks onto two lines + // while its neighbors stay on one reads broken. Overlong labels scale + // down to fit instead. + child: content == null ? null : FittedBox(fit: BoxFit.scaleDown, child: content), + ); + + if (!enabled) { + child = Opacity(opacity: 0.4, child: child); + } else { + child = GestureDetector( + onTapDown: (_) => setState(() => _pressedIndex = i), + onTapUp: (_) => setState(() { + _pressedIndex = null; + _releasedIndex = i; + }), + onTapCancel: () => setState(() { + _pressedIndex = null; + _releasedIndex = i; + }), + onTap: () => _commit(segment), + child: child, + ); + // Hover tracking + click cursor; skipped on TV like ClickableCursor. + if (!PlatformDetector.isTV()) { + child = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hoveredIndex = i), + onExit: (_) => setState(() => _hoveredIndex = _hoveredIndex == i ? null : _hoveredIndex), + child: child, + ); + } + } + + if (segment.tooltip != null) child = Tooltip(message: segment.tooltip!, child: child); + + return Focus( + focusNode: _focusNodes[i], + canRequestFocus: enabled, + descendantsAreFocusable: false, + onKeyEvent: (node, event) => dpadKeyHandler( + onSelect: () => _commit(segment), + onLeft: i > 0 ? () => _focusNodes[i - 1].requestFocus() : null, + onRight: i < widget.segments.length - 1 ? () => _focusNodes[i + 1].requestFocus() : null, + trapHorizontalEdges: true, + )(node, event), + child: Semantics(button: true, selected: selected, enabled: enabled, child: child), + ); + } +} diff --git a/lib/widgets/settings_section.dart b/lib/widgets/settings_section.dart index 1eef9189..be6619d3 100644 --- a/lib/widgets/settings_section.dart +++ b/lib/widgets/settings_section.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../theme/mono_tokens.dart'; import 'app_icon.dart'; +import 'expressive_button_group.dart'; class SettingsSectionHeader extends StatelessWidget { final String title; @@ -19,8 +20,68 @@ class SettingsSectionHeader extends StatelessWidget { } } -/// A setting with a label + icon row and a full-width SegmentedButton below. -/// Used for settings with 2-4 short options. +/// M3E grouped list section: each child gets its own rounded surface card — +/// large radii on the section's outer corners, small radii between adjacent +/// items, hairline gaps — with an optional [SettingsSectionHeader] above. +/// +/// Corner shapes are computed from the child list index, so conditional tiles +/// MUST be excluded with `if (...)` at list-build time. A child that renders +/// `SizedBox.shrink()` still occupies a corner slot and corrupts the group's +/// shape; hoist its condition (or wrap the whole group in a SettingsBuilder). +/// +/// Each item is a shaped [Material] so the tiles' native ink focus/hover +/// highlight paints clipped inside the card — that is the d-pad focus visual +/// (background focus). The group adds no [Focus] nodes of its own; traversal +/// order and externally-owned tile focus nodes are untouched. +class SettingsGroup extends StatelessWidget { + final String? title; + final List children; + final EdgeInsetsGeometry margin; + + const SettingsGroup({ + super.key, + this.title, + required this.children, + this.margin = const EdgeInsets.symmetric(horizontal: 16), + }); + + BorderRadius _radiusFor(int i, MonoTokens t) { + return BorderRadius.vertical( + top: Radius.circular(i == 0 ? t.radiusLg : t.radiusXs), + bottom: Radius.circular(i == children.length - 1 ? t.radiusLg : t.radiusXs), + ); + } + + @override + Widget build(BuildContext context) { + final t = tokens(context); + return Column( + crossAxisAlignment: .start, + children: [ + if (title != null) SettingsSectionHeader(title!), + Padding( + padding: margin, + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) SizedBox(height: t.groupGap), + Material( + color: t.surface, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: _radiusFor(i, t)), + child: children[i], + ), + ], + ], + ), + ), + ], + ); + } +} + +/// A setting with a label + icon row and a full-width button group below. +/// Used for settings with 2-3 short options. class SegmentedSetting extends StatelessWidget { final IconData icon; final String title; @@ -40,7 +101,7 @@ class SegmentedSetting extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: .start, children: [ @@ -52,17 +113,7 @@ class SegmentedSetting extends StatelessWidget { ], ), const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: SegmentedButton( - segments: segments, - selected: {selected}, - onSelectionChanged: (Set newSelection) { - onChanged(newSelection.first); - }, - showSelectedIcon: false, - ), - ), + ExpressiveButtonGroup(segments: segments, selected: selected, onChanged: onChanged), ], ), ); diff --git a/test/widgets/player_queue_spoilers_test.dart b/test/widgets/player_queue_spoilers_test.dart index d43c4028..dbd2e116 100644 --- a/test/widgets/player_queue_spoilers_test.dart +++ b/test/widgets/player_queue_spoilers_test.dart @@ -19,10 +19,14 @@ import '../test_helpers/prefs.dart'; const _testTokens = MonoTokens( radiusSm: 8, radiusMd: 12, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, space: 8, fast: Duration(milliseconds: 1), normal: Duration(milliseconds: 1), slow: Duration(milliseconds: 1), + expressive: Duration(milliseconds: 1), bg: Colors.black, surface: Colors.black, outline: Colors.white24, diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 047e42fe..5f0128c6 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -26,10 +26,14 @@ import '../test_helpers/prefs.dart'; const _testTokens = MonoTokens( radiusSm: 8, radiusMd: 12, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, space: 8, fast: Duration(milliseconds: 1), normal: Duration(milliseconds: 1), slow: Duration(milliseconds: 1), + expressive: Duration(milliseconds: 1), bg: Colors.black, surface: Colors.black, outline: Colors.white24, diff --git a/test/widgets/track_sheet_test.dart b/test/widgets/track_sheet_test.dart index 40a8f680..6a8f4506 100644 --- a/test/widgets/track_sheet_test.dart +++ b/test/widgets/track_sheet_test.dart @@ -10,10 +10,14 @@ import 'package:plezy/widgets/video_controls/sheets/track_sheet.dart'; const _testTokens = MonoTokens( radiusSm: 8, radiusMd: 12, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, space: 8, fast: Duration(milliseconds: 1), normal: Duration(milliseconds: 1), slow: Duration(milliseconds: 1), + expressive: Duration(milliseconds: 1), bg: Colors.black, surface: Colors.black, outline: Colors.white24, diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 9f5d46f5..cd33b27f 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -20,10 +20,14 @@ import '../test_helpers/watch_together_fakes.dart'; const _testTokens = MonoTokens( radiusSm: 8, radiusMd: 12, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, space: 8, fast: Duration(milliseconds: 1), normal: Duration(milliseconds: 1), slow: Duration(milliseconds: 1), + expressive: Duration(milliseconds: 1), bg: Colors.black, surface: Colors.black, outline: Colors.white24, diff --git a/test/widgets/video_settings_sheet_test.dart b/test/widgets/video_settings_sheet_test.dart index be81c8de..21195d42 100644 --- a/test/widgets/video_settings_sheet_test.dart +++ b/test/widgets/video_settings_sheet_test.dart @@ -14,10 +14,14 @@ import '../test_helpers/prefs.dart'; const _testTokens = MonoTokens( radiusSm: 4, radiusMd: 8, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, space: 8, fast: Duration(milliseconds: 100), normal: Duration(milliseconds: 200), slow: Duration(milliseconds: 300), + expressive: Duration(milliseconds: 300), bg: Colors.black, surface: Color(0xFF111111), outline: Color(0xFF333333),