feat(ui): M3E restyle for settings, auth, and profile screens

This commit is contained in:
edde746
2026-07-02 11:47:43 +02:00
parent 074f92d144
commit 5f49dddb4d
62 changed files with 1415 additions and 874 deletions
+7 -1
View File
@@ -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,
+11 -3
View File
@@ -24,17 +24,20 @@ class FocusTheme {
return Theme.of(context).extension<MonoTokens>()?.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,
);
}
+11 -1
View File
@@ -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<FocusableWrapper> 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(
+1 -1
View File
@@ -142,7 +142,7 @@
"hideSpoilers": "Скривай спойлери за негледани епизоди",
"hideSpoilersDescription": "Замазвай миниатюри и описания за негледани епизоди",
"playerBackend": "Енджин на плеъра",
"exoPlayer": "ExoPlayer (препоръчително)",
"exoPlayer": "ExoPlayer",
"mpv": "mpv",
"hardwareDecoding": "Хардуерно декодиране",
"hardwareDecodingDescription": "Използвай хардуерно ускорение, когато е налично",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -142,7 +142,7 @@
"hideSpoilers": "未視聴エピソードのネタバレを非表示",
"hideSpoilersDescription": "未視聴エピソードのサムネイルと説明をぼかします",
"playerBackend": "プレーヤーバックエンド",
"exoPlayer": "ExoPlayer(推奨)",
"exoPlayer": "ExoPlayer",
"mpv": "mpv",
"hardwareDecoding": "ハードウェアデコード",
"hardwareDecodingDescription": "利用可能な場合にハードウェアアクセラレーションを使用",
+1 -1
View File
@@ -142,7 +142,7 @@
"hideSpoilers": "미시청 에피소드 스포일러 숨기기",
"hideSpoilersDescription": "시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리",
"playerBackend": "플레이어 백엔드",
"exoPlayer": "ExoPlayer (권장)",
"exoPlayer": "ExoPlayer",
"mpv": "mpv",
"hardwareDecoding": "하드웨어 디코딩",
"hardwareDecodingDescription": "가능한 경우 하드웨어 가속을 사용합니다",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -142,7 +142,7 @@
"hideSpoilers": "Скрыть спойлеры непросмотренных эпизодов",
"hideSpoilersDescription": "Размывать миниатюры и описания непросмотренных серий",
"playerBackend": "Бэкенд плеера",
"exoPlayer": "ExoPlayer (Рекомендуется)",
"exoPlayer": "ExoPlayer",
"mpv": "mpv",
"hardwareDecoding": "Аппаратное декодирование",
"hardwareDecodingDescription": "Использовать аппаратное ускорение, когда доступно",
+2 -2
View File
@@ -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' => 'Използвай хардуерно ускорение, когато е налично',
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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',
+3 -3
View File
@@ -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',
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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.',
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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' => '利用可能な場合にハードウェアアクセラレーションを使用',
+2 -2
View File
@@ -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' => '가능한 경우 하드웨어 가속을 사용합니다',
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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' => 'Использовать аппаратное ускорение, когда доступно',
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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' => '如果可用,使用硬件加速',
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -142,7 +142,7 @@
"hideSpoilers": "隐藏未看剧集的剧透内容",
"hideSpoilersDescription": "模糊未观看剧集的缩略图和描述",
"playerBackend": "播放器引擎",
"exoPlayer": "ExoPlayer(推荐)",
"exoPlayer": "ExoPlayer",
"mpv": "mpv",
"hardwareDecoding": "硬件解码",
"hardwareDecodingDescription": "如果可用,使用硬件加速",
@@ -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<BorrowConnectionScreen> {
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(
+39 -39
View File
@@ -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<String>(
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<String>(
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<String>(
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<String>(
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),
],
),
),
],
+5 -4
View File
@@ -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(),
+16 -2
View File
@@ -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<ProfileSwitchScreen> 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<ProfileSwitchScreen> 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<ProfileSwitchScreen> 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(
+14 -10
View File
@@ -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),
+33 -24
View File
@@ -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<bool>(
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(
+18 -6
View File
@@ -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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> with AsyncFormSta
},
onTap: busy ? null : () => unawaited(_useDiscoveredServer(server)),
),
const SizedBox(height: 8),
],
const SizedBox(height: 8),
];
}
@@ -746,6 +756,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> 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(
@@ -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<ActiveProfileProvider>().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<ThemeProvider>(
builder: (context, themeProvider, _) {
return SegmentedSetting<settings.ThemeMode>(
return SettingSelectionTile<settings.ThemeMode, settings.ThemeMode>(
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<int>(
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<ActiveProfileProvider>(
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:
@@ -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<CustomPlayerType>(
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<CustomPlayerType>(
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(
@@ -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),
],
),
),
),
],
+41 -48
View File
@@ -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<MpvConfigScreen> with SettingsEffectMi
Widget _buildPresetsCard() {
return SettingValueBuilder<List<MpvPreset>>(
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<String>(
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<String>(
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),
),
),
],
),
],
),
);
}
+238 -225
View File
@@ -46,172 +46,217 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
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<bool, bool>(
pref: SettingsService.useExoPlayer,
icon: Symbols.play_circle_rounded,
@@ -283,62 +328,36 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
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<bool>(
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<bool>(
pref: SettingsService.useExoPlayer,
builder: (_, useExo, _) {
if (!Platform.isAndroid || !useExo) return const SizedBox.shrink();
return SettingSelectionTile<DvConversionModePreference, DvConversionModePreference>(
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<DvConversionModePreference, DvConversionModePreference>(
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<PlaybackSettingsScreen> {
encode: (p) => p,
);
Widget _mpvConfigTile() => SettingValueBuilder<bool>(
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(),
);
}
+36 -37
View File
@@ -144,18 +144,18 @@ class _SettingsScreenState extends State<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<String>(
future: storageService.getCurrentDownloadPathDisplay(),
@@ -342,10 +346,9 @@ class _SettingsScreenState extends State<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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(
+118 -106
View File
@@ -45,116 +45,128 @@ class SubtitleStylingScreen extends StatelessWidget {
return SettingsPage(
title: Text(t.screens.subtitleStyling),
children: [
SettingsSectionHeader(t.subtitlingStyling.text),
SettingSelectionTile<SubAssOverride, SubAssOverride>(
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<SubtitleRenderResolution, SubtitleRenderResolution>(
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<SubtitleRenderResolution, SubtitleRenderResolution>(
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<SubAssOverride, SubAssOverride>(
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<SubtitleRenderResolution, SubtitleRenderResolution>(
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<SubtitleRenderResolution, SubtitleRenderResolution>(
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),
],
@@ -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<void>(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<void>(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<void>.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<void>.sync(onDisconnect)),
),
],
),
const SizedBox(height: 24),
],
@@ -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<String>.of(selectedIds);
if (v) {
next.add(lib.globalKey);
} else {
next.remove(lib.globalKey);
}
await settings.write(idsPref, next.toList());
},
);
final children = <Widget>[
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<TrackerLibraryFilterMode, TrackerLibraryFilterMode>(
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<TrackerLibraryFilterMode, TrackerLibraryFilterMode>(
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<String>.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));
@@ -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),
]),
),
+27
View File
@@ -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);
}
+12
View File
@@ -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,
+38 -9
View File
@@ -5,12 +5,29 @@ MonoTokens tokens(BuildContext context) => Theme.of(context).extension<MonoToken
@immutable
class MonoTokens extends ThemeExtension<MonoTokens> {
/// 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<MonoTokens> {
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> {
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> {
}) => 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<MonoTokens> {
ThemeExtension<MonoTokens> 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),
+239
View File
@@ -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<T> extends StatefulWidget {
final List<ButtonSegment<T>> segments;
final T selected;
final ValueChanged<T> 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<ExpressiveButtonGroup<T>> createState() => _ExpressiveButtonGroupState<T>();
}
class _ExpressiveButtonGroupState<T> extends State<ExpressiveButtonGroup<T>> {
late List<FocusNode> _focusNodes;
late List<VoidCallback> _focusListeners;
late List<bool> _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<T> 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<T> 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),
);
}
}
+65 -14
View File
@@ -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<Widget> 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<T> extends StatelessWidget {
final IconData icon;
final String title;
@@ -40,7 +101,7 @@ class SegmentedSetting<T> 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<T> extends StatelessWidget {
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: SegmentedButton<T>(
segments: segments,
selected: {selected},
onSelectionChanged: (Set<T> newSelection) {
onChanged(newSelection.first);
},
showSelectedIcon: false,
),
),
ExpressiveButtonGroup<T>(segments: segments, selected: selected, onChanged: onChanged),
],
),
);
@@ -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,
@@ -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,
+4
View File
@@ -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,
+4
View File
@@ -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,
@@ -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),