fix(tv): hug poster with focus border and add focus glow toggle

close #1278
This commit is contained in:
edde746
2026-06-12 08:50:29 +02:00
parent cceba28d0a
commit 587609d7f6
46 changed files with 594 additions and 400 deletions
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'focus_theme.dart';
/// Exposes the focus state of an enclosing focus wrapper to a descendant
/// [CardFocusBorder] that draws the focus border itself.
///
/// Wrappers ([FocusableWrapper]/[FocusBuilders.buildFocusableCard]) insert this
/// instead of painting a border when `delegateFocusBorder` is set, so cards can
/// put the border on the exact rect the design highlights (the poster image,
/// not the card-plus-captions rect — issue #1278). Only the [CardFocusBorder]
/// element registers a dependency, so a focus flip rebuilds just that border
/// box: a stable `child:` card subtree (e.g. the TV rail's MediaCard) is not
/// rebuilt.
class CardFocusScope extends InheritedWidget {
const CardFocusScope({super.key, required this.showFocus, required super.child});
/// Whether the enclosing wrapper currently shows focus visuals
/// (focused while in keyboard/d-pad input mode).
final bool showFocus;
/// Null when no delegating wrapper is above (touch mode skips the focus
/// wrappers entirely) — [CardFocusBorder] then renders its child bare.
static bool? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType<CardFocusScope>()?.showFocus;
@override
bool updateShouldNotify(CardFocusScope oldWidget) => showFocus != oldWidget.showFocus;
}
/// Draws the focus border around its child based on the enclosing
/// [CardFocusScope], letting the card decide which rect gets highlighted.
///
/// Defaults to an outside stroke so the border hugs the child exactly like the
/// full-bleed card treatment (the child's own corner radius nests inside it).
/// Decoration only — never affects layout. Renders the child unchanged when no
/// scope is present.
class CardFocusBorder extends StatelessWidget {
const CardFocusBorder({
super.key,
required this.borderRadius,
this.strokeAlign = BorderSide.strokeAlignOutside,
required this.child,
});
final double borderRadius;
final double strokeAlign;
final Widget child;
@override
Widget build(BuildContext context) {
final showFocus = CardFocusScope.maybeOf(context);
if (showFocus == null) return child;
return AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
foregroundDecoration: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: borderRadius,
borderStrokeAlign: strokeAlign,
),
child: child,
);
}
}
+9 -5
View File
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../services/device_performance.dart';
import '../services/settings_service.dart';
import 'focus_theme.dart';
/// Renders the focus glow for a focused card in the root [Overlay] so it paints
@@ -56,10 +57,15 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
/// fades out before the portal is hidden in [_handleFadeEnd].
bool _visible = false;
/// Glow is skipped on the reduced effects tier (blurred shadows + fade
/// saveLayer are too expensive on weak GPUs) and when the user turned the
/// Focus Glow setting off (#1278). The crisp focus border remains.
static bool get _disabled => DevicePerformance.isReduced || !SettingsService.instance.read(SettingsService.focusGlow);
@override
void initState() {
super.initState();
if (widget.isFocused && !DevicePerformance.isReduced) {
if (widget.isFocused && !_disabled) {
_visible = true;
_controller.show();
}
@@ -68,7 +74,7 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
@override
void didUpdateWidget(FocusGlowOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (DevicePerformance.isReduced) return;
if (_disabled) return;
if (widget.isFocused == oldWidget.isFocused) return;
if (widget.isFocused) {
_controller.show();
@@ -92,9 +98,7 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
@override
Widget build(BuildContext context) {
// Reduced tier: no glow at all — the blurred shadows + fade saveLayer are
// too expensive on weak GPUs. The crisp in-card focus border remains.
if (DevicePerformance.isReduced) return widget.child;
if (_disabled) return widget.child;
// Gate the LeaderLayer to the focused card only: when not focused and not
// mid-fade, return the bare child (no OverlayPortal, no leader).
+25 -24
View File
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart';
import 'card_focus_scope.dart';
import 'dpad_navigator.dart';
import 'focus_glow_overlay.dart';
import 'focus_theme.dart';
@@ -110,14 +111,13 @@ class FocusableWrapper extends StatefulWidget {
/// Scale used for the focus animation.
final double focusScale;
/// Stroke alignment for the focus border.
final double focusBorderStrokeAlign;
/// Whether to draw a glow around the focused widget.
final bool useFocusGlow;
/// Whether to draw the focus border as a foreground decoration.
final bool useForegroundFocusDecoration;
/// Skip drawing the focus border here and expose the focus state through a
/// [CardFocusScope] instead, so the child places the border on the exact
/// rect it wants highlighted (e.g. MediaCard's poster image).
final bool delegateFocusBorder;
/// Whether descendants can receive focus.
/// Set to false when the child widget has its own Focus (e.g. buttons)
@@ -150,9 +150,8 @@ class FocusableWrapper extends StatefulWidget {
this.focusColor,
this.disableScale = false,
this.focusScale = FocusTheme.focusScale,
this.focusBorderStrokeAlign = BorderSide.strokeAlignInside,
this.useFocusGlow = false,
this.useForegroundFocusDecoration = false,
this.delegateFocusBorder = false,
this.descendantsAreFocusable = true,
});
@@ -471,16 +470,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
_animationController.duration = duration;
}
// Choose decoration based on useBackgroundFocus
final focusDecoration = widget.useBackgroundFocus
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor,
borderStrokeAlign: widget.focusBorderStrokeAlign,
);
Widget result = Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
@@ -493,13 +482,25 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
final shouldScale = showFocus && !widget.disableScale;
// The glow (full-bleed cards) is drawn in an overlay above siblings so
// it stays symmetric; the in-card decoration only carries the border.
Widget card = AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: widget.useForegroundFocusDecoration ? null : focusDecoration,
foregroundDecoration: widget.useForegroundFocusDecoration ? focusDecoration : null,
child: widget.child,
);
Widget card;
if (widget.delegateFocusBorder) {
card = CardFocusScope(showFocus: showFocus, child: widget.child);
} else {
final focusDecoration = widget.useBackgroundFocus
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor,
);
card = AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: focusDecoration,
child: widget.child,
);
}
if (widget.useFocusGlow) {
card = FocusGlowOverlay(
isFocused: showFocus,
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Показвай постера на всеки сезон над неговия таб",
"tvFullCardLayout": "Пълни TV карти",
"tvFullCardLayoutDescription": "Използвай TV карти само с изображения, с насложени имена на актьорите",
"focusGlow": "Сияние при фокус",
"focusGlowDescription": "Показвай меко сияние около фокусираната карта",
"hideSpoilers": "Скривай спойлери за негледани епизоди",
"hideSpoilersDescription": "Замазвай миниатюри и описания за негледани епизоди",
"playerBackend": "Енджин на плеъра",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Vis hver sæsons plakat over dens fane",
"tvFullCardLayout": "Fuldflade TV-kort",
"tvFullCardLayoutDescription": "Brug TV-kort kun med billeder og skuespillernavne ovenpå",
"focusGlow": "Fokusglød",
"focusGlowDescription": "Vis en blød glød omkring det fokuserede kort",
"hideSpoilers": "Skjul spoilere for usete episoder",
"hideSpoilersDescription": "Slør miniaturebilleder og beskrivelser for usete episoder",
"playerBackend": "Afspillerbackend",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Poster jeder Staffel über ihrem Tab anzeigen",
"tvFullCardLayout": "Vollflächige TV-Karten",
"tvFullCardLayoutDescription": "TV-Karten nur mit Bild verwenden und Darstellernamen einblenden",
"focusGlow": "Fokus-Leuchten",
"focusGlowDescription": "Sanftes Leuchten um die fokussierte Karte anzeigen",
"hideSpoilers": "Spoiler für nicht gesehene Episoden verbergen",
"hideSpoilersDescription": "Vorschaubilder und Beschreibungen ungesehener Episoden verwischen",
"playerBackend": "Player-Backend",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Show each season's poster above its tab",
"tvFullCardLayout": "Full TV Cards",
"tvFullCardLayoutDescription": "Use image-only TV cards with actor names overlaid",
"focusGlow": "Focus Glow",
"focusGlowDescription": "Draw a soft glow around the focused card",
"visualEffects": "Visual Effects",
"visualEffectsAuto": "Auto",
"visualEffectsAutoDescription": "Reduce effects automatically on low-power devices",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Mostrar el póster de cada temporada sobre su pestaña",
"tvFullCardLayout": "Tarjetas TV completas",
"tvFullCardLayoutDescription": "Usar tarjetas TV solo con imagen y nombres de actores superpuestos",
"focusGlow": "Brillo de foco",
"focusGlowDescription": "Mostrar un brillo suave alrededor de la tarjeta con foco",
"hideSpoilers": "Ocultar spoilers de episodios no vistos",
"hideSpoilersDescription": "Desenfocar miniaturas y descripciones de episodios no vistos",
"playerBackend": "Reproductor",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Afficher l'affiche de chaque saison au-dessus de son onglet",
"tvFullCardLayout": "Cartes TV pleines",
"tvFullCardLayoutDescription": "Utiliser des cartes TV avec image seule et noms des acteurs superposés",
"focusGlow": "Halo de sélection",
"focusGlowDescription": "Afficher un léger halo autour de la carte sélectionnée",
"hideSpoilers": "Masquer les spoilers des épisodes non vus",
"hideSpoilersDescription": "Flouter les miniatures et descriptions des épisodes non vus",
"playerBackend": "Moteur de lecture",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Mostra il poster di ogni stagione sopra la sua scheda",
"tvFullCardLayout": "Schede TV piene",
"tvFullCardLayoutDescription": "Usa schede TV solo immagine con i nomi degli attori sovrapposti",
"focusGlow": "Bagliore di selezione",
"focusGlowDescription": "Mostra un leggero bagliore attorno alla scheda selezionata",
"hideSpoilers": "Nascondi spoiler per episodi non visti",
"hideSpoilersDescription": "Sfoca miniature e descrizioni degli episodi non visti",
"playerBackend": "Motore di riproduzione",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "各シーズンのポスターをタブの上に表示します",
"tvFullCardLayout": "フルTVカード",
"tvFullCardLayoutDescription": "TVカードを画像のみで表示し、俳優名を重ねて表示します",
"focusGlow": "フォーカス時の光彩",
"focusGlowDescription": "フォーカス中のカードの周りに柔らかい光彩を表示します",
"hideSpoilers": "未視聴エピソードのネタバレを非表示",
"hideSpoilersDescription": "未視聴エピソードのサムネイルと説明をぼかします",
"playerBackend": "プレーヤーバックエンド",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "각 시즌 포스터를 탭 위에 표시",
"tvFullCardLayout": "전체 TV 카드",
"tvFullCardLayoutDescription": "TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시",
"focusGlow": "포커스 글로우",
"focusGlowDescription": "포커스된 카드 주위에 은은한 빛 효과를 표시",
"hideSpoilers": "미시청 에피소드 스포일러 숨기기",
"hideSpoilersDescription": "시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리",
"playerBackend": "플레이어 백엔드",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Vis hver sesongs plakat over fanen",
"tvFullCardLayout": "Fulle TV-kort",
"tvFullCardLayoutDescription": "Bruk bildebaserte TV-kort med skuespillernavn lagt over",
"focusGlow": "Fokusglød",
"focusGlowDescription": "Vis en myk glød rundt kortet i fokus",
"hideSpoilers": "Skjul spoilere for usette episoder",
"hideSpoilersDescription": "Slør miniatyrbilder og beskrivelser for usette episoder",
"playerBackend": "Spillermotor",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Toon de poster van elk seizoen boven het tabblad",
"tvFullCardLayout": "Volledige tv-kaarten",
"tvFullCardLayoutDescription": "Gebruik tv-kaarten met alleen afbeeldingen en namen van acteurs als overlay",
"focusGlow": "Focusgloed",
"focusGlowDescription": "Toon een zachte gloed rond de kaart met focus",
"hideSpoilers": "Spoilers voor ongekeken afleveringen verbergen",
"hideSpoilersDescription": "Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen",
"playerBackend": "Speler backend",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Pokazuj plakat każdego sezonu nad jego kartą",
"tvFullCardLayout": "Pełne karty TV",
"tvFullCardLayoutDescription": "Używaj kart TV tylko z obrazem i nałożonymi nazwiskami aktorów",
"focusGlow": "Poświata zaznaczenia",
"focusGlowDescription": "Wyświetlaj delikatną poświatę wokół zaznaczonej karty",
"hideSpoilers": "Ukryj spoilery nieobejrzanych odcinków",
"hideSpoilersDescription": "Rozmywaj miniatury i opisy nieobejrzanych odcinków",
"playerBackend": "Backend odtwarzacza",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Mostrar o pôster de cada temporada acima da aba",
"tvFullCardLayout": "Cartões TV completos",
"tvFullCardLayoutDescription": "Usar cartões de TV só com imagem e nomes dos atores sobrepostos",
"focusGlow": "Brilho de foco",
"focusGlowDescription": "Mostrar um brilho suave à volta do cartão em foco",
"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",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Показывать постер каждого сезона над его вкладкой",
"tvFullCardLayout": "Полные TV-карточки",
"tvFullCardLayoutDescription": "Использовать TV-карточки только с изображением и именами актёров поверх него",
"focusGlow": "Свечение при фокусе",
"focusGlowDescription": "Показывать мягкое свечение вокруг карточки в фокусе",
"hideSpoilers": "Скрыть спойлеры непросмотренных эпизодов",
"hideSpoilersDescription": "Размывать миниатюры и описания непросмотренных серий",
"playerBackend": "Бэкенд плеера",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 20265 (1266 per locale)
/// Strings: 20297 (1268 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsBg extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Показвай постера на всеки сезон над неговия таб';
@override String get tvFullCardLayout => 'Пълни TV карти';
@override String get tvFullCardLayoutDescription => 'Използвай TV карти само с изображения, с насложени имена на актьорите';
@override String get focusGlow => 'Сияние при фокус';
@override String get focusGlowDescription => 'Показвай меко сияние около фокусираната карта';
@override String get hideSpoilers => 'Скривай спойлери за негледани епизоди';
@override String get hideSpoilersDescription => 'Замазвай миниатюри и описания за негледани епизоди';
@override String get playerBackend => 'Енджин на плеъра';
@@ -2039,6 +2041,8 @@ extension on TranslationsBg {
'settings.showSeasonPostersOnTabsDescription' => 'Показвай постера на всеки сезон над неговия таб',
'settings.tvFullCardLayout' => 'Пълни TV карти',
'settings.tvFullCardLayoutDescription' => 'Използвай TV карти само с изображения, с насложени имена на актьорите',
'settings.focusGlow' => 'Сияние при фокус',
'settings.focusGlowDescription' => 'Показвай меко сияние около фокусираната карта',
'settings.hideSpoilers' => 'Скривай спойлери за негледани епизоди',
'settings.hideSpoilersDescription' => 'Замазвай миниатюри и описания за негледани епизоди',
'settings.playerBackend' => 'Енджин на плеъра',
@@ -2425,10 +2429,10 @@ extension on TranslationsBg {
'subtitlingStyling.text' => 'Текст',
'subtitlingStyling.border' => 'Рамка',
'subtitlingStyling.background' => 'Фон',
'subtitlingStyling.fontSize' => 'Размер на шрифта',
'subtitlingStyling.textColor' => 'Цвят на текста',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Размер на шрифта',
'subtitlingStyling.textColor' => 'Цвят на текста',
'subtitlingStyling.borderSize' => 'Размер на рамката',
'subtitlingStyling.borderColor' => 'Цвят на рамката',
'subtitlingStyling.backgroundOpacity' => 'Прозрачност на фона',
@@ -2939,10 +2943,10 @@ extension on TranslationsBg {
'companionRemote.remote.tabMore' => 'Още',
'companionRemote.remote.menu' => 'Меню',
'companionRemote.remote.tabNavigation' => 'Навигация с Tab',
'companionRemote.remote.tabDiscover' => 'Открий',
'companionRemote.remote.tabLibraries' => 'Библиотеки',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Открий',
'companionRemote.remote.tabLibraries' => 'Библиотеки',
'companionRemote.remote.tabSearch' => 'Търсене',
'companionRemote.remote.tabDownloads' => 'Изтегляния',
'companionRemote.remote.tabSettings' => 'Настройки',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsDa extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Vis hver sæsons plakat over dens fane';
@override String get tvFullCardLayout => 'Fuldflade TV-kort';
@override String get tvFullCardLayoutDescription => 'Brug TV-kort kun med billeder og skuespillernavne ovenpå';
@override String get focusGlow => 'Fokusglød';
@override String get focusGlowDescription => 'Vis en blød glød omkring det fokuserede kort';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsDa {
'settings.showSeasonPostersOnTabsDescription' => 'Vis hver sæsons plakat over dens fane',
'settings.tvFullCardLayout' => 'Fuldflade TV-kort',
'settings.tvFullCardLayoutDescription' => 'Brug TV-kort kun med billeder og skuespillernavne ovenpå',
'settings.focusGlow' => 'Fokusglød',
'settings.focusGlowDescription' => 'Vis en blød glød omkring det fokuserede kort',
'settings.hideSpoilers' => 'Skjul spoilere for usete episoder',
'settings.hideSpoilersDescription' => 'Slør miniaturebilleder og beskrivelser for usete episoder',
'settings.playerBackend' => 'Afspillerbackend',
@@ -2425,10 +2429,10 @@ extension on TranslationsDa {
'subtitlingStyling.text' => 'Tekst',
'subtitlingStyling.border' => 'Kant',
'subtitlingStyling.background' => 'Baggrund',
'subtitlingStyling.fontSize' => 'Skriftstørrelse',
'subtitlingStyling.textColor' => 'Tekstfarve',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Skriftstørrelse',
'subtitlingStyling.textColor' => 'Tekstfarve',
'subtitlingStyling.borderSize' => 'Kantstørrelse',
'subtitlingStyling.borderColor' => 'Kantfarve',
'subtitlingStyling.backgroundOpacity' => 'Baggrundsgennemsigtighed',
@@ -2939,10 +2943,10 @@ extension on TranslationsDa {
'companionRemote.remote.tabMore' => 'Mere',
'companionRemote.remote.menu' => 'Menu',
'companionRemote.remote.tabNavigation' => 'Fanenavigation',
'companionRemote.remote.tabDiscover' => 'Opdag',
'companionRemote.remote.tabLibraries' => 'Biblioteker',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Opdag',
'companionRemote.remote.tabLibraries' => 'Biblioteker',
'companionRemote.remote.tabSearch' => 'Søg',
'companionRemote.remote.tabDownloads' => 'Downloads',
'companionRemote.remote.tabSettings' => 'Indstillinger',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsDe extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Poster jeder Staffel über ihrem Tab anzeigen';
@override String get tvFullCardLayout => 'Vollflächige TV-Karten';
@override String get tvFullCardLayoutDescription => 'TV-Karten nur mit Bild verwenden und Darstellernamen einblenden';
@override String get focusGlow => 'Fokus-Leuchten';
@override String get focusGlowDescription => 'Sanftes Leuchten um die fokussierte Karte anzeigen';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsDe {
'settings.showSeasonPostersOnTabsDescription' => 'Poster jeder Staffel über ihrem Tab anzeigen',
'settings.tvFullCardLayout' => 'Vollflächige TV-Karten',
'settings.tvFullCardLayoutDescription' => 'TV-Karten nur mit Bild verwenden und Darstellernamen einblenden',
'settings.focusGlow' => 'Fokus-Leuchten',
'settings.focusGlowDescription' => 'Sanftes Leuchten um die fokussierte Karte anzeigen',
'settings.hideSpoilers' => 'Spoiler für nicht gesehene Episoden verbergen',
'settings.hideSpoilersDescription' => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen',
'settings.playerBackend' => 'Player-Backend',
@@ -2425,10 +2429,10 @@ extension on TranslationsDe {
'subtitlingStyling.text' => 'Text',
'subtitlingStyling.border' => 'Rahmen',
'subtitlingStyling.background' => 'Hintergrund',
'subtitlingStyling.fontSize' => 'Schriftgröße',
'subtitlingStyling.textColor' => 'Textfarbe',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Schriftgröße',
'subtitlingStyling.textColor' => 'Textfarbe',
'subtitlingStyling.borderSize' => 'Rahmengröße',
'subtitlingStyling.borderColor' => 'Rahmenfarbe',
'subtitlingStyling.backgroundOpacity' => 'Hintergrunddeckkraft',
@@ -2939,10 +2943,10 @@ extension on TranslationsDe {
'companionRemote.remote.tabMore' => 'Mehr',
'companionRemote.remote.menu' => 'Menü',
'companionRemote.remote.tabNavigation' => 'Tab-Navigation',
'companionRemote.remote.tabDiscover' => 'Entdecken',
'companionRemote.remote.tabLibraries' => 'Mediatheken',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Entdecken',
'companionRemote.remote.tabLibraries' => 'Mediatheken',
'companionRemote.remote.tabSearch' => 'Suche',
'companionRemote.remote.tabDownloads' => 'Downloads',
'companionRemote.remote.tabSettings' => 'Einstellungen',
+12 -4
View File
@@ -513,6 +513,12 @@ class TranslationsSettingsEn {
/// en: 'Use image-only TV cards with actor names overlaid'
String get tvFullCardLayoutDescription => 'Use image-only TV cards with actor names overlaid';
/// en: 'Focus Glow'
String get focusGlow => 'Focus Glow';
/// en: 'Draw a soft glow around the focused card'
String get focusGlowDescription => 'Draw a soft glow around the focused card';
/// en: 'Visual Effects'
String get visualEffects => 'Visual Effects';
@@ -4604,6 +4610,8 @@ extension on Translations {
'settings.showSeasonPostersOnTabsDescription' => 'Show each season\'s poster above its tab',
'settings.tvFullCardLayout' => 'Full TV Cards',
'settings.tvFullCardLayoutDescription' => 'Use image-only TV cards with actor names overlaid',
'settings.focusGlow' => 'Focus Glow',
'settings.focusGlowDescription' => 'Draw a soft glow around the focused card',
'settings.visualEffects' => 'Visual Effects',
'settings.visualEffectsAuto' => 'Auto',
'settings.visualEffectsAutoDescription' => 'Reduce effects automatically on low-power devices',
@@ -4990,10 +4998,10 @@ extension on Translations {
'messages.switchingToCompatiblePlayer' => 'Switching to compatible player...',
'messages.serverLimitTitle' => 'Playback failed',
'messages.serverLimitBody' => 'Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.',
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
_ => null,
} ?? switch (path) {
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'subtitlingStyling.text' => 'Text',
'subtitlingStyling.border' => 'Border',
@@ -5504,10 +5512,10 @@ extension on Translations {
'companionRemote.pairing.sessionNotFound' => 'Device not found. Make sure Plezy is running on the host.',
'companionRemote.pairing.authFailed' => 'Authentication failed. Both devices need the same Plex account.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Failed to connect: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Do you want to disconnect from the remote session?',
'companionRemote.remote.reconnecting' => 'Reconnecting...',
_ => null,
} ?? switch (path) {
'companionRemote.remote.disconnectConfirm' => 'Do you want to disconnect from the remote session?',
'companionRemote.remote.reconnecting' => 'Reconnecting...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Attempt ${current} of 5',
'companionRemote.remote.retryNow' => 'Retry Now',
'companionRemote.remote.tabRemote' => 'Remote',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsEs extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Mostrar el póster de cada temporada sobre su pestaña';
@override String get tvFullCardLayout => 'Tarjetas TV completas';
@override String get tvFullCardLayoutDescription => 'Usar tarjetas TV solo con imagen y nombres de actores superpuestos';
@override String get focusGlow => 'Brillo de foco';
@override String get focusGlowDescription => 'Mostrar un brillo suave alrededor de la tarjeta con foco';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsEs {
'settings.showSeasonPostersOnTabsDescription' => 'Mostrar el póster de cada temporada sobre su pestaña',
'settings.tvFullCardLayout' => 'Tarjetas TV completas',
'settings.tvFullCardLayoutDescription' => 'Usar tarjetas TV solo con imagen y nombres de actores superpuestos',
'settings.focusGlow' => 'Brillo de foco',
'settings.focusGlowDescription' => 'Mostrar un brillo suave alrededor de la tarjeta con foco',
'settings.hideSpoilers' => 'Ocultar spoilers de episodios no vistos',
'settings.hideSpoilersDescription' => 'Desenfocar miniaturas y descripciones de episodios no vistos',
'settings.playerBackend' => 'Reproductor',
@@ -2425,10 +2429,10 @@ extension on TranslationsEs {
'subtitlingStyling.text' => 'Texto',
'subtitlingStyling.border' => 'Borde',
'subtitlingStyling.background' => 'Fondo',
'subtitlingStyling.fontSize' => 'Tamaño de Fuente',
'subtitlingStyling.textColor' => 'Color de Texto',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Tamaño de Fuente',
'subtitlingStyling.textColor' => 'Color de Texto',
'subtitlingStyling.borderSize' => 'Tamaño de Borde',
'subtitlingStyling.borderColor' => 'Color de Borde',
'subtitlingStyling.backgroundOpacity' => 'Opacidad de Fondo',
@@ -2939,10 +2943,10 @@ extension on TranslationsEs {
'companionRemote.remote.tabMore' => 'Más',
'companionRemote.remote.menu' => 'Menú',
'companionRemote.remote.tabNavigation' => 'Navegación por pestañas',
'companionRemote.remote.tabDiscover' => 'Descubrir',
'companionRemote.remote.tabLibraries' => 'Bibliotecas',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Descubrir',
'companionRemote.remote.tabLibraries' => 'Bibliotecas',
'companionRemote.remote.tabSearch' => 'Buscar',
'companionRemote.remote.tabDownloads' => 'Descargas',
'companionRemote.remote.tabSettings' => 'Configuración',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsFr extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Afficher l\'affiche de chaque saison au-dessus de son onglet';
@override String get tvFullCardLayout => 'Cartes TV pleines';
@override String get tvFullCardLayoutDescription => 'Utiliser des cartes TV avec image seule et noms des acteurs superposés';
@override String get focusGlow => 'Halo de sélection';
@override String get focusGlowDescription => 'Afficher un léger halo autour de la carte sélectionnée';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsFr {
'settings.showSeasonPostersOnTabsDescription' => 'Afficher l\'affiche de chaque saison au-dessus de son onglet',
'settings.tvFullCardLayout' => 'Cartes TV pleines',
'settings.tvFullCardLayoutDescription' => 'Utiliser des cartes TV avec image seule et noms des acteurs superposés',
'settings.focusGlow' => 'Halo de sélection',
'settings.focusGlowDescription' => 'Afficher un léger halo autour de la carte sélectionnée',
'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',
@@ -2425,10 +2429,10 @@ extension on TranslationsFr {
'subtitlingStyling.text' => 'Texte',
'subtitlingStyling.border' => 'Bordure',
'subtitlingStyling.background' => 'Arrière-plan',
'subtitlingStyling.fontSize' => 'Taille de la police',
'subtitlingStyling.textColor' => 'Couleur du texte',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Taille de la police',
'subtitlingStyling.textColor' => 'Couleur du texte',
'subtitlingStyling.borderSize' => 'Taille de la bordure',
'subtitlingStyling.borderColor' => 'Couleur de la bordure',
'subtitlingStyling.backgroundOpacity' => 'Opacité d\'arrière-plan',
@@ -2939,10 +2943,10 @@ extension on TranslationsFr {
'companionRemote.remote.tabMore' => 'Plus',
'companionRemote.remote.menu' => 'Menu',
'companionRemote.remote.tabNavigation' => 'Navigation par onglets',
'companionRemote.remote.tabDiscover' => 'Découvrir',
'companionRemote.remote.tabLibraries' => 'Bibliothèques',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Découvrir',
'companionRemote.remote.tabLibraries' => 'Bibliothèques',
'companionRemote.remote.tabSearch' => 'Rechercher',
'companionRemote.remote.tabDownloads' => 'Téléchargements',
'companionRemote.remote.tabSettings' => 'Paramètres',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsIt extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Mostra il poster di ogni stagione sopra la sua scheda';
@override String get tvFullCardLayout => 'Schede TV piene';
@override String get tvFullCardLayoutDescription => 'Usa schede TV solo immagine con i nomi degli attori sovrapposti';
@override String get focusGlow => 'Bagliore di selezione';
@override String get focusGlowDescription => 'Mostra un leggero bagliore attorno alla scheda selezionata';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsIt {
'settings.showSeasonPostersOnTabsDescription' => 'Mostra il poster di ogni stagione sopra la sua scheda',
'settings.tvFullCardLayout' => 'Schede TV piene',
'settings.tvFullCardLayoutDescription' => 'Usa schede TV solo immagine con i nomi degli attori sovrapposti',
'settings.focusGlow' => 'Bagliore di selezione',
'settings.focusGlowDescription' => 'Mostra un leggero bagliore attorno alla scheda selezionata',
'settings.hideSpoilers' => 'Nascondi spoiler per episodi non visti',
'settings.hideSpoilersDescription' => 'Sfoca miniature e descrizioni degli episodi non visti',
'settings.playerBackend' => 'Motore di riproduzione',
@@ -2425,10 +2429,10 @@ extension on TranslationsIt {
'subtitlingStyling.text' => 'Testo',
'subtitlingStyling.border' => 'Bordo',
'subtitlingStyling.background' => 'Sfondo',
'subtitlingStyling.fontSize' => 'Dimensione',
'subtitlingStyling.textColor' => 'Colore testo',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Dimensione',
'subtitlingStyling.textColor' => 'Colore testo',
'subtitlingStyling.borderSize' => 'Dimensione bordo',
'subtitlingStyling.borderColor' => 'Colore bordo',
'subtitlingStyling.backgroundOpacity' => 'Opacità sfondo',
@@ -2939,10 +2943,10 @@ extension on TranslationsIt {
'companionRemote.remote.tabMore' => 'Altro',
'companionRemote.remote.menu' => 'Menu',
'companionRemote.remote.tabNavigation' => 'Navigazione schede',
'companionRemote.remote.tabDiscover' => 'Esplora',
'companionRemote.remote.tabLibraries' => 'Librerie',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Esplora',
'companionRemote.remote.tabLibraries' => 'Librerie',
'companionRemote.remote.tabSearch' => 'Cerca',
'companionRemote.remote.tabDownloads' => 'Download',
'companionRemote.remote.tabSettings' => 'Impostazioni',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsJa extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => '各シーズンのポスターをタブの上に表示します';
@override String get tvFullCardLayout => 'フルTVカード';
@override String get tvFullCardLayoutDescription => 'TVカードを画像のみで表示し、俳優名を重ねて表示します';
@override String get focusGlow => 'フォーカス時の光彩';
@override String get focusGlowDescription => 'フォーカス中のカードの周りに柔らかい光彩を表示します';
@override String get hideSpoilers => '未視聴エピソードのネタバレを非表示';
@override String get hideSpoilersDescription => '未視聴エピソードのサムネイルと説明をぼかします';
@override String get playerBackend => 'プレーヤーバックエンド';
@@ -2039,6 +2041,8 @@ extension on TranslationsJa {
'settings.showSeasonPostersOnTabsDescription' => '各シーズンのポスターをタブの上に表示します',
'settings.tvFullCardLayout' => 'フルTVカード',
'settings.tvFullCardLayoutDescription' => 'TVカードを画像のみで表示し、俳優名を重ねて表示します',
'settings.focusGlow' => 'フォーカス時の光彩',
'settings.focusGlowDescription' => 'フォーカス中のカードの周りに柔らかい光彩を表示します',
'settings.hideSpoilers' => '未視聴エピソードのネタバレを非表示',
'settings.hideSpoilersDescription' => '未視聴エピソードのサムネイルと説明をぼかします',
'settings.playerBackend' => 'プレーヤーバックエンド',
@@ -2425,10 +2429,10 @@ extension on TranslationsJa {
'subtitlingStyling.text' => 'テキスト',
'subtitlingStyling.border' => '枠線',
'subtitlingStyling.background' => '背景',
'subtitlingStyling.fontSize' => 'フォントサイズ',
'subtitlingStyling.textColor' => 'テキストの色',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'フォントサイズ',
'subtitlingStyling.textColor' => 'テキストの色',
'subtitlingStyling.borderSize' => '枠線サイズ',
'subtitlingStyling.borderColor' => '枠線の色',
'subtitlingStyling.backgroundOpacity' => '背景の不透明度',
@@ -2939,10 +2943,10 @@ extension on TranslationsJa {
'companionRemote.remote.tabMore' => 'その他',
'companionRemote.remote.menu' => 'メニュー',
'companionRemote.remote.tabNavigation' => 'タブナビゲーション',
'companionRemote.remote.tabDiscover' => '探す',
'companionRemote.remote.tabLibraries' => 'ライブラリ',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => '探す',
'companionRemote.remote.tabLibraries' => 'ライブラリ',
'companionRemote.remote.tabSearch' => '検索',
'companionRemote.remote.tabDownloads' => 'ダウンロード',
'companionRemote.remote.tabSettings' => '設定',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsKo extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => '각 시즌 포스터를 탭 위에 표시';
@override String get tvFullCardLayout => '전체 TV 카드';
@override String get tvFullCardLayoutDescription => 'TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시';
@override String get focusGlow => '포커스 글로우';
@override String get focusGlowDescription => '포커스된 카드 주위에 은은한 빛 효과를 표시';
@override String get hideSpoilers => '미시청 에피소드 스포일러 숨기기';
@override String get hideSpoilersDescription => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리';
@override String get playerBackend => '플레이어 백엔드';
@@ -2039,6 +2041,8 @@ extension on TranslationsKo {
'settings.showSeasonPostersOnTabsDescription' => '각 시즌 포스터를 탭 위에 표시',
'settings.tvFullCardLayout' => '전체 TV 카드',
'settings.tvFullCardLayoutDescription' => 'TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시',
'settings.focusGlow' => '포커스 글로우',
'settings.focusGlowDescription' => '포커스된 카드 주위에 은은한 빛 효과를 표시',
'settings.hideSpoilers' => '미시청 에피소드 스포일러 숨기기',
'settings.hideSpoilersDescription' => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리',
'settings.playerBackend' => '플레이어 백엔드',
@@ -2425,10 +2429,10 @@ extension on TranslationsKo {
'subtitlingStyling.text' => '텍스트',
'subtitlingStyling.border' => '테두리',
'subtitlingStyling.background' => '배경',
'subtitlingStyling.fontSize' => '글자 크기',
'subtitlingStyling.textColor' => '텍스트 색상',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => '글자 크기',
'subtitlingStyling.textColor' => '텍스트 색상',
'subtitlingStyling.borderSize' => '테두리 크기',
'subtitlingStyling.borderColor' => '테두리 색상',
'subtitlingStyling.backgroundOpacity' => '배경 불투명도',
@@ -2939,10 +2943,10 @@ extension on TranslationsKo {
'companionRemote.remote.tabMore' => '더 보기',
'companionRemote.remote.menu' => '메뉴',
'companionRemote.remote.tabNavigation' => '탭 탐색',
'companionRemote.remote.tabDiscover' => '발견',
'companionRemote.remote.tabLibraries' => '미디어 라이브러리',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => '발견',
'companionRemote.remote.tabLibraries' => '미디어 라이브러리',
'companionRemote.remote.tabSearch' => '검색',
'companionRemote.remote.tabDownloads' => '다운로드',
'companionRemote.remote.tabSettings' => '설정',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsNb extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Vis hver sesongs plakat over fanen';
@override String get tvFullCardLayout => 'Fulle TV-kort';
@override String get tvFullCardLayoutDescription => 'Bruk bildebaserte TV-kort med skuespillernavn lagt over';
@override String get focusGlow => 'Fokusglød';
@override String get focusGlowDescription => 'Vis en myk glød rundt kortet i fokus';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsNb {
'settings.showSeasonPostersOnTabsDescription' => 'Vis hver sesongs plakat over fanen',
'settings.tvFullCardLayout' => 'Fulle TV-kort',
'settings.tvFullCardLayoutDescription' => 'Bruk bildebaserte TV-kort med skuespillernavn lagt over',
'settings.focusGlow' => 'Fokusglød',
'settings.focusGlowDescription' => 'Vis en myk glød rundt kortet i fokus',
'settings.hideSpoilers' => 'Skjul spoilere for usette episoder',
'settings.hideSpoilersDescription' => 'Slør miniatyrbilder og beskrivelser for usette episoder',
'settings.playerBackend' => 'Spillermotor',
@@ -2425,10 +2429,10 @@ extension on TranslationsNb {
'subtitlingStyling.text' => 'Tekst',
'subtitlingStyling.border' => 'Kantlinje',
'subtitlingStyling.background' => 'Bakgrunn',
'subtitlingStyling.fontSize' => 'Skriftstørrelse',
'subtitlingStyling.textColor' => 'Tekstfarge',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Skriftstørrelse',
'subtitlingStyling.textColor' => 'Tekstfarge',
'subtitlingStyling.borderSize' => 'Kantstørrelse',
'subtitlingStyling.borderColor' => 'Kantfarge',
'subtitlingStyling.backgroundOpacity' => 'Bakgrunnsopasitet',
@@ -2939,10 +2943,10 @@ extension on TranslationsNb {
'companionRemote.remote.tabMore' => 'Mer',
'companionRemote.remote.menu' => 'Meny',
'companionRemote.remote.tabNavigation' => 'Fanenavigering',
'companionRemote.remote.tabDiscover' => 'Oppdag',
'companionRemote.remote.tabLibraries' => 'Biblioteker',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Oppdag',
'companionRemote.remote.tabLibraries' => 'Biblioteker',
'companionRemote.remote.tabSearch' => 'Søk',
'companionRemote.remote.tabDownloads' => 'Nedlastinger',
'companionRemote.remote.tabSettings' => 'Innstillinger',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsNl extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Toon de poster van elk seizoen boven het tabblad';
@override String get tvFullCardLayout => 'Volledige tv-kaarten';
@override String get tvFullCardLayoutDescription => 'Gebruik tv-kaarten met alleen afbeeldingen en namen van acteurs als overlay';
@override String get focusGlow => 'Focusgloed';
@override String get focusGlowDescription => 'Toon een zachte gloed rond de kaart met focus';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsNl {
'settings.showSeasonPostersOnTabsDescription' => 'Toon de poster van elk seizoen boven het tabblad',
'settings.tvFullCardLayout' => 'Volledige tv-kaarten',
'settings.tvFullCardLayoutDescription' => 'Gebruik tv-kaarten met alleen afbeeldingen en namen van acteurs als overlay',
'settings.focusGlow' => 'Focusgloed',
'settings.focusGlowDescription' => 'Toon een zachte gloed rond de kaart met focus',
'settings.hideSpoilers' => 'Spoilers voor ongekeken afleveringen verbergen',
'settings.hideSpoilersDescription' => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen',
'settings.playerBackend' => 'Speler backend',
@@ -2425,10 +2429,10 @@ extension on TranslationsNl {
'subtitlingStyling.text' => 'Tekst',
'subtitlingStyling.border' => 'Rand',
'subtitlingStyling.background' => 'Achtergrond',
'subtitlingStyling.fontSize' => 'Lettergrootte',
'subtitlingStyling.textColor' => 'Tekstkleur',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Lettergrootte',
'subtitlingStyling.textColor' => 'Tekstkleur',
'subtitlingStyling.borderSize' => 'Rand grootte',
'subtitlingStyling.borderColor' => 'Randkleur',
'subtitlingStyling.backgroundOpacity' => 'Achtergrond transparantie',
@@ -2939,10 +2943,10 @@ extension on TranslationsNl {
'companionRemote.remote.tabMore' => 'Meer',
'companionRemote.remote.menu' => 'Menu',
'companionRemote.remote.tabNavigation' => 'Tabnavigatie',
'companionRemote.remote.tabDiscover' => 'Ontdekken',
'companionRemote.remote.tabLibraries' => 'Bibliotheken',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Ontdekken',
'companionRemote.remote.tabLibraries' => 'Bibliotheken',
'companionRemote.remote.tabSearch' => 'Zoeken',
'companionRemote.remote.tabDownloads' => 'Downloads',
'companionRemote.remote.tabSettings' => 'Instellingen',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsPl extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Pokazuj plakat każdego sezonu nad jego kartą';
@override String get tvFullCardLayout => 'Pełne karty TV';
@override String get tvFullCardLayoutDescription => 'Używaj kart TV tylko z obrazem i nałożonymi nazwiskami aktorów';
@override String get focusGlow => 'Poświata zaznaczenia';
@override String get focusGlowDescription => 'Wyświetlaj delikatną poświatę wokół zaznaczonej karty';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsPl {
'settings.showSeasonPostersOnTabsDescription' => 'Pokazuj plakat każdego sezonu nad jego kartą',
'settings.tvFullCardLayout' => 'Pełne karty TV',
'settings.tvFullCardLayoutDescription' => 'Używaj kart TV tylko z obrazem i nałożonymi nazwiskami aktorów',
'settings.focusGlow' => 'Poświata zaznaczenia',
'settings.focusGlowDescription' => 'Wyświetlaj delikatną poświatę wokół zaznaczonej karty',
'settings.hideSpoilers' => 'Ukryj spoilery nieobejrzanych odcinków',
'settings.hideSpoilersDescription' => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków',
'settings.playerBackend' => 'Backend odtwarzacza',
@@ -2425,10 +2429,10 @@ extension on TranslationsPl {
'subtitlingStyling.text' => 'Tekst',
'subtitlingStyling.border' => 'Obramowanie',
'subtitlingStyling.background' => 'Tło',
'subtitlingStyling.fontSize' => 'Rozmiar czcionki',
'subtitlingStyling.textColor' => 'Kolor tekstu',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Rozmiar czcionki',
'subtitlingStyling.textColor' => 'Kolor tekstu',
'subtitlingStyling.borderSize' => 'Rozmiar obramowania',
'subtitlingStyling.borderColor' => 'Kolor obramowania',
'subtitlingStyling.backgroundOpacity' => 'Przezroczystość tła',
@@ -2939,10 +2943,10 @@ extension on TranslationsPl {
'companionRemote.remote.tabMore' => 'Więcej',
'companionRemote.remote.menu' => 'Menu',
'companionRemote.remote.tabNavigation' => 'Nawigacja',
'companionRemote.remote.tabDiscover' => 'Odkryj',
'companionRemote.remote.tabLibraries' => 'Biblioteki',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Odkryj',
'companionRemote.remote.tabLibraries' => 'Biblioteki',
'companionRemote.remote.tabSearch' => 'Szukaj',
'companionRemote.remote.tabDownloads' => 'Pobrania',
'companionRemote.remote.tabSettings' => 'Ustawienia',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsPt extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Mostrar o pôster de cada temporada acima da aba';
@override String get tvFullCardLayout => 'Cartões TV completos';
@override String get tvFullCardLayoutDescription => 'Usar cartões de TV só com imagem e nomes dos atores sobrepostos';
@override String get focusGlow => 'Brilho de foco';
@override String get focusGlowDescription => 'Mostrar um brilho suave à volta do cartão em foco';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsPt {
'settings.showSeasonPostersOnTabsDescription' => 'Mostrar o pôster de cada temporada acima da aba',
'settings.tvFullCardLayout' => 'Cartões TV completos',
'settings.tvFullCardLayoutDescription' => 'Usar cartões de TV só com imagem e nomes dos atores sobrepostos',
'settings.focusGlow' => 'Brilho de foco',
'settings.focusGlowDescription' => 'Mostrar um brilho suave à volta do cartão em foco',
'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',
@@ -2425,10 +2429,10 @@ extension on TranslationsPt {
'subtitlingStyling.text' => 'Texto',
'subtitlingStyling.border' => 'Borda',
'subtitlingStyling.background' => 'Fundo',
'subtitlingStyling.fontSize' => 'Tamanho da Fonte',
'subtitlingStyling.textColor' => 'Cor do Texto',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Tamanho da Fonte',
'subtitlingStyling.textColor' => 'Cor do Texto',
'subtitlingStyling.borderSize' => 'Tamanho da Borda',
'subtitlingStyling.borderColor' => 'Cor da Borda',
'subtitlingStyling.backgroundOpacity' => 'Opacidade do Fundo',
@@ -2939,10 +2943,10 @@ extension on TranslationsPt {
'companionRemote.remote.tabMore' => 'Mais',
'companionRemote.remote.menu' => 'Menu',
'companionRemote.remote.tabNavigation' => 'Navegação',
'companionRemote.remote.tabDiscover' => 'Descobrir',
'companionRemote.remote.tabLibraries' => 'Bibliotecas',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Descobrir',
'companionRemote.remote.tabLibraries' => 'Bibliotecas',
'companionRemote.remote.tabSearch' => 'Buscar',
'companionRemote.remote.tabDownloads' => 'Downloads',
'companionRemote.remote.tabSettings' => 'Configurações',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsRu extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Показывать постер каждого сезона над его вкладкой';
@override String get tvFullCardLayout => 'Полные TV-карточки';
@override String get tvFullCardLayoutDescription => 'Использовать TV-карточки только с изображением и именами актёров поверх него';
@override String get focusGlow => 'Свечение при фокусе';
@override String get focusGlowDescription => 'Показывать мягкое свечение вокруг карточки в фокусе';
@override String get hideSpoilers => 'Скрыть спойлеры непросмотренных эпизодов';
@override String get hideSpoilersDescription => 'Размывать миниатюры и описания непросмотренных серий';
@override String get playerBackend => 'Бэкенд плеера';
@@ -2039,6 +2041,8 @@ extension on TranslationsRu {
'settings.showSeasonPostersOnTabsDescription' => 'Показывать постер каждого сезона над его вкладкой',
'settings.tvFullCardLayout' => 'Полные TV-карточки',
'settings.tvFullCardLayoutDescription' => 'Использовать TV-карточки только с изображением и именами актёров поверх него',
'settings.focusGlow' => 'Свечение при фокусе',
'settings.focusGlowDescription' => 'Показывать мягкое свечение вокруг карточки в фокусе',
'settings.hideSpoilers' => 'Скрыть спойлеры непросмотренных эпизодов',
'settings.hideSpoilersDescription' => 'Размывать миниатюры и описания непросмотренных серий',
'settings.playerBackend' => 'Бэкенд плеера',
@@ -2425,10 +2429,10 @@ extension on TranslationsRu {
'subtitlingStyling.text' => 'Текст',
'subtitlingStyling.border' => 'Обводка',
'subtitlingStyling.background' => 'Фон',
'subtitlingStyling.fontSize' => 'Размер шрифта',
'subtitlingStyling.textColor' => 'Цвет текста',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Размер шрифта',
'subtitlingStyling.textColor' => 'Цвет текста',
'subtitlingStyling.borderSize' => 'Размер обводки',
'subtitlingStyling.borderColor' => 'Цвет обводки',
'subtitlingStyling.backgroundOpacity' => 'Прозрачность фона',
@@ -2939,10 +2943,10 @@ extension on TranslationsRu {
'companionRemote.remote.tabMore' => 'Ещё',
'companionRemote.remote.menu' => 'Меню',
'companionRemote.remote.tabNavigation' => 'Навигация',
'companionRemote.remote.tabDiscover' => 'Обзор',
'companionRemote.remote.tabLibraries' => 'Библиотеки',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Обзор',
'companionRemote.remote.tabLibraries' => 'Библиотеки',
'companionRemote.remote.tabSearch' => 'Поиск',
'companionRemote.remote.tabDownloads' => 'Загрузки',
'companionRemote.remote.tabSettings' => 'Настройки',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsSv extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => 'Visa varje säsongs affisch ovanför fliken';
@override String get tvFullCardLayout => 'Heltäckande TV-kort';
@override String get tvFullCardLayoutDescription => 'Använd TV-kort med enbart bild och skådespelarnamn ovanpå';
@override String get focusGlow => 'Fokusglöd';
@override String get focusGlowDescription => 'Visa en mjuk glöd runt kortet i fokus';
@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';
@@ -2039,6 +2041,8 @@ extension on TranslationsSv {
'settings.showSeasonPostersOnTabsDescription' => 'Visa varje säsongs affisch ovanför fliken',
'settings.tvFullCardLayout' => 'Heltäckande TV-kort',
'settings.tvFullCardLayoutDescription' => 'Använd TV-kort med enbart bild och skådespelarnamn ovanpå',
'settings.focusGlow' => 'Fokusglöd',
'settings.focusGlowDescription' => 'Visa en mjuk glöd runt kortet i fokus',
'settings.hideSpoilers' => 'Dölj spoilers för osedda avsnitt',
'settings.hideSpoilersDescription' => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt',
'settings.playerBackend' => 'Spelarmotor',
@@ -2425,10 +2429,10 @@ extension on TranslationsSv {
'subtitlingStyling.text' => 'Text',
'subtitlingStyling.border' => 'Kantlinje',
'subtitlingStyling.background' => 'Bakgrund',
'subtitlingStyling.fontSize' => 'Teckenstorlek',
'subtitlingStyling.textColor' => 'Textfärg',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => 'Teckenstorlek',
'subtitlingStyling.textColor' => 'Textfärg',
'subtitlingStyling.borderSize' => 'Kantstorlek',
'subtitlingStyling.borderColor' => 'Kantfärg',
'subtitlingStyling.backgroundOpacity' => 'Bakgrundsopacitet',
@@ -2939,10 +2943,10 @@ extension on TranslationsSv {
'companionRemote.remote.tabMore' => 'Mer',
'companionRemote.remote.menu' => 'Meny',
'companionRemote.remote.tabNavigation' => 'Fliknavigering',
'companionRemote.remote.tabDiscover' => 'Upptäck',
'companionRemote.remote.tabLibraries' => 'Bibliotek',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => 'Upptäck',
'companionRemote.remote.tabLibraries' => 'Bibliotek',
'companionRemote.remote.tabSearch' => 'Sök',
'companionRemote.remote.tabDownloads' => 'Nedladdningar',
'companionRemote.remote.tabSettings' => 'Inställningar',
+8 -4
View File
@@ -263,6 +263,8 @@ class _TranslationsSettingsZh extends TranslationsSettingsEn {
@override String get showSeasonPostersOnTabsDescription => '在每季标签上方显示该季海报';
@override String get tvFullCardLayout => '完整 TV 卡片';
@override String get tvFullCardLayoutDescription => '使用仅显示图片的 TV 卡片,并叠加演员姓名';
@override String get focusGlow => '焦点光晕';
@override String get focusGlowDescription => '在获得焦点的卡片周围显示柔和的光晕';
@override String get hideSpoilers => '隐藏未看剧集的剧透内容';
@override String get hideSpoilersDescription => '模糊未观看剧集的缩略图和描述';
@override String get playerBackend => '播放器引擎';
@@ -2039,6 +2041,8 @@ extension on TranslationsZh {
'settings.showSeasonPostersOnTabsDescription' => '在每季标签上方显示该季海报',
'settings.tvFullCardLayout' => '完整 TV 卡片',
'settings.tvFullCardLayoutDescription' => '使用仅显示图片的 TV 卡片,并叠加演员姓名',
'settings.focusGlow' => '焦点光晕',
'settings.focusGlowDescription' => '在获得焦点的卡片周围显示柔和的光晕',
'settings.hideSpoilers' => '隐藏未看剧集的剧透内容',
'settings.hideSpoilersDescription' => '模糊未观看剧集的缩略图和描述',
'settings.playerBackend' => '播放器引擎',
@@ -2425,10 +2429,10 @@ extension on TranslationsZh {
'subtitlingStyling.text' => '文本',
'subtitlingStyling.border' => '边框',
'subtitlingStyling.background' => '背景',
'subtitlingStyling.fontSize' => '字号',
'subtitlingStyling.textColor' => '文本颜色',
_ => null,
} ?? switch (path) {
'subtitlingStyling.fontSize' => '字号',
'subtitlingStyling.textColor' => '文本颜色',
'subtitlingStyling.borderSize' => '边框大小',
'subtitlingStyling.borderColor' => '边框颜色',
'subtitlingStyling.backgroundOpacity' => '背景不透明度',
@@ -2939,10 +2943,10 @@ extension on TranslationsZh {
'companionRemote.remote.tabMore' => '更多',
'companionRemote.remote.menu' => '菜单',
'companionRemote.remote.tabNavigation' => '标签导航',
'companionRemote.remote.tabDiscover' => '发现',
'companionRemote.remote.tabLibraries' => '媒体库',
_ => null,
} ?? switch (path) {
'companionRemote.remote.tabDiscover' => '发现',
'companionRemote.remote.tabLibraries' => '媒体库',
'companionRemote.remote.tabSearch' => '搜索',
'companionRemote.remote.tabDownloads' => '下载',
'companionRemote.remote.tabSettings' => '设置',
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "Visa varje säsongs affisch ovanför fliken",
"tvFullCardLayout": "Heltäckande TV-kort",
"tvFullCardLayoutDescription": "Använd TV-kort med enbart bild och skådespelarnamn ovanpå",
"focusGlow": "Fokusglöd",
"focusGlowDescription": "Visa en mjuk glöd runt kortet i fokus",
"hideSpoilers": "Dölj spoilers för osedda avsnitt",
"hideSpoilersDescription": "Sudda miniatyrbilder och beskrivningar för osedda avsnitt",
"playerBackend": "Spelarmotor",
+2
View File
@@ -134,6 +134,8 @@
"showSeasonPostersOnTabsDescription": "在每季标签上方显示该季海报",
"tvFullCardLayout": "完整 TV 卡片",
"tvFullCardLayoutDescription": "使用仅显示图片的 TV 卡片,并叠加演员姓名",
"focusGlow": "焦点光晕",
"focusGlowDescription": "在获得焦点的卡片周围显示柔和的光晕",
"hideSpoilers": "隐藏未看剧集的剧透内容",
"hideSpoilersDescription": "模糊未观看剧集的缩略图和描述",
"playerBackend": "播放器引擎",
+16 -10
View File
@@ -21,6 +21,7 @@ import '../focus/focusable_action_bar.dart';
import '../focus/focusable_wrapper.dart';
import '../focus/key_event_utils.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/card_focus_scope.dart';
import '../widgets/focus_builders.dart';
import '../media/media_hub.dart';
import '../utils/provider_extensions.dart';
@@ -4177,6 +4178,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
isFocused: isFocused,
borderRadius: tokens(context).radiusSm,
onTap: () => _navigateToActorMedia(actor),
delegateFocusBorder: true,
child: Padding(
padding: const EdgeInsets.all(innerPadding),
child: SizedBox(
@@ -4184,16 +4186,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: Column(
crossAxisAlignment: .start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: OptimizedMediaImage(
client: getServerBoundMediaClient(context),
imagePath: actor.thumbPath,
width: imageSize,
height: imageSize,
fit: BoxFit.cover,
imageType: ImageType.avatar,
fallbackIcon: Symbols.person_rounded,
CardFocusBorder(
borderRadius: tokens(context).radiusSm,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: OptimizedMediaImage(
client: getServerBoundMediaClient(context),
imagePath: actor.thumbPath,
width: imageSize,
height: imageSize,
fit: BoxFit.cover,
imageType: ImageType.avatar,
fallbackIcon: Symbols.person_rounded,
),
),
),
const SizedBox(height: 8),
@@ -4266,6 +4271,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
context: context,
isFocused: isFocused,
onTap: () => navigateToVideoPlayer(context, metadata: extra),
delegateFocusBorder: true,
child: MediaCard(
key: cardKey,
item: extra,
+37 -32
View File
@@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart';
import '../../connection/connection.dart';
import '../../exceptions/media_server_exceptions.dart';
import '../../focus/card_focus_scope.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/focusable_wrapper.dart';
@@ -759,45 +760,49 @@ class _DiscoveredJellyfinServerTile extends StatelessWidget {
return FocusableWrapper(
focusNode: focusNode,
disableScale: true,
borderRadius: 12,
useForegroundFocusDecoration: true,
// Border drawn by CardFocusBorder so it paints over the opaque Material.
delegateFocusBorder: true,
descendantsAreFocusable: false,
onSelect: onTap,
onNavigateUp: onNavigateUp,
onNavigateDown: onNavigateDown,
child: Material(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
child: CardFocusBorder(
borderRadius: 12,
strokeAlign: BorderSide.strokeAlignInside,
child: Material(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
const AppIcon(Symbols.dns_rounded, fill: 1),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Text(server.name, style: theme.textTheme.titleSmall),
const SizedBox(height: 2),
Text(
server.address,
maxLines: 1,
overflow: .ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
const AppIcon(Symbols.dns_rounded, fill: 1),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Text(server.name, style: theme.textTheme.titleSmall),
const SizedBox(height: 2),
Text(
server.address,
maxLines: 1,
overflow: .ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
],
],
),
),
),
const SizedBox(width: 12),
const AppIcon(Symbols.chevron_right_rounded, fill: 1),
],
const SizedBox(width: 12),
const AppIcon(Symbols.chevron_right_rounded, fill: 1),
],
),
),
),
),
@@ -43,6 +43,13 @@ class AppearanceSettingsScreen extends StatelessWidget {
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,
+1
View File
@@ -290,6 +290,7 @@ class SettingsService extends BaseSharedPreferencesService {
static const rewindOnResume = IntPref('rewind_on_resume');
static const showHeroSection = BoolPref('show_hero_section', defaultValue: true);
static const tvFullCardLayout = BoolPref('tv_full_card_layout', defaultValue: false);
static const focusGlow = BoolPref('focus_glow', defaultValue: true);
static const useGlobalHubs = BoolPref('use_global_hubs', defaultValue: true);
static const showServerNameOnHubs = BoolPref('show_server_name_on_hubs');
static const groupLibrariesByServer = BoolPref('group_libraries_by_server', defaultValue: true);
+12 -19
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../focus/card_focus_scope.dart';
import '../focus/focus_glow_overlay.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
@@ -75,9 +76,8 @@ class FocusBuilders {
VoidCallback? onLongPress,
double borderRadius = FocusTheme.defaultBorderRadius,
double focusScale = FocusTheme.focusScale,
double focusBorderStrokeAlign = BorderSide.strokeAlignInside,
bool useFocusGlow = false,
bool useForegroundFocusDecoration = false,
bool delegateFocusBorder = false,
Size? glowSize,
required Widget child,
}) {
@@ -99,21 +99,16 @@ class FocusBuilders {
final duration = FocusTheme.getAnimationDuration(context);
final showFocus = isFocused && isKeyboardMode;
final focusDecoration = FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: borderRadius,
borderStrokeAlign: focusBorderStrokeAlign,
);
// Glow (full-bleed cards) renders in an overlay above siblings so it stays
// symmetric; the in-card decoration only carries the border.
Widget card = AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: useForegroundFocusDecoration ? null : focusDecoration,
foregroundDecoration: useForegroundFocusDecoration ? focusDecoration : null,
child: child,
);
Widget card = delegateFocusBorder
? CardFocusScope(showFocus: showFocus, child: child)
: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: borderRadius),
child: child,
);
if (useFocusGlow) {
card = FocusGlowOverlay(
isFocused: showFocus,
@@ -164,9 +159,8 @@ class FocusBuilders {
VoidCallback? onLongPress,
double borderRadius = FocusTheme.defaultBorderRadius,
double focusScale = FocusTheme.focusScale,
double focusBorderStrokeAlign = BorderSide.strokeAlignInside,
bool useFocusGlow = false,
bool useForegroundFocusDecoration = false,
bool delegateFocusBorder = false,
Size? glowSize,
required Widget child,
}) {
@@ -179,9 +173,8 @@ class FocusBuilders {
onLongPress: onLongPress,
borderRadius: borderRadius,
focusScale: focusScale,
focusBorderStrokeAlign: focusBorderStrokeAlign,
useFocusGlow: useFocusGlow,
useForegroundFocusDecoration: useForegroundFocusDecoration,
delegateFocusBorder: delegateFocusBorder,
glowSize: glowSize,
child: child,
);
+3 -2
View File
@@ -117,9 +117,10 @@ class _FocusableMediaCardState extends State<FocusableMediaCard> {
enableLongPress: true,
disableScale: widget.disableScale,
focusScale: widget.fullBleedImage ? FocusTheme.fullCardFocusScale : FocusTheme.focusScale,
focusBorderStrokeAlign: widget.fullBleedImage ? BorderSide.strokeAlignOutside : BorderSide.strokeAlignInside,
useFocusGlow: widget.fullBleedImage,
useForegroundFocusDecoration: widget.fullBleedImage,
// MediaCard draws the focus border itself, on the rect its layout
// highlights (poster for standard grid cards, whole card otherwise).
delegateFocusBorder: true,
useComfortableZone: !PlatformDetector.isTV(), // Always center on TV
scrollAlignment: 0.5,
child: MediaCard(
+1
View File
@@ -558,6 +558,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
isFocused: isItemFocused,
onTap: () => _onItemTapped(index),
onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(),
delegateFocusBorder: true,
child: MediaCard(
key: _getMediaCardKey(index),
item: item,
+171 -175
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/card_focus_scope.dart';
import '../focus/input_mode_tracker.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
@@ -299,23 +300,26 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
onSecondaryTapDown: storeTapPosition,
onSecondaryTap: showContextMenuFromTap,
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Stack(
fit: StackFit.expand,
children: [
_buildPosterImage(
context,
item,
isOffline: widget.isOffline,
localPosterPath: localPosterPath,
mixedHubContext: widget.mixedHubContext,
episodePosterModeOverride: widget.episodePosterModeOverride,
knownWidth: width,
knownHeight: height,
),
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item),
],
child: CardFocusBorder(
borderRadius: tokens(context).radiusSm,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Stack(
fit: StackFit.expand,
children: [
_buildPosterImage(
context,
item,
isOffline: widget.isOffline,
localPosterPath: localPosterPath,
mixedHubContext: widget.mixedHubContext,
episodePosterModeOverride: widget.episodePosterModeOverride,
knownWidth: width,
knownHeight: height,
),
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item),
],
),
),
),
),
@@ -327,6 +331,30 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
final posterWidth = widget.width != null ? widget.width! - 6 : null;
final posterHeight = widget.height;
// The focus border hugs the poster (captions stay outside it), matching
// the full-bleed card treatment.
final poster = CardFocusBorder(
borderRadius: tokens(context).radiusSm,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: _buildPosterImage(
context,
item,
isOffline: widget.isOffline,
localPosterPath: localPosterPath,
mixedHubContext: widget.mixedHubContext,
episodePosterModeOverride: widget.episodePosterModeOverride,
knownWidth: posterHeight != null ? posterWidth : null,
knownHeight: posterHeight,
),
),
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item),
],
),
);
return SizedBox(
width: widget.width,
child: InkWell(
@@ -346,47 +374,9 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
children: [
// Poster with overlay
if (posterHeight != null)
SizedBox(
width: double.infinity,
height: posterHeight,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: _buildPosterImage(
context,
item,
isOffline: widget.isOffline,
localPosterPath: localPosterPath,
mixedHubContext: widget.mixedHubContext,
episodePosterModeOverride: widget.episodePosterModeOverride,
knownWidth: posterWidth,
knownHeight: posterHeight,
),
),
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item),
],
),
)
SizedBox(width: double.infinity, height: posterHeight, child: poster)
else
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: _buildPosterImage(
context,
item,
isOffline: widget.isOffline,
localPosterPath: localPosterPath,
mixedHubContext: widget.mixedHubContext,
episodePosterModeOverride: widget.episodePosterModeOverride,
),
),
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item),
],
),
),
Expanded(child: poster),
const SizedBox(height: 2),
// Title (flattened — no inner Column)
if (item is MediaItem && _hasClickableTitle(item))
@@ -592,133 +582,139 @@ class _MediaCardList extends StatelessWidget {
final metadataLine = _buildMetadataLine();
final subtitle = _buildSubtitleText();
return InkWell(
mouseCursor: SystemMouseCursors.click,
canRequestFocus: false, // Keyboard handled by FocusableMediaCard
onTap: onTap,
onTapDown: onTapDown,
onLongPress: onLongPress,
onSecondaryTapDown: onSecondaryTapDown,
onSecondaryTap: onSecondaryTap,
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: .start,
children: [
SizedBox(
width: _posterWidth(),
height: _posterHeight(),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: _buildPosterImage(
context,
item,
isOffline: isOffline,
localPosterPath: localPosterPath,
episodePosterModeOverride: episodePosterModeOverride,
// List rows keep the whole-row border; inside stroke so adjacent rows
// don't overlap.
return CardFocusBorder(
borderRadius: tokens(context).radiusSm,
strokeAlign: BorderSide.strokeAlignInside,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
canRequestFocus: false, // Keyboard handled by FocusableMediaCard
onTap: onTap,
onTapDown: onTapDown,
onLongPress: onLongPress,
onSecondaryTapDown: onSecondaryTapDown,
onSecondaryTap: onSecondaryTap,
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: .start,
children: [
SizedBox(
width: _posterWidth(),
height: _posterHeight(),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: _buildPosterImage(
context,
item,
isOffline: isOffline,
localPosterPath: localPosterPath,
episodePosterModeOverride: episodePosterModeOverride,
),
),
),
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item as MediaItem),
],
if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item as MediaItem),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
mainAxisAlignment: .start,
children: [
if (item is MediaItem && _hasClickableTitle(item as MediaItem))
_ClickableText(
text: (item as MediaItem).displayTitle,
style: TextStyle(fontWeight: .w600, fontSize: _titleFontSize, height: 1.2),
onTap: () => _navigateToFocusedDetail(context, item as MediaItem, isOffline: isOffline),
)
else
Text(
_displayTitle(),
maxLines: 2,
overflow: .ellipsis,
style: TextStyle(fontWeight: .w600, fontSize: _titleFontSize, height: 1.2),
),
const SizedBox(height: 4),
if (metadataLine.isNotEmpty) ...[
Text(
metadataLine,
maxLines: 1,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.9),
fontSize: _metadataFontSize,
fontWeight: .w500,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
mainAxisAlignment: .start,
children: [
if (item is MediaItem && _hasClickableTitle(item as MediaItem))
_ClickableText(
text: (item as MediaItem).displayTitle,
style: TextStyle(fontWeight: .w600, fontSize: _titleFontSize, height: 1.2),
onTap: () => _navigateToFocusedDetail(context, item as MediaItem, isOffline: isOffline),
)
else
Text(
_displayTitle(),
maxLines: 2,
overflow: .ellipsis,
style: TextStyle(fontWeight: .w600, fontSize: _titleFontSize, height: 1.2),
),
),
const SizedBox(height: 2),
],
if (item is MediaItem &&
(item as MediaItem).isEpisode &&
(item as MediaItem).parentIndex != null &&
(item as MediaItem).parentId != null) ...[
_buildEpisodeSubtitle(context, item as MediaItem),
const SizedBox(height: 4),
] else if (subtitle != null) ...[
Text(
subtitle,
maxLines: 1,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
),
),
const SizedBox(height: 4),
],
if (!(item is MediaItem &&
SettingsService.instance.read(SettingsService.hideSpoilers) &&
(item as MediaItem).shouldHideSpoiler) &&
_summary() != null) ...[
Text(
_summary()!,
maxLines: _summaryMaxLines,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.7),
fontSize: _summaryFontSize,
height: 1.3,
),
),
],
if (showServerName && item is MediaItem && (item as MediaItem).serverName != null) ...[
const SizedBox(height: 4),
Row(
children: [
BackendBadge(
backend: (item as MediaItem).backend,
size: _metadataFontSize + 2,
color: tokens(context).textMuted.withValues(alpha: 0.6),
if (metadataLine.isNotEmpty) ...[
Text(
metadataLine,
maxLines: 1,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.9),
fontSize: _metadataFontSize,
fontWeight: .w500,
),
const SizedBox(width: 4),
Flexible(
child: Text(
(item as MediaItem).serverName!,
maxLines: 1,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.6),
fontSize: _metadataFontSize,
),
const SizedBox(height: 2),
],
if (item is MediaItem &&
(item as MediaItem).isEpisode &&
(item as MediaItem).parentIndex != null &&
(item as MediaItem).parentId != null) ...[
_buildEpisodeSubtitle(context, item as MediaItem),
const SizedBox(height: 4),
] else if (subtitle != null) ...[
Text(
subtitle,
maxLines: 1,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
),
),
const SizedBox(height: 4),
],
if (!(item is MediaItem &&
SettingsService.instance.read(SettingsService.hideSpoilers) &&
(item as MediaItem).shouldHideSpoiler) &&
_summary() != null) ...[
Text(
_summary()!,
maxLines: _summaryMaxLines,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.7),
fontSize: _summaryFontSize,
height: 1.3,
),
),
],
if (showServerName && item is MediaItem && (item as MediaItem).serverName != null) ...[
const SizedBox(height: 4),
Row(
children: [
BackendBadge(
backend: (item as MediaItem).backend,
size: _metadataFontSize + 2,
color: tokens(context).textMuted.withValues(alpha: 0.6),
),
const SizedBox(width: 4),
Flexible(
child: Text(
(item as MediaItem).serverName!,
maxLines: 1,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.6),
fontSize: _metadataFontSize,
),
),
),
),
],
),
],
),
],
],
],
),
),
),
],
],
),
),
),
);
+68 -62
View File
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/card_focus_scope.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/key_event_utils.dart';
@@ -1275,11 +1276,10 @@ class TvBrowseRailState extends State<TvBrowseRail> {
isFocused: isFocused,
borderRadius: tokens(context).radiusSm,
focusScale: fullCardLayout ? TvBrowseRailLayout.fullCardFocusScale : FocusTheme.focusScale,
focusBorderStrokeAlign: fullCardLayout
? BorderSide.strokeAlignOutside
: BorderSide.strokeAlignInside,
useFocusGlow: fullCardLayout,
useForegroundFocusDecoration: fullCardLayout,
// The card draws the border itself (poster rect for
// standard cards, whole card when full-bleed).
delegateFocusBorder: true,
glowSize: fullCardLayout ? Size(metrics.cardWidth, metrics.posterHeight) : null,
onTap: () {
_selectHubItem(hub, hubIndex, itemIndex);
@@ -1352,62 +1352,65 @@ class TvBrowseRailState extends State<TvBrowseRail> {
return SizedBox(
width: cardWidth,
height: imageSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Stack(
fit: StackFit.expand,
children: [
OptimizedMediaImage(
client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: item.thumbPath,
width: cardWidth,
height: imageSize,
fit: BoxFit.cover,
imageType: ImageType.avatar,
fallbackIcon: Symbols.person_rounded,
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.78)],
stops: const [0.45, 1.0],
child: CardFocusBorder(
borderRadius: tokens(context).radiusSm,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Stack(
fit: StackFit.expand,
children: [
OptimizedMediaImage(
client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: item.thumbPath,
width: cardWidth,
height: imageSize,
fit: BoxFit.cover,
imageType: ImageType.avatar,
fallbackIcon: Symbols.person_rounded,
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.78)],
stops: const [0.45, 1.0],
),
),
),
),
Positioned(
left: 10 * scale,
right: 10 * scale,
bottom: 9 * scale,
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(
item.displayTitle,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(color: Colors.white, fontSize: 13 * scale, height: 1.1, fontWeight: .w800),
),
if (characterName != null && characterName.isNotEmpty) ...[
SizedBox(height: 2 * scale),
Positioned(
left: 10 * scale,
right: 10 * scale,
bottom: 9 * scale,
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(
characterName,
item.displayTitle,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.82),
fontSize: 11 * scale,
height: 1.1,
fontWeight: .w600,
),
style: TextStyle(color: Colors.white, fontSize: 13 * scale, height: 1.1, fontWeight: .w800),
),
if (characterName != null && characterName.isNotEmpty) ...[
SizedBox(height: 2 * scale),
Text(
characterName,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.82),
fontSize: 11 * scale,
height: 1.1,
fontWeight: .w600,
),
),
],
],
],
),
),
),
],
],
),
),
),
);
@@ -1421,16 +1424,19 @@ class TvBrowseRailState extends State<TvBrowseRail> {
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: OptimizedMediaImage(
client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: item.thumbPath,
width: imageSize,
height: imageSize,
fit: BoxFit.cover,
imageType: ImageType.avatar,
fallbackIcon: Symbols.person_rounded,
CardFocusBorder(
borderRadius: tokens(context).radiusSm,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: OptimizedMediaImage(
client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: item.thumbPath,
width: imageSize,
height: imageSize,
fit: BoxFit.cover,
imageType: ImageType.avatar,
fallbackIcon: Symbols.person_rounded,
),
),
),
SizedBox(height: 6 * scale),
+13 -6
View File
@@ -14,6 +14,7 @@ import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
import 'package:plezy/widgets/tv_browse_rail.dart';
import 'package:provider/provider.dart';
@@ -559,7 +560,7 @@ void main() {
expect(find.text('Visible Movie'), findsOneWidget);
});
testWidgets('detailed card focus ring wraps card content height', (tester) async {
testWidgets('detailed card focus border hugs the poster, captions outside', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
@@ -599,11 +600,13 @@ void main() {
);
await tester.pump();
final focusDecoration = find.ancestor(
of: find.text('Visible Movie'),
// The border is drawn inside MediaCard around the poster only (#1278);
// the title/year captions render below it, outside the focus rect.
final focusDecoration = find.descendant(
of: find.ancestor(of: find.text('Visible Movie'), matching: find.byType(MediaCard)),
matching: find.byWidgetPredicate((widget) {
if (widget is! AnimatedContainer || widget.decoration is! BoxDecoration) return false;
return (widget.decoration as BoxDecoration).border is Border && widget.foregroundDecoration == null;
if (widget is! AnimatedContainer || widget.foregroundDecoration is! BoxDecoration) return false;
return (widget.foregroundDecoration as BoxDecoration).border is Border;
}),
);
@@ -611,8 +614,12 @@ void main() {
expect(find.text('2024'), findsOneWidget);
final focusRect = tester.getRect(focusDecoration);
final titleRect = tester.getRect(find.text('Visible Movie'));
final subtitleRect = tester.getRect(find.text('2024'));
expect(focusRect.bottom - subtitleRect.bottom, lessThan(8));
expect(focusRect.bottom, lessThanOrEqualTo(titleRect.top));
expect(focusRect.bottom, lessThanOrEqualTo(subtitleRect.top));
// Still a tight ring: the poster fills the card width above the captions.
expect(focusRect.top, lessThan(titleRect.top));
});
testWidgets('view all item uses compact pill focus style', (tester) async {