feat(tv): add optional full-card layouts

This commit is contained in:
edde746
2026-05-28 22:33:06 +02:00
parent c8670a8d6a
commit 47276e1bac
57 changed files with 1339 additions and 193 deletions
+34 -1
View File
@@ -5,8 +5,12 @@ class FocusTheme {
FocusTheme._(); FocusTheme._();
static const double focusScale = 1.02; static const double focusScale = 1.02;
static const double fullCardFocusScale = 1.03;
static const double focusBorderWidth = 2.5; static const double focusBorderWidth = 2.5;
static const double defaultBorderRadius = 8.0; static const double defaultBorderRadius = 8.0;
static const double focusGlowInnerBlurRadius = 18;
static const double focusGlowOuterBlurRadius = 34;
static const double focusGlowSpreadRadius = 1.5;
static Color getFocusBorderColor(BuildContext context) { static Color getFocusBorderColor(BuildContext context) {
return Theme.of(context).colorScheme.primary; return Theme.of(context).colorScheme.primary;
@@ -20,13 +24,42 @@ class FocusTheme {
BuildContext context, { BuildContext context, {
required bool isFocused, required bool isFocused,
double borderRadius = defaultBorderRadius, double borderRadius = defaultBorderRadius,
double borderStrokeAlign = BorderSide.strokeAlignInside,
Color? color, Color? color,
}) { }) {
final focusColor = color ?? getFocusBorderColor(context); final focusColor = color ?? getFocusBorderColor(context);
return BoxDecoration( return BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: isFocused ? focusColor : Colors.transparent, width: focusBorderWidth), border: Border.all(
color: isFocused ? focusColor : Colors.transparent,
width: focusBorderWidth,
strokeAlign: borderStrokeAlign,
),
);
}
static BoxDecoration focusGlowDecoration(
BuildContext context, {
required bool isFocused,
double borderRadius = defaultBorderRadius,
Color? color,
}) {
final focusColor = color ?? getFocusBorderColor(context);
return BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: isFocused ? focusColor.withValues(alpha: 0.34) : Colors.transparent,
blurRadius: focusGlowInnerBlurRadius,
spreadRadius: focusGlowSpreadRadius,
),
BoxShadow(
color: isFocused ? focusColor.withValues(alpha: 0.20) : Colors.transparent,
blurRadius: focusGlowOuterBlurRadius,
),
],
); );
} }
+39 -5
View File
@@ -106,6 +106,18 @@ class FocusableWrapper extends StatefulWidget {
/// Useful for elements like sliders where scaling looks odd. /// Useful for elements like sliders where scaling looks odd.
final bool disableScale; final bool disableScale;
/// 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;
/// Whether descendants can receive focus. /// Whether descendants can receive focus.
/// Set to false when the child widget has its own Focus (e.g. buttons) /// Set to false when the child widget has its own Focus (e.g. buttons)
/// that would compete with this wrapper's focus handling. /// that would compete with this wrapper's focus handling.
@@ -136,6 +148,10 @@ class FocusableWrapper extends StatefulWidget {
this.useBackgroundFocus = false, this.useBackgroundFocus = false,
this.focusColor, this.focusColor,
this.disableScale = false, this.disableScale = false,
this.focusScale = FocusTheme.focusScale,
this.focusBorderStrokeAlign = BorderSide.strokeAlignInside,
this.useFocusGlow = false,
this.useForegroundFocusDecoration = false,
this.descendantsAreFocusable = true, this.descendantsAreFocusable = true,
}); });
@@ -149,7 +165,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
bool _isFocused = false; bool _isFocused = false;
late final AnimationController _animationController; late final AnimationController _animationController;
late final Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
// Long-press detection for SELECT key // Long-press detection for SELECT key
Timer? _longPressTimer; Timer? _longPressTimer;
@@ -178,9 +194,13 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
void _initAnimations() { void _initAnimations() {
_animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 150)); _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 150));
_scaleAnimation = Tween<double>( _scaleAnimation = _createScaleAnimation();
}
Animation<double> _createScaleAnimation() {
return Tween<double>(
begin: 1.0, begin: 1.0,
end: FocusTheme.focusScale, end: widget.focusScale,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic)); ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic));
} }
@@ -200,6 +220,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
if (widget.canRequestFocus != oldWidget.canRequestFocus) { if (widget.canRequestFocus != oldWidget.canRequestFocus) {
_focusNode.canRequestFocus = widget.canRequestFocus; _focusNode.canRequestFocus = widget.canRequestFocus;
} }
if (widget.focusScale != oldWidget.focusScale) {
_scaleAnimation = _createScaleAnimation();
}
} }
@override @override
@@ -447,14 +471,23 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
} }
// Choose decoration based on useBackgroundFocus // Choose decoration based on useBackgroundFocus
final decoration = widget.useBackgroundFocus final focusDecoration = widget.useBackgroundFocus
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius) ? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
: FocusTheme.focusDecoration( : FocusTheme.focusDecoration(
context, context,
isFocused: showFocus, isFocused: showFocus,
borderRadius: widget.borderRadius, borderRadius: widget.borderRadius,
color: widget.focusColor, color: widget.focusColor,
borderStrokeAlign: widget.focusBorderStrokeAlign,
); );
final glowDecoration = widget.useFocusGlow
? FocusTheme.focusGlowDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor,
)
: null;
Widget result = Focus( Widget result = Focus(
focusNode: _focusNode, focusNode: _focusNode,
@@ -471,7 +504,8 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
child: AnimatedContainer( child: AnimatedContainer(
duration: duration, duration: duration,
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
decoration: decoration, decoration: widget.useForegroundFocusDecoration ? glowDecoration : focusDecoration,
foregroundDecoration: widget.useForegroundFocusDecoration ? focusDecoration : null,
child: widget.child, child: widget.child,
), ),
); );
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Показвай сезон и номер на епизод върху картите на епизодите", "showEpisodeNumberOnCardsDescription": "Показвай сезон и номер на епизод върху картите на епизодите",
"showSeasonPostersOnTabs": "Показвай постери на сезоните в табовете", "showSeasonPostersOnTabs": "Показвай постери на сезоните в табовете",
"showSeasonPostersOnTabsDescription": "Показвай постера на всеки сезон над неговия таб", "showSeasonPostersOnTabsDescription": "Показвай постера на всеки сезон над неговия таб",
"tvFullCardLayout": "Пълни TV карти",
"tvFullCardLayoutDescription": "Използвай TV карти само с изображения, с насложени имена на актьорите",
"hideSpoilers": "Скривай спойлери за негледани епизоди", "hideSpoilers": "Скривай спойлери за негледани епизоди",
"hideSpoilersDescription": "Замазвай миниатюри и описания за негледани епизоди", "hideSpoilersDescription": "Замазвай миниатюри и описания за негледани епизоди",
"playerBackend": "Енджин на плеъра", "playerBackend": "Енджин на плеъра",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Vis sæson- og episodenummer på episodekort", "showEpisodeNumberOnCardsDescription": "Vis sæson- og episodenummer på episodekort",
"showSeasonPostersOnTabs": "Vis sæsonplakater på faner", "showSeasonPostersOnTabs": "Vis sæsonplakater på faner",
"showSeasonPostersOnTabsDescription": "Vis hver sæsons plakat over dens fane", "showSeasonPostersOnTabsDescription": "Vis hver sæsons plakat over dens fane",
"tvFullCardLayout": "Fuldflade TV-kort",
"tvFullCardLayoutDescription": "Brug TV-kort kun med billeder og skuespillernavne ovenpå",
"hideSpoilers": "Skjul spoilere for usete episoder", "hideSpoilers": "Skjul spoilere for usete episoder",
"hideSpoilersDescription": "Slør miniaturebilleder og beskrivelser for usete episoder", "hideSpoilersDescription": "Slør miniaturebilleder og beskrivelser for usete episoder",
"playerBackend": "Afspillerbackend", "playerBackend": "Afspillerbackend",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Staffel- und Episodennummer auf Episodenkarten anzeigen", "showEpisodeNumberOnCardsDescription": "Staffel- und Episodennummer auf Episodenkarten anzeigen",
"showSeasonPostersOnTabs": "Staffelposter auf Tabs anzeigen", "showSeasonPostersOnTabs": "Staffelposter auf Tabs anzeigen",
"showSeasonPostersOnTabsDescription": "Poster jeder Staffel über ihrem Tab anzeigen", "showSeasonPostersOnTabsDescription": "Poster jeder Staffel über ihrem Tab anzeigen",
"tvFullCardLayout": "Vollflächige TV-Karten",
"tvFullCardLayoutDescription": "TV-Karten nur mit Bild verwenden und Darstellernamen einblenden",
"hideSpoilers": "Spoiler für nicht gesehene Episoden verbergen", "hideSpoilers": "Spoiler für nicht gesehene Episoden verbergen",
"hideSpoilersDescription": "Vorschaubilder und Beschreibungen ungesehener Episoden verwischen", "hideSpoilersDescription": "Vorschaubilder und Beschreibungen ungesehener Episoden verwischen",
"playerBackend": "Player-Backend", "playerBackend": "Player-Backend",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Show season and episode number on episode cards", "showEpisodeNumberOnCardsDescription": "Show season and episode number on episode cards",
"showSeasonPostersOnTabs": "Show Season Posters on Tabs", "showSeasonPostersOnTabs": "Show Season Posters on Tabs",
"showSeasonPostersOnTabsDescription": "Show each season's poster above its tab", "showSeasonPostersOnTabsDescription": "Show each season's poster above its tab",
"tvFullCardLayout": "Full TV Cards",
"tvFullCardLayoutDescription": "Use image-only TV cards with actor names overlaid",
"hideSpoilers": "Hide Spoilers for Unwatched Episodes", "hideSpoilers": "Hide Spoilers for Unwatched Episodes",
"hideSpoilersDescription": "Blur thumbnails and descriptions for unwatched episodes", "hideSpoilersDescription": "Blur thumbnails and descriptions for unwatched episodes",
"playerBackend": "Player Backend", "playerBackend": "Player Backend",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Mostrar temporada y episodio en tarjetas de episodio", "showEpisodeNumberOnCardsDescription": "Mostrar temporada y episodio en tarjetas de episodio",
"showSeasonPostersOnTabs": "Mostrar pósters de temporada en las pestañas", "showSeasonPostersOnTabs": "Mostrar pósters de temporada en las pestañas",
"showSeasonPostersOnTabsDescription": "Mostrar el póster de cada temporada sobre su pestaña", "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",
"hideSpoilers": "Ocultar spoilers de episodios no vistos", "hideSpoilers": "Ocultar spoilers de episodios no vistos",
"hideSpoilersDescription": "Desenfocar miniaturas y descripciones de episodios no vistos", "hideSpoilersDescription": "Desenfocar miniaturas y descripciones de episodios no vistos",
"playerBackend": "Reproductor", "playerBackend": "Reproductor",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Afficher la saison et l'épisode sur les cartes d'épisode", "showEpisodeNumberOnCardsDescription": "Afficher la saison et l'épisode sur les cartes d'épisode",
"showSeasonPostersOnTabs": "Afficher les posters de saison sur les onglets", "showSeasonPostersOnTabs": "Afficher les posters de saison sur les onglets",
"showSeasonPostersOnTabsDescription": "Afficher l'affiche de chaque saison au-dessus de son onglet", "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",
"hideSpoilers": "Masquer les spoilers des épisodes non vus", "hideSpoilers": "Masquer les spoilers des épisodes non vus",
"hideSpoilersDescription": "Flouter les miniatures et descriptions des épisodes non vus", "hideSpoilersDescription": "Flouter les miniatures et descriptions des épisodes non vus",
"playerBackend": "Moteur de lecture", "playerBackend": "Moteur de lecture",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Mostra stagione ed episodio sulle schede episodio", "showEpisodeNumberOnCardsDescription": "Mostra stagione ed episodio sulle schede episodio",
"showSeasonPostersOnTabs": "Mostra poster delle stagioni sulle schede", "showSeasonPostersOnTabs": "Mostra poster delle stagioni sulle schede",
"showSeasonPostersOnTabsDescription": "Mostra il poster di ogni stagione sopra la sua scheda", "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",
"hideSpoilers": "Nascondi spoiler per episodi non visti", "hideSpoilers": "Nascondi spoiler per episodi non visti",
"hideSpoilersDescription": "Sfoca miniature e descrizioni degli episodi non visti", "hideSpoilersDescription": "Sfoca miniature e descrizioni degli episodi non visti",
"playerBackend": "Motore di riproduzione", "playerBackend": "Motore di riproduzione",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "エピソードカードにシーズン番号とエピソード番号を表示します", "showEpisodeNumberOnCardsDescription": "エピソードカードにシーズン番号とエピソード番号を表示します",
"showSeasonPostersOnTabs": "タブにシーズンポスターを表示", "showSeasonPostersOnTabs": "タブにシーズンポスターを表示",
"showSeasonPostersOnTabsDescription": "各シーズンのポスターをタブの上に表示します", "showSeasonPostersOnTabsDescription": "各シーズンのポスターをタブの上に表示します",
"tvFullCardLayout": "フルTVカード",
"tvFullCardLayoutDescription": "TVカードを画像のみで表示し、俳優名を重ねて表示します",
"hideSpoilers": "未視聴エピソードのネタバレを非表示", "hideSpoilers": "未視聴エピソードのネタバレを非表示",
"hideSpoilersDescription": "未視聴エピソードのサムネイルと説明をぼかします", "hideSpoilersDescription": "未視聴エピソードのサムネイルと説明をぼかします",
"playerBackend": "プレーヤーバックエンド", "playerBackend": "プレーヤーバックエンド",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "에피소드 카드에 시즌 및 에피소드 번호 표시", "showEpisodeNumberOnCardsDescription": "에피소드 카드에 시즌 및 에피소드 번호 표시",
"showSeasonPostersOnTabs": "탭에 시즌 포스터 표시", "showSeasonPostersOnTabs": "탭에 시즌 포스터 표시",
"showSeasonPostersOnTabsDescription": "각 시즌 포스터를 탭 위에 표시", "showSeasonPostersOnTabsDescription": "각 시즌 포스터를 탭 위에 표시",
"tvFullCardLayout": "전체 TV 카드",
"tvFullCardLayoutDescription": "TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시",
"hideSpoilers": "미시청 에피소드 스포일러 숨기기", "hideSpoilers": "미시청 에피소드 스포일러 숨기기",
"hideSpoilersDescription": "시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리", "hideSpoilersDescription": "시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리",
"playerBackend": "플레이어 백엔드", "playerBackend": "플레이어 백엔드",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Vis sesong- og episodenummer på episodekort", "showEpisodeNumberOnCardsDescription": "Vis sesong- og episodenummer på episodekort",
"showSeasonPostersOnTabs": "Vis sesongplakater på faner", "showSeasonPostersOnTabs": "Vis sesongplakater på faner",
"showSeasonPostersOnTabsDescription": "Vis hver sesongs plakat over fanen", "showSeasonPostersOnTabsDescription": "Vis hver sesongs plakat over fanen",
"tvFullCardLayout": "Fulle TV-kort",
"tvFullCardLayoutDescription": "Bruk bildebaserte TV-kort med skuespillernavn lagt over",
"hideSpoilers": "Skjul spoilere for usette episoder", "hideSpoilers": "Skjul spoilere for usette episoder",
"hideSpoilersDescription": "Slør miniatyrbilder og beskrivelser for usette episoder", "hideSpoilersDescription": "Slør miniatyrbilder og beskrivelser for usette episoder",
"playerBackend": "Spillermotor", "playerBackend": "Spillermotor",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Toon seizoen- en afleveringsnummer op afleveringskaarten", "showEpisodeNumberOnCardsDescription": "Toon seizoen- en afleveringsnummer op afleveringskaarten",
"showSeasonPostersOnTabs": "Toon seizoensposters op tabbladen", "showSeasonPostersOnTabs": "Toon seizoensposters op tabbladen",
"showSeasonPostersOnTabsDescription": "Toon de poster van elk seizoen boven het tabblad", "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",
"hideSpoilers": "Spoilers voor ongekeken afleveringen verbergen", "hideSpoilers": "Spoilers voor ongekeken afleveringen verbergen",
"hideSpoilersDescription": "Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen", "hideSpoilersDescription": "Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen",
"playerBackend": "Speler backend", "playerBackend": "Speler backend",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Pokazuj numer sezonu i odcinka na kartach odcinków", "showEpisodeNumberOnCardsDescription": "Pokazuj numer sezonu i odcinka na kartach odcinków",
"showSeasonPostersOnTabs": "Pokaż plakaty sezonów na zakładkach", "showSeasonPostersOnTabs": "Pokaż plakaty sezonów na zakładkach",
"showSeasonPostersOnTabsDescription": "Pokazuj plakat każdego sezonu nad jego kartą", "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",
"hideSpoilers": "Ukryj spoilery nieobejrzanych odcinków", "hideSpoilers": "Ukryj spoilery nieobejrzanych odcinków",
"hideSpoilersDescription": "Rozmywaj miniatury i opisy nieobejrzanych odcinków", "hideSpoilersDescription": "Rozmywaj miniatury i opisy nieobejrzanych odcinków",
"playerBackend": "Backend odtwarzacza", "playerBackend": "Backend odtwarzacza",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Mostrar temporada e episódio nos cartões de episódio", "showEpisodeNumberOnCardsDescription": "Mostrar temporada e episódio nos cartões de episódio",
"showSeasonPostersOnTabs": "Mostrar Pôsteres de Temporada nas Abas", "showSeasonPostersOnTabs": "Mostrar Pôsteres de Temporada nas Abas",
"showSeasonPostersOnTabsDescription": "Mostrar o pôster de cada temporada acima da aba", "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",
"hideSpoilers": "Ocultar Spoilers de Episódios Não Assistidos", "hideSpoilers": "Ocultar Spoilers de Episódios Não Assistidos",
"hideSpoilersDescription": "Desfocar miniaturas e descrições de episódios não vistos", "hideSpoilersDescription": "Desfocar miniaturas e descrições de episódios não vistos",
"playerBackend": "Backend do Player", "playerBackend": "Backend do Player",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Показывать номер сезона и серии на карточках серий", "showEpisodeNumberOnCardsDescription": "Показывать номер сезона и серии на карточках серий",
"showSeasonPostersOnTabs": "Показывать постеры сезонов на вкладках", "showSeasonPostersOnTabs": "Показывать постеры сезонов на вкладках",
"showSeasonPostersOnTabsDescription": "Показывать постер каждого сезона над его вкладкой", "showSeasonPostersOnTabsDescription": "Показывать постер каждого сезона над его вкладкой",
"tvFullCardLayout": "Полные TV-карточки",
"tvFullCardLayoutDescription": "Использовать TV-карточки только с изображением и именами актёров поверх него",
"hideSpoilers": "Скрыть спойлеры непросмотренных эпизодов", "hideSpoilers": "Скрыть спойлеры непросмотренных эпизодов",
"hideSpoilersDescription": "Размывать миниатюры и описания непросмотренных серий", "hideSpoilersDescription": "Размывать миниатюры и описания непросмотренных серий",
"playerBackend": "Бэкенд плеера", "playerBackend": "Бэкенд плеера",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 16 /// Locales: 16
/// Strings: 18832 (1177 per locale) /// Strings: 18864 (1179 per locale)
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsBg extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Показвай сезон и номер на епизод върху картите на епизодите'; @override String get showEpisodeNumberOnCardsDescription => 'Показвай сезон и номер на епизод върху картите на епизодите';
@override String get showSeasonPostersOnTabs => 'Показвай постери на сезоните в табовете'; @override String get showSeasonPostersOnTabs => 'Показвай постери на сезоните в табовете';
@override String get showSeasonPostersOnTabsDescription => 'Показвай постера на всеки сезон над неговия таб'; @override String get showSeasonPostersOnTabsDescription => 'Показвай постера на всеки сезон над неговия таб';
@override String get tvFullCardLayout => 'Пълни TV карти';
@override String get tvFullCardLayoutDescription => 'Използвай TV карти само с изображения, с насложени имена на актьорите';
@override String get hideSpoilers => 'Скривай спойлери за негледани епизоди'; @override String get hideSpoilers => 'Скривай спойлери за негледани епизоди';
@override String get hideSpoilersDescription => 'Замазвай миниатюри и описания за негледани епизоди'; @override String get hideSpoilersDescription => 'Замазвай миниатюри и описания за негледани епизоди';
@override String get playerBackend => 'Енджин на плеъра'; @override String get playerBackend => 'Енджин на плеъра';
@@ -1922,6 +1924,8 @@ extension on TranslationsBg {
'settings.showEpisodeNumberOnCardsDescription' => 'Показвай сезон и номер на епизод върху картите на епизодите', 'settings.showEpisodeNumberOnCardsDescription' => 'Показвай сезон и номер на епизод върху картите на епизодите',
'settings.showSeasonPostersOnTabs' => 'Показвай постери на сезоните в табовете', 'settings.showSeasonPostersOnTabs' => 'Показвай постери на сезоните в табовете',
'settings.showSeasonPostersOnTabsDescription' => 'Показвай постера на всеки сезон над неговия таб', 'settings.showSeasonPostersOnTabsDescription' => 'Показвай постера на всеки сезон над неговия таб',
'settings.tvFullCardLayout' => 'Пълни TV карти',
'settings.tvFullCardLayoutDescription' => 'Използвай TV карти само с изображения, с насложени имена на актьорите',
'settings.hideSpoilers' => 'Скривай спойлери за негледани епизоди', 'settings.hideSpoilers' => 'Скривай спойлери за негледани епизоди',
'settings.hideSpoilersDescription' => 'Замазвай миниатюри и описания за негледани епизоди', 'settings.hideSpoilersDescription' => 'Замазвай миниатюри и описания за негледани епизоди',
'settings.playerBackend' => 'Енджин на плеъра', 'settings.playerBackend' => 'Енджин на плеъра',
@@ -2316,10 +2320,10 @@ extension on TranslationsBg {
'profiles.active' => 'Активен', 'profiles.active' => 'Активен',
'profiles.manage' => 'Управление', 'profiles.manage' => 'Управление',
'profiles.delete' => 'Изтрий', 'profiles.delete' => 'Изтрий',
'profiles.signOut' => 'Изход',
'profiles.signOutPlexTitle' => 'Изход от Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Изход',
'profiles.signOutPlexTitle' => 'Изход от Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Премахване на ${displayName} и всички Plex Home потребители? Можете да влезете отново по всяко време.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Премахване на ${displayName} и всички Plex Home потребители? Можете да влезете отново по всяко време.',
'profiles.signedOutPlex' => 'Излязохте от Plex.', 'profiles.signedOutPlex' => 'Излязохте от Plex.',
'profiles.signOutFailed' => 'Изходът е неуспешен.', 'profiles.signOutFailed' => 'Изходът е неуспешен.',
@@ -2830,10 +2834,10 @@ extension on TranslationsBg {
'metadataEdit.originalTitle' => 'Оригинално заглавие', 'metadataEdit.originalTitle' => 'Оригинално заглавие',
'metadataEdit.releaseDate' => 'Дата на излизане', 'metadataEdit.releaseDate' => 'Дата на излизане',
'metadataEdit.contentRating' => 'Възрастов рейтинг', 'metadataEdit.contentRating' => 'Възрастов рейтинг',
'metadataEdit.studio' => 'Студио',
'metadataEdit.tagline' => 'Слоган',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Студио',
'metadataEdit.tagline' => 'Слоган',
'metadataEdit.summary' => 'Резюме', 'metadataEdit.summary' => 'Резюме',
'metadataEdit.poster' => 'Постер', 'metadataEdit.poster' => 'Постер',
'metadataEdit.background' => 'Фон', 'metadataEdit.background' => 'Фон',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsDa extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Vis sæson- og episodenummer på episodekort'; @override String get showEpisodeNumberOnCardsDescription => 'Vis sæson- og episodenummer på episodekort';
@override String get showSeasonPostersOnTabs => 'Vis sæsonplakater på faner'; @override String get showSeasonPostersOnTabs => 'Vis sæsonplakater på faner';
@override String get showSeasonPostersOnTabsDescription => 'Vis hver sæsons plakat over dens fane'; @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 hideSpoilers => 'Skjul spoilere for usete episoder'; @override String get hideSpoilers => 'Skjul spoilere for usete episoder';
@override String get hideSpoilersDescription => 'Slør miniaturebilleder og beskrivelser for usete episoder'; @override String get hideSpoilersDescription => 'Slør miniaturebilleder og beskrivelser for usete episoder';
@override String get playerBackend => 'Afspillerbackend'; @override String get playerBackend => 'Afspillerbackend';
@@ -1922,6 +1924,8 @@ extension on TranslationsDa {
'settings.showEpisodeNumberOnCardsDescription' => 'Vis sæson- og episodenummer på episodekort', 'settings.showEpisodeNumberOnCardsDescription' => 'Vis sæson- og episodenummer på episodekort',
'settings.showSeasonPostersOnTabs' => 'Vis sæsonplakater på faner', 'settings.showSeasonPostersOnTabs' => 'Vis sæsonplakater på faner',
'settings.showSeasonPostersOnTabsDescription' => 'Vis hver sæsons plakat over dens fane', '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.hideSpoilers' => 'Skjul spoilere for usete episoder', 'settings.hideSpoilers' => 'Skjul spoilere for usete episoder',
'settings.hideSpoilersDescription' => 'Slør miniaturebilleder og beskrivelser for usete episoder', 'settings.hideSpoilersDescription' => 'Slør miniaturebilleder og beskrivelser for usete episoder',
'settings.playerBackend' => 'Afspillerbackend', 'settings.playerBackend' => 'Afspillerbackend',
@@ -2316,10 +2320,10 @@ extension on TranslationsDa {
'profiles.active' => 'Aktiv', 'profiles.active' => 'Aktiv',
'profiles.manage' => 'Administrer', 'profiles.manage' => 'Administrer',
'profiles.delete' => 'Slet', 'profiles.delete' => 'Slet',
'profiles.signOut' => 'Log ud',
'profiles.signOutPlexTitle' => 'Log ud af Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Log ud',
'profiles.signOutPlexTitle' => 'Log ud af Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjern ${displayName} og alle Plex Home-brugere? Log ind igen når som helst.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjern ${displayName} og alle Plex Home-brugere? Log ind igen når som helst.',
'profiles.signedOutPlex' => 'Logget ud af Plex.', 'profiles.signedOutPlex' => 'Logget ud af Plex.',
'profiles.signOutFailed' => 'Log ud mislykkedes.', 'profiles.signOutFailed' => 'Log ud mislykkedes.',
@@ -2830,10 +2834,10 @@ extension on TranslationsDa {
'metadataEdit.originalTitle' => 'Originaltitel', 'metadataEdit.originalTitle' => 'Originaltitel',
'metadataEdit.releaseDate' => 'Udgivelsesdato', 'metadataEdit.releaseDate' => 'Udgivelsesdato',
'metadataEdit.contentRating' => 'Aldersgrænse', 'metadataEdit.contentRating' => 'Aldersgrænse',
'metadataEdit.studio' => 'Studie',
'metadataEdit.tagline' => 'Tagline',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Studie',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Resumé', 'metadataEdit.summary' => 'Resumé',
'metadataEdit.poster' => 'Plakat', 'metadataEdit.poster' => 'Plakat',
'metadataEdit.background' => 'Baggrund', 'metadataEdit.background' => 'Baggrund',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsDe extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Staffel- und Episodennummer auf Episodenkarten anzeigen'; @override String get showEpisodeNumberOnCardsDescription => 'Staffel- und Episodennummer auf Episodenkarten anzeigen';
@override String get showSeasonPostersOnTabs => 'Staffelposter auf Tabs anzeigen'; @override String get showSeasonPostersOnTabs => 'Staffelposter auf Tabs anzeigen';
@override String get showSeasonPostersOnTabsDescription => 'Poster jeder Staffel über ihrem Tab anzeigen'; @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 hideSpoilers => 'Spoiler für nicht gesehene Episoden verbergen'; @override String get hideSpoilers => 'Spoiler für nicht gesehene Episoden verbergen';
@override String get hideSpoilersDescription => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen'; @override String get hideSpoilersDescription => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen';
@override String get playerBackend => 'Player-Backend'; @override String get playerBackend => 'Player-Backend';
@@ -1922,6 +1924,8 @@ extension on TranslationsDe {
'settings.showEpisodeNumberOnCardsDescription' => 'Staffel- und Episodennummer auf Episodenkarten anzeigen', 'settings.showEpisodeNumberOnCardsDescription' => 'Staffel- und Episodennummer auf Episodenkarten anzeigen',
'settings.showSeasonPostersOnTabs' => 'Staffelposter auf Tabs anzeigen', 'settings.showSeasonPostersOnTabs' => 'Staffelposter auf Tabs anzeigen',
'settings.showSeasonPostersOnTabsDescription' => 'Poster jeder Staffel über ihrem Tab anzeigen', '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.hideSpoilers' => 'Spoiler für nicht gesehene Episoden verbergen', 'settings.hideSpoilers' => 'Spoiler für nicht gesehene Episoden verbergen',
'settings.hideSpoilersDescription' => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen', 'settings.hideSpoilersDescription' => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen',
'settings.playerBackend' => 'Player-Backend', 'settings.playerBackend' => 'Player-Backend',
@@ -2316,10 +2320,10 @@ extension on TranslationsDe {
'profiles.active' => 'Aktiv', 'profiles.active' => 'Aktiv',
'profiles.manage' => 'Verwalten', 'profiles.manage' => 'Verwalten',
'profiles.delete' => 'Löschen', 'profiles.delete' => 'Löschen',
'profiles.signOut' => 'Abmelden',
'profiles.signOutPlexTitle' => 'Von Plex abmelden?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Abmelden',
'profiles.signOutPlexTitle' => 'Von Plex abmelden?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} und alle Plex Home-Benutzer entfernen? Du kannst dich jederzeit wieder anmelden.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} und alle Plex Home-Benutzer entfernen? Du kannst dich jederzeit wieder anmelden.',
'profiles.signedOutPlex' => 'Von Plex abgemeldet.', 'profiles.signedOutPlex' => 'Von Plex abgemeldet.',
'profiles.signOutFailed' => 'Abmeldung fehlgeschlagen.', 'profiles.signOutFailed' => 'Abmeldung fehlgeschlagen.',
@@ -2830,10 +2834,10 @@ extension on TranslationsDe {
'metadataEdit.originalTitle' => 'Originaltitel', 'metadataEdit.originalTitle' => 'Originaltitel',
'metadataEdit.releaseDate' => 'Erscheinungsdatum', 'metadataEdit.releaseDate' => 'Erscheinungsdatum',
'metadataEdit.contentRating' => 'Altersfreigabe', 'metadataEdit.contentRating' => 'Altersfreigabe',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Zusammenfassung', 'metadataEdit.summary' => 'Zusammenfassung',
'metadataEdit.poster' => 'Poster', 'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Hintergrund', 'metadataEdit.background' => 'Hintergrund',
+12 -4
View File
@@ -488,6 +488,12 @@ class TranslationsSettingsEn {
/// en: 'Show each season's poster above its tab' /// en: 'Show each season's poster above its tab'
String get showSeasonPostersOnTabsDescription => 'Show each season\'s poster above its tab'; String get showSeasonPostersOnTabsDescription => 'Show each season\'s poster above its tab';
/// en: 'Full TV Cards'
String get tvFullCardLayout => 'Full TV Cards';
/// en: 'Use image-only TV cards with actor names overlaid'
String get tvFullCardLayoutDescription => 'Use image-only TV cards with actor names overlaid';
/// en: 'Hide Spoilers for Unwatched Episodes' /// en: 'Hide Spoilers for Unwatched Episodes'
String get hideSpoilers => 'Hide Spoilers for Unwatched Episodes'; String get hideSpoilers => 'Hide Spoilers for Unwatched Episodes';
@@ -4282,6 +4288,8 @@ extension on Translations {
'settings.showEpisodeNumberOnCardsDescription' => 'Show season and episode number on episode cards', 'settings.showEpisodeNumberOnCardsDescription' => 'Show season and episode number on episode cards',
'settings.showSeasonPostersOnTabs' => 'Show Season Posters on Tabs', 'settings.showSeasonPostersOnTabs' => 'Show Season Posters on Tabs',
'settings.showSeasonPostersOnTabsDescription' => 'Show each season\'s poster above its tab', '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.hideSpoilers' => 'Hide Spoilers for Unwatched Episodes', 'settings.hideSpoilers' => 'Hide Spoilers for Unwatched Episodes',
'settings.hideSpoilersDescription' => 'Blur thumbnails and descriptions for unwatched episodes', 'settings.hideSpoilersDescription' => 'Blur thumbnails and descriptions for unwatched episodes',
'settings.playerBackend' => 'Player Backend', 'settings.playerBackend' => 'Player Backend',
@@ -4676,10 +4684,10 @@ extension on Translations {
'profiles.active' => 'Active', 'profiles.active' => 'Active',
'profiles.manage' => 'Manage', 'profiles.manage' => 'Manage',
'profiles.delete' => 'Delete', 'profiles.delete' => 'Delete',
'profiles.signOut' => 'Sign out',
'profiles.signOutPlexTitle' => 'Sign out of Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Sign out',
'profiles.signOutPlexTitle' => 'Sign out of Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remove ${displayName} and all Plex Home users? Sign back in anytime.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remove ${displayName} and all Plex Home users? Sign back in anytime.',
'profiles.signedOutPlex' => 'Signed out of Plex.', 'profiles.signedOutPlex' => 'Signed out of Plex.',
'profiles.signOutFailed' => 'Sign out failed.', 'profiles.signOutFailed' => 'Sign out failed.',
@@ -5190,10 +5198,10 @@ extension on Translations {
'metadataEdit.originalTitle' => 'Original Title', 'metadataEdit.originalTitle' => 'Original Title',
'metadataEdit.releaseDate' => 'Release Date', 'metadataEdit.releaseDate' => 'Release Date',
'metadataEdit.contentRating' => 'Content Rating', 'metadataEdit.contentRating' => 'Content Rating',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Summary', 'metadataEdit.summary' => 'Summary',
'metadataEdit.poster' => 'Poster', 'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Background', 'metadataEdit.background' => 'Background',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsEs extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Mostrar temporada y episodio en tarjetas de episodio'; @override String get showEpisodeNumberOnCardsDescription => 'Mostrar temporada y episodio en tarjetas de episodio';
@override String get showSeasonPostersOnTabs => 'Mostrar pósters de temporada en las pestañas'; @override String get showSeasonPostersOnTabs => 'Mostrar pósters de temporada en las pestañas';
@override String get showSeasonPostersOnTabsDescription => 'Mostrar el póster de cada temporada sobre su pestaña'; @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 hideSpoilers => 'Ocultar spoilers de episodios no vistos'; @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 hideSpoilersDescription => 'Desenfocar miniaturas y descripciones de episodios no vistos';
@override String get playerBackend => 'Reproductor'; @override String get playerBackend => 'Reproductor';
@@ -1922,6 +1924,8 @@ extension on TranslationsEs {
'settings.showEpisodeNumberOnCardsDescription' => 'Mostrar temporada y episodio en tarjetas de episodio', 'settings.showEpisodeNumberOnCardsDescription' => 'Mostrar temporada y episodio en tarjetas de episodio',
'settings.showSeasonPostersOnTabs' => 'Mostrar pósters de temporada en las pestañas', 'settings.showSeasonPostersOnTabs' => 'Mostrar pósters de temporada en las pestañas',
'settings.showSeasonPostersOnTabsDescription' => 'Mostrar el póster de cada temporada sobre su pestaña', '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.hideSpoilers' => 'Ocultar spoilers de episodios no vistos', 'settings.hideSpoilers' => 'Ocultar spoilers de episodios no vistos',
'settings.hideSpoilersDescription' => 'Desenfocar miniaturas y descripciones de episodios no vistos', 'settings.hideSpoilersDescription' => 'Desenfocar miniaturas y descripciones de episodios no vistos',
'settings.playerBackend' => 'Reproductor', 'settings.playerBackend' => 'Reproductor',
@@ -2316,10 +2320,10 @@ extension on TranslationsEs {
'profiles.active' => 'Activo', 'profiles.active' => 'Activo',
'profiles.manage' => 'Administrar', 'profiles.manage' => 'Administrar',
'profiles.delete' => 'Eliminar', 'profiles.delete' => 'Eliminar',
'profiles.signOut' => 'Cerrar sesión',
'profiles.signOutPlexTitle' => '¿Cerrar sesión de Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Cerrar sesión',
'profiles.signOutPlexTitle' => '¿Cerrar sesión de Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '¿Eliminar ${displayName} y todos los usuarios de Plex Home? Puedes iniciar sesión de nuevo cuando quieras.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '¿Eliminar ${displayName} y todos los usuarios de Plex Home? Puedes iniciar sesión de nuevo cuando quieras.',
'profiles.signedOutPlex' => 'Sesión de Plex cerrada.', 'profiles.signedOutPlex' => 'Sesión de Plex cerrada.',
'profiles.signOutFailed' => 'Error al cerrar sesión.', 'profiles.signOutFailed' => 'Error al cerrar sesión.',
@@ -2830,10 +2834,10 @@ extension on TranslationsEs {
'metadataEdit.originalTitle' => 'Título original', 'metadataEdit.originalTitle' => 'Título original',
'metadataEdit.releaseDate' => 'Fecha de estreno', 'metadataEdit.releaseDate' => 'Fecha de estreno',
'metadataEdit.contentRating' => 'Clasificación de contenido', 'metadataEdit.contentRating' => 'Clasificación de contenido',
'metadataEdit.studio' => 'Estudio',
'metadataEdit.tagline' => 'Eslogan',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Estudio',
'metadataEdit.tagline' => 'Eslogan',
'metadataEdit.summary' => 'Resumen', 'metadataEdit.summary' => 'Resumen',
'metadataEdit.poster' => 'Póster', 'metadataEdit.poster' => 'Póster',
'metadataEdit.background' => 'Fondo', 'metadataEdit.background' => 'Fondo',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsFr extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Afficher la saison et l\'épisode sur les cartes d\'épisode'; @override String get showEpisodeNumberOnCardsDescription => 'Afficher la saison et l\'épisode sur les cartes d\'épisode';
@override String get showSeasonPostersOnTabs => 'Afficher les posters de saison sur les onglets'; @override String get showSeasonPostersOnTabs => 'Afficher les posters de saison sur les onglets';
@override String get showSeasonPostersOnTabsDescription => 'Afficher l\'affiche de chaque saison au-dessus de son onglet'; @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 hideSpoilers => 'Masquer les spoilers des épisodes non vus'; @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 hideSpoilersDescription => 'Flouter les miniatures et descriptions des épisodes non vus';
@override String get playerBackend => 'Moteur de lecture'; @override String get playerBackend => 'Moteur de lecture';
@@ -1922,6 +1924,8 @@ extension on TranslationsFr {
'settings.showEpisodeNumberOnCardsDescription' => 'Afficher la saison et l\'épisode sur les cartes d\'épisode', 'settings.showEpisodeNumberOnCardsDescription' => 'Afficher la saison et l\'épisode sur les cartes d\'épisode',
'settings.showSeasonPostersOnTabs' => 'Afficher les posters de saison sur les onglets', 'settings.showSeasonPostersOnTabs' => 'Afficher les posters de saison sur les onglets',
'settings.showSeasonPostersOnTabsDescription' => 'Afficher l\'affiche de chaque saison au-dessus de son onglet', '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.hideSpoilers' => 'Masquer les spoilers des épisodes non vus', 'settings.hideSpoilers' => 'Masquer les spoilers des épisodes non vus',
'settings.hideSpoilersDescription' => 'Flouter les miniatures et descriptions des épisodes non vus', 'settings.hideSpoilersDescription' => 'Flouter les miniatures et descriptions des épisodes non vus',
'settings.playerBackend' => 'Moteur de lecture', 'settings.playerBackend' => 'Moteur de lecture',
@@ -2316,10 +2320,10 @@ extension on TranslationsFr {
'profiles.active' => 'Actif', 'profiles.active' => 'Actif',
'profiles.manage' => 'Gérer', 'profiles.manage' => 'Gérer',
'profiles.delete' => 'Supprimer', 'profiles.delete' => 'Supprimer',
'profiles.signOut' => 'Se déconnecter',
'profiles.signOutPlexTitle' => 'Se déconnecter de Plex ?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Se déconnecter',
'profiles.signOutPlexTitle' => 'Se déconnecter de Plex ?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Supprimer ${displayName} et tous les utilisateurs Plex Home ? Reconnexion possible à tout moment.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Supprimer ${displayName} et tous les utilisateurs Plex Home ? Reconnexion possible à tout moment.',
'profiles.signedOutPlex' => 'Déconnecté de Plex.', 'profiles.signedOutPlex' => 'Déconnecté de Plex.',
'profiles.signOutFailed' => 'Échec de la déconnexion.', 'profiles.signOutFailed' => 'Échec de la déconnexion.',
@@ -2830,10 +2834,10 @@ extension on TranslationsFr {
'metadataEdit.originalTitle' => 'Titre original', 'metadataEdit.originalTitle' => 'Titre original',
'metadataEdit.releaseDate' => 'Date de sortie', 'metadataEdit.releaseDate' => 'Date de sortie',
'metadataEdit.contentRating' => 'Classification', 'metadataEdit.contentRating' => 'Classification',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slogan',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slogan',
'metadataEdit.summary' => 'Résumé', 'metadataEdit.summary' => 'Résumé',
'metadataEdit.poster' => 'Affiche', 'metadataEdit.poster' => 'Affiche',
'metadataEdit.background' => 'Arrière-plan', 'metadataEdit.background' => 'Arrière-plan',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsIt extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Mostra stagione ed episodio sulle schede episodio'; @override String get showEpisodeNumberOnCardsDescription => 'Mostra stagione ed episodio sulle schede episodio';
@override String get showSeasonPostersOnTabs => 'Mostra poster delle stagioni sulle schede'; @override String get showSeasonPostersOnTabs => 'Mostra poster delle stagioni sulle schede';
@override String get showSeasonPostersOnTabsDescription => 'Mostra il poster di ogni stagione sopra la sua scheda'; @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 hideSpoilers => 'Nascondi spoiler per episodi non visti'; @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 hideSpoilersDescription => 'Sfoca miniature e descrizioni degli episodi non visti';
@override String get playerBackend => 'Motore di riproduzione'; @override String get playerBackend => 'Motore di riproduzione';
@@ -1922,6 +1924,8 @@ extension on TranslationsIt {
'settings.showEpisodeNumberOnCardsDescription' => 'Mostra stagione ed episodio sulle schede episodio', 'settings.showEpisodeNumberOnCardsDescription' => 'Mostra stagione ed episodio sulle schede episodio',
'settings.showSeasonPostersOnTabs' => 'Mostra poster delle stagioni sulle schede', 'settings.showSeasonPostersOnTabs' => 'Mostra poster delle stagioni sulle schede',
'settings.showSeasonPostersOnTabsDescription' => 'Mostra il poster di ogni stagione sopra la sua scheda', '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.hideSpoilers' => 'Nascondi spoiler per episodi non visti', 'settings.hideSpoilers' => 'Nascondi spoiler per episodi non visti',
'settings.hideSpoilersDescription' => 'Sfoca miniature e descrizioni degli episodi non visti', 'settings.hideSpoilersDescription' => 'Sfoca miniature e descrizioni degli episodi non visti',
'settings.playerBackend' => 'Motore di riproduzione', 'settings.playerBackend' => 'Motore di riproduzione',
@@ -2316,10 +2320,10 @@ extension on TranslationsIt {
'profiles.active' => 'Attivo', 'profiles.active' => 'Attivo',
'profiles.manage' => 'Gestisci', 'profiles.manage' => 'Gestisci',
'profiles.delete' => 'Elimina', 'profiles.delete' => 'Elimina',
'profiles.signOut' => 'Esci',
'profiles.signOutPlexTitle' => 'Uscire da Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Esci',
'profiles.signOutPlexTitle' => 'Uscire da Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Rimuovere ${displayName} e tutti gli utenti Plex Home? Puoi accedere di nuovo quando vuoi.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Rimuovere ${displayName} e tutti gli utenti Plex Home? Puoi accedere di nuovo quando vuoi.',
'profiles.signedOutPlex' => 'Uscito da Plex.', 'profiles.signedOutPlex' => 'Uscito da Plex.',
'profiles.signOutFailed' => 'Uscita non riuscita.', 'profiles.signOutFailed' => 'Uscita non riuscita.',
@@ -2830,10 +2834,10 @@ extension on TranslationsIt {
'metadataEdit.originalTitle' => 'Titolo originale', 'metadataEdit.originalTitle' => 'Titolo originale',
'metadataEdit.releaseDate' => 'Data di uscita', 'metadataEdit.releaseDate' => 'Data di uscita',
'metadataEdit.contentRating' => 'Classificazione', 'metadataEdit.contentRating' => 'Classificazione',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Trama', 'metadataEdit.summary' => 'Trama',
'metadataEdit.poster' => 'Poster', 'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Sfondo', 'metadataEdit.background' => 'Sfondo',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsJa extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'エピソードカードにシーズン番号とエピソード番号を表示します'; @override String get showEpisodeNumberOnCardsDescription => 'エピソードカードにシーズン番号とエピソード番号を表示します';
@override String get showSeasonPostersOnTabs => 'タブにシーズンポスターを表示'; @override String get showSeasonPostersOnTabs => 'タブにシーズンポスターを表示';
@override String get showSeasonPostersOnTabsDescription => '各シーズンのポスターをタブの上に表示します'; @override String get showSeasonPostersOnTabsDescription => '各シーズンのポスターをタブの上に表示します';
@override String get tvFullCardLayout => 'フルTVカード';
@override String get tvFullCardLayoutDescription => 'TVカードを画像のみで表示し、俳優名を重ねて表示します';
@override String get hideSpoilers => '未視聴エピソードのネタバレを非表示'; @override String get hideSpoilers => '未視聴エピソードのネタバレを非表示';
@override String get hideSpoilersDescription => '未視聴エピソードのサムネイルと説明をぼかします'; @override String get hideSpoilersDescription => '未視聴エピソードのサムネイルと説明をぼかします';
@override String get playerBackend => 'プレーヤーバックエンド'; @override String get playerBackend => 'プレーヤーバックエンド';
@@ -1922,6 +1924,8 @@ extension on TranslationsJa {
'settings.showEpisodeNumberOnCardsDescription' => 'エピソードカードにシーズン番号とエピソード番号を表示します', 'settings.showEpisodeNumberOnCardsDescription' => 'エピソードカードにシーズン番号とエピソード番号を表示します',
'settings.showSeasonPostersOnTabs' => 'タブにシーズンポスターを表示', 'settings.showSeasonPostersOnTabs' => 'タブにシーズンポスターを表示',
'settings.showSeasonPostersOnTabsDescription' => '各シーズンのポスターをタブの上に表示します', 'settings.showSeasonPostersOnTabsDescription' => '各シーズンのポスターをタブの上に表示します',
'settings.tvFullCardLayout' => 'フルTVカード',
'settings.tvFullCardLayoutDescription' => 'TVカードを画像のみで表示し、俳優名を重ねて表示します',
'settings.hideSpoilers' => '未視聴エピソードのネタバレを非表示', 'settings.hideSpoilers' => '未視聴エピソードのネタバレを非表示',
'settings.hideSpoilersDescription' => '未視聴エピソードのサムネイルと説明をぼかします', 'settings.hideSpoilersDescription' => '未視聴エピソードのサムネイルと説明をぼかします',
'settings.playerBackend' => 'プレーヤーバックエンド', 'settings.playerBackend' => 'プレーヤーバックエンド',
@@ -2316,10 +2320,10 @@ extension on TranslationsJa {
'profiles.active' => 'アクティブ', 'profiles.active' => 'アクティブ',
'profiles.manage' => '管理', 'profiles.manage' => '管理',
'profiles.delete' => '削除', 'profiles.delete' => '削除',
'profiles.signOut' => 'サインアウト',
'profiles.signOutPlexTitle' => 'Plex からサインアウトしますか?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'サインアウト',
'profiles.signOutPlexTitle' => 'Plex からサインアウトしますか?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName}とすべてのPlex Homeユーザーを削除しますか?いつでも再サインインできます。', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName}とすべてのPlex Homeユーザーを削除しますか?いつでも再サインインできます。',
'profiles.signedOutPlex' => 'Plex からサインアウトしました。', 'profiles.signedOutPlex' => 'Plex からサインアウトしました。',
'profiles.signOutFailed' => 'サインアウトに失敗しました。', 'profiles.signOutFailed' => 'サインアウトに失敗しました。',
@@ -2830,10 +2834,10 @@ extension on TranslationsJa {
'metadataEdit.originalTitle' => '原題', 'metadataEdit.originalTitle' => '原題',
'metadataEdit.releaseDate' => '公開日', 'metadataEdit.releaseDate' => '公開日',
'metadataEdit.contentRating' => 'コンテンツレーティング', 'metadataEdit.contentRating' => 'コンテンツレーティング',
'metadataEdit.studio' => 'スタジオ',
'metadataEdit.tagline' => 'タグライン',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'スタジオ',
'metadataEdit.tagline' => 'タグライン',
'metadataEdit.summary' => 'あらすじ', 'metadataEdit.summary' => 'あらすじ',
'metadataEdit.poster' => 'ポスター', 'metadataEdit.poster' => 'ポスター',
'metadataEdit.background' => '背景', 'metadataEdit.background' => '背景',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsKo extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => '에피소드 카드에 시즌 및 에피소드 번호 표시'; @override String get showEpisodeNumberOnCardsDescription => '에피소드 카드에 시즌 및 에피소드 번호 표시';
@override String get showSeasonPostersOnTabs => '탭에 시즌 포스터 표시'; @override String get showSeasonPostersOnTabs => '탭에 시즌 포스터 표시';
@override String get showSeasonPostersOnTabsDescription => '각 시즌 포스터를 탭 위에 표시'; @override String get showSeasonPostersOnTabsDescription => '각 시즌 포스터를 탭 위에 표시';
@override String get tvFullCardLayout => '전체 TV 카드';
@override String get tvFullCardLayoutDescription => 'TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시';
@override String get hideSpoilers => '미시청 에피소드 스포일러 숨기기'; @override String get hideSpoilers => '미시청 에피소드 스포일러 숨기기';
@override String get hideSpoilersDescription => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리'; @override String get hideSpoilersDescription => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리';
@override String get playerBackend => '플레이어 백엔드'; @override String get playerBackend => '플레이어 백엔드';
@@ -1922,6 +1924,8 @@ extension on TranslationsKo {
'settings.showEpisodeNumberOnCardsDescription' => '에피소드 카드에 시즌 및 에피소드 번호 표시', 'settings.showEpisodeNumberOnCardsDescription' => '에피소드 카드에 시즌 및 에피소드 번호 표시',
'settings.showSeasonPostersOnTabs' => '탭에 시즌 포스터 표시', 'settings.showSeasonPostersOnTabs' => '탭에 시즌 포스터 표시',
'settings.showSeasonPostersOnTabsDescription' => '각 시즌 포스터를 탭 위에 표시', 'settings.showSeasonPostersOnTabsDescription' => '각 시즌 포스터를 탭 위에 표시',
'settings.tvFullCardLayout' => '전체 TV 카드',
'settings.tvFullCardLayoutDescription' => 'TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시',
'settings.hideSpoilers' => '미시청 에피소드 스포일러 숨기기', 'settings.hideSpoilers' => '미시청 에피소드 스포일러 숨기기',
'settings.hideSpoilersDescription' => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리', 'settings.hideSpoilersDescription' => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리',
'settings.playerBackend' => '플레이어 백엔드', 'settings.playerBackend' => '플레이어 백엔드',
@@ -2316,10 +2320,10 @@ extension on TranslationsKo {
'profiles.active' => '활성', 'profiles.active' => '활성',
'profiles.manage' => '관리', 'profiles.manage' => '관리',
'profiles.delete' => '삭제', 'profiles.delete' => '삭제',
'profiles.signOut' => '로그아웃',
'profiles.signOutPlexTitle' => 'Plex에서 로그아웃하시겠습니까?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => '로그아웃',
'profiles.signOutPlexTitle' => 'Plex에서 로그아웃하시겠습니까?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} 및 모든 Plex Home 사용자를 제거할까요? 언제든 다시 로그인할 수 있습니다.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} 및 모든 Plex Home 사용자를 제거할까요? 언제든 다시 로그인할 수 있습니다.',
'profiles.signedOutPlex' => 'Plex에서 로그아웃되었습니다.', 'profiles.signedOutPlex' => 'Plex에서 로그아웃되었습니다.',
'profiles.signOutFailed' => '로그아웃에 실패했습니다.', 'profiles.signOutFailed' => '로그아웃에 실패했습니다.',
@@ -2830,10 +2834,10 @@ extension on TranslationsKo {
'metadataEdit.originalTitle' => '원제', 'metadataEdit.originalTitle' => '원제',
'metadataEdit.releaseDate' => '출시일', 'metadataEdit.releaseDate' => '출시일',
'metadataEdit.contentRating' => '콘텐츠 등급', 'metadataEdit.contentRating' => '콘텐츠 등급',
'metadataEdit.studio' => '스튜디오',
'metadataEdit.tagline' => '태그라인',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => '스튜디오',
'metadataEdit.tagline' => '태그라인',
'metadataEdit.summary' => '줄거리', 'metadataEdit.summary' => '줄거리',
'metadataEdit.poster' => '포스터', 'metadataEdit.poster' => '포스터',
'metadataEdit.background' => '배경', 'metadataEdit.background' => '배경',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsNb extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Vis sesong- og episodenummer på episodekort'; @override String get showEpisodeNumberOnCardsDescription => 'Vis sesong- og episodenummer på episodekort';
@override String get showSeasonPostersOnTabs => 'Vis sesongplakater på faner'; @override String get showSeasonPostersOnTabs => 'Vis sesongplakater på faner';
@override String get showSeasonPostersOnTabsDescription => 'Vis hver sesongs plakat over fanen'; @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 hideSpoilers => 'Skjul spoilere for usette episoder'; @override String get hideSpoilers => 'Skjul spoilere for usette episoder';
@override String get hideSpoilersDescription => 'Slør miniatyrbilder og beskrivelser for usette episoder'; @override String get hideSpoilersDescription => 'Slør miniatyrbilder og beskrivelser for usette episoder';
@override String get playerBackend => 'Spillermotor'; @override String get playerBackend => 'Spillermotor';
@@ -1922,6 +1924,8 @@ extension on TranslationsNb {
'settings.showEpisodeNumberOnCardsDescription' => 'Vis sesong- og episodenummer på episodekort', 'settings.showEpisodeNumberOnCardsDescription' => 'Vis sesong- og episodenummer på episodekort',
'settings.showSeasonPostersOnTabs' => 'Vis sesongplakater på faner', 'settings.showSeasonPostersOnTabs' => 'Vis sesongplakater på faner',
'settings.showSeasonPostersOnTabsDescription' => 'Vis hver sesongs plakat over fanen', 'settings.showSeasonPostersOnTabsDescription' => 'Vis hver sesongs plakat over fanen',
'settings.tvFullCardLayout' => 'Fulle TV-kort',
'settings.tvFullCardLayoutDescription' => 'Bruk bildebaserte TV-kort med skuespillernavn lagt over',
'settings.hideSpoilers' => 'Skjul spoilere for usette episoder', 'settings.hideSpoilers' => 'Skjul spoilere for usette episoder',
'settings.hideSpoilersDescription' => 'Slør miniatyrbilder og beskrivelser for usette episoder', 'settings.hideSpoilersDescription' => 'Slør miniatyrbilder og beskrivelser for usette episoder',
'settings.playerBackend' => 'Spillermotor', 'settings.playerBackend' => 'Spillermotor',
@@ -2316,10 +2320,10 @@ extension on TranslationsNb {
'profiles.active' => 'Aktiv', 'profiles.active' => 'Aktiv',
'profiles.manage' => 'Administrer', 'profiles.manage' => 'Administrer',
'profiles.delete' => 'Slett', 'profiles.delete' => 'Slett',
'profiles.signOut' => 'Logg ut',
'profiles.signOutPlexTitle' => 'Logge ut av Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Logg ut',
'profiles.signOutPlexTitle' => 'Logge ut av Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjerne ${displayName} og alle Plex Home-brukere? Du kan logge inn igjen når som helst.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjerne ${displayName} og alle Plex Home-brukere? Du kan logge inn igjen når som helst.',
'profiles.signedOutPlex' => 'Logget ut av Plex.', 'profiles.signedOutPlex' => 'Logget ut av Plex.',
'profiles.signOutFailed' => 'Utlogging mislyktes.', 'profiles.signOutFailed' => 'Utlogging mislyktes.',
@@ -2830,10 +2834,10 @@ extension on TranslationsNb {
'metadataEdit.originalTitle' => 'Originaltittel', 'metadataEdit.originalTitle' => 'Originaltittel',
'metadataEdit.releaseDate' => 'Utgivelsesdato', 'metadataEdit.releaseDate' => 'Utgivelsesdato',
'metadataEdit.contentRating' => 'Innholdsvurdering', 'metadataEdit.contentRating' => 'Innholdsvurdering',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slagord',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slagord',
'metadataEdit.summary' => 'Sammendrag', 'metadataEdit.summary' => 'Sammendrag',
'metadataEdit.poster' => 'Plakat', 'metadataEdit.poster' => 'Plakat',
'metadataEdit.background' => 'Bakgrunn', 'metadataEdit.background' => 'Bakgrunn',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsNl extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Toon seizoen- en afleveringsnummer op afleveringskaarten'; @override String get showEpisodeNumberOnCardsDescription => 'Toon seizoen- en afleveringsnummer op afleveringskaarten';
@override String get showSeasonPostersOnTabs => 'Toon seizoensposters op tabbladen'; @override String get showSeasonPostersOnTabs => 'Toon seizoensposters op tabbladen';
@override String get showSeasonPostersOnTabsDescription => 'Toon de poster van elk seizoen boven het tabblad'; @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 hideSpoilers => 'Spoilers voor ongekeken afleveringen verbergen'; @override String get hideSpoilers => 'Spoilers voor ongekeken afleveringen verbergen';
@override String get hideSpoilersDescription => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen'; @override String get hideSpoilersDescription => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen';
@override String get playerBackend => 'Speler backend'; @override String get playerBackend => 'Speler backend';
@@ -1922,6 +1924,8 @@ extension on TranslationsNl {
'settings.showEpisodeNumberOnCardsDescription' => 'Toon seizoen- en afleveringsnummer op afleveringskaarten', 'settings.showEpisodeNumberOnCardsDescription' => 'Toon seizoen- en afleveringsnummer op afleveringskaarten',
'settings.showSeasonPostersOnTabs' => 'Toon seizoensposters op tabbladen', 'settings.showSeasonPostersOnTabs' => 'Toon seizoensposters op tabbladen',
'settings.showSeasonPostersOnTabsDescription' => 'Toon de poster van elk seizoen boven het tabblad', '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.hideSpoilers' => 'Spoilers voor ongekeken afleveringen verbergen', 'settings.hideSpoilers' => 'Spoilers voor ongekeken afleveringen verbergen',
'settings.hideSpoilersDescription' => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen', 'settings.hideSpoilersDescription' => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen',
'settings.playerBackend' => 'Speler backend', 'settings.playerBackend' => 'Speler backend',
@@ -2316,10 +2320,10 @@ extension on TranslationsNl {
'profiles.active' => 'Actief', 'profiles.active' => 'Actief',
'profiles.manage' => 'Beheren', 'profiles.manage' => 'Beheren',
'profiles.delete' => 'Verwijderen', 'profiles.delete' => 'Verwijderen',
'profiles.signOut' => 'Afmelden',
'profiles.signOutPlexTitle' => 'Afmelden bij Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Afmelden',
'profiles.signOutPlexTitle' => 'Afmelden bij Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} en alle Plex Home-gebruikers verwijderen? Je kunt altijd opnieuw inloggen.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} en alle Plex Home-gebruikers verwijderen? Je kunt altijd opnieuw inloggen.',
'profiles.signedOutPlex' => 'Afgemeld bij Plex.', 'profiles.signedOutPlex' => 'Afgemeld bij Plex.',
'profiles.signOutFailed' => 'Afmelden mislukt.', 'profiles.signOutFailed' => 'Afmelden mislukt.',
@@ -2830,10 +2834,10 @@ extension on TranslationsNl {
'metadataEdit.originalTitle' => 'Oorspronkelijke titel', 'metadataEdit.originalTitle' => 'Oorspronkelijke titel',
'metadataEdit.releaseDate' => 'Releasedatum', 'metadataEdit.releaseDate' => 'Releasedatum',
'metadataEdit.contentRating' => 'Leeftijdsclassificatie', 'metadataEdit.contentRating' => 'Leeftijdsclassificatie',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Samenvatting', 'metadataEdit.summary' => 'Samenvatting',
'metadataEdit.poster' => 'Poster', 'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Achtergrond', 'metadataEdit.background' => 'Achtergrond',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsPl extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Pokazuj numer sezonu i odcinka na kartach odcinków'; @override String get showEpisodeNumberOnCardsDescription => 'Pokazuj numer sezonu i odcinka na kartach odcinków';
@override String get showSeasonPostersOnTabs => 'Pokaż plakaty sezonów na zakładkach'; @override String get showSeasonPostersOnTabs => 'Pokaż plakaty sezonów na zakładkach';
@override String get showSeasonPostersOnTabsDescription => 'Pokazuj plakat każdego sezonu nad jego kartą'; @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 hideSpoilers => 'Ukryj spoilery nieobejrzanych odcinków'; @override String get hideSpoilers => 'Ukryj spoilery nieobejrzanych odcinków';
@override String get hideSpoilersDescription => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków'; @override String get hideSpoilersDescription => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków';
@override String get playerBackend => 'Backend odtwarzacza'; @override String get playerBackend => 'Backend odtwarzacza';
@@ -1922,6 +1924,8 @@ extension on TranslationsPl {
'settings.showEpisodeNumberOnCardsDescription' => 'Pokazuj numer sezonu i odcinka na kartach odcinków', 'settings.showEpisodeNumberOnCardsDescription' => 'Pokazuj numer sezonu i odcinka na kartach odcinków',
'settings.showSeasonPostersOnTabs' => 'Pokaż plakaty sezonów na zakładkach', 'settings.showSeasonPostersOnTabs' => 'Pokaż plakaty sezonów na zakładkach',
'settings.showSeasonPostersOnTabsDescription' => 'Pokazuj plakat każdego sezonu nad jego kartą', '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.hideSpoilers' => 'Ukryj spoilery nieobejrzanych odcinków', 'settings.hideSpoilers' => 'Ukryj spoilery nieobejrzanych odcinków',
'settings.hideSpoilersDescription' => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków', 'settings.hideSpoilersDescription' => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków',
'settings.playerBackend' => 'Backend odtwarzacza', 'settings.playerBackend' => 'Backend odtwarzacza',
@@ -2316,10 +2320,10 @@ extension on TranslationsPl {
'profiles.active' => 'Aktywny', 'profiles.active' => 'Aktywny',
'profiles.manage' => 'Zarządzaj', 'profiles.manage' => 'Zarządzaj',
'profiles.delete' => 'Usuń', 'profiles.delete' => 'Usuń',
'profiles.signOut' => 'Wyloguj się',
'profiles.signOutPlexTitle' => 'Wylogować się z Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Wyloguj się',
'profiles.signOutPlexTitle' => 'Wylogować się z Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Usunąć ${displayName} i wszystkich użytkowników Plex Home? Możesz zalogować się ponownie w każdej chwili.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Usunąć ${displayName} i wszystkich użytkowników Plex Home? Możesz zalogować się ponownie w każdej chwili.',
'profiles.signedOutPlex' => 'Wylogowano z Plex.', 'profiles.signedOutPlex' => 'Wylogowano z Plex.',
'profiles.signOutFailed' => 'Wylogowanie nie powiodło się.', 'profiles.signOutFailed' => 'Wylogowanie nie powiodło się.',
@@ -2830,10 +2834,10 @@ extension on TranslationsPl {
'metadataEdit.originalTitle' => 'Tytuł oryginalny', 'metadataEdit.originalTitle' => 'Tytuł oryginalny',
'metadataEdit.releaseDate' => 'Data premiery', 'metadataEdit.releaseDate' => 'Data premiery',
'metadataEdit.contentRating' => 'Klasyfikacja wiekowa', 'metadataEdit.contentRating' => 'Klasyfikacja wiekowa',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Opis', 'metadataEdit.summary' => 'Opis',
'metadataEdit.poster' => 'Plakat', 'metadataEdit.poster' => 'Plakat',
'metadataEdit.background' => 'Tło', 'metadataEdit.background' => 'Tło',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsPt extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Mostrar temporada e episódio nos cartões de episódio'; @override String get showEpisodeNumberOnCardsDescription => 'Mostrar temporada e episódio nos cartões de episódio';
@override String get showSeasonPostersOnTabs => 'Mostrar Pôsteres de Temporada nas Abas'; @override String get showSeasonPostersOnTabs => 'Mostrar Pôsteres de Temporada nas Abas';
@override String get showSeasonPostersOnTabsDescription => 'Mostrar o pôster de cada temporada acima da aba'; @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 hideSpoilers => 'Ocultar Spoilers de Episódios Não Assistidos'; @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 hideSpoilersDescription => 'Desfocar miniaturas e descrições de episódios não vistos';
@override String get playerBackend => 'Backend do Player'; @override String get playerBackend => 'Backend do Player';
@@ -1922,6 +1924,8 @@ extension on TranslationsPt {
'settings.showEpisodeNumberOnCardsDescription' => 'Mostrar temporada e episódio nos cartões de episódio', 'settings.showEpisodeNumberOnCardsDescription' => 'Mostrar temporada e episódio nos cartões de episódio',
'settings.showSeasonPostersOnTabs' => 'Mostrar Pôsteres de Temporada nas Abas', 'settings.showSeasonPostersOnTabs' => 'Mostrar Pôsteres de Temporada nas Abas',
'settings.showSeasonPostersOnTabsDescription' => 'Mostrar o pôster de cada temporada acima da aba', '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.hideSpoilers' => 'Ocultar Spoilers de Episódios Não Assistidos', 'settings.hideSpoilers' => 'Ocultar Spoilers de Episódios Não Assistidos',
'settings.hideSpoilersDescription' => 'Desfocar miniaturas e descrições de episódios não vistos', 'settings.hideSpoilersDescription' => 'Desfocar miniaturas e descrições de episódios não vistos',
'settings.playerBackend' => 'Backend do Player', 'settings.playerBackend' => 'Backend do Player',
@@ -2316,10 +2320,10 @@ extension on TranslationsPt {
'profiles.active' => 'Ativo', 'profiles.active' => 'Ativo',
'profiles.manage' => 'Gerenciar', 'profiles.manage' => 'Gerenciar',
'profiles.delete' => 'Excluir', 'profiles.delete' => 'Excluir',
'profiles.signOut' => 'Sair',
'profiles.signOutPlexTitle' => 'Sair do Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Sair',
'profiles.signOutPlexTitle' => 'Sair do Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remover ${displayName} e todos os usuários Plex Home? Você pode entrar novamente quando quiser.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remover ${displayName} e todos os usuários Plex Home? Você pode entrar novamente quando quiser.',
'profiles.signedOutPlex' => 'Saiu do Plex.', 'profiles.signedOutPlex' => 'Saiu do Plex.',
'profiles.signOutFailed' => 'Falha ao sair.', 'profiles.signOutFailed' => 'Falha ao sair.',
@@ -2830,10 +2834,10 @@ extension on TranslationsPt {
'metadataEdit.originalTitle' => 'Título Original', 'metadataEdit.originalTitle' => 'Título Original',
'metadataEdit.releaseDate' => 'Data de Lançamento', 'metadataEdit.releaseDate' => 'Data de Lançamento',
'metadataEdit.contentRating' => 'Classificação Indicativa', 'metadataEdit.contentRating' => 'Classificação Indicativa',
'metadataEdit.studio' => 'Estúdio',
'metadataEdit.tagline' => 'Tagline',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Estúdio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Sinopse', 'metadataEdit.summary' => 'Sinopse',
'metadataEdit.poster' => 'Poster', 'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Plano de Fundo', 'metadataEdit.background' => 'Plano de Fundo',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsRu extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Показывать номер сезона и серии на карточках серий'; @override String get showEpisodeNumberOnCardsDescription => 'Показывать номер сезона и серии на карточках серий';
@override String get showSeasonPostersOnTabs => 'Показывать постеры сезонов на вкладках'; @override String get showSeasonPostersOnTabs => 'Показывать постеры сезонов на вкладках';
@override String get showSeasonPostersOnTabsDescription => 'Показывать постер каждого сезона над его вкладкой'; @override String get showSeasonPostersOnTabsDescription => 'Показывать постер каждого сезона над его вкладкой';
@override String get tvFullCardLayout => 'Полные TV-карточки';
@override String get tvFullCardLayoutDescription => 'Использовать TV-карточки только с изображением и именами актёров поверх него';
@override String get hideSpoilers => 'Скрыть спойлеры непросмотренных эпизодов'; @override String get hideSpoilers => 'Скрыть спойлеры непросмотренных эпизодов';
@override String get hideSpoilersDescription => 'Размывать миниатюры и описания непросмотренных серий'; @override String get hideSpoilersDescription => 'Размывать миниатюры и описания непросмотренных серий';
@override String get playerBackend => 'Бэкенд плеера'; @override String get playerBackend => 'Бэкенд плеера';
@@ -1922,6 +1924,8 @@ extension on TranslationsRu {
'settings.showEpisodeNumberOnCardsDescription' => 'Показывать номер сезона и серии на карточках серий', 'settings.showEpisodeNumberOnCardsDescription' => 'Показывать номер сезона и серии на карточках серий',
'settings.showSeasonPostersOnTabs' => 'Показывать постеры сезонов на вкладках', 'settings.showSeasonPostersOnTabs' => 'Показывать постеры сезонов на вкладках',
'settings.showSeasonPostersOnTabsDescription' => 'Показывать постер каждого сезона над его вкладкой', 'settings.showSeasonPostersOnTabsDescription' => 'Показывать постер каждого сезона над его вкладкой',
'settings.tvFullCardLayout' => 'Полные TV-карточки',
'settings.tvFullCardLayoutDescription' => 'Использовать TV-карточки только с изображением и именами актёров поверх него',
'settings.hideSpoilers' => 'Скрыть спойлеры непросмотренных эпизодов', 'settings.hideSpoilers' => 'Скрыть спойлеры непросмотренных эпизодов',
'settings.hideSpoilersDescription' => 'Размывать миниатюры и описания непросмотренных серий', 'settings.hideSpoilersDescription' => 'Размывать миниатюры и описания непросмотренных серий',
'settings.playerBackend' => 'Бэкенд плеера', 'settings.playerBackend' => 'Бэкенд плеера',
@@ -2316,10 +2320,10 @@ extension on TranslationsRu {
'profiles.active' => 'Активный', 'profiles.active' => 'Активный',
'profiles.manage' => 'Управление', 'profiles.manage' => 'Управление',
'profiles.delete' => 'Удалить', 'profiles.delete' => 'Удалить',
'profiles.signOut' => 'Выйти',
'profiles.signOutPlexTitle' => 'Выйти из Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Выйти',
'profiles.signOutPlexTitle' => 'Выйти из Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Удалить ${displayName} и всех пользователей Plex Home? Вы сможете войти снова в любое время.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Удалить ${displayName} и всех пользователей Plex Home? Вы сможете войти снова в любое время.',
'profiles.signedOutPlex' => 'Вы вышли из Plex.', 'profiles.signedOutPlex' => 'Вы вышли из Plex.',
'profiles.signOutFailed' => 'Не удалось выйти.', 'profiles.signOutFailed' => 'Не удалось выйти.',
@@ -2830,10 +2834,10 @@ extension on TranslationsRu {
'metadataEdit.originalTitle' => 'Оригинальное название', 'metadataEdit.originalTitle' => 'Оригинальное название',
'metadataEdit.releaseDate' => 'Дата выпуска', 'metadataEdit.releaseDate' => 'Дата выпуска',
'metadataEdit.contentRating' => 'Возрастной рейтинг', 'metadataEdit.contentRating' => 'Возрастной рейтинг',
'metadataEdit.studio' => 'Студия',
'metadataEdit.tagline' => 'Слоган',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Студия',
'metadataEdit.tagline' => 'Слоган',
'metadataEdit.summary' => 'Описание', 'metadataEdit.summary' => 'Описание',
'metadataEdit.poster' => 'Постер', 'metadataEdit.poster' => 'Постер',
'metadataEdit.background' => 'Фон', 'metadataEdit.background' => 'Фон',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsSv extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Visa säsongs- och avsnittsnummer på avsnittskort'; @override String get showEpisodeNumberOnCardsDescription => 'Visa säsongs- och avsnittsnummer på avsnittskort';
@override String get showSeasonPostersOnTabs => 'Visa säsongsaffischer på flikar'; @override String get showSeasonPostersOnTabs => 'Visa säsongsaffischer på flikar';
@override String get showSeasonPostersOnTabsDescription => 'Visa varje säsongs affisch ovanför fliken'; @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 hideSpoilers => 'Dölj spoilers för osedda avsnitt'; @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 hideSpoilersDescription => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt';
@override String get playerBackend => 'Spelarmotor'; @override String get playerBackend => 'Spelarmotor';
@@ -1922,6 +1924,8 @@ extension on TranslationsSv {
'settings.showEpisodeNumberOnCardsDescription' => 'Visa säsongs- och avsnittsnummer på avsnittskort', 'settings.showEpisodeNumberOnCardsDescription' => 'Visa säsongs- och avsnittsnummer på avsnittskort',
'settings.showSeasonPostersOnTabs' => 'Visa säsongsaffischer på flikar', 'settings.showSeasonPostersOnTabs' => 'Visa säsongsaffischer på flikar',
'settings.showSeasonPostersOnTabsDescription' => 'Visa varje säsongs affisch ovanför fliken', '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.hideSpoilers' => 'Dölj spoilers för osedda avsnitt', 'settings.hideSpoilers' => 'Dölj spoilers för osedda avsnitt',
'settings.hideSpoilersDescription' => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt', 'settings.hideSpoilersDescription' => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt',
'settings.playerBackend' => 'Spelarmotor', 'settings.playerBackend' => 'Spelarmotor',
@@ -2316,10 +2320,10 @@ extension on TranslationsSv {
'profiles.active' => 'Aktiv', 'profiles.active' => 'Aktiv',
'profiles.manage' => 'Hantera', 'profiles.manage' => 'Hantera',
'profiles.delete' => 'Ta bort', 'profiles.delete' => 'Ta bort',
'profiles.signOut' => 'Logga ut',
'profiles.signOutPlexTitle' => 'Logga ut från Plex?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => 'Logga ut',
'profiles.signOutPlexTitle' => 'Logga ut från Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Ta bort ${displayName} och alla Plex Home-användare? Du kan logga in igen när som helst.', 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Ta bort ${displayName} och alla Plex Home-användare? Du kan logga in igen när som helst.',
'profiles.signedOutPlex' => 'Utloggad från Plex.', 'profiles.signedOutPlex' => 'Utloggad från Plex.',
'profiles.signOutFailed' => 'Utloggningen misslyckades.', 'profiles.signOutFailed' => 'Utloggningen misslyckades.',
@@ -2830,10 +2834,10 @@ extension on TranslationsSv {
'metadataEdit.originalTitle' => 'Originaltitel', 'metadataEdit.originalTitle' => 'Originaltitel',
'metadataEdit.releaseDate' => 'Utgivningsdatum', 'metadataEdit.releaseDate' => 'Utgivningsdatum',
'metadataEdit.contentRating' => 'Åldersgräns', 'metadataEdit.contentRating' => 'Åldersgräns',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Sammanfattning', 'metadataEdit.summary' => 'Sammanfattning',
'metadataEdit.poster' => 'Poster', 'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Bakgrund', 'metadataEdit.background' => 'Bakgrund',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsZh extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => '在剧集卡片上显示季和集编号'; @override String get showEpisodeNumberOnCardsDescription => '在剧集卡片上显示季和集编号';
@override String get showSeasonPostersOnTabs => '在选项卡上显示季海报'; @override String get showSeasonPostersOnTabs => '在选项卡上显示季海报';
@override String get showSeasonPostersOnTabsDescription => '在每季标签上方显示该季海报'; @override String get showSeasonPostersOnTabsDescription => '在每季标签上方显示该季海报';
@override String get tvFullCardLayout => '完整 TV 卡片';
@override String get tvFullCardLayoutDescription => '使用仅显示图片的 TV 卡片,并叠加演员姓名';
@override String get hideSpoilers => '隐藏未看剧集的剧透内容'; @override String get hideSpoilers => '隐藏未看剧集的剧透内容';
@override String get hideSpoilersDescription => '模糊未观看剧集的缩略图和描述'; @override String get hideSpoilersDescription => '模糊未观看剧集的缩略图和描述';
@override String get playerBackend => '播放器引擎'; @override String get playerBackend => '播放器引擎';
@@ -1922,6 +1924,8 @@ extension on TranslationsZh {
'settings.showEpisodeNumberOnCardsDescription' => '在剧集卡片上显示季和集编号', 'settings.showEpisodeNumberOnCardsDescription' => '在剧集卡片上显示季和集编号',
'settings.showSeasonPostersOnTabs' => '在选项卡上显示季海报', 'settings.showSeasonPostersOnTabs' => '在选项卡上显示季海报',
'settings.showSeasonPostersOnTabsDescription' => '在每季标签上方显示该季海报', 'settings.showSeasonPostersOnTabsDescription' => '在每季标签上方显示该季海报',
'settings.tvFullCardLayout' => '完整 TV 卡片',
'settings.tvFullCardLayoutDescription' => '使用仅显示图片的 TV 卡片,并叠加演员姓名',
'settings.hideSpoilers' => '隐藏未看剧集的剧透内容', 'settings.hideSpoilers' => '隐藏未看剧集的剧透内容',
'settings.hideSpoilersDescription' => '模糊未观看剧集的缩略图和描述', 'settings.hideSpoilersDescription' => '模糊未观看剧集的缩略图和描述',
'settings.playerBackend' => '播放器引擎', 'settings.playerBackend' => '播放器引擎',
@@ -2316,10 +2320,10 @@ extension on TranslationsZh {
'profiles.active' => '活跃', 'profiles.active' => '活跃',
'profiles.manage' => '管理', 'profiles.manage' => '管理',
'profiles.delete' => '删除', 'profiles.delete' => '删除',
'profiles.signOut' => '退出登录',
'profiles.signOutPlexTitle' => '退出 Plex 登录?',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'profiles.signOut' => '退出登录',
'profiles.signOutPlexTitle' => '退出 Plex 登录?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '要移除 ${displayName} 和所有 Plex Home 用户吗?可随时重新登录。', 'profiles.signOutPlexMessage' => ({required Object displayName}) => '要移除 ${displayName} 和所有 Plex Home 用户吗?可随时重新登录。',
'profiles.signedOutPlex' => '已退出 Plex 登录。', 'profiles.signedOutPlex' => '已退出 Plex 登录。',
'profiles.signOutFailed' => '退出登录失败。', 'profiles.signOutFailed' => '退出登录失败。',
@@ -2830,10 +2834,10 @@ extension on TranslationsZh {
'metadataEdit.originalTitle' => '原始标题', 'metadataEdit.originalTitle' => '原始标题',
'metadataEdit.releaseDate' => '上映日期', 'metadataEdit.releaseDate' => '上映日期',
'metadataEdit.contentRating' => '内容分级', 'metadataEdit.contentRating' => '内容分级',
'metadataEdit.studio' => '制片厂',
'metadataEdit.tagline' => '标语',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.studio' => '制片厂',
'metadataEdit.tagline' => '标语',
'metadataEdit.summary' => '简介', 'metadataEdit.summary' => '简介',
'metadataEdit.poster' => '海报', 'metadataEdit.poster' => '海报',
'metadataEdit.background' => '背景', 'metadataEdit.background' => '背景',
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Visa säsongs- och avsnittsnummer på avsnittskort", "showEpisodeNumberOnCardsDescription": "Visa säsongs- och avsnittsnummer på avsnittskort",
"showSeasonPostersOnTabs": "Visa säsongsaffischer på flikar", "showSeasonPostersOnTabs": "Visa säsongsaffischer på flikar",
"showSeasonPostersOnTabsDescription": "Visa varje säsongs affisch ovanför fliken", "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å",
"hideSpoilers": "Dölj spoilers för osedda avsnitt", "hideSpoilers": "Dölj spoilers för osedda avsnitt",
"hideSpoilersDescription": "Sudda miniatyrbilder och beskrivningar för osedda avsnitt", "hideSpoilersDescription": "Sudda miniatyrbilder och beskrivningar för osedda avsnitt",
"playerBackend": "Spelarmotor", "playerBackend": "Spelarmotor",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "在剧集卡片上显示季和集编号", "showEpisodeNumberOnCardsDescription": "在剧集卡片上显示季和集编号",
"showSeasonPostersOnTabs": "在选项卡上显示季海报", "showSeasonPostersOnTabs": "在选项卡上显示季海报",
"showSeasonPostersOnTabsDescription": "在每季标签上方显示该季海报", "showSeasonPostersOnTabsDescription": "在每季标签上方显示该季海报",
"tvFullCardLayout": "完整 TV 卡片",
"tvFullCardLayoutDescription": "使用仅显示图片的 TV 卡片,并叠加演员姓名",
"hideSpoilers": "隐藏未看剧集的剧透内容", "hideSpoilers": "隐藏未看剧集的剧透内容",
"hideSpoilersDescription": "模糊未观看剧集的缩略图和描述", "hideSpoilersDescription": "模糊未观看剧集的缩略图和描述",
"playerBackend": "播放器引擎", "playerBackend": "播放器引擎",
+1
View File
@@ -1554,6 +1554,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
hubs: browseHubs, hubs: browseHubs,
density: svc.read(SettingsService.libraryDensity), density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode), episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
); );
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble(); final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
+18 -5
View File
@@ -317,21 +317,33 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> {
// Extra top padding for focus decoration (scale + border extends beyond item bounds) // Extra top padding for focus decoration (scale + border extends beyond item bounds)
const effectivePadding = EdgeInsets.only(left: 8, right: 8, top: 8); const effectivePadding = EdgeInsets.only(left: 8, right: 8, top: 8);
return SettingValueBuilder<int>( return SettingsBuilder(
pref: SettingsService.libraryDensity, prefs: const [SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context, density, _) { builder: (context) {
final settings = SettingsService.instanceOrNull!;
final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density);
// Use LayoutBuilder to get actual available width (accounting for sidebar) // Use LayoutBuilder to get actual available width (accounting for sidebar)
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final availableWidth = constraints.maxWidth - effectivePadding.left - effectivePadding.right; final availableWidth = constraints.maxWidth - effectivePadding.left - effectivePadding.right;
final columnCount = GridSizeCalculator.getColumnCount(availableWidth, maxCrossAxisExtent); final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
final columnCount = GridSizeCalculator.getColumnCount(
availableWidth,
maxCrossAxisExtent,
crossAxisSpacing: gridSpacing,
);
return GridView.builder( return GridView.builder(
padding: effectivePadding, padding: effectivePadding,
// Allow focus decoration to render outside scroll bounds // Allow focus decoration to render outside scroll bounds
clipBehavior: Clip.none, clipBehavior: Clip.none,
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density), gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: density,
fullBleedImage: fullCardLayout,
),
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
@@ -342,6 +354,7 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> {
focusNode: isFirst ? _firstItemFocusNode : null, focusNode: isFirst ? _firstItemFocusNode : null,
onBack: widget.onBack, onBack: widget.onBack,
isOffline: true, // Downloaded content works without server isOffline: true, // Downloaded content works without server
fullBleedImage: fullCardLayout,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
); );
}, },
+28 -6
View File
@@ -169,11 +169,12 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
VoidCallback? onListRefresh, VoidCallback? onListRefresh,
}) { }) {
return SettingsBuilder( return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) { builder: (context) {
final svc = SettingsService.instanceOrNull!; final svc = SettingsService.instanceOrNull!;
final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list;
final libraryDensity = svc.read(SettingsService.libraryDensity); final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
if (isListMode) { if (isListMode) {
return SliverPadding( return SliverPadding(
@@ -206,9 +207,18 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
sliver: SliverLayoutBuilder( sliver: SliverLayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent); final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
final columnCount = GridSizeCalculator.getColumnCount(
constraints.crossAxisExtent,
maxExtent,
crossAxisSpacing: gridSpacing,
);
return SliverGrid.builder( return SliverGrid.builder(
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: libraryDensity), gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: libraryDensity,
fullBleedImage: fullCardLayout,
),
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
@@ -222,6 +232,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
onRefresh: onRefresh, onRefresh: onRefresh,
collectionId: collectionId, collectionId: collectionId,
onListRefresh: onListRefresh, onListRefresh: onListRefresh,
fullBleedImage: fullCardLayout,
onNavigateUp: inFirstRow ? navigateToAppBar : null, onNavigateUp: inFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent, onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
@@ -248,11 +259,12 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
VoidCallback? onListRefresh, VoidCallback? onListRefresh,
}) { }) {
return SettingsBuilder( return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) { builder: (context) {
final svc = SettingsService.instanceOrNull!; final svc = SettingsService.instanceOrNull!;
final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list;
final libraryDensity = svc.read(SettingsService.libraryDensity); final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
Widget buildTile(int index, {required bool inFirstRow, required bool disableScale}) { Widget buildTile(int index, {required bool inFirstRow, required bool disableScale}) {
final item = itemAt(index); final item = itemAt(index);
@@ -269,6 +281,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
onRefresh: onRefresh, onRefresh: onRefresh,
collectionId: collectionId, collectionId: collectionId,
onListRefresh: onListRefresh, onListRefresh: onListRefresh,
fullBleedImage: fullCardLayout && !disableScale,
onNavigateUp: inFirstRow ? navigateToAppBar : null, onNavigateUp: inFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent, onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
@@ -290,9 +303,18 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
sliver: SliverLayoutBuilder( sliver: SliverLayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent); final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
final columnCount = GridSizeCalculator.getColumnCount(
constraints.crossAxisExtent,
maxExtent,
crossAxisSpacing: gridSpacing,
);
return SliverGrid.builder( return SliverGrid.builder(
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: libraryDensity), gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: libraryDensity,
fullBleedImage: fullCardLayout,
),
itemCount: totalItems, itemCount: totalItems,
itemBuilder: (context, index) => buildTile( itemBuilder: (context, index) => buildTile(
index, index,
+9
View File
@@ -510,12 +510,14 @@ class _HubDetailScreenState extends State<HubDetailScreen>
SettingsService.viewMode, SettingsService.viewMode,
SettingsService.episodePosterMode, SettingsService.episodePosterMode,
SettingsService.libraryDensity, SettingsService.libraryDensity,
SettingsService.tvFullCardLayout,
], ],
builder: (context) { builder: (context) {
final svc = SettingsService.instanceOrNull!; final svc = SettingsService.instanceOrNull!;
final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list;
final episodePosterMode = svc.read(SettingsService.episodePosterMode); final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final libraryDensity = svc.read(SettingsService.libraryDensity); final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
// Determine hub content type for layout decisions // Determine hub content type for layout decisions
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
@@ -570,9 +572,14 @@ class _HubDetailScreenState extends State<HubDetailScreen>
libraryDensity, libraryDensity,
16, 16,
); );
final gridSpacing = MediaGridDelegate.spacingFor(
context: context,
fullBleedImage: fullCardLayout,
);
final columnCount = GridSizeCalculator.getColumnCount( final columnCount = GridSizeCalculator.getColumnCount(
constraints.crossAxisExtent, constraints.crossAxisExtent,
useWideLayout ? maxExtent * 1.8 : maxExtent, useWideLayout ? maxExtent * 1.8 : maxExtent,
crossAxisSpacing: gridSpacing,
); );
return SliverGrid( return SliverGrid(
@@ -582,6 +589,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
usePaddingAware: true, usePaddingAware: true,
horizontalPadding: 16, horizontalPadding: 16,
useWideAspectRatio: useWideLayout, useWideAspectRatio: useWideLayout,
fullBleedImage: fullCardLayout,
), ),
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final item = _filteredItems[index]; final item = _filteredItems[index];
@@ -602,6 +610,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
onBack: handleBackFromContent, onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
mixedHubContext: isMixedHub, mixedHubContext: isMixedHub,
fullBleedImage: fullCardLayout,
); );
}, childCount: _filteredItems.length), }, childCount: _filteredItems.length),
); );
@@ -1511,7 +1511,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return [ return [
SettingsBuilder( SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.episodePosterMode], prefs: const [
SettingsService.viewMode,
SettingsService.libraryDensity,
SettingsService.episodePosterMode,
SettingsService.tvFullCardLayout,
],
builder: (context) => _buildItemsSliver(context), builder: (context) => _buildItemsSliver(context),
), ),
]; ];
@@ -1568,6 +1573,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final viewMode = svc.read(SettingsService.viewMode); final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity); final libraryDensity = svc.read(SettingsService.libraryDensity);
final episodePosterMode = svc.read(SettingsService.episodePosterMode); final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
final itemCount = totalSize; final itemCount = totalSize;
final isPhone = _isPhone(context); final isPhone = _isPhone(context);
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding; final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
@@ -1611,17 +1617,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8), padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
sliver: SliverLayoutBuilder( sliver: SliverLayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
// Compute column count from the width the grid would have without the alpha // Compute column count from the width the grid would have without the alpha
// bar's reservation, so toggling the bar doesn't repack the grid into one // bar's reservation, so toggling the bar doesn't repack the grid into one
// fewer column and blow up poster size. // fewer column and blow up poster size.
final baselineWidth = constraints.crossAxisExtent + (rightPadding - 8.0); final baselineWidth = constraints.crossAxisExtent + (rightPadding - 8.0);
final columnCount = GridSizeCalculator.getColumnCount(baselineWidth, effectiveMaxExtent); final columnCount = GridSizeCalculator.getColumnCount(
baselineWidth,
effectiveMaxExtent,
crossAxisSpacing: gridSpacing,
);
// Cache grid metrics for alpha jump bar scroll calculations // Cache grid metrics for alpha jump bar scroll calculations
final itemWidth = constraints.crossAxisExtent / columnCount; final itemWidth = GridSizeCalculator.getCellWidthForColumnCount(
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio; constraints.crossAxisExtent,
columnCount,
crossAxisSpacing: gridSpacing,
);
final itemHeight =
itemWidth /
MediaGridDelegate.aspectRatioFor(useWideAspectRatio: useWideRatio, fullBleedImage: fullCardLayout);
_scrollMetrics = LibraryAlphaScrollMetrics( _scrollMetrics = LibraryAlphaScrollMetrics(
columnCount: columnCount, columnCount: columnCount,
rowHeight: itemHeight + GridLayoutConstants.mainAxisSpacing, rowHeight: itemHeight + gridSpacing,
itemWidth: itemWidth, itemWidth: itemWidth,
itemHeight: itemHeight, itemHeight: itemHeight,
); );
@@ -1630,7 +1647,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
context: context, context: context,
density: libraryDensity, density: libraryDensity,
useWideAspectRatio: useWideRatio, useWideAspectRatio: useWideRatio,
maxCrossAxisExtentOverride: hasAlphaBarReservation ? constraints.crossAxisExtent / columnCount : null, fullBleedImage: fullCardLayout,
maxCrossAxisExtentOverride: hasAlphaBarReservation ? itemWidth : null,
), ),
itemCount: itemCount, itemCount: itemCount,
itemBuilder: (context, index) => _buildMediaCardItem( itemBuilder: (context, index) => _buildMediaCardItem(
@@ -1640,6 +1658,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
isLastColumn: (index % columnCount) == (columnCount - 1), isLastColumn: (index % columnCount) == (columnCount - 1),
columnCount: columnCount, columnCount: columnCount,
itemCount: itemCount, itemCount: itemCount,
fullBleedImage: fullCardLayout,
), ),
); );
}, },
@@ -1654,6 +1673,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
required bool isFirstColumn, required bool isFirstColumn,
bool isLastColumn = false, bool isLastColumn = false,
bool disableScale = false, bool disableScale = false,
bool fullBleedImage = false,
int columnCount = 1, int columnCount = 1,
int itemCount = 0, int itemCount = 0,
}) { }) {
@@ -1711,6 +1731,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
onBack: widget.onBack, onBack: widget.onBack,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
onListRefresh: _loadItems, onListRefresh: _loadItems,
fullBleedImage: fullBleedImage,
); );
} }
@@ -10,6 +10,7 @@ import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart'; import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart'; import '../../../utils/media_server_http_client.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/settings_builder.dart'; import '../../../widgets/settings_builder.dart';
@@ -106,16 +107,20 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
@override @override
Widget buildContent(List<MediaItem> items) { Widget buildContent(List<MediaItem> items) {
return SettingsBuilder( return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) { builder: (context) {
final settings = SettingsService.instanceOrNull!; final settings = SettingsService.instanceOrNull!;
final viewMode = settings.read(SettingsService.viewMode); final viewMode = settings.read(SettingsService.viewMode);
final density = settings.read(SettingsService.libraryDensity); final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
return CustomScrollView( return CustomScrollView(
clipBehavior: Clip.none, clipBehavior: Clip.none,
slivers: [ slivers: [
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
if (viewMode == ViewMode.list) _buildListSliver(density) else _buildGridSliver(density), if (viewMode == ViewMode.list)
_buildListSliver(density)
else
_buildGridSliver(density, fullCardLayout: fullCardLayout),
], ],
); );
}, },
@@ -139,25 +144,42 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
); );
} }
Widget _buildGridSliver(int density) { Widget _buildGridSliver(int density, {required bool fullCardLayout}) {
return SliverPadding( return SliverPadding(
padding: _effectivePadding, padding: _effectivePadding,
sliver: SliverLayoutBuilder( sliver: SliverLayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density);
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxCrossAxisExtent); final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
final columnCount = GridSizeCalculator.getColumnCount(
constraints.crossAxisExtent,
maxCrossAxisExtent,
crossAxisSpacing: gridSpacing,
);
return SliverGrid.builder( return SliverGrid.builder(
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density), gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: density,
fullBleedImage: fullCardLayout,
),
itemCount: totalSize, itemCount: totalSize,
itemBuilder: (context, index) => itemBuilder: (context, index) => _buildMediaCardItem(
_buildMediaCardItem(index, isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount)), index,
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
fullBleedImage: fullCardLayout,
),
); );
}, },
), ),
); );
} }
Widget _buildMediaCardItem(int index, {required bool isFirstColumn, bool disableScale = false}) { Widget _buildMediaCardItem(
int index, {
required bool isFirstColumn,
bool disableScale = false,
bool fullBleedImage = false,
}) {
final item = loadedItems[index]; final item = loadedItems[index];
if (item == null) { if (item == null) {
ensureIndexLoaded(index, pageSize: _pageSize); ensureIndexLoaded(index, pageSize: _pageSize);
@@ -169,6 +191,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
item: item, item: item,
focusNode: index == 0 ? firstItemFocusNode : null, focusNode: index == 0 ? firstItemFocusNode : null,
disableScale: disableScale, disableScale: disableScale,
fullBleedImage: fullBleedImage,
onListRefresh: loadItems, onListRefresh: loadItems,
onBack: widget.onBack, onBack: widget.onBack,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
@@ -10,6 +10,7 @@ import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart'; import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart'; import '../../../utils/media_server_http_client.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/settings_builder.dart'; import '../../../widgets/settings_builder.dart';
@@ -108,16 +109,20 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
@override @override
Widget buildContent(List<MediaPlaylist> items) { Widget buildContent(List<MediaPlaylist> items) {
return SettingsBuilder( return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity], prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) { builder: (context) {
final settings = SettingsService.instanceOrNull!; final settings = SettingsService.instanceOrNull!;
final viewMode = settings.read(SettingsService.viewMode); final viewMode = settings.read(SettingsService.viewMode);
final density = settings.read(SettingsService.libraryDensity); final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
return CustomScrollView( return CustomScrollView(
clipBehavior: Clip.none, clipBehavior: Clip.none,
slivers: [ slivers: [
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
if (viewMode == ViewMode.list) _buildListSliver(density) else _buildGridSliver(density), if (viewMode == ViewMode.list)
_buildListSliver(density)
else
_buildGridSliver(density, fullCardLayout: fullCardLayout),
], ],
); );
}, },
@@ -141,25 +146,42 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
); );
} }
Widget _buildGridSliver(int density) { Widget _buildGridSliver(int density, {required bool fullCardLayout}) {
return SliverPadding( return SliverPadding(
padding: _effectivePadding, padding: _effectivePadding,
sliver: SliverLayoutBuilder( sliver: SliverLayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density);
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxCrossAxisExtent); final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
final columnCount = GridSizeCalculator.getColumnCount(
constraints.crossAxisExtent,
maxCrossAxisExtent,
crossAxisSpacing: gridSpacing,
);
return SliverGrid.builder( return SliverGrid.builder(
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density), gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: density,
fullBleedImage: fullCardLayout,
),
itemCount: totalSize, itemCount: totalSize,
itemBuilder: (context, index) => itemBuilder: (context, index) => _buildPlaylistCard(
_buildPlaylistCard(index, isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount)), index,
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
fullBleedImage: fullCardLayout,
),
); );
}, },
), ),
); );
} }
Widget _buildPlaylistCard(int index, {required bool isFirstColumn, bool disableScale = false}) { Widget _buildPlaylistCard(
int index, {
required bool isFirstColumn,
bool disableScale = false,
bool fullBleedImage = false,
}) {
final playlist = loadedItems[index]; final playlist = loadedItems[index];
if (playlist == null) { if (playlist == null) {
ensureIndexLoaded(index, pageSize: _pageSize); ensureIndexLoaded(index, pageSize: _pageSize);
@@ -171,6 +193,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
item: playlist, item: playlist,
focusNode: index == 0 ? firstItemFocusNode : null, focusNode: index == 0 ? firstItemFocusNode : null,
disableScale: disableScale, disableScale: disableScale,
fullBleedImage: fullBleedImage,
onListRefresh: loadItems, onListRefresh: loadItems,
onBack: widget.onBack, onBack: widget.onBack,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
@@ -316,6 +316,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
hubs: tvHubs, hubs: tvHubs,
density: svc.read(SettingsService.libraryDensity), density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode), episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
); );
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble(); final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
+2
View File
@@ -3445,6 +3445,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
episodePosterMode: svc.read(SettingsService.episodePosterMode), episodePosterMode: svc.read(SettingsService.episodePosterMode),
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub, episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
widePosterScaleForHub: _tvDetailWidePosterScaleForHub, widePosterScaleForHub: _tvDetailWidePosterScaleForHub,
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: _tvDetailTallPosterScale, tallPosterScale: _tvDetailTallPosterScale,
); );
} }
@@ -3471,6 +3472,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
availableWidth: availableWidth, availableWidth: availableWidth,
density: svc.read(SettingsService.libraryDensity), density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode), episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
scale: scale, scale: scale,
tallPosterScale: _tvDetailTallPosterScale, tallPosterScale: _tvDetailTallPosterScale,
widePosterScale: 1.0, widePosterScale: 1.0,
@@ -33,6 +33,13 @@ class AppearanceSettingsScreen extends StatelessWidget {
_densitySelector(), _densitySelector(),
_viewModeSelector(), _viewModeSelector(),
_episodePosterModeSelector(), _episodePosterModeSelector(),
if (PlatformDetector.isTV())
SettingSwitchTile(
pref: SettingsService.tvFullCardLayout,
icon: Symbols.image_rounded,
title: t.settings.tvFullCardLayout,
subtitle: t.settings.tvFullCardLayoutDescription,
),
SettingSwitchTile( SettingSwitchTile(
pref: SettingsService.showEpisodeNumberOnCards, pref: SettingsService.showEpisodeNumberOnCards,
icon: Symbols.tag_rounded, icon: Symbols.tag_rounded,
+1
View File
@@ -278,6 +278,7 @@ class SettingsService extends BaseSharedPreferencesService {
static const seekTimeLarge = IntPref('seek_time_large', defaultValue: 30); static const seekTimeLarge = IntPref('seek_time_large', defaultValue: 30);
static const rewindOnResume = IntPref('rewind_on_resume'); static const rewindOnResume = IntPref('rewind_on_resume');
static const showHeroSection = BoolPref('show_hero_section', defaultValue: true); static const showHeroSection = BoolPref('show_hero_section', defaultValue: true);
static const tvFullCardLayout = BoolPref('tv_full_card_layout', defaultValue: false);
static const useGlobalHubs = BoolPref('use_global_hubs', defaultValue: true); static const useGlobalHubs = BoolPref('use_global_hubs', defaultValue: true);
static const showServerNameOnHubs = BoolPref('show_server_name_on_hubs'); static const showServerNameOnHubs = BoolPref('show_server_name_on_hubs');
static const groupLibrariesByServer = BoolPref('group_libraries_by_server', defaultValue: true); static const groupLibrariesByServer = BoolPref('group_libraries_by_server', defaultValue: true);
+14 -3
View File
@@ -53,18 +53,29 @@ class GridSizeCalculator {
/// [crossAxisExtent] should come from layout constraints (e.g. `SliverLayoutBuilder` /// [crossAxisExtent] should come from layout constraints (e.g. `SliverLayoutBuilder`
/// or `LayoutBuilder`), not from `MediaQuery`, to account for sidebars or other /// or `LayoutBuilder`), not from `MediaQuery`, to account for sidebars or other
/// elements that reduce the grid's actual width. /// elements that reduce the grid's actual width.
static int getColumnCount(double crossAxisExtent, double maxCrossAxisExtent) { static int getColumnCount(
final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing; double crossAxisExtent,
double maxCrossAxisExtent, {
double crossAxisSpacing = GridLayoutConstants.crossAxisSpacing,
}) {
return ((crossAxisExtent + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100); return ((crossAxisExtent + crossAxisSpacing) / (maxCrossAxisExtent + crossAxisSpacing)).ceil().clamp(1, 100);
} }
static double getCellWidthForColumnCount(
double crossAxisExtent,
int columnCount, {
double crossAxisSpacing = GridLayoutConstants.crossAxisSpacing,
}) {
return (crossAxisExtent - (crossAxisSpacing * (columnCount - 1))) / columnCount;
}
/// Computes the actual cell width that a grid with [getMaxCrossAxisExtent] would produce /// Computes the actual cell width that a grid with [getMaxCrossAxisExtent] would produce
/// for the given [availableWidth]. This matches SliverGridDelegateWithMaxCrossAxisExtent's /// for the given [availableWidth]. This matches SliverGridDelegateWithMaxCrossAxisExtent's
/// internal calculation, so horizontal scroll lists can use the same width as grids. /// internal calculation, so horizontal scroll lists can use the same width as grids.
static double getCellWidth(double availableWidth, BuildContext context, int density) { static double getCellWidth(double availableWidth, BuildContext context, int density) {
final maxExtent = getMaxCrossAxisExtent(context, density); final maxExtent = getMaxCrossAxisExtent(context, density);
final columns = getColumnCount(availableWidth, maxExtent); final columns = getColumnCount(availableWidth, maxExtent);
return availableWidth / columns; return getCellWidthForColumnCount(availableWidth, columns);
} }
static bool isFirstRow(int index, int columnCount) { static bool isFirstRow(int index, int columnCount) {
+4
View File
@@ -41,6 +41,8 @@ class AppDurations {
class GridLayoutConstants { class GridLayoutConstants {
static const double posterAspectRatio = 2 / 3.3; static const double posterAspectRatio = 2 / 3.3;
static const double fullCardPosterAspectRatio = 2 / 3;
static const double episodeThumbnailAspectRatio = 16 / 9; static const double episodeThumbnailAspectRatio = 16 / 9;
static const double episodeGridCellAspectRatio = 1.4; static const double episodeGridCellAspectRatio = 1.4;
@@ -48,6 +50,8 @@ class GridLayoutConstants {
static const double crossAxisSpacing = 0; static const double crossAxisSpacing = 0;
static const double mainAxisSpacing = 0; static const double mainAxisSpacing = 0;
static double fullCardGridSpacingForScale(double scale) => (12 * scale).clamp(8, 18).toDouble();
/// Standard grid padding /// Standard grid padding
static EdgeInsets get gridPadding => const EdgeInsets.only(left: 2, right: 2, bottom: 2); static EdgeInsets get gridPadding => const EdgeInsets.only(left: 2, right: 2, bottom: 2);
} }
+24 -2
View File
@@ -73,6 +73,10 @@ class FocusBuilders {
VoidCallback? onTap, VoidCallback? onTap,
VoidCallback? onLongPress, VoidCallback? onLongPress,
double borderRadius = FocusTheme.defaultBorderRadius, double borderRadius = FocusTheme.defaultBorderRadius,
double focusScale = FocusTheme.focusScale,
double focusBorderStrokeAlign = BorderSide.strokeAlignInside,
bool useFocusGlow = false,
bool useForegroundFocusDecoration = false,
required Widget child, required Widget child,
}) { }) {
final isKeyboardMode = InputModeTracker.isKeyboardMode(context); final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
@@ -93,15 +97,25 @@ class FocusBuilders {
final duration = FocusTheme.getAnimationDuration(context); final duration = FocusTheme.getAnimationDuration(context);
final showFocus = isFocused && isKeyboardMode; final showFocus = isFocused && isKeyboardMode;
final focusDecoration = FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: borderRadius,
borderStrokeAlign: focusBorderStrokeAlign,
);
final glowDecoration = useFocusGlow
? FocusTheme.focusGlowDecoration(context, isFocused: showFocus, borderRadius: borderRadius)
: null;
final focusedWidget = AnimatedScale( final focusedWidget = AnimatedScale(
scale: showFocus ? FocusTheme.focusScale : 1.0, scale: showFocus ? focusScale : 1.0,
duration: duration, duration: duration,
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
child: AnimatedContainer( child: AnimatedContainer(
duration: duration, duration: duration,
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
decoration: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: borderRadius), decoration: useForegroundFocusDecoration ? glowDecoration : focusDecoration,
foregroundDecoration: useForegroundFocusDecoration ? focusDecoration : null,
child: child, child: child,
), ),
); );
@@ -138,6 +152,10 @@ class FocusBuilders {
VoidCallback? onTap, VoidCallback? onTap,
VoidCallback? onLongPress, VoidCallback? onLongPress,
double borderRadius = FocusTheme.defaultBorderRadius, double borderRadius = FocusTheme.defaultBorderRadius,
double focusScale = FocusTheme.focusScale,
double focusBorderStrokeAlign = BorderSide.strokeAlignInside,
bool useFocusGlow = false,
bool useForegroundFocusDecoration = false,
required Widget child, required Widget child,
}) { }) {
return buildFocusableCard( return buildFocusableCard(
@@ -148,6 +166,10 @@ class FocusBuilders {
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
borderRadius: borderRadius, borderRadius: borderRadius,
focusScale: focusScale,
focusBorderStrokeAlign: focusBorderStrokeAlign,
useFocusGlow: useFocusGlow,
useForegroundFocusDecoration: useForegroundFocusDecoration,
child: child, child: child,
); );
} }
+10
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../focus/focus_theme.dart';
import '../focus/focusable_wrapper.dart'; import '../focus/focusable_wrapper.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import 'media_card.dart'; import 'media_card.dart';
@@ -30,6 +31,9 @@ class FocusableMediaCard extends StatefulWidget {
/// True when in a hub with mixed content (movies + episodes) /// True when in a hub with mixed content (movies + episodes)
final bool mixedHubContext; final bool mixedHubContext;
/// Render grid cards as image-only full-bleed cards.
final bool fullBleedImage;
/// Show server name in list view (multi-server) /// Show server name in list view (multi-server)
final bool showServerName; final bool showServerName;
@@ -79,6 +83,7 @@ class FocusableMediaCard extends StatefulWidget {
this.collectionId, this.collectionId,
this.isOffline = false, this.isOffline = false,
this.mixedHubContext = false, this.mixedHubContext = false,
this.fullBleedImage = false,
this.showServerName = false, this.showServerName = false,
this.disableScale = false, this.disableScale = false,
this.focusNode, this.focusNode,
@@ -111,6 +116,10 @@ class _FocusableMediaCardState extends State<FocusableMediaCard> {
onFocusChange: widget.onFocusChange, onFocusChange: widget.onFocusChange,
enableLongPress: true, enableLongPress: true,
disableScale: widget.disableScale, disableScale: widget.disableScale,
focusScale: widget.fullBleedImage ? FocusTheme.fullCardFocusScale : FocusTheme.focusScale,
focusBorderStrokeAlign: widget.fullBleedImage ? BorderSide.strokeAlignOutside : BorderSide.strokeAlignInside,
useFocusGlow: widget.fullBleedImage,
useForegroundFocusDecoration: widget.fullBleedImage,
useComfortableZone: !PlatformDetector.isTV(), // Always center on TV useComfortableZone: !PlatformDetector.isTV(), // Always center on TV
scrollAlignment: 0.5, scrollAlignment: 0.5,
child: MediaCard( child: MediaCard(
@@ -127,6 +136,7 @@ class _FocusableMediaCardState extends State<FocusableMediaCard> {
collectionId: widget.collectionId, collectionId: widget.collectionId,
isOffline: widget.isOffline, isOffline: widget.isOffline,
mixedHubContext: widget.mixedHubContext, mixedHubContext: widget.mixedHubContext,
fullBleedImage: widget.fullBleedImage,
showServerName: widget.showServerName, showServerName: widget.showServerName,
), ),
); );
+60 -1
View File
@@ -60,6 +60,7 @@ class MediaCard extends StatefulWidget {
final bool mixedHubContext; // True when in a hub with mixed content (movies + episodes) final bool mixedHubContext; // True when in a hub with mixed content (movies + episodes)
final bool showServerName; // Show server name in list view (multi-server) final bool showServerName; // Show server name in list view (multi-server)
final EpisodePosterMode? episodePosterModeOverride; final EpisodePosterMode? episodePosterModeOverride;
final bool fullBleedImage;
const MediaCard({ const MediaCard({
super.key, super.key,
@@ -77,6 +78,7 @@ class MediaCard extends StatefulWidget {
this.mixedHubContext = false, this.mixedHubContext = false,
this.showServerName = false, this.showServerName = false,
this.episodePosterModeOverride, this.episodePosterModeOverride,
this.fullBleedImage = false,
}); });
@override @override
@@ -264,8 +266,65 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
/// Grid layout — inlined from former _MediaCardGrid, _PosterOverlay, and /// Grid layout — inlined from former _MediaCardGrid, _PosterOverlay, and
/// flattened Column. Semantics removed (InkWell provides button semantics). /// flattened Column. Semantics removed (InkWell provides button semantics).
Widget _buildGridCard(BuildContext context, Object item, String? localPosterPath) { Widget _buildGridCard(BuildContext context, Object item, String? localPosterPath) {
if (widget.fullBleedImage) {
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = widget.width ?? (constraints.hasBoundedWidth ? constraints.maxWidth : null);
final cardHeight = widget.height ?? (constraints.hasBoundedHeight ? constraints.maxHeight : null);
if (cardHeight == null) return _buildStandardGridCard(context, item, localPosterPath);
return _buildFullBleedGridCard(context, item, localPosterPath, width: cardWidth, height: cardHeight);
},
);
}
return _buildStandardGridCard(context, item, localPosterPath);
}
Widget _buildFullBleedGridCard(
BuildContext context,
Object item,
String? localPosterPath, {
required double? width,
required double height,
}) {
return SizedBox(
width: width,
height: height,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
canRequestFocus: false,
onTap: () => _handleTap(context, item),
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
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),
],
),
),
),
);
}
Widget _buildStandardGridCard(BuildContext context, Object item, String? localPosterPath) {
// Compute actual poster dimensions from card dimensions // Compute actual poster dimensions from card dimensions
final posterWidth = widget.width != null ? widget.width! - 6 : null; // 3px padding each side final posterWidth = widget.width != null ? widget.width! - 6 : null;
final posterHeight = widget.height; final posterHeight = widget.height;
return SizedBox( return SizedBox(
+22 -6
View File
@@ -3,13 +3,14 @@ import '../utils/grid_size_calculator.dart';
import '../utils/layout_constants.dart'; import '../utils/layout_constants.dart';
/// Shared grid delegate configuration for media item grids /// Shared grid delegate configuration for media item grids
/// Maintains consistent spacing (2/3.3 aspect ratio, 0 spacing) across all media grids /// Maintains consistent aspect ratio and spacing across all media grids.
class MediaGridDelegate { class MediaGridDelegate {
/// Creates a standard grid delegate for media items /// Creates a standard grid delegate for media items
/// ///
/// Uses [GridSizeCalculator.getMaxCrossAxisExtent] by default. /// Uses [GridSizeCalculator.getMaxCrossAxisExtent] by default.
/// Set [usePaddingAware] to true to use [GridSizeCalculator.getMaxCrossAxisExtentWithPadding] instead. /// Set [usePaddingAware] to true to use [GridSizeCalculator.getMaxCrossAxisExtentWithPadding] instead.
/// Set [useWideAspectRatio] to true to use 16:9 aspect ratio for episode thumbnails. /// Set [useWideAspectRatio] to true to use 16:9 aspect ratio for episode thumbnails.
/// Set [fullBleedImage] to true when the card is image-only and should not reserve text height.
/// Pass [maxCrossAxisExtentOverride] to bypass the calculator and the wide-aspect multiplier — /// Pass [maxCrossAxisExtentOverride] to bypass the calculator and the wide-aspect multiplier —
/// the caller is then responsible for providing a fully-resolved per-cell width. /// the caller is then responsible for providing a fully-resolved per-cell width.
static SliverGridDelegateWithMaxCrossAxisExtent createDelegate({ static SliverGridDelegateWithMaxCrossAxisExtent createDelegate({
@@ -18,11 +19,11 @@ class MediaGridDelegate {
bool usePaddingAware = false, bool usePaddingAware = false,
double horizontalPadding = 16, double horizontalPadding = 16,
bool useWideAspectRatio = false, bool useWideAspectRatio = false,
bool fullBleedImage = false,
double? maxCrossAxisExtentOverride, double? maxCrossAxisExtentOverride,
}) { }) {
final aspectRatio = useWideAspectRatio final aspectRatio = aspectRatioFor(useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage);
? GridLayoutConstants.episodeGridCellAspectRatio final spacing = spacingFor(context: context, fullBleedImage: fullBleedImage);
: GridLayoutConstants.posterAspectRatio;
double maxCrossAxisExtent; double maxCrossAxisExtent;
if (maxCrossAxisExtentOverride != null) { if (maxCrossAxisExtentOverride != null) {
@@ -42,8 +43,23 @@ class MediaGridDelegate {
return SliverGridDelegateWithMaxCrossAxisExtent( return SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent, maxCrossAxisExtent: maxCrossAxisExtent,
childAspectRatio: aspectRatio, childAspectRatio: aspectRatio,
crossAxisSpacing: GridLayoutConstants.crossAxisSpacing, crossAxisSpacing: spacing,
mainAxisSpacing: GridLayoutConstants.mainAxisSpacing, mainAxisSpacing: spacing,
); );
} }
static double spacingFor({required BuildContext context, bool fullBleedImage = false}) {
if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing;
return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context));
}
static double aspectRatioFor({bool useWideAspectRatio = false, bool fullBleedImage = false}) {
if (fullBleedImage) {
return useWideAspectRatio
? GridLayoutConstants.episodeThumbnailAspectRatio
: GridLayoutConstants.fullCardPosterAspectRatio;
}
return useWideAspectRatio ? GridLayoutConstants.episodeGridCellAspectRatio : GridLayoutConstants.posterAspectRatio;
}
} }
+262 -77
View File
@@ -20,6 +20,7 @@ import '../utils/media_navigation_helper.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../utils/layout_constants.dart'; import '../utils/layout_constants.dart';
import 'app_icon.dart'; import 'app_icon.dart';
import 'clickable_cursor.dart';
import 'focus_builders.dart'; import 'focus_builders.dart';
import 'horizontal_scroll_with_arrows.dart'; import 'horizontal_scroll_with_arrows.dart';
import 'media_card.dart'; import 'media_card.dart';
@@ -57,6 +58,7 @@ class TvBrowseRailLayoutMetrics {
class TvBrowseRailLayout { class TvBrowseRailLayout {
static const double compactTallPosterScale = 0.80; static const double compactTallPosterScale = 0.80;
static const double compactEpisodeThumbnailScale = compactTallPosterScale; static const double compactEpisodeThumbnailScale = compactTallPosterScale;
static const double fullCardFocusScale = FocusTheme.fullCardFocusScale;
static double scaleForSize(Size size) => TvLayoutConstants.scaleForSize(size); static double scaleForSize(Size size) => TvLayoutConstants.scaleForSize(size);
@@ -70,6 +72,21 @@ class TvBrowseRailLayout {
static double itemGapForScale(double _) => 0; static double itemGapForScale(double _) => 0;
static double fullCardItemGapForScale(double scale) => (12 * scale).clamp(8, 18).toDouble();
static double viewAllItemWidthForScale(double scale) => (104 * scale).clamp(88, 132).toDouble();
static double viewAllPillHeightForScale(double scale) => (44 * scale).clamp(36, 54).toDouble();
static double fullCardFocusPaintOverflowForScale(double scale) {
return (FocusTheme.focusGlowOuterBlurRadius +
FocusTheme.focusGlowSpreadRadius +
FocusTheme.focusBorderWidth +
(10 * scale))
.clamp(42, 64)
.toDouble();
}
static double hubStripHeightForScale(double scale) => 36 * scale; static double hubStripHeightForScale(double scale) => 36 * scale;
static double hubStripGapForScale(double _) => 0; static double hubStripGapForScale(double _) => 0;
@@ -111,12 +128,13 @@ class TvBrowseRailLayout {
required int density, required int density,
required EpisodePosterMode episodePosterMode, required EpisodePosterMode episodePosterMode,
required double scale, required double scale,
bool fullCardLayout = false,
double tallPosterScale = 1.0, double tallPosterScale = 1.0,
double widePosterScale = 1.0, double widePosterScale = 1.0,
}) { }) {
final focusExtra = FocusTheme.focusBorderWidth * 2 * scale; final focusExtra = FocusTheme.focusBorderWidth * 2 * scale;
final railEdgePadding = focusExtra + (12 * scale); final railEdgePadding = focusExtra + (12 * scale);
final itemGap = itemGapForScale(scale); final itemGap = fullCardLayout ? fullCardItemGapForScale(scale) : itemGapForScale(scale);
final isPersonHub = TvBrowseRailLayout.isPersonHub(hub); final isPersonHub = TvBrowseRailLayout.isPersonHub(hub);
final hasWide = !isPersonHub && hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode)); final hasWide = !isPersonHub && hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasTall = !isPersonHub && hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode)); final hasTall = !isPersonHub && hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode));
@@ -131,9 +149,10 @@ class TvBrowseRailLayout {
itemGap: itemGap, itemGap: itemGap,
); );
final cardWidth = baseCardWidth * (useWideLayout ? widePosterScale : tallPosterScale); final cardWidth = baseCardWidth * (useWideLayout ? widePosterScale : tallPosterScale);
final posterWidth = cardWidth - (6 * scale); final posterWidth = fullCardLayout ? cardWidth : cardWidth - (6 * scale);
final posterHeight = isPersonHub ? posterWidth : (useWideLayout ? posterWidth * 9 / 16 : posterWidth * 1.5); final posterHeight = isPersonHub ? posterWidth : (useWideLayout ? posterWidth * 9 / 16 : posterWidth * 1.5);
final containerHeight = (posterHeight + ((isPersonHub ? 58 : 42) * scale)).ceilToDouble(); final labelHeight = fullCardLayout ? 0.0 : ((isPersonHub ? 58 : 42) * scale);
final containerHeight = (posterHeight + labelHeight).ceilToDouble();
final height = containerHeight + focusExtra + (14 * scale); final height = containerHeight + focusExtra + (14 * scale);
return TvBrowseRailLayoutMetrics( return TvBrowseRailLayoutMetrics(
@@ -159,6 +178,7 @@ class TvBrowseRailLayout {
EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub, EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub,
double Function(MediaHub hub)? widePosterScaleForHub, double Function(MediaHub hub)? widePosterScaleForHub,
required double scale, required double scale,
bool fullCardLayout = false,
double tallPosterScale = 1.0, double tallPosterScale = 1.0,
double widePosterScale = 1.0, double widePosterScale = 1.0,
}) { }) {
@@ -170,6 +190,7 @@ class TvBrowseRailLayout {
density: density, density: density,
episodePosterMode: episodePosterModeForHub?.call(hub) ?? episodePosterMode, episodePosterMode: episodePosterModeForHub?.call(hub) ?? episodePosterMode,
scale: scale, scale: scale,
fullCardLayout: fullCardLayout,
tallPosterScale: tallPosterScale, tallPosterScale: tallPosterScale,
widePosterScale: widePosterScaleForHub?.call(hub) ?? widePosterScale, widePosterScale: widePosterScaleForHub?.call(hub) ?? widePosterScale,
); );
@@ -185,7 +206,7 @@ class TvBrowseRailLayout {
required double scale, required double scale,
}) { }) {
final itemContentWidth = hub.items.length * (metrics.cardWidth + metrics.itemGap); final itemContentWidth = hub.items.length * (metrics.cardWidth + metrics.itemGap);
final moreContentWidth = hub.more ? (132 * scale) + metrics.itemGap : 0.0; final moreContentWidth = hub.more ? viewAllItemWidthForScale(scale) + metrics.itemGap : 0.0;
final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth; final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth;
return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble(); return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble();
} }
@@ -196,7 +217,7 @@ class TvBrowseRailLayout {
required TvBrowseRailLayoutMetrics metrics, required TvBrowseRailLayoutMetrics metrics,
required double scale, required double scale,
}) { }) {
if (index == hub.items.length && hub.more) return (132 * scale) + metrics.itemGap; if (index == hub.items.length && hub.more) return viewAllItemWidthForScale(scale) + metrics.itemGap;
return metrics.cardWidth + metrics.itemGap; return metrics.cardWidth + metrics.itemGap;
} }
@@ -227,6 +248,7 @@ class TvBrowseRailLayout {
required EpisodePosterMode episodePosterMode, required EpisodePosterMode episodePosterMode,
EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub, EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub,
double Function(MediaHub hub)? widePosterScaleForHub, double Function(MediaHub hub)? widePosterScaleForHub,
bool fullCardLayout = false,
double tallPosterScale = 1.0, double tallPosterScale = 1.0,
double widePosterScale = 1.0, double widePosterScale = 1.0,
}) { }) {
@@ -244,6 +266,7 @@ class TvBrowseRailLayout {
episodePosterModeForHub: episodePosterModeForHub, episodePosterModeForHub: episodePosterModeForHub,
widePosterScaleForHub: widePosterScaleForHub, widePosterScaleForHub: widePosterScaleForHub,
scale: scale, scale: scale,
fullCardLayout: fullCardLayout,
tallPosterScale: tallPosterScale, tallPosterScale: tallPosterScale,
widePosterScale: widePosterScale, widePosterScale: widePosterScale,
); );
@@ -784,7 +807,11 @@ class TvBrowseRailState extends State<TvBrowseRail> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_activeHub == null) return const SizedBox.shrink(); if (_activeHub == null) return const SizedBox.shrink();
return SettingsBuilder( return SettingsBuilder(
prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode], prefs: const [
SettingsService.libraryDensity,
SettingsService.episodePosterMode,
SettingsService.tvFullCardLayout,
],
builder: (context) => LayoutBuilder( builder: (context) => LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final svc = SettingsService.instanceOrNull!; final svc = SettingsService.instanceOrNull!;
@@ -800,6 +827,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final railViewportWidth = (availableWidth + interactionExpansion).clamp(1.0, double.infinity).toDouble(); final railViewportWidth = (availableWidth + interactionExpansion).clamp(1.0, double.infinity).toDouble();
final density = svc.read(SettingsService.libraryDensity); final density = svc.read(SettingsService.libraryDensity);
final episodePosterMode = svc.read(SettingsService.episodePosterMode); final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final fullCardLayout = svc.read(SettingsService.tvFullCardLayout);
final modes = [for (final hub in widget.hubs) widget.episodePosterModeForHub?.call(hub) ?? episodePosterMode]; final modes = [for (final hub in widget.hubs) widget.episodePosterModeForHub?.call(hub) ?? episodePosterMode];
final wideScales = [ final wideScales = [
for (final hub in widget.hubs) widget.widePosterScaleForHub?.call(hub) ?? widget.widePosterScale, for (final hub in widget.hubs) widget.widePosterScaleForHub?.call(hub) ?? widget.widePosterScale,
@@ -812,6 +840,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
density: density, density: density,
episodePosterMode: modes[i], episodePosterMode: modes[i],
scale: scale, scale: scale,
fullCardLayout: fullCardLayout,
tallPosterScale: widget.tallPosterScale, tallPosterScale: widget.tallPosterScale,
widePosterScale: wideScales[i], widePosterScale: wideScales[i],
), ),
@@ -845,7 +874,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
TvBrowseRailLayout.railTopPaddingForScale(scale) + TvBrowseRailLayout.railTopPaddingForScale(scale) +
viewportHeight + viewportHeight +
TvBrowseRailLayout.railBottomPaddingForScale(scale); TvBrowseRailLayout.railBottomPaddingForScale(scale);
final paintOverflow = fullCardLayout && hasFocus
? TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale)
: 0.0;
return Focus( return Focus(
focusNode: _focusNode, focusNode: _focusNode,
onKeyEvent: _handleKeyEvent, onKeyEvent: _handleKeyEvent,
@@ -874,7 +905,12 @@ class TvBrowseRailState extends State<TvBrowseRail> {
duration: FocusTheme.getAnimationDuration(context), duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
child: ClipRect( child: ClipRect(
clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0, verticalOverflow: 0), clipper: _RailClipper(
leftOverflow: horizontalInset,
rightOverflow: paintOverflow,
topOverflow: 0,
bottomOverflow: paintOverflow,
),
child: SizedBox( child: SizedBox(
height: viewportHeight, height: viewportHeight,
child: _buildHubSectionList( child: _buildHubSectionList(
@@ -883,6 +919,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
metricsByHub: metricsByHub, metricsByHub: metricsByHub,
sectionHeights: sectionHeights, sectionHeights: sectionHeights,
scale: scale, scale: scale,
fullCardLayout: fullCardLayout,
leftOverflow: horizontalInset, leftOverflow: horizontalInset,
interactionExpansion: interactionExpansion, interactionExpansion: interactionExpansion,
railViewportWidth: railViewportWidth, railViewportWidth: railViewportWidth,
@@ -908,6 +945,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
required List<TvBrowseRailLayoutMetrics> metricsByHub, required List<TvBrowseRailLayoutMetrics> metricsByHub,
required List<double> sectionHeights, required List<double> sectionHeights,
required double scale, required double scale,
required bool fullCardLayout,
required double leftOverflow, required double leftOverflow,
required double interactionExpansion, required double interactionExpansion,
required double railViewportWidth, required double railViewportWidth,
@@ -946,6 +984,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
episodePosterMode: modes[hubIndex], episodePosterMode: modes[hubIndex],
metrics: metrics, metrics: metrics,
scale: scale, scale: scale,
fullCardLayout: fullCardLayout,
leftOverflow: leftOverflow, leftOverflow: leftOverflow,
interactionExpansion: interactionExpansion, interactionExpansion: interactionExpansion,
railViewportWidth: railViewportWidth, railViewportWidth: railViewportWidth,
@@ -1010,6 +1049,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
required EpisodePosterMode episodePosterMode, required EpisodePosterMode episodePosterMode,
required TvBrowseRailLayoutMetrics metrics, required TvBrowseRailLayoutMetrics metrics,
required double scale, required double scale,
required bool fullCardLayout,
required double leftOverflow, required double leftOverflow,
required double interactionExpansion, required double interactionExpansion,
required double railViewportWidth, required double railViewportWidth,
@@ -1019,6 +1059,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final inactiveIndex = HubFocusMemory.getForHubOnly(hub.id, totalCount); final inactiveIndex = HubFocusMemory.getForHubOnly(hub.id, totalCount);
final focusedIndex = isActiveHub ? _itemIndex : inactiveIndex; final focusedIndex = isActiveHub ? _itemIndex : inactiveIndex;
final scrollController = _scrollControllerForHub(hub, metrics, railViewportWidth, scale, focusedIndex); final scrollController = _scrollControllerForHub(hub, metrics, railViewportWidth, scale, focusedIndex);
final paintOverflow = fullCardLayout && hasFocus && isActiveHub
? TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale)
: 0.0;
_metricsByHub[hub.id] = metrics; _metricsByHub[hub.id] = metrics;
_scaleByHub[hub.id] = scale; _scaleByHub[hub.id] = scale;
@@ -1030,8 +1073,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
child: ClipRect( child: ClipRect(
clipper: _RailClipper( clipper: _RailClipper(
leftOverflow: leftOverflow, leftOverflow: leftOverflow,
rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap, rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap + paintOverflow,
verticalOverflow: metrics.focusExtra, verticalOverflow: fullCardLayout ? math.max(metrics.focusExtra, paintOverflow) : metrics.focusExtra,
), ),
child: HorizontalScrollWithArrows( child: HorizontalScrollWithArrows(
controller: scrollController, controller: scrollController,
@@ -1048,79 +1091,69 @@ class TvBrowseRailState extends State<TvBrowseRail> {
if (itemIndex == hub.items.length) { if (itemIndex == hub.items.length) {
return Padding( return Padding(
padding: EdgeInsets.only(right: metrics.itemGap), padding: EdgeInsets.only(right: metrics.itemGap),
child: FocusBuilders.buildLockedFocusWrapper( child: Align(
context: context, alignment: Alignment.centerLeft,
isFocused: isFocused, child: _buildViewAllButton(
onTap: () { context,
_selectHubItem(hub, hubIndex, itemIndex); isFocused: isFocused,
_navigateToHubDetail(hub); scale: scale,
}, onTap: () {
child: SizedBox( _selectHubItem(hub, hubIndex, itemIndex);
width: 132 * scale, _navigateToHubDetail(hub);
height: metrics.containerHeight - metrics.itemGap, },
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(
Symbols.arrow_forward_rounded,
fill: 1,
size: 42 * scale,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72),
),
SizedBox(height: 6 * scale),
Text(
t.common.viewAll,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72),
fontWeight: FontWeight.w700,
),
),
],
),
), ),
), ),
); );
} }
final item = hub.items[itemIndex]; final item = hub.items[itemIndex];
final focusableCard = FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
borderRadius: tokens(context).radiusSm,
focusScale: fullCardLayout ? TvBrowseRailLayout.fullCardFocusScale : FocusTheme.focusScale,
focusBorderStrokeAlign: fullCardLayout ? BorderSide.strokeAlignOutside : BorderSide.strokeAlignInside,
useFocusGlow: fullCardLayout,
useForegroundFocusDecoration: fullCardLayout,
onTap: () {
_selectHubItem(hub, hubIndex, itemIndex);
unawaited(_activateCurrentItem());
},
onLongPress: metrics.isPersonHub
? null
: () {
_selectHubItem(hub, hubIndex, itemIndex);
_cardKeyFor(hub, itemIndex).currentState?.showContextMenu();
},
child: metrics.isPersonHub
? _buildPersonCard(
context,
item,
cardWidth: metrics.cardWidth,
imageSize: metrics.posterHeight,
scale: scale,
fullCardLayout: fullCardLayout,
)
: MediaCard(
key: _cardKeyFor(hub, itemIndex),
item: item,
width: metrics.cardWidth,
height: metrics.posterHeight,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
forceGridMode: true,
fullBleedImage: fullCardLayout,
isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false,
mixedHubContext: metrics.isMixedHub,
episodePosterModeOverride: episodePosterMode,
),
);
return Padding( return Padding(
padding: EdgeInsets.only(right: metrics.itemGap), padding: EdgeInsets.only(right: metrics.itemGap),
child: MouseRegion( child: MouseRegion(
onEnter: (_) => _setHoveredItem(hub, itemIndex), onEnter: (_) => _setHoveredItem(hub, itemIndex),
child: FocusBuilders.buildLockedFocusWrapper( child: Align(alignment: Alignment.topLeft, child: focusableCard),
context: context,
isFocused: isFocused,
onTap: () {
_selectHubItem(hub, hubIndex, itemIndex);
unawaited(_activateCurrentItem());
},
onLongPress: metrics.isPersonHub
? null
: () {
_selectHubItem(hub, hubIndex, itemIndex);
_cardKeyFor(hub, itemIndex).currentState?.showContextMenu();
},
child: metrics.isPersonHub
? _buildPersonCard(
context,
item,
cardWidth: metrics.cardWidth,
imageSize: metrics.posterHeight,
scale: scale,
)
: MediaCard(
key: _cardKeyFor(hub, itemIndex),
item: item,
width: metrics.cardWidth,
height: metrics.posterHeight,
onRefresh: widget.onRefresh,
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
forceGridMode: true,
isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false,
mixedHubContext: metrics.isMixedHub,
episodePosterModeOverride: episodePosterMode,
),
),
), ),
); );
}, },
@@ -1137,10 +1170,81 @@ class TvBrowseRailState extends State<TvBrowseRail> {
required double cardWidth, required double cardWidth,
required double imageSize, required double imageSize,
required double scale, required double scale,
required bool fullCardLayout,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
final characterName = item.parentTitle; final characterName = item.parentTitle;
if (fullCardLayout) {
return SizedBox(
width: cardWidth,
height: imageSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Stack(
fit: StackFit.expand,
children: [
OptimizedMediaImage(
client: context.tryGetMediaClientWithFallback(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: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 13 * scale,
height: 1.1,
fontWeight: FontWeight.w800,
),
),
if (characterName != null && characterName.isNotEmpty) ...[
SizedBox(height: 2 * scale),
Text(
characterName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.82),
fontSize: 11 * scale,
height: 1.1,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
],
),
),
);
}
return SizedBox( return SizedBox(
width: cardWidth, width: cardWidth,
child: Padding( child: Padding(
@@ -1191,6 +1295,78 @@ class TvBrowseRailState extends State<TvBrowseRail> {
), ),
); );
} }
Widget _buildViewAllButton(
BuildContext context, {
required bool isFocused,
required double scale,
required VoidCallback onTap,
}) {
final theme = Theme.of(context);
final duration = FocusTheme.getAnimationDuration(context);
final width = TvBrowseRailLayout.viewAllItemWidthForScale(scale);
final height = TvBrowseRailLayout.viewAllPillHeightForScale(scale);
final foreground = isFocused ? theme.colorScheme.primary : theme.colorScheme.onSurface.withValues(alpha: 0.78);
final background = isFocused
? theme.colorScheme.primary.withValues(alpha: 0.20)
: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.42);
return ClickableCursor(
child: GestureDetector(
onTap: onTap,
child: AnimatedScale(
scale: isFocused ? 1.04 : 1.0,
duration: duration,
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
width: width,
height: height,
padding: EdgeInsets.symmetric(horizontal: (12 * scale).clamp(10, 16).toDouble()),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(height / 2),
boxShadow: isFocused
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.20),
blurRadius: 18,
spreadRadius: 1,
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
t.common.viewAll,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: (13 * scale).clamp(12, 16).toDouble(),
fontWeight: FontWeight.w800,
letterSpacing: 0.1,
),
),
),
SizedBox(width: (5 * scale).clamp(4, 7).toDouble()),
AppIcon(
Symbols.arrow_forward_rounded,
fill: 1,
size: (18 * scale).clamp(16, 22).toDouble(),
color: foreground,
),
],
),
),
),
),
);
}
} }
class _RailBackgroundBleed extends StatelessWidget { class _RailBackgroundBleed extends StatelessWidget {
@@ -1226,18 +1402,27 @@ class _RailBackgroundBleed extends StatelessWidget {
class _RailClipper extends CustomClipper<Rect> { class _RailClipper extends CustomClipper<Rect> {
final double leftOverflow; final double leftOverflow;
final double rightOverflow; final double rightOverflow;
final double verticalOverflow; final double topOverflow;
final double bottomOverflow;
const _RailClipper({this.leftOverflow = 0, required this.rightOverflow, required this.verticalOverflow}); const _RailClipper({
this.leftOverflow = 0,
required this.rightOverflow,
double verticalOverflow = 0,
double? topOverflow,
double? bottomOverflow,
}) : topOverflow = topOverflow ?? verticalOverflow,
bottomOverflow = bottomOverflow ?? verticalOverflow;
@override @override
Rect getClip(Size size) => Rect getClip(Size size) =>
Rect.fromLTRB(-leftOverflow, -verticalOverflow, size.width + rightOverflow, size.height + verticalOverflow); Rect.fromLTRB(-leftOverflow, -topOverflow, size.width + rightOverflow, size.height + bottomOverflow);
@override @override
bool shouldReclip(covariant _RailClipper oldClipper) { bool shouldReclip(covariant _RailClipper oldClipper) {
return oldClipper.leftOverflow != leftOverflow || return oldClipper.leftOverflow != leftOverflow ||
oldClipper.rightOverflow != rightOverflow || oldClipper.rightOverflow != rightOverflow ||
oldClipper.verticalOverflow != verticalOverflow; oldClipper.topOverflow != topOverflow ||
oldClipper.bottomOverflow != bottomOverflow;
} }
} }
+1
View File
@@ -165,6 +165,7 @@ void main() {
hubs: [hub], hubs: [hub],
density: LibraryDensity.max, density: LibraryDensity.max,
episodePosterMode: settings.read(SettingsService.episodePosterMode), episodePosterMode: settings.read(SettingsService.episodePosterMode),
fullCardLayout: settings.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
); );
final minimumSpotlightBottom = railHeight + (8 * scale); final minimumSpotlightBottom = railHeight + (8 * scale);
+8
View File
@@ -74,6 +74,14 @@ void main() {
}); });
}); });
group('SettingsService TV card defaults', () {
test('full card layout starts disabled', () async {
final settings = await SettingsService.getInstance();
expect(settings.read(SettingsService.tvFullCardLayout), isFalse);
});
});
group('SettingsService companion remote prefs', () { group('SettingsService companion remote prefs', () {
test('last manual host address trims whitespace and drops blanks', () async { test('last manual host address trims whitespace and drops blanks', () async {
final settings = await SettingsService.getInstance(); final settings = await SettingsService.getInstance();
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/layout_constants.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/media_grid_delegate.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
test('full bleed grid delegates use image aspect ratios', () {
expect(MediaGridDelegate.aspectRatioFor(fullBleedImage: true), GridLayoutConstants.fullCardPosterAspectRatio);
expect(
MediaGridDelegate.aspectRatioFor(useWideAspectRatio: true, fullBleedImage: true),
GridLayoutConstants.episodeThumbnailAspectRatio,
);
expect(MediaGridDelegate.aspectRatioFor(useWideAspectRatio: true), GridLayoutConstants.episodeGridCellAspectRatio);
});
testWidgets('full bleed grid delegates use scaled gutters', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
late SliverGridDelegateWithMaxCrossAxisExtent delegate;
await tester.pumpWidget(
_TestApp(
child: Builder(
builder: (context) {
delegate = MediaGridDelegate.createDelegate(
context: context,
density: LibraryDensity.defaultValue,
fullBleedImage: true,
);
return const SizedBox.shrink();
},
),
),
);
expect(delegate.crossAxisSpacing, greaterThan(0));
expect(delegate.mainAxisSpacing, delegate.crossAxisSpacing);
expect(delegate.crossAxisSpacing, GridLayoutConstants.fullCardGridSpacingForScale(0.85));
});
testWidgets('full bleed grid media cards hide text when constrained by a grid cell', (tester) async {
final item = MediaItem(
id: 'movie_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Hidden Movie',
year: 2024,
);
await tester.pumpWidget(
_TestApp(
child: SizedBox(
width: 200,
height: 300,
child: MediaCard(item: item, forceGridMode: true, fullBleedImage: true, isOffline: true),
),
),
);
expect(find.text('Hidden Movie'), findsNothing);
expect(find.text('2024'), findsNothing);
expect(tester.getSize(find.byType(InkWell)), const Size(200, 300));
});
testWidgets('standard grid media cards still show text', (tester) async {
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Visible Movie');
await tester.pumpWidget(
_TestApp(
child: SizedBox(width: 200, height: 330, child: MediaCard(item: item, forceGridMode: true, isOffline: true)),
),
);
expect(find.text('Visible Movie'), findsOneWidget);
});
testWidgets('full bleed flag does not hide list media card text', (tester) async {
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'List Movie');
await tester.pumpWidget(
_TestApp(
child: SizedBox(
width: 420,
height: 160,
child: MediaCard(item: item, forceListMode: true, fullBleedImage: true, isOffline: true),
),
),
);
expect(find.text('List Movie'), findsOneWidget);
});
testWidgets('full bleed focusable media card uses outside ring and local glow', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final focusNode = FocusNode(debugLabel: 'full_bleed_card');
addTearDown(focusNode.dispose);
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Focused Movie');
await tester.pumpWidget(
InputModeTracker(
child: _TestApp(
child: SizedBox(
width: 200,
height: 300,
child: FocusableMediaCard(
item: item,
forceGridMode: true,
fullBleedImage: true,
focusNode: focusNode,
isOffline: true,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
final focusDecoration = find.byWidgetPredicate(
(widget) =>
widget is AnimatedContainer &&
widget.decoration is BoxDecoration &&
widget.foregroundDecoration is BoxDecoration,
);
expect(focusDecoration, findsOneWidget);
final focusedContainer = tester.widget<AnimatedContainer>(focusDecoration);
final glowDecoration = focusedContainer.decoration as BoxDecoration;
final foregroundDecoration = focusedContainer.foregroundDecoration as BoxDecoration;
final border = foregroundDecoration.border as Border;
expect(glowDecoration.boxShadow, hasLength(2));
expect(glowDecoration.boxShadow!.first.color, isNot(Colors.transparent));
expect(border.top.strokeAlign, BorderSide.strokeAlignOutside);
});
}
class _TestApp extends StatelessWidget {
final Widget child;
const _TestApp({required this.child});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(body: Center(child: child)),
);
}
}
+361
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart'; import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/focus/locked_hub_controller.dart'; import 'package:plezy/focus/locked_hub_controller.dart';
import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart'; import 'package:plezy/media/media_hub.dart';
@@ -12,6 +13,7 @@ import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:plezy/widgets/side_navigation_rail.dart';
import 'package:plezy/widgets/tv_browse_rail.dart'; import 'package:plezy/widgets/tv_browse_rail.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -176,6 +178,40 @@ void main() {
expect(compactHeight, lessThan(defaultHeight)); expect(compactHeight, lessThan(defaultHeight));
}); });
test('full card layout removes label reserve and preserves episode poster mode', () {
final episode = MediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode-thumb',
grandparentThumbPath: '/show-poster',
);
final hub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: [episode], size: 1);
final detailed = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85,
);
final full = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85,
fullCardLayout: true,
);
expect(full.height, lessThan(detailed.height));
expect(full.useWideLayout, isTrue);
expect(detailed.itemGap, 0);
expect(full.itemGap, closeTo(12 * 0.85, 0.001));
expect(full.posterHeight, closeTo(full.posterWidth * 9 / 16, 0.001));
});
test('compact wide poster scale makes clips match compact episode thumbnails', () { test('compact wide poster scale makes clips match compact episode thumbnails', () {
final episode = MediaItem( final episode = MediaItem(
id: 'episode_1', id: 'episode_1',
@@ -275,6 +311,326 @@ void main() {
expect(headerText.style?.color, theme.colorScheme.onSurface); expect(headerText.style?.color, theme.colorScheme.onSurface);
}); });
testWidgets('full card layout hides media text and overlays actor text when enabled', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true);
final serverManager = MultiServerManager();
final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Hidden Movie');
final actor = MediaItem(
id: 'actor_1',
backend: MediaBackend.plex,
kind: MediaKind.unknown,
title: 'Actor Name',
parentTitle: 'Character Name',
);
final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
final actorHub = MediaHub(id: 'actors', title: 'Cast', type: 'person', items: [actor], size: 1);
Widget rail(MediaHub hub) {
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded),
),
),
),
);
}
await tester.pumpWidget(rail(movieHub));
await tester.pump();
expect(find.text('Hidden Movie'), findsNothing);
await tester.pumpWidget(rail(actorHub));
await tester.pump();
expect(find.text('Actor Name'), findsOneWidget);
expect(find.text('Character Name'), findsOneWidget);
});
testWidgets('full card focus adds outside ring, local glow, and card image scale', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true);
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], autofocus: true, iconForHub: (_, _) => Icons.movie_rounded),
),
),
),
),
),
);
await tester.pump();
final focusDecoration = find.byWidgetPredicate(
(widget) =>
widget is AnimatedContainer &&
widget.decoration is BoxDecoration &&
widget.foregroundDecoration is BoxDecoration,
);
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final metrics = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale),
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.seriesPoster,
scale: scale,
fullCardLayout: true,
);
expect(focusDecoration, findsOneWidget);
final focusDecorationWidget = tester.widget<AnimatedContainer>(focusDecoration);
final glowDecoration = focusDecorationWidget.decoration as BoxDecoration;
final foregroundDecoration = focusDecorationWidget.foregroundDecoration as BoxDecoration;
final border = foregroundDecoration.border as Border;
final focusDecorationSize = tester.getSize(focusDecoration);
final focusScale = tester.widget<AnimatedScale>(
find.ancestor(of: focusDecoration, matching: find.byType(AnimatedScale)).first,
);
expect(border.top.strokeAlign, BorderSide.strokeAlignOutside);
expect(find.byType(ShaderMask), findsNothing);
expect(find.byType(CompositedTransformFollower), findsNothing);
expect(find.byType(CompositedTransformTarget), findsNothing);
expect(glowDecoration.boxShadow, hasLength(2));
expect(glowDecoration.boxShadow!.first.color, isNot(Colors.transparent));
expect(focusScale.scale, closeTo(1.03, 0.0001));
expect(focusDecorationSize.width, closeTo(metrics.cardWidth, 0.001));
expect(focusDecorationSize.height, closeTo(metrics.posterHeight, 0.001));
});
testWidgets('vertical hub viewport keeps top clipping while switching hubs', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true);
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final firstMovie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
final secondMovie = MediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 2');
final firstHub = MediaHub(id: 'movies_1', title: 'Movies 1', type: 'movie', items: [firstMovie], size: 1);
final secondHub = MediaHub(id: 'movies_2', title: 'Movies 2', type: 'movie', items: [secondMovie], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
hubs: [firstHub, secondHub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final expectedPaintOverflow = TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale);
final expectedLeftOverflow = TvBrowseRailLayout.horizontalInsetForScale(scale);
final verticalViewportClip = tester
.widgetList<ClipRect>(
find.ancestor(of: find.byKey(const ValueKey('tv_browse_rail_vertical')), matching: find.byType(ClipRect)),
)
.singleWhere((widget) => widget.clipper != null);
final clipRectSize = tester.getSize(find.byWidget(verticalViewportClip));
final clip = verticalViewportClip.clipper!.getClip(clipRectSize);
expect(clip.left, closeTo(-expectedLeftOverflow, 0.001));
expect(clip.left, greaterThan(-expectedPaintOverflow));
expect(clip.top, 0);
expect(clip.bottom, greaterThanOrEqualTo(clipRectSize.height + expectedPaintOverflow));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(find.byType(CompositedTransformFollower), findsNothing);
});
testWidgets('detailed card layout can still show media text', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
final serverManager = MultiServerManager();
final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Visible Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded),
),
),
),
),
);
await tester.pump();
expect(find.text('Visible Movie'), findsOneWidget);
});
testWidgets('detailed card focus ring wraps card content height', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = MediaItem(
id: 'movie_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Visible Movie',
year: 2024,
);
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], autofocus: true, iconForHub: (_, _) => Icons.movie_rounded),
),
),
),
),
),
);
await tester.pump();
final focusDecoration = find.ancestor(
of: find.text('Visible Movie'),
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;
}),
);
expect(focusDecoration, findsOneWidget);
expect(find.text('2024'), findsOneWidget);
final focusRect = tester.getRect(focusDecoration);
final subtitleRect = tester.getRect(find.text('2024'));
expect(focusRect.bottom - subtitleRect.bottom, lessThan(8));
});
testWidgets('view all item uses compact pill focus style', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 2, more: true);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], autofocus: true, iconForHub: (_, _) => Icons.movie_rounded),
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
final viewAllText = find.text('View All');
final pill = find.ancestor(of: viewAllText, matching: find.byType(AnimatedContainer));
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
expect(viewAllText, findsOneWidget);
expect(pill, findsOneWidget);
final pillWidget = tester.widget<AnimatedContainer>(pill);
final decoration = pillWidget.decoration as BoxDecoration;
final pillSize = tester.getSize(pill);
expect(decoration.border, isNull);
expect(decoration.boxShadow, isNotNull);
expect(pillSize.width, closeTo(TvBrowseRailLayout.viewAllItemWidthForScale(scale), 0.001));
expect(pillSize.width, lessThan(132 * scale));
expect(pillSize.height, closeTo(TvBrowseRailLayout.viewAllPillHeightForScale(scale), 0.001));
});
testWidgets('inactive hub contents render at reduced opacity', (tester) async { testWidgets('inactive hub contents render at reduced opacity', (tester) async {
final serverManager = MultiServerManager(); final serverManager = MultiServerManager();
final firstItem = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); final firstItem = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
@@ -500,6 +856,7 @@ void main() {
await tester.pump(); await tester.pump();
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio); final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final fullCardLayout = SettingsService.instanceOrNull!.read(SettingsService.tvFullCardLayout);
final availableWidth = 700 - TvBrowseRailLayout.horizontalInsetForScale(scale); final availableWidth = 700 - TvBrowseRailLayout.horizontalInsetForScale(scale);
final movieMetrics = TvBrowseRailLayout.metricsForHub( final movieMetrics = TvBrowseRailLayout.metricsForHub(
hub: movieHub, hub: movieHub,
@@ -507,6 +864,7 @@ void main() {
density: LibraryDensity.defaultValue, density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail, episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale, scale: scale,
fullCardLayout: fullCardLayout,
); );
final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor( final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale, scale: scale,
@@ -533,6 +891,7 @@ void main() {
density: LibraryDensity.defaultValue, density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail, episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale, scale: scale,
fullCardLayout: fullCardLayout,
); );
final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex( final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex(
hub: episodeHub, hub: episodeHub,
@@ -620,6 +979,7 @@ void main() {
}); });
testWidgets('keeps late episode thumbnails visible in long TV rows', (tester) async { testWidgets('keeps late episode thumbnails visible in long TV rows', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720); tester.view.physicalSize = const Size(1280, 720);
addTearDown(() { addTearDown(() {
@@ -707,6 +1067,7 @@ void main() {
}); });
testWidgets('keeps late episode thumbnails visible during rapid key repeat', (tester) async { testWidgets('keeps late episode thumbnails visible during rapid key repeat', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720); tester.view.physicalSize = const Size(1280, 720);
addTearDown(() { addTearDown(() {