feat(ui): M3E restyle for settings, auth, and profile screens
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
"hideSpoilers": "Скривай спойлери за негледани епизоди",
|
||||
"hideSpoilersDescription": "Замазвай миниатюри и описания за негледани епизоди",
|
||||
"playerBackend": "Енджин на плеъра",
|
||||
"exoPlayer": "ExoPlayer (препоръчително)",
|
||||
"exoPlayer": "ExoPlayer",
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Хардуерно декодиране",
|
||||
"hardwareDecodingDescription": "Използвай хардуерно ускорение, когато е налично",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
"hideSpoilers": "未視聴エピソードのネタバレを非表示",
|
||||
"hideSpoilersDescription": "未視聴エピソードのサムネイルと説明をぼかします",
|
||||
"playerBackend": "プレーヤーバックエンド",
|
||||
"exoPlayer": "ExoPlayer(推奨)",
|
||||
"exoPlayer": "ExoPlayer",
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "ハードウェアデコード",
|
||||
"hardwareDecodingDescription": "利用可能な場合にハードウェアアクセラレーションを使用",
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
"hideSpoilers": "미시청 에피소드 스포일러 숨기기",
|
||||
"hideSpoilersDescription": "시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리",
|
||||
"playerBackend": "플레이어 백엔드",
|
||||
"exoPlayer": "ExoPlayer (권장)",
|
||||
"exoPlayer": "ExoPlayer",
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "하드웨어 디코딩",
|
||||
"hardwareDecodingDescription": "가능한 경우 하드웨어 가속을 사용합니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
"hideSpoilers": "Скрыть спойлеры непросмотренных эпизодов",
|
||||
"hideSpoilersDescription": "Размывать миниатюры и описания непросмотренных серий",
|
||||
"playerBackend": "Бэкенд плеера",
|
||||
"exoPlayer": "ExoPlayer (Рекомендуется)",
|
||||
"exoPlayer": "ExoPlayer",
|
||||
"mpv": "mpv",
|
||||
"hardwareDecoding": "Аппаратное декодирование",
|
||||
"hardwareDecodingDescription": "Использовать аппаратное ускорение, когда доступно",
|
||||
|
||||
@@ -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' => 'Използвай хардуерно ускорение, когато е налично',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '利用可能な場合にハードウェアアクセラレーションを使用',
|
||||
|
||||
@@ -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' => '가능한 경우 하드웨어 가속을 사용합니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Использовать аппаратное ускорение, когда доступно',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '如果可用,使用硬件加速',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user