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._();
static const double focusScale = 1.02;
static const double fullCardFocusScale = 1.03;
static const double focusBorderWidth = 2.5;
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) {
return Theme.of(context).colorScheme.primary;
@@ -20,13 +24,42 @@ class FocusTheme {
BuildContext context, {
required bool isFocused,
double borderRadius = defaultBorderRadius,
double borderStrokeAlign = BorderSide.strokeAlignInside,
Color? color,
}) {
final focusColor = color ?? getFocusBorderColor(context);
return BoxDecoration(
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.
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.
/// Set to false when the child widget has its own Focus (e.g. buttons)
/// that would compete with this wrapper's focus handling.
@@ -136,6 +148,10 @@ class FocusableWrapper extends StatefulWidget {
this.useBackgroundFocus = false,
this.focusColor,
this.disableScale = false,
this.focusScale = FocusTheme.focusScale,
this.focusBorderStrokeAlign = BorderSide.strokeAlignInside,
this.useFocusGlow = false,
this.useForegroundFocusDecoration = false,
this.descendantsAreFocusable = true,
});
@@ -149,7 +165,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
bool _isFocused = false;
late final AnimationController _animationController;
late final Animation<double> _scaleAnimation;
late Animation<double> _scaleAnimation;
// Long-press detection for SELECT key
Timer? _longPressTimer;
@@ -178,9 +194,13 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
void _initAnimations() {
_animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 150));
_scaleAnimation = Tween<double>(
_scaleAnimation = _createScaleAnimation();
}
Animation<double> _createScaleAnimation() {
return Tween<double>(
begin: 1.0,
end: FocusTheme.focusScale,
end: widget.focusScale,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic));
}
@@ -200,6 +220,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
if (widget.canRequestFocus != oldWidget.canRequestFocus) {
_focusNode.canRequestFocus = widget.canRequestFocus;
}
if (widget.focusScale != oldWidget.focusScale) {
_scaleAnimation = _createScaleAnimation();
}
}
@override
@@ -447,14 +471,23 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
}
// Choose decoration based on useBackgroundFocus
final decoration = widget.useBackgroundFocus
final focusDecoration = widget.useBackgroundFocus
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor,
borderStrokeAlign: widget.focusBorderStrokeAlign,
);
final glowDecoration = widget.useFocusGlow
? FocusTheme.focusGlowDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor,
)
: null;
Widget result = Focus(
focusNode: _focusNode,
@@ -471,7 +504,8 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
child: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: decoration,
decoration: widget.useForegroundFocusDecoration ? glowDecoration : focusDecoration,
foregroundDecoration: widget.useForegroundFocusDecoration ? focusDecoration : null,
child: widget.child,
),
);
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Показвай сезон и номер на епизод върху картите на епизодите",
"showSeasonPostersOnTabs": "Показвай постери на сезоните в табовете",
"showSeasonPostersOnTabsDescription": "Показвай постера на всеки сезон над неговия таб",
"tvFullCardLayout": "Пълни TV карти",
"tvFullCardLayoutDescription": "Използвай TV карти само с изображения, с насложени имена на актьорите",
"hideSpoilers": "Скривай спойлери за негледани епизоди",
"hideSpoilersDescription": "Замазвай миниатюри и описания за негледани епизоди",
"playerBackend": "Енджин на плеъра",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Vis sæson- og episodenummer på episodekort",
"showSeasonPostersOnTabs": "Vis sæsonplakater på faner",
"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",
"hideSpoilersDescription": "Slør miniaturebilleder og beskrivelser for usete episoder",
"playerBackend": "Afspillerbackend",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Staffel- und Episodennummer auf Episodenkarten anzeigen",
"showSeasonPostersOnTabs": "Staffelposter auf Tabs 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",
"hideSpoilersDescription": "Vorschaubilder und Beschreibungen ungesehener Episoden verwischen",
"playerBackend": "Player-Backend",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Show season and episode number on episode cards",
"showSeasonPostersOnTabs": "Show Season Posters on Tabs",
"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",
"hideSpoilersDescription": "Blur thumbnails and descriptions for unwatched episodes",
"playerBackend": "Player Backend",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Mostrar temporada y episodio en tarjetas de episodio",
"showSeasonPostersOnTabs": "Mostrar pósters de temporada en las pestañas",
"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",
"hideSpoilersDescription": "Desenfocar miniaturas y descripciones de episodios no vistos",
"playerBackend": "Reproductor",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Afficher la saison et l'épisode sur les cartes d'épisode",
"showSeasonPostersOnTabs": "Afficher les posters de saison sur les onglets",
"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",
"hideSpoilersDescription": "Flouter les miniatures et descriptions des épisodes non vus",
"playerBackend": "Moteur de lecture",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Mostra stagione ed episodio sulle schede episodio",
"showSeasonPostersOnTabs": "Mostra poster delle stagioni sulle schede",
"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",
"hideSpoilersDescription": "Sfoca miniature e descrizioni degli episodi non visti",
"playerBackend": "Motore di riproduzione",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "エピソードカードにシーズン番号とエピソード番号を表示します",
"showSeasonPostersOnTabs": "タブにシーズンポスターを表示",
"showSeasonPostersOnTabsDescription": "各シーズンのポスターをタブの上に表示します",
"tvFullCardLayout": "フルTVカード",
"tvFullCardLayoutDescription": "TVカードを画像のみで表示し、俳優名を重ねて表示します",
"hideSpoilers": "未視聴エピソードのネタバレを非表示",
"hideSpoilersDescription": "未視聴エピソードのサムネイルと説明をぼかします",
"playerBackend": "プレーヤーバックエンド",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "에피소드 카드에 시즌 및 에피소드 번호 표시",
"showSeasonPostersOnTabs": "탭에 시즌 포스터 표시",
"showSeasonPostersOnTabsDescription": "각 시즌 포스터를 탭 위에 표시",
"tvFullCardLayout": "전체 TV 카드",
"tvFullCardLayoutDescription": "TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시",
"hideSpoilers": "미시청 에피소드 스포일러 숨기기",
"hideSpoilersDescription": "시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리",
"playerBackend": "플레이어 백엔드",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Vis sesong- og episodenummer på episodekort",
"showSeasonPostersOnTabs": "Vis sesongplakater på faner",
"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",
"hideSpoilersDescription": "Slør miniatyrbilder og beskrivelser for usette episoder",
"playerBackend": "Spillermotor",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Toon seizoen- en afleveringsnummer op afleveringskaarten",
"showSeasonPostersOnTabs": "Toon seizoensposters op tabbladen",
"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",
"hideSpoilersDescription": "Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen",
"playerBackend": "Speler backend",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Pokazuj numer sezonu i odcinka na kartach odcinków",
"showSeasonPostersOnTabs": "Pokaż plakaty sezonów na zakładkach",
"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",
"hideSpoilersDescription": "Rozmywaj miniatury i opisy nieobejrzanych odcinków",
"playerBackend": "Backend odtwarzacza",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Mostrar temporada e episódio nos cartões de episódio",
"showSeasonPostersOnTabs": "Mostrar Pôsteres de Temporada nas Abas",
"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",
"hideSpoilersDescription": "Desfocar miniaturas e descrições de episódios não vistos",
"playerBackend": "Backend do Player",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Показывать номер сезона и серии на карточках серий",
"showSeasonPostersOnTabs": "Показывать постеры сезонов на вкладках",
"showSeasonPostersOnTabsDescription": "Показывать постер каждого сезона над его вкладкой",
"tvFullCardLayout": "Полные TV-карточки",
"tvFullCardLayoutDescription": "Использовать TV-карточки только с изображением и именами актёров поверх него",
"hideSpoilers": "Скрыть спойлеры непросмотренных эпизодов",
"hideSpoilersDescription": "Размывать миниатюры и описания непросмотренных серий",
"playerBackend": "Бэкенд плеера",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 18832 (1177 per locale)
/// Strings: 18864 (1179 per locale)
// coverage:ignore-file
// 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 showSeasonPostersOnTabs => 'Показвай постери на сезоните в табовете';
@override String get showSeasonPostersOnTabsDescription => 'Показвай постера на всеки сезон над неговия таб';
@override String get tvFullCardLayout => 'Пълни TV карти';
@override String get tvFullCardLayoutDescription => 'Използвай TV карти само с изображения, с насложени имена на актьорите';
@override String get hideSpoilers => 'Скривай спойлери за негледани епизоди';
@override String get hideSpoilersDescription => 'Замазвай миниатюри и описания за негледани епизоди';
@override String get playerBackend => 'Енджин на плеъра';
@@ -1922,6 +1924,8 @@ extension on TranslationsBg {
'settings.showEpisodeNumberOnCardsDescription' => 'Показвай сезон и номер на епизод върху картите на епизодите',
'settings.showSeasonPostersOnTabs' => 'Показвай постери на сезоните в табовете',
'settings.showSeasonPostersOnTabsDescription' => 'Показвай постера на всеки сезон над неговия таб',
'settings.tvFullCardLayout' => 'Пълни TV карти',
'settings.tvFullCardLayoutDescription' => 'Използвай TV карти само с изображения, с насложени имена на актьорите',
'settings.hideSpoilers' => 'Скривай спойлери за негледани епизоди',
'settings.hideSpoilersDescription' => 'Замазвай миниатюри и описания за негледани епизоди',
'settings.playerBackend' => 'Енджин на плеъра',
@@ -2316,10 +2320,10 @@ extension on TranslationsBg {
'profiles.active' => 'Активен',
'profiles.manage' => 'Управление',
'profiles.delete' => 'Изтрий',
'profiles.signOut' => 'Изход',
'profiles.signOutPlexTitle' => 'Изход от Plex?',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Изход',
'profiles.signOutPlexTitle' => 'Изход от Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Премахване на ${displayName} и всички Plex Home потребители? Можете да влезете отново по всяко време.',
'profiles.signedOutPlex' => 'Излязохте от Plex.',
'profiles.signOutFailed' => 'Изходът е неуспешен.',
@@ -2830,10 +2834,10 @@ extension on TranslationsBg {
'metadataEdit.originalTitle' => 'Оригинално заглавие',
'metadataEdit.releaseDate' => 'Дата на излизане',
'metadataEdit.contentRating' => 'Възрастов рейтинг',
'metadataEdit.studio' => 'Студио',
'metadataEdit.tagline' => 'Слоган',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Студио',
'metadataEdit.tagline' => 'Слоган',
'metadataEdit.summary' => 'Резюме',
'metadataEdit.poster' => 'Постер',
'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 showSeasonPostersOnTabs => 'Vis sæsonplakater på faner';
@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 hideSpoilersDescription => 'Slør miniaturebilleder og beskrivelser for usete episoder';
@override String get playerBackend => 'Afspillerbackend';
@@ -1922,6 +1924,8 @@ extension on TranslationsDa {
'settings.showEpisodeNumberOnCardsDescription' => 'Vis sæson- og episodenummer på episodekort',
'settings.showSeasonPostersOnTabs' => 'Vis sæsonplakater på faner',
'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.hideSpoilersDescription' => 'Slør miniaturebilleder og beskrivelser for usete episoder',
'settings.playerBackend' => 'Afspillerbackend',
@@ -2316,10 +2320,10 @@ extension on TranslationsDa {
'profiles.active' => 'Aktiv',
'profiles.manage' => 'Administrer',
'profiles.delete' => 'Slet',
'profiles.signOut' => 'Log ud',
'profiles.signOutPlexTitle' => 'Log ud af Plex?',
_ => null,
} ?? 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.signedOutPlex' => 'Logget ud af Plex.',
'profiles.signOutFailed' => 'Log ud mislykkedes.',
@@ -2830,10 +2834,10 @@ extension on TranslationsDa {
'metadataEdit.originalTitle' => 'Originaltitel',
'metadataEdit.releaseDate' => 'Udgivelsesdato',
'metadataEdit.contentRating' => 'Aldersgrænse',
'metadataEdit.studio' => 'Studie',
'metadataEdit.tagline' => 'Tagline',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studie',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Resumé',
'metadataEdit.poster' => 'Plakat',
'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 showSeasonPostersOnTabs => 'Staffelposter auf Tabs 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 hideSpoilersDescription => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen';
@override String get playerBackend => 'Player-Backend';
@@ -1922,6 +1924,8 @@ extension on TranslationsDe {
'settings.showEpisodeNumberOnCardsDescription' => 'Staffel- und Episodennummer auf Episodenkarten anzeigen',
'settings.showSeasonPostersOnTabs' => 'Staffelposter auf Tabs 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.hideSpoilersDescription' => 'Vorschaubilder und Beschreibungen ungesehener Episoden verwischen',
'settings.playerBackend' => 'Player-Backend',
@@ -2316,10 +2320,10 @@ extension on TranslationsDe {
'profiles.active' => 'Aktiv',
'profiles.manage' => 'Verwalten',
'profiles.delete' => 'Löschen',
'profiles.signOut' => 'Abmelden',
'profiles.signOutPlexTitle' => 'Von Plex abmelden?',
_ => null,
} ?? 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.signedOutPlex' => 'Von Plex abgemeldet.',
'profiles.signOutFailed' => 'Abmeldung fehlgeschlagen.',
@@ -2830,10 +2834,10 @@ extension on TranslationsDe {
'metadataEdit.originalTitle' => 'Originaltitel',
'metadataEdit.releaseDate' => 'Erscheinungsdatum',
'metadataEdit.contentRating' => 'Altersfreigabe',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Zusammenfassung',
'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Hintergrund',
+12 -4
View File
@@ -488,6 +488,12 @@ class TranslationsSettingsEn {
/// en: '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'
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.showSeasonPostersOnTabs' => 'Show Season Posters on Tabs',
'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.hideSpoilersDescription' => 'Blur thumbnails and descriptions for unwatched episodes',
'settings.playerBackend' => 'Player Backend',
@@ -4676,10 +4684,10 @@ extension on Translations {
'profiles.active' => 'Active',
'profiles.manage' => 'Manage',
'profiles.delete' => 'Delete',
'profiles.signOut' => 'Sign out',
'profiles.signOutPlexTitle' => 'Sign out of Plex?',
_ => null,
} ?? 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.signedOutPlex' => 'Signed out of Plex.',
'profiles.signOutFailed' => 'Sign out failed.',
@@ -5190,10 +5198,10 @@ extension on Translations {
'metadataEdit.originalTitle' => 'Original Title',
'metadataEdit.releaseDate' => 'Release Date',
'metadataEdit.contentRating' => 'Content Rating',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Summary',
'metadataEdit.poster' => 'Poster',
'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 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 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 hideSpoilersDescription => 'Desenfocar miniaturas y descripciones de episodios no vistos';
@override String get playerBackend => 'Reproductor';
@@ -1922,6 +1924,8 @@ extension on TranslationsEs {
'settings.showEpisodeNumberOnCardsDescription' => 'Mostrar temporada y episodio en tarjetas de episodio',
'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.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.hideSpoilersDescription' => 'Desenfocar miniaturas y descripciones de episodios no vistos',
'settings.playerBackend' => 'Reproductor',
@@ -2316,10 +2320,10 @@ extension on TranslationsEs {
'profiles.active' => 'Activo',
'profiles.manage' => 'Administrar',
'profiles.delete' => 'Eliminar',
'profiles.signOut' => 'Cerrar sesión',
'profiles.signOutPlexTitle' => '¿Cerrar sesión de Plex?',
_ => null,
} ?? 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.signedOutPlex' => 'Sesión de Plex cerrada.',
'profiles.signOutFailed' => 'Error al cerrar sesión.',
@@ -2830,10 +2834,10 @@ extension on TranslationsEs {
'metadataEdit.originalTitle' => 'Título original',
'metadataEdit.releaseDate' => 'Fecha de estreno',
'metadataEdit.contentRating' => 'Clasificación de contenido',
'metadataEdit.studio' => 'Estudio',
'metadataEdit.tagline' => 'Eslogan',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Estudio',
'metadataEdit.tagline' => 'Eslogan',
'metadataEdit.summary' => 'Resumen',
'metadataEdit.poster' => 'Póster',
'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 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 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 hideSpoilersDescription => 'Flouter les miniatures et descriptions des épisodes non vus';
@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.showSeasonPostersOnTabs' => 'Afficher les posters de saison sur les onglets',
'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.hideSpoilersDescription' => 'Flouter les miniatures et descriptions des épisodes non vus',
'settings.playerBackend' => 'Moteur de lecture',
@@ -2316,10 +2320,10 @@ extension on TranslationsFr {
'profiles.active' => 'Actif',
'profiles.manage' => 'Gérer',
'profiles.delete' => 'Supprimer',
'profiles.signOut' => 'Se déconnecter',
'profiles.signOutPlexTitle' => 'Se déconnecter de Plex ?',
_ => null,
} ?? 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.signedOutPlex' => 'Déconnecté de Plex.',
'profiles.signOutFailed' => 'Échec de la déconnexion.',
@@ -2830,10 +2834,10 @@ extension on TranslationsFr {
'metadataEdit.originalTitle' => 'Titre original',
'metadataEdit.releaseDate' => 'Date de sortie',
'metadataEdit.contentRating' => 'Classification',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slogan',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slogan',
'metadataEdit.summary' => 'Résumé',
'metadataEdit.poster' => 'Affiche',
'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 showSeasonPostersOnTabs => 'Mostra poster delle stagioni sulle schede';
@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 hideSpoilersDescription => 'Sfoca miniature e descrizioni degli episodi non visti';
@override String get playerBackend => 'Motore di riproduzione';
@@ -1922,6 +1924,8 @@ extension on TranslationsIt {
'settings.showEpisodeNumberOnCardsDescription' => 'Mostra stagione ed episodio sulle schede episodio',
'settings.showSeasonPostersOnTabs' => 'Mostra poster delle stagioni sulle schede',
'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.hideSpoilersDescription' => 'Sfoca miniature e descrizioni degli episodi non visti',
'settings.playerBackend' => 'Motore di riproduzione',
@@ -2316,10 +2320,10 @@ extension on TranslationsIt {
'profiles.active' => 'Attivo',
'profiles.manage' => 'Gestisci',
'profiles.delete' => 'Elimina',
'profiles.signOut' => 'Esci',
'profiles.signOutPlexTitle' => 'Uscire da Plex?',
_ => null,
} ?? 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.signedOutPlex' => 'Uscito da Plex.',
'profiles.signOutFailed' => 'Uscita non riuscita.',
@@ -2830,10 +2834,10 @@ extension on TranslationsIt {
'metadataEdit.originalTitle' => 'Titolo originale',
'metadataEdit.releaseDate' => 'Data di uscita',
'metadataEdit.contentRating' => 'Classificazione',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Trama',
'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Sfondo',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsJa extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'エピソードカードにシーズン番号とエピソード番号を表示します';
@override String get showSeasonPostersOnTabs => 'タブにシーズンポスターを表示';
@override String get showSeasonPostersOnTabsDescription => '各シーズンのポスターをタブの上に表示します';
@override String get tvFullCardLayout => 'フルTVカード';
@override String get tvFullCardLayoutDescription => 'TVカードを画像のみで表示し、俳優名を重ねて表示します';
@override String get hideSpoilers => '未視聴エピソードのネタバレを非表示';
@override String get hideSpoilersDescription => '未視聴エピソードのサムネイルと説明をぼかします';
@override String get playerBackend => 'プレーヤーバックエンド';
@@ -1922,6 +1924,8 @@ extension on TranslationsJa {
'settings.showEpisodeNumberOnCardsDescription' => 'エピソードカードにシーズン番号とエピソード番号を表示します',
'settings.showSeasonPostersOnTabs' => 'タブにシーズンポスターを表示',
'settings.showSeasonPostersOnTabsDescription' => '各シーズンのポスターをタブの上に表示します',
'settings.tvFullCardLayout' => 'フルTVカード',
'settings.tvFullCardLayoutDescription' => 'TVカードを画像のみで表示し、俳優名を重ねて表示します',
'settings.hideSpoilers' => '未視聴エピソードのネタバレを非表示',
'settings.hideSpoilersDescription' => '未視聴エピソードのサムネイルと説明をぼかします',
'settings.playerBackend' => 'プレーヤーバックエンド',
@@ -2316,10 +2320,10 @@ extension on TranslationsJa {
'profiles.active' => 'アクティブ',
'profiles.manage' => '管理',
'profiles.delete' => '削除',
'profiles.signOut' => 'サインアウト',
'profiles.signOutPlexTitle' => 'Plex からサインアウトしますか?',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'サインアウト',
'profiles.signOutPlexTitle' => 'Plex からサインアウトしますか?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName}とすべてのPlex Homeユーザーを削除しますか?いつでも再サインインできます。',
'profiles.signedOutPlex' => 'Plex からサインアウトしました。',
'profiles.signOutFailed' => 'サインアウトに失敗しました。',
@@ -2830,10 +2834,10 @@ extension on TranslationsJa {
'metadataEdit.originalTitle' => '原題',
'metadataEdit.releaseDate' => '公開日',
'metadataEdit.contentRating' => 'コンテンツレーティング',
'metadataEdit.studio' => 'スタジオ',
'metadataEdit.tagline' => 'タグライン',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'スタジオ',
'metadataEdit.tagline' => 'タグライン',
'metadataEdit.summary' => 'あらすじ',
'metadataEdit.poster' => 'ポスター',
'metadataEdit.background' => '背景',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsKo extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => '에피소드 카드에 시즌 및 에피소드 번호 표시';
@override String get showSeasonPostersOnTabs => '탭에 시즌 포스터 표시';
@override String get showSeasonPostersOnTabsDescription => '각 시즌 포스터를 탭 위에 표시';
@override String get tvFullCardLayout => '전체 TV 카드';
@override String get tvFullCardLayoutDescription => 'TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시';
@override String get hideSpoilers => '미시청 에피소드 스포일러 숨기기';
@override String get hideSpoilersDescription => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리';
@override String get playerBackend => '플레이어 백엔드';
@@ -1922,6 +1924,8 @@ extension on TranslationsKo {
'settings.showEpisodeNumberOnCardsDescription' => '에피소드 카드에 시즌 및 에피소드 번호 표시',
'settings.showSeasonPostersOnTabs' => '탭에 시즌 포스터 표시',
'settings.showSeasonPostersOnTabsDescription' => '각 시즌 포스터를 탭 위에 표시',
'settings.tvFullCardLayout' => '전체 TV 카드',
'settings.tvFullCardLayoutDescription' => 'TV 카드에 이미지만 표시하고 배우 이름을 오버레이로 표시',
'settings.hideSpoilers' => '미시청 에피소드 스포일러 숨기기',
'settings.hideSpoilersDescription' => '시청하지 않은 에피소드의 썸네일과 설명을 흐리게 처리',
'settings.playerBackend' => '플레이어 백엔드',
@@ -2316,10 +2320,10 @@ extension on TranslationsKo {
'profiles.active' => '활성',
'profiles.manage' => '관리',
'profiles.delete' => '삭제',
'profiles.signOut' => '로그아웃',
'profiles.signOutPlexTitle' => 'Plex에서 로그아웃하시겠습니까?',
_ => null,
} ?? switch (path) {
'profiles.signOut' => '로그아웃',
'profiles.signOutPlexTitle' => 'Plex에서 로그아웃하시겠습니까?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} 및 모든 Plex Home 사용자를 제거할까요? 언제든 다시 로그인할 수 있습니다.',
'profiles.signedOutPlex' => 'Plex에서 로그아웃되었습니다.',
'profiles.signOutFailed' => '로그아웃에 실패했습니다.',
@@ -2830,10 +2834,10 @@ extension on TranslationsKo {
'metadataEdit.originalTitle' => '원제',
'metadataEdit.releaseDate' => '출시일',
'metadataEdit.contentRating' => '콘텐츠 등급',
'metadataEdit.studio' => '스튜디오',
'metadataEdit.tagline' => '태그라인',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => '스튜디오',
'metadataEdit.tagline' => '태그라인',
'metadataEdit.summary' => '줄거리',
'metadataEdit.poster' => '포스터',
'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 showSeasonPostersOnTabs => 'Vis sesongplakater på faner';
@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 hideSpoilersDescription => 'Slør miniatyrbilder og beskrivelser for usette episoder';
@override String get playerBackend => 'Spillermotor';
@@ -1922,6 +1924,8 @@ extension on TranslationsNb {
'settings.showEpisodeNumberOnCardsDescription' => 'Vis sesong- og episodenummer på episodekort',
'settings.showSeasonPostersOnTabs' => 'Vis sesongplakater på faner',
'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.hideSpoilersDescription' => 'Slør miniatyrbilder og beskrivelser for usette episoder',
'settings.playerBackend' => 'Spillermotor',
@@ -2316,10 +2320,10 @@ extension on TranslationsNb {
'profiles.active' => 'Aktiv',
'profiles.manage' => 'Administrer',
'profiles.delete' => 'Slett',
'profiles.signOut' => 'Logg ut',
'profiles.signOutPlexTitle' => 'Logge ut av Plex?',
_ => null,
} ?? 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.signedOutPlex' => 'Logget ut av Plex.',
'profiles.signOutFailed' => 'Utlogging mislyktes.',
@@ -2830,10 +2834,10 @@ extension on TranslationsNb {
'metadataEdit.originalTitle' => 'Originaltittel',
'metadataEdit.releaseDate' => 'Utgivelsesdato',
'metadataEdit.contentRating' => 'Innholdsvurdering',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slagord',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slagord',
'metadataEdit.summary' => 'Sammendrag',
'metadataEdit.poster' => 'Plakat',
'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 showSeasonPostersOnTabs => 'Toon seizoensposters op tabbladen';
@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 hideSpoilersDescription => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen';
@override String get playerBackend => 'Speler backend';
@@ -1922,6 +1924,8 @@ extension on TranslationsNl {
'settings.showEpisodeNumberOnCardsDescription' => 'Toon seizoen- en afleveringsnummer op afleveringskaarten',
'settings.showSeasonPostersOnTabs' => 'Toon seizoensposters op tabbladen',
'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.hideSpoilersDescription' => 'Vervaag miniaturen en beschrijvingen voor niet-bekeken afleveringen',
'settings.playerBackend' => 'Speler backend',
@@ -2316,10 +2320,10 @@ extension on TranslationsNl {
'profiles.active' => 'Actief',
'profiles.manage' => 'Beheren',
'profiles.delete' => 'Verwijderen',
'profiles.signOut' => 'Afmelden',
'profiles.signOutPlexTitle' => 'Afmelden bij Plex?',
_ => null,
} ?? 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.signedOutPlex' => 'Afgemeld bij Plex.',
'profiles.signOutFailed' => 'Afmelden mislukt.',
@@ -2830,10 +2834,10 @@ extension on TranslationsNl {
'metadataEdit.originalTitle' => 'Oorspronkelijke titel',
'metadataEdit.releaseDate' => 'Releasedatum',
'metadataEdit.contentRating' => 'Leeftijdsclassificatie',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Samenvatting',
'metadataEdit.poster' => 'Poster',
'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 showSeasonPostersOnTabs => 'Pokaż plakaty sezonów na zakładkach';
@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 hideSpoilersDescription => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków';
@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.showSeasonPostersOnTabs' => 'Pokaż plakaty sezonów na zakładkach',
'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.hideSpoilersDescription' => 'Rozmywaj miniatury i opisy nieobejrzanych odcinków',
'settings.playerBackend' => 'Backend odtwarzacza',
@@ -2316,10 +2320,10 @@ extension on TranslationsPl {
'profiles.active' => 'Aktywny',
'profiles.manage' => 'Zarządzaj',
'profiles.delete' => 'Usuń',
'profiles.signOut' => 'Wyloguj się',
'profiles.signOutPlexTitle' => 'Wylogować się z Plex?',
_ => null,
} ?? 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.signedOutPlex' => 'Wylogowano z Plex.',
'profiles.signOutFailed' => 'Wylogowanie nie powiodło się.',
@@ -2830,10 +2834,10 @@ extension on TranslationsPl {
'metadataEdit.originalTitle' => 'Tytuł oryginalny',
'metadataEdit.releaseDate' => 'Data premiery',
'metadataEdit.contentRating' => 'Klasyfikacja wiekowa',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Opis',
'metadataEdit.poster' => 'Plakat',
'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 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 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 hideSpoilersDescription => 'Desfocar miniaturas e descrições de episódios não vistos';
@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.showSeasonPostersOnTabs' => 'Mostrar Pôsteres de Temporada nas Abas',
'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.hideSpoilersDescription' => 'Desfocar miniaturas e descrições de episódios não vistos',
'settings.playerBackend' => 'Backend do Player',
@@ -2316,10 +2320,10 @@ extension on TranslationsPt {
'profiles.active' => 'Ativo',
'profiles.manage' => 'Gerenciar',
'profiles.delete' => 'Excluir',
'profiles.signOut' => 'Sair',
'profiles.signOutPlexTitle' => 'Sair do Plex?',
_ => null,
} ?? 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.signedOutPlex' => 'Saiu do Plex.',
'profiles.signOutFailed' => 'Falha ao sair.',
@@ -2830,10 +2834,10 @@ extension on TranslationsPt {
'metadataEdit.originalTitle' => 'Título Original',
'metadataEdit.releaseDate' => 'Data de Lançamento',
'metadataEdit.contentRating' => 'Classificação Indicativa',
'metadataEdit.studio' => 'Estúdio',
'metadataEdit.tagline' => 'Tagline',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Estúdio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Sinopse',
'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Plano de Fundo',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsRu extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => 'Показывать номер сезона и серии на карточках серий';
@override String get showSeasonPostersOnTabs => 'Показывать постеры сезонов на вкладках';
@override String get showSeasonPostersOnTabsDescription => 'Показывать постер каждого сезона над его вкладкой';
@override String get tvFullCardLayout => 'Полные TV-карточки';
@override String get tvFullCardLayoutDescription => 'Использовать TV-карточки только с изображением и именами актёров поверх него';
@override String get hideSpoilers => 'Скрыть спойлеры непросмотренных эпизодов';
@override String get hideSpoilersDescription => 'Размывать миниатюры и описания непросмотренных серий';
@override String get playerBackend => 'Бэкенд плеера';
@@ -1922,6 +1924,8 @@ extension on TranslationsRu {
'settings.showEpisodeNumberOnCardsDescription' => 'Показывать номер сезона и серии на карточках серий',
'settings.showSeasonPostersOnTabs' => 'Показывать постеры сезонов на вкладках',
'settings.showSeasonPostersOnTabsDescription' => 'Показывать постер каждого сезона над его вкладкой',
'settings.tvFullCardLayout' => 'Полные TV-карточки',
'settings.tvFullCardLayoutDescription' => 'Использовать TV-карточки только с изображением и именами актёров поверх него',
'settings.hideSpoilers' => 'Скрыть спойлеры непросмотренных эпизодов',
'settings.hideSpoilersDescription' => 'Размывать миниатюры и описания непросмотренных серий',
'settings.playerBackend' => 'Бэкенд плеера',
@@ -2316,10 +2320,10 @@ extension on TranslationsRu {
'profiles.active' => 'Активный',
'profiles.manage' => 'Управление',
'profiles.delete' => 'Удалить',
'profiles.signOut' => 'Выйти',
'profiles.signOutPlexTitle' => 'Выйти из Plex?',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Выйти',
'profiles.signOutPlexTitle' => 'Выйти из Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Удалить ${displayName} и всех пользователей Plex Home? Вы сможете войти снова в любое время.',
'profiles.signedOutPlex' => 'Вы вышли из Plex.',
'profiles.signOutFailed' => 'Не удалось выйти.',
@@ -2830,10 +2834,10 @@ extension on TranslationsRu {
'metadataEdit.originalTitle' => 'Оригинальное название',
'metadataEdit.releaseDate' => 'Дата выпуска',
'metadataEdit.contentRating' => 'Возрастной рейтинг',
'metadataEdit.studio' => 'Студия',
'metadataEdit.tagline' => 'Слоган',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Студия',
'metadataEdit.tagline' => 'Слоган',
'metadataEdit.summary' => 'Описание',
'metadataEdit.poster' => 'Постер',
'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 showSeasonPostersOnTabs => 'Visa säsongsaffischer på flikar';
@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 hideSpoilersDescription => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt';
@override String get playerBackend => 'Spelarmotor';
@@ -1922,6 +1924,8 @@ extension on TranslationsSv {
'settings.showEpisodeNumberOnCardsDescription' => 'Visa säsongs- och avsnittsnummer på avsnittskort',
'settings.showSeasonPostersOnTabs' => 'Visa säsongsaffischer på flikar',
'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.hideSpoilersDescription' => 'Sudda miniatyrbilder och beskrivningar för osedda avsnitt',
'settings.playerBackend' => 'Spelarmotor',
@@ -2316,10 +2320,10 @@ extension on TranslationsSv {
'profiles.active' => 'Aktiv',
'profiles.manage' => 'Hantera',
'profiles.delete' => 'Ta bort',
'profiles.signOut' => 'Logga ut',
'profiles.signOutPlexTitle' => 'Logga ut från Plex?',
_ => null,
} ?? 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.signedOutPlex' => 'Utloggad från Plex.',
'profiles.signOutFailed' => 'Utloggningen misslyckades.',
@@ -2830,10 +2834,10 @@ extension on TranslationsSv {
'metadataEdit.originalTitle' => 'Originaltitel',
'metadataEdit.releaseDate' => 'Utgivningsdatum',
'metadataEdit.contentRating' => 'Åldersgräns',
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Sammanfattning',
'metadataEdit.poster' => 'Poster',
'metadataEdit.background' => 'Bakgrund',
+8 -4
View File
@@ -254,6 +254,8 @@ class _TranslationsSettingsZh extends TranslationsSettingsEn {
@override String get showEpisodeNumberOnCardsDescription => '在剧集卡片上显示季和集编号';
@override String get showSeasonPostersOnTabs => '在选项卡上显示季海报';
@override String get showSeasonPostersOnTabsDescription => '在每季标签上方显示该季海报';
@override String get tvFullCardLayout => '完整 TV 卡片';
@override String get tvFullCardLayoutDescription => '使用仅显示图片的 TV 卡片,并叠加演员姓名';
@override String get hideSpoilers => '隐藏未看剧集的剧透内容';
@override String get hideSpoilersDescription => '模糊未观看剧集的缩略图和描述';
@override String get playerBackend => '播放器引擎';
@@ -1922,6 +1924,8 @@ extension on TranslationsZh {
'settings.showEpisodeNumberOnCardsDescription' => '在剧集卡片上显示季和集编号',
'settings.showSeasonPostersOnTabs' => '在选项卡上显示季海报',
'settings.showSeasonPostersOnTabsDescription' => '在每季标签上方显示该季海报',
'settings.tvFullCardLayout' => '完整 TV 卡片',
'settings.tvFullCardLayoutDescription' => '使用仅显示图片的 TV 卡片,并叠加演员姓名',
'settings.hideSpoilers' => '隐藏未看剧集的剧透内容',
'settings.hideSpoilersDescription' => '模糊未观看剧集的缩略图和描述',
'settings.playerBackend' => '播放器引擎',
@@ -2316,10 +2320,10 @@ extension on TranslationsZh {
'profiles.active' => '活跃',
'profiles.manage' => '管理',
'profiles.delete' => '删除',
'profiles.signOut' => '退出登录',
'profiles.signOutPlexTitle' => '退出 Plex 登录?',
_ => null,
} ?? switch (path) {
'profiles.signOut' => '退出登录',
'profiles.signOutPlexTitle' => '退出 Plex 登录?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '要移除 ${displayName} 和所有 Plex Home 用户吗?可随时重新登录。',
'profiles.signedOutPlex' => '已退出 Plex 登录。',
'profiles.signOutFailed' => '退出登录失败。',
@@ -2830,10 +2834,10 @@ extension on TranslationsZh {
'metadataEdit.originalTitle' => '原始标题',
'metadataEdit.releaseDate' => '上映日期',
'metadataEdit.contentRating' => '内容分级',
'metadataEdit.studio' => '制片厂',
'metadataEdit.tagline' => '标语',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => '制片厂',
'metadataEdit.tagline' => '标语',
'metadataEdit.summary' => '简介',
'metadataEdit.poster' => '海报',
'metadataEdit.background' => '背景',
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "Visa säsongs- och avsnittsnummer på avsnittskort",
"showSeasonPostersOnTabs": "Visa säsongsaffischer på flikar",
"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",
"hideSpoilersDescription": "Sudda miniatyrbilder och beskrivningar för osedda avsnitt",
"playerBackend": "Spelarmotor",
+2
View File
@@ -126,6 +126,8 @@
"showEpisodeNumberOnCardsDescription": "在剧集卡片上显示季和集编号",
"showSeasonPostersOnTabs": "在选项卡上显示季海报",
"showSeasonPostersOnTabsDescription": "在每季标签上方显示该季海报",
"tvFullCardLayout": "完整 TV 卡片",
"tvFullCardLayoutDescription": "使用仅显示图片的 TV 卡片,并叠加演员姓名",
"hideSpoilers": "隐藏未看剧集的剧透内容",
"hideSpoilersDescription": "模糊未观看剧集的缩略图和描述",
"playerBackend": "播放器引擎",
+1
View File
@@ -1554,6 +1554,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
hubs: browseHubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
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)
const effectivePadding = EdgeInsets.only(left: 8, right: 8, top: 8);
return SettingValueBuilder<int>(
pref: SettingsService.libraryDensity,
builder: (context, density, _) {
return SettingsBuilder(
prefs: const [SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
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);
// Use LayoutBuilder to get actual available width (accounting for sidebar)
return LayoutBuilder(
builder: (context, constraints) {
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(
padding: effectivePadding,
// Allow focus decoration to render outside scroll bounds
clipBehavior: Clip.none,
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density),
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: density,
fullBleedImage: fullCardLayout,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
@@ -342,6 +354,7 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> {
focusNode: isFirst ? _firstItemFocusNode : null,
onBack: widget.onBack,
isOffline: true, // Downloaded content works without server
fullBleedImage: fullCardLayout,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
);
},
+28 -6
View File
@@ -169,11 +169,12 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
VoidCallback? onListRefresh,
}) {
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity],
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final svc = SettingsService.instanceOrNull!;
final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list;
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
if (isListMode) {
return SliverPadding(
@@ -206,9 +207,18 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
padding: const EdgeInsets.all(8),
sliver: SliverLayoutBuilder(
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(
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: libraryDensity),
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: libraryDensity,
fullBleedImage: fullCardLayout,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
@@ -222,6 +232,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
onRefresh: onRefresh,
collectionId: collectionId,
onListRefresh: onListRefresh,
fullBleedImage: fullCardLayout,
onNavigateUp: inFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
@@ -248,11 +259,12 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
VoidCallback? onListRefresh,
}) {
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity],
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final svc = SettingsService.instanceOrNull!;
final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list;
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
Widget buildTile(int index, {required bool inFirstRow, required bool disableScale}) {
final item = itemAt(index);
@@ -269,6 +281,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
onRefresh: onRefresh,
collectionId: collectionId,
onListRefresh: onListRefresh,
fullBleedImage: fullCardLayout && !disableScale,
onNavigateUp: inFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
@@ -290,9 +303,18 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
padding: const EdgeInsets.all(8),
sliver: SliverLayoutBuilder(
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(
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: libraryDensity),
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: libraryDensity,
fullBleedImage: fullCardLayout,
),
itemCount: totalItems,
itemBuilder: (context, index) => buildTile(
index,
+9
View File
@@ -510,12 +510,14 @@ class _HubDetailScreenState extends State<HubDetailScreen>
SettingsService.viewMode,
SettingsService.episodePosterMode,
SettingsService.libraryDensity,
SettingsService.tvFullCardLayout,
],
builder: (context) {
final svc = SettingsService.instanceOrNull!;
final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list;
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
// Determine hub content type for layout decisions
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
@@ -570,9 +572,14 @@ class _HubDetailScreenState extends State<HubDetailScreen>
libraryDensity,
16,
);
final gridSpacing = MediaGridDelegate.spacingFor(
context: context,
fullBleedImage: fullCardLayout,
);
final columnCount = GridSizeCalculator.getColumnCount(
constraints.crossAxisExtent,
useWideLayout ? maxExtent * 1.8 : maxExtent,
crossAxisSpacing: gridSpacing,
);
return SliverGrid(
@@ -582,6 +589,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
usePaddingAware: true,
horizontalPadding: 16,
useWideAspectRatio: useWideLayout,
fullBleedImage: fullCardLayout,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = _filteredItems[index];
@@ -602,6 +610,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
mixedHubContext: isMixedHub,
fullBleedImage: fullCardLayout,
);
}, childCount: _filteredItems.length),
);
@@ -1511,7 +1511,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return [
SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.episodePosterMode],
prefs: const [
SettingsService.viewMode,
SettingsService.libraryDensity,
SettingsService.episodePosterMode,
SettingsService.tvFullCardLayout,
],
builder: (context) => _buildItemsSliver(context),
),
];
@@ -1568,6 +1573,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
final itemCount = totalSize;
final isPhone = _isPhone(context);
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
@@ -1611,17 +1617,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout);
// 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
// fewer column and blow up poster size.
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
final itemWidth = constraints.crossAxisExtent / columnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final itemWidth = GridSizeCalculator.getCellWidthForColumnCount(
constraints.crossAxisExtent,
columnCount,
crossAxisSpacing: gridSpacing,
);
final itemHeight =
itemWidth /
MediaGridDelegate.aspectRatioFor(useWideAspectRatio: useWideRatio, fullBleedImage: fullCardLayout);
_scrollMetrics = LibraryAlphaScrollMetrics(
columnCount: columnCount,
rowHeight: itemHeight + GridLayoutConstants.mainAxisSpacing,
rowHeight: itemHeight + gridSpacing,
itemWidth: itemWidth,
itemHeight: itemHeight,
);
@@ -1630,7 +1647,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
context: context,
density: libraryDensity,
useWideAspectRatio: useWideRatio,
maxCrossAxisExtentOverride: hasAlphaBarReservation ? constraints.crossAxisExtent / columnCount : null,
fullBleedImage: fullCardLayout,
maxCrossAxisExtentOverride: hasAlphaBarReservation ? itemWidth : null,
),
itemCount: itemCount,
itemBuilder: (context, index) => _buildMediaCardItem(
@@ -1640,6 +1658,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
isLastColumn: (index % columnCount) == (columnCount - 1),
columnCount: columnCount,
itemCount: itemCount,
fullBleedImage: fullCardLayout,
),
);
},
@@ -1654,6 +1673,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
required bool isFirstColumn,
bool isLastColumn = false,
bool disableScale = false,
bool fullBleedImage = false,
int columnCount = 1,
int itemCount = 0,
}) {
@@ -1711,6 +1731,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
onBack: widget.onBack,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
onListRefresh: _loadItems,
fullBleedImage: fullBleedImage,
);
}
@@ -10,6 +10,7 @@ import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/settings_builder.dart';
@@ -106,16 +107,20 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
@override
Widget buildContent(List<MediaItem> items) {
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity],
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final settings = SettingsService.instanceOrNull!;
final viewMode = settings.read(SettingsService.viewMode);
final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
return CustomScrollView(
clipBehavior: Clip.none,
slivers: [
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(
padding: _effectivePadding,
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
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(
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density),
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: density,
fullBleedImage: fullCardLayout,
),
itemCount: totalSize,
itemBuilder: (context, index) =>
_buildMediaCardItem(index, isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount)),
itemBuilder: (context, index) => _buildMediaCardItem(
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];
if (item == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
@@ -169,6 +191,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
item: item,
focusNode: index == 0 ? firstItemFocusNode : null,
disableScale: disableScale,
fullBleedImage: fullBleedImage,
onListRefresh: loadItems,
onBack: widget.onBack,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
@@ -10,6 +10,7 @@ import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/settings_builder.dart';
@@ -108,16 +109,20 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
@override
Widget buildContent(List<MediaPlaylist> items) {
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity],
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final settings = SettingsService.instanceOrNull!;
final viewMode = settings.read(SettingsService.viewMode);
final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
return CustomScrollView(
clipBehavior: Clip.none,
slivers: [
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(
padding: _effectivePadding,
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
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(
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: density),
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: density,
fullBleedImage: fullCardLayout,
),
itemCount: totalSize,
itemBuilder: (context, index) =>
_buildPlaylistCard(index, isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount)),
itemBuilder: (context, index) => _buildPlaylistCard(
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];
if (playlist == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
@@ -171,6 +193,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
item: playlist,
focusNode: index == 0 ? firstItemFocusNode : null,
disableScale: disableScale,
fullBleedImage: fullBleedImage,
onListRefresh: loadItems,
onBack: widget.onBack,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
@@ -316,6 +316,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
hubs: tvHubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
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),
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
widePosterScaleForHub: _tvDetailWidePosterScaleForHub,
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: _tvDetailTallPosterScale,
);
}
@@ -3471,6 +3472,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
availableWidth: availableWidth,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
scale: scale,
tallPosterScale: _tvDetailTallPosterScale,
widePosterScale: 1.0,
@@ -33,6 +33,13 @@ class AppearanceSettingsScreen extends StatelessWidget {
_densitySelector(),
_viewModeSelector(),
_episodePosterModeSelector(),
if (PlatformDetector.isTV())
SettingSwitchTile(
pref: SettingsService.tvFullCardLayout,
icon: Symbols.image_rounded,
title: t.settings.tvFullCardLayout,
subtitle: t.settings.tvFullCardLayoutDescription,
),
SettingSwitchTile(
pref: SettingsService.showEpisodeNumberOnCards,
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 rewindOnResume = IntPref('rewind_on_resume');
static const showHeroSection = BoolPref('show_hero_section', defaultValue: true);
static const tvFullCardLayout = BoolPref('tv_full_card_layout', defaultValue: false);
static const useGlobalHubs = BoolPref('use_global_hubs', defaultValue: true);
static const showServerNameOnHubs = BoolPref('show_server_name_on_hubs');
static const groupLibrariesByServer = BoolPref('group_libraries_by_server', defaultValue: true);
+14 -3
View File
@@ -53,18 +53,29 @@ class GridSizeCalculator {
/// [crossAxisExtent] should come from layout constraints (e.g. `SliverLayoutBuilder`
/// or `LayoutBuilder`), not from `MediaQuery`, to account for sidebars or other
/// elements that reduce the grid's actual width.
static int getColumnCount(double crossAxisExtent, double maxCrossAxisExtent) {
final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing;
static int getColumnCount(
double crossAxisExtent,
double maxCrossAxisExtent, {
double crossAxisSpacing = GridLayoutConstants.crossAxisSpacing,
}) {
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
/// for the given [availableWidth]. This matches SliverGridDelegateWithMaxCrossAxisExtent's
/// internal calculation, so horizontal scroll lists can use the same width as grids.
static double getCellWidth(double availableWidth, BuildContext context, int density) {
final maxExtent = getMaxCrossAxisExtent(context, density);
final columns = getColumnCount(availableWidth, maxExtent);
return availableWidth / columns;
return getCellWidthForColumnCount(availableWidth, columns);
}
static bool isFirstRow(int index, int columnCount) {
+4
View File
@@ -41,6 +41,8 @@ class AppDurations {
class GridLayoutConstants {
static const double posterAspectRatio = 2 / 3.3;
static const double fullCardPosterAspectRatio = 2 / 3;
static const double episodeThumbnailAspectRatio = 16 / 9;
static const double episodeGridCellAspectRatio = 1.4;
@@ -48,6 +50,8 @@ class GridLayoutConstants {
static const double crossAxisSpacing = 0;
static const double mainAxisSpacing = 0;
static double fullCardGridSpacingForScale(double scale) => (12 * scale).clamp(8, 18).toDouble();
/// Standard grid padding
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? onLongPress,
double borderRadius = FocusTheme.defaultBorderRadius,
double focusScale = FocusTheme.focusScale,
double focusBorderStrokeAlign = BorderSide.strokeAlignInside,
bool useFocusGlow = false,
bool useForegroundFocusDecoration = false,
required Widget child,
}) {
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
@@ -93,15 +97,25 @@ class FocusBuilders {
final duration = FocusTheme.getAnimationDuration(context);
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(
scale: showFocus ? FocusTheme.focusScale : 1.0,
scale: showFocus ? focusScale : 1.0,
duration: duration,
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: borderRadius),
decoration: useForegroundFocusDecoration ? glowDecoration : focusDecoration,
foregroundDecoration: useForegroundFocusDecoration ? focusDecoration : null,
child: child,
),
);
@@ -138,6 +152,10 @@ class FocusBuilders {
VoidCallback? onTap,
VoidCallback? onLongPress,
double borderRadius = FocusTheme.defaultBorderRadius,
double focusScale = FocusTheme.focusScale,
double focusBorderStrokeAlign = BorderSide.strokeAlignInside,
bool useFocusGlow = false,
bool useForegroundFocusDecoration = false,
required Widget child,
}) {
return buildFocusableCard(
@@ -148,6 +166,10 @@ class FocusBuilders {
onTap: onTap,
onLongPress: onLongPress,
borderRadius: borderRadius,
focusScale: focusScale,
focusBorderStrokeAlign: focusBorderStrokeAlign,
useFocusGlow: useFocusGlow,
useForegroundFocusDecoration: useForegroundFocusDecoration,
child: child,
);
}
+10
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../focus/focus_theme.dart';
import '../focus/focusable_wrapper.dart';
import '../utils/platform_detector.dart';
import 'media_card.dart';
@@ -30,6 +31,9 @@ class FocusableMediaCard extends StatefulWidget {
/// True when in a hub with mixed content (movies + episodes)
final bool mixedHubContext;
/// Render grid cards as image-only full-bleed cards.
final bool fullBleedImage;
/// Show server name in list view (multi-server)
final bool showServerName;
@@ -79,6 +83,7 @@ class FocusableMediaCard extends StatefulWidget {
this.collectionId,
this.isOffline = false,
this.mixedHubContext = false,
this.fullBleedImage = false,
this.showServerName = false,
this.disableScale = false,
this.focusNode,
@@ -111,6 +116,10 @@ class _FocusableMediaCardState extends State<FocusableMediaCard> {
onFocusChange: widget.onFocusChange,
enableLongPress: true,
disableScale: widget.disableScale,
focusScale: widget.fullBleedImage ? FocusTheme.fullCardFocusScale : FocusTheme.focusScale,
focusBorderStrokeAlign: widget.fullBleedImage ? BorderSide.strokeAlignOutside : BorderSide.strokeAlignInside,
useFocusGlow: widget.fullBleedImage,
useForegroundFocusDecoration: widget.fullBleedImage,
useComfortableZone: !PlatformDetector.isTV(), // Always center on TV
scrollAlignment: 0.5,
child: MediaCard(
@@ -127,6 +136,7 @@ class _FocusableMediaCardState extends State<FocusableMediaCard> {
collectionId: widget.collectionId,
isOffline: widget.isOffline,
mixedHubContext: widget.mixedHubContext,
fullBleedImage: widget.fullBleedImage,
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 showServerName; // Show server name in list view (multi-server)
final EpisodePosterMode? episodePosterModeOverride;
final bool fullBleedImage;
const MediaCard({
super.key,
@@ -77,6 +78,7 @@ class MediaCard extends StatefulWidget {
this.mixedHubContext = false,
this.showServerName = false,
this.episodePosterModeOverride,
this.fullBleedImage = false,
});
@override
@@ -264,8 +266,65 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
/// Grid layout — inlined from former _MediaCardGrid, _PosterOverlay, and
/// flattened Column. Semantics removed (InkWell provides button semantics).
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
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;
return SizedBox(
+22 -6
View File
@@ -3,13 +3,14 @@ import '../utils/grid_size_calculator.dart';
import '../utils/layout_constants.dart';
/// 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 {
/// Creates a standard grid delegate for media items
///
/// Uses [GridSizeCalculator.getMaxCrossAxisExtent] by default.
/// Set [usePaddingAware] to true to use [GridSizeCalculator.getMaxCrossAxisExtentWithPadding] instead.
/// 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 —
/// the caller is then responsible for providing a fully-resolved per-cell width.
static SliverGridDelegateWithMaxCrossAxisExtent createDelegate({
@@ -18,11 +19,11 @@ class MediaGridDelegate {
bool usePaddingAware = false,
double horizontalPadding = 16,
bool useWideAspectRatio = false,
bool fullBleedImage = false,
double? maxCrossAxisExtentOverride,
}) {
final aspectRatio = useWideAspectRatio
? GridLayoutConstants.episodeGridCellAspectRatio
: GridLayoutConstants.posterAspectRatio;
final aspectRatio = aspectRatioFor(useWideAspectRatio: useWideAspectRatio, fullBleedImage: fullBleedImage);
final spacing = spacingFor(context: context, fullBleedImage: fullBleedImage);
double maxCrossAxisExtent;
if (maxCrossAxisExtentOverride != null) {
@@ -42,8 +43,23 @@ class MediaGridDelegate {
return SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent,
childAspectRatio: aspectRatio,
crossAxisSpacing: GridLayoutConstants.crossAxisSpacing,
mainAxisSpacing: GridLayoutConstants.mainAxisSpacing,
crossAxisSpacing: spacing,
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/layout_constants.dart';
import 'app_icon.dart';
import 'clickable_cursor.dart';
import 'focus_builders.dart';
import 'horizontal_scroll_with_arrows.dart';
import 'media_card.dart';
@@ -57,6 +58,7 @@ class TvBrowseRailLayoutMetrics {
class TvBrowseRailLayout {
static const double compactTallPosterScale = 0.80;
static const double compactEpisodeThumbnailScale = compactTallPosterScale;
static const double fullCardFocusScale = FocusTheme.fullCardFocusScale;
static double scaleForSize(Size size) => TvLayoutConstants.scaleForSize(size);
@@ -70,6 +72,21 @@ class TvBrowseRailLayout {
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 hubStripGapForScale(double _) => 0;
@@ -111,12 +128,13 @@ class TvBrowseRailLayout {
required int density,
required EpisodePosterMode episodePosterMode,
required double scale,
bool fullCardLayout = false,
double tallPosterScale = 1.0,
double widePosterScale = 1.0,
}) {
final focusExtra = FocusTheme.focusBorderWidth * 2 * scale;
final railEdgePadding = focusExtra + (12 * scale);
final itemGap = itemGapForScale(scale);
final itemGap = fullCardLayout ? fullCardItemGapForScale(scale) : itemGapForScale(scale);
final isPersonHub = TvBrowseRailLayout.isPersonHub(hub);
final hasWide = !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,
);
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 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);
return TvBrowseRailLayoutMetrics(
@@ -159,6 +178,7 @@ class TvBrowseRailLayout {
EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub,
double Function(MediaHub hub)? widePosterScaleForHub,
required double scale,
bool fullCardLayout = false,
double tallPosterScale = 1.0,
double widePosterScale = 1.0,
}) {
@@ -170,6 +190,7 @@ class TvBrowseRailLayout {
density: density,
episodePosterMode: episodePosterModeForHub?.call(hub) ?? episodePosterMode,
scale: scale,
fullCardLayout: fullCardLayout,
tallPosterScale: tallPosterScale,
widePosterScale: widePosterScaleForHub?.call(hub) ?? widePosterScale,
);
@@ -185,7 +206,7 @@ class TvBrowseRailLayout {
required double scale,
}) {
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;
return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble();
}
@@ -196,7 +217,7 @@ class TvBrowseRailLayout {
required TvBrowseRailLayoutMetrics metrics,
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;
}
@@ -227,6 +248,7 @@ class TvBrowseRailLayout {
required EpisodePosterMode episodePosterMode,
EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub,
double Function(MediaHub hub)? widePosterScaleForHub,
bool fullCardLayout = false,
double tallPosterScale = 1.0,
double widePosterScale = 1.0,
}) {
@@ -244,6 +266,7 @@ class TvBrowseRailLayout {
episodePosterModeForHub: episodePosterModeForHub,
widePosterScaleForHub: widePosterScaleForHub,
scale: scale,
fullCardLayout: fullCardLayout,
tallPosterScale: tallPosterScale,
widePosterScale: widePosterScale,
);
@@ -784,7 +807,11 @@ class TvBrowseRailState extends State<TvBrowseRail> {
Widget build(BuildContext context) {
if (_activeHub == null) return const SizedBox.shrink();
return SettingsBuilder(
prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode],
prefs: const [
SettingsService.libraryDensity,
SettingsService.episodePosterMode,
SettingsService.tvFullCardLayout,
],
builder: (context) => LayoutBuilder(
builder: (context, constraints) {
final svc = SettingsService.instanceOrNull!;
@@ -800,6 +827,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final railViewportWidth = (availableWidth + interactionExpansion).clamp(1.0, double.infinity).toDouble();
final density = svc.read(SettingsService.libraryDensity);
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 wideScales = [
for (final hub in widget.hubs) widget.widePosterScaleForHub?.call(hub) ?? widget.widePosterScale,
@@ -812,6 +840,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
density: density,
episodePosterMode: modes[i],
scale: scale,
fullCardLayout: fullCardLayout,
tallPosterScale: widget.tallPosterScale,
widePosterScale: wideScales[i],
),
@@ -845,7 +874,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
TvBrowseRailLayout.railTopPaddingForScale(scale) +
viewportHeight +
TvBrowseRailLayout.railBottomPaddingForScale(scale);
final paintOverflow = fullCardLayout && hasFocus
? TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale)
: 0.0;
return Focus(
focusNode: _focusNode,
onKeyEvent: _handleKeyEvent,
@@ -874,7 +905,12 @@ class TvBrowseRailState extends State<TvBrowseRail> {
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
child: ClipRect(
clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0, verticalOverflow: 0),
clipper: _RailClipper(
leftOverflow: horizontalInset,
rightOverflow: paintOverflow,
topOverflow: 0,
bottomOverflow: paintOverflow,
),
child: SizedBox(
height: viewportHeight,
child: _buildHubSectionList(
@@ -883,6 +919,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
metricsByHub: metricsByHub,
sectionHeights: sectionHeights,
scale: scale,
fullCardLayout: fullCardLayout,
leftOverflow: horizontalInset,
interactionExpansion: interactionExpansion,
railViewportWidth: railViewportWidth,
@@ -908,6 +945,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
required List<TvBrowseRailLayoutMetrics> metricsByHub,
required List<double> sectionHeights,
required double scale,
required bool fullCardLayout,
required double leftOverflow,
required double interactionExpansion,
required double railViewportWidth,
@@ -946,6 +984,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
episodePosterMode: modes[hubIndex],
metrics: metrics,
scale: scale,
fullCardLayout: fullCardLayout,
leftOverflow: leftOverflow,
interactionExpansion: interactionExpansion,
railViewportWidth: railViewportWidth,
@@ -1010,6 +1049,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
required EpisodePosterMode episodePosterMode,
required TvBrowseRailLayoutMetrics metrics,
required double scale,
required bool fullCardLayout,
required double leftOverflow,
required double interactionExpansion,
required double railViewportWidth,
@@ -1019,6 +1059,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final inactiveIndex = HubFocusMemory.getForHubOnly(hub.id, totalCount);
final focusedIndex = isActiveHub ? _itemIndex : inactiveIndex;
final scrollController = _scrollControllerForHub(hub, metrics, railViewportWidth, scale, focusedIndex);
final paintOverflow = fullCardLayout && hasFocus && isActiveHub
? TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale)
: 0.0;
_metricsByHub[hub.id] = metrics;
_scaleByHub[hub.id] = scale;
@@ -1030,8 +1073,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
child: ClipRect(
clipper: _RailClipper(
leftOverflow: leftOverflow,
rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap,
verticalOverflow: metrics.focusExtra,
rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap + paintOverflow,
verticalOverflow: fullCardLayout ? math.max(metrics.focusExtra, paintOverflow) : metrics.focusExtra,
),
child: HorizontalScrollWithArrows(
controller: scrollController,
@@ -1048,79 +1091,69 @@ class TvBrowseRailState extends State<TvBrowseRail> {
if (itemIndex == hub.items.length) {
return Padding(
padding: EdgeInsets.only(right: metrics.itemGap),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: () {
_selectHubItem(hub, hubIndex, itemIndex);
_navigateToHubDetail(hub);
},
child: SizedBox(
width: 132 * scale,
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,
),
),
],
),
child: Align(
alignment: Alignment.centerLeft,
child: _buildViewAllButton(
context,
isFocused: isFocused,
scale: scale,
onTap: () {
_selectHubItem(hub, hubIndex, itemIndex);
_navigateToHubDetail(hub);
},
),
),
);
}
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(
padding: EdgeInsets.only(right: metrics.itemGap),
child: MouseRegion(
onEnter: (_) => _setHoveredItem(hub, itemIndex),
child: FocusBuilders.buildLockedFocusWrapper(
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,
),
),
child: Align(alignment: Alignment.topLeft, child: focusableCard),
),
);
},
@@ -1137,10 +1170,81 @@ class TvBrowseRailState extends State<TvBrowseRail> {
required double cardWidth,
required double imageSize,
required double scale,
required bool fullCardLayout,
}) {
final theme = Theme.of(context);
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(
width: cardWidth,
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 {
@@ -1226,18 +1402,27 @@ class _RailBackgroundBleed extends StatelessWidget {
class _RailClipper extends CustomClipper<Rect> {
final double leftOverflow;
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
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
bool shouldReclip(covariant _RailClipper oldClipper) {
return oldClipper.leftOverflow != leftOverflow ||
oldClipper.rightOverflow != rightOverflow ||
oldClipper.verticalOverflow != verticalOverflow;
oldClipper.topOverflow != topOverflow ||
oldClipper.bottomOverflow != bottomOverflow;
}
}
+1
View File
@@ -165,6 +165,7 @@ void main() {
hubs: [hub],
density: LibraryDensity.max,
episodePosterMode: settings.read(SettingsService.episodePosterMode),
fullCardLayout: settings.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
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', () {
test('last manual host address trims whitespace and drops blanks', () async {
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_test/flutter_test.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/media/media_backend.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/settings_service.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/tv_browse_rail.dart';
import 'package:provider/provider.dart';
@@ -176,6 +178,40 @@ void main() {
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', () {
final episode = MediaItem(
id: 'episode_1',
@@ -275,6 +311,326 @@ void main() {
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 {
final serverManager = MultiServerManager();
final firstItem = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
@@ -500,6 +856,7 @@ void main() {
await tester.pump();
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final fullCardLayout = SettingsService.instanceOrNull!.read(SettingsService.tvFullCardLayout);
final availableWidth = 700 - TvBrowseRailLayout.horizontalInsetForScale(scale);
final movieMetrics = TvBrowseRailLayout.metricsForHub(
hub: movieHub,
@@ -507,6 +864,7 @@ void main() {
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
fullCardLayout: fullCardLayout,
);
final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale,
@@ -533,6 +891,7 @@ void main() {
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
fullCardLayout: fullCardLayout,
);
final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex(
hub: episodeHub,
@@ -620,6 +979,7 @@ void main() {
});
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.physicalSize = const Size(1280, 720);
addTearDown(() {
@@ -707,6 +1067,7 @@ void main() {
});
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.physicalSize = const Size(1280, 720);
addTearDown(() {