diff --git a/lib/focus/card_focus_scope.dart b/lib/focus/card_focus_scope.dart index ffb247c2..ebcbf76b 100644 --- a/lib/focus/card_focus_scope.dart +++ b/lib/focus/card_focus_scope.dart @@ -5,8 +5,8 @@ import 'focus_theme.dart'; /// Exposes the focus state of an enclosing focus wrapper to a descendant /// [CardFocusBorder] that draws the focus border itself. /// -/// Wrappers ([FocusableWrapper]/[FocusBuilders.buildFocusableCard]) insert this -/// instead of painting a border when `delegateFocusBorder` is set, so cards can +/// The shared focus chrome ([buildFocusChrome]) inserts this instead of painting +/// a border when `delegateFocusBorder` is set, so cards can /// put the border on the exact rect the design highlights (the poster image, /// not the card-plus-captions rect — issue #1278). Only the [CardFocusBorder] /// element registers a dependency, so a focus flip rebuilds just that border diff --git a/lib/focus/focus_chrome.dart b/lib/focus/focus_chrome.dart new file mode 100644 index 00000000..841339d4 --- /dev/null +++ b/lib/focus/focus_chrome.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'card_focus_scope.dart'; +import 'focus_glow_overlay.dart'; +import 'focus_theme.dart'; + +/// Builds the focus border/glow chrome shared by [FocusableWrapper] and +/// [FocusBuilders.buildLockedFocusWrapper]. +/// +/// A function rather than a widget so it costs no element per card in dense TV +/// grids. Scale and input handling stay with the callers: the wrapper drives a +/// paint-only scale from its own controller and owns the [Focus] node, while +/// the locked builder scales implicitly and wraps gestures itself. +/// +/// Callers pass the [duration] they already resolved via +/// [FocusTheme.getAnimationDuration] so a build resolves it once. +Widget buildFocusChrome( + BuildContext context, { + required bool showFocus, + required Duration duration, + double borderRadius = FocusTheme.defaultBorderRadius, + BorderRadius? borderRadii, + Color? focusColor, + bool useBackgroundFocus = false, + bool useFocusGlow = false, + bool delegateFocusBorder = false, + Size? glowSize, + required Widget child, +}) { + Widget card; + if (delegateFocusBorder) { + card = CardFocusScope(showFocus: showFocus, child: child); + } else { + final decoration = useBackgroundFocus + ? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: borderRadius, radii: borderRadii) + : FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: borderRadius, + radii: borderRadii, + color: focusColor, + ); + card = AnimatedContainer(duration: duration, curve: Curves.easeOutCubic, decoration: decoration, child: child); + } + + // Glow (full-bleed cards) renders in an overlay above siblings so it stays + // symmetric; the in-card decoration only carries the border. + if (useFocusGlow) { + card = FocusGlowOverlay( + isFocused: showFocus, + borderRadius: borderRadius, + color: focusColor ?? FocusTheme.getFocusBorderColor(context), + glowSize: glowSize, + child: card, + ); + } + + return card; +} diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index fe2b30e8..71100e6a 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -4,10 +4,9 @@ import 'package:flutter/rendering.dart'; import '../widgets/clickable_cursor.dart'; import '../utils/text_input_diagnostics.dart'; -import 'card_focus_scope.dart'; import 'dpad_navigator.dart'; import 'dpad_select_long_press_controller.dart'; -import 'focus_glow_overlay.dart'; +import 'focus_chrome.dart'; import 'focus_theme.dart'; import 'input_mode_tracker.dart'; import 'owned_focus_node_binding.dart'; @@ -541,41 +540,20 @@ class _FocusableWrapperState extends State with SingleTickerPr // Keep the card subtree outside the scale builder. Rebuilding media-card // semantics on every animation tick is substantially more expensive than // changing the paint transform alone on dense TV grids. - Widget card; - if (widget.delegateFocusBorder) { - card = CardFocusScope(showFocus: showFocus, child: widget.child); - } else { - final focusDecoration = widget.useBackgroundFocus - ? FocusTheme.focusBackgroundDecoration( - isFocused: showFocus, - borderRadius: widget.borderRadius, - radii: widget.borderRadii, - ) - : FocusTheme.focusDecoration( - context, - isFocused: showFocus, - borderRadius: widget.borderRadius, - radii: widget.borderRadii, - color: widget.focusColor, - ); - card = AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - decoration: focusDecoration, - child: widget.child, - ); - } - if (widget.useFocusGlow) { - card = FocusGlowOverlay( - isFocused: showFocus, - borderRadius: widget.borderRadius, - color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context), - child: card, - ); - } inner = AnimatedBuilder( animation: _scaleAnimation!, - child: card, + child: buildFocusChrome( + context, + showFocus: showFocus, + duration: duration, + borderRadius: widget.borderRadius, + borderRadii: widget.borderRadii, + focusColor: widget.focusColor, + useBackgroundFocus: widget.useBackgroundFocus, + useFocusGlow: widget.useFocusGlow, + delegateFocusBorder: widget.delegateFocusBorder, + child: widget.child, + ), builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!), ); } diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 42b05a5c..0ec10bb6 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -642,13 +642,6 @@ sealed class MediaItem with _$MediaItem { return resolvedBackdropPaths; } - /// Returns the best hero art path based on the container's aspect ratio. - String? heroArt({required double containerAspectRatio}) { - final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio); - if (candidates.isEmpty) return null; - return candidates.first; - } - /// Returns hero art candidates in display-preference order. List heroArtCandidates({required double containerAspectRatio}) { final own = resolvedBackdropPaths; diff --git a/lib/media/media_playlist.dart b/lib/media/media_playlist.dart index 4b8b06fa..e142db04 100644 --- a/lib/media/media_playlist.dart +++ b/lib/media/media_playlist.dart @@ -61,11 +61,6 @@ class MediaPlaylist { /// Display-friendly title (alias of [title] for parity with [MediaItem]). String get displayTitle => title; - /// Whether this playlist's contents can be reordered/edited by the client. - /// Plex smart playlists are read-only; manual playlists and Jellyfin - /// playlists are editable. - bool get isEditable => !smart; - String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), id) : id; MediaPlaylist copyWith({ diff --git a/lib/models/plex/plex_user_profile.dart b/lib/models/plex/plex_user_profile.dart index 9e19b3da..88773fa7 100644 --- a/lib/models/plex/plex_user_profile.dart +++ b/lib/models/plex/plex_user_profile.dart @@ -10,8 +10,7 @@ part 'plex_user_profile.g.dart'; /// /// Every field parses tolerantly: the account API drifts (~July 2026 the /// language-list fields switched from arrays to CSV strings, #1488), and a -/// profile blob must never fail to parse — token minting embeds it (see -/// UserSwitchResponse.fromJson). +/// single drifted field must never sink the whole profile. @JsonSerializable() class PlexUserProfile implements MediaServerUserProfile { @JsonKey(fromJson: _boolOrTrue) @@ -62,19 +61,6 @@ class PlexUserProfile implements MediaServerUserProfile { this.mediaReviewsLanguages, }); - /// Neutral fallback matching the generated defaults — used when the account - /// API returns a profile blob that cannot be parsed at all (schema drift - /// must never break token minting, see UserSwitchResponse.fromJson). - factory PlexUserProfile.defaults() => PlexUserProfile( - autoSelectAudio: true, - defaultAudioAccessibility: 0, - autoSelectSubtitle: 0, - defaultSubtitleAccessibility: 0, - defaultSubtitleForced: 1, - watchedIndicator: 1, - mediaReviewsVisibility: 0, - ); - factory PlexUserProfile.fromJson(Map json) { final envelope = json['profile']; final profile = envelope is Map ? envelope : json; diff --git a/lib/models/user_switch_response.dart b/lib/models/user_switch_response.dart index 03149da3..05c4e263 100644 --- a/lib/models/user_switch_response.dart +++ b/lib/models/user_switch_response.dart @@ -1,120 +1,13 @@ -import '../utils/app_logger.dart'; -import '../utils/json_utils.dart'; -import 'plex/plex_user_profile.dart'; - -class UserSwitchResponse { - final int id; - final String uuid; - final String username; - final String title; - final String email; - final String? friendlyName; - final String? locale; - final bool confirmed; - final int joinedAt; - final bool emailOnlyAuth; - final bool hasPassword; - final bool protected; - final String thumb; - final String authToken; - final bool? mailingListActive; - final String scrobbleTypes; - final String country; - final bool restricted; - final bool? anonymous; - final bool home; - final bool guest; - final int homeSize; - final bool homeAdmin; - final int maxHomeSize; - final PlexUserProfile profile; - final bool twoFactorEnabled; - final bool backupCodesCreated; - final String? attributionPartner; - - UserSwitchResponse({ - required this.id, - required this.uuid, - required this.username, - required this.title, - required this.email, - this.friendlyName, - this.locale, - required this.confirmed, - required this.joinedAt, - required this.emailOnlyAuth, - required this.hasPassword, - required this.protected, - required this.thumb, - required this.authToken, - this.mailingListActive, - required this.scrobbleTypes, - required this.country, - required this.restricted, - this.anonymous, - required this.home, - required this.guest, - required this.homeSize, - required this.homeAdmin, - required this.maxHomeSize, - required this.profile, - required this.twoFactorEnabled, - required this.backupCodesCreated, - this.attributionPartner, - }); - - /// INVARIANT (#1488): a successful token mint must never be lost to parsing - /// of decorative fields. `authToken` is the only field any caller consumes - /// (see plex_home_switch.dart) — it alone parses strictly; every other - /// field tolerates missing/wrong-typed values with sane defaults. Plex has - /// changed field shapes on this endpoint before (July 2026: profile - /// language lists became CSV strings), and each drift used to brick token - /// minting outright. - factory UserSwitchResponse.fromJson(Map json) { - final authToken = json['authToken']; - if (authToken is! String || authToken.isEmpty) { - throw const FormatException('Plex /switch response has no usable authToken'); - } - - PlexUserProfile profile; - try { - profile = PlexUserProfile.fromJson(json); - } catch (e, st) { - appLogger.w('UserSwitchResponse: profile blob failed to parse; using defaults', error: e, stackTrace: st); - profile = PlexUserProfile.defaults(); - } - - String? optString(String key) => json[key]?.toString(); - - return UserSwitchResponse( - id: flexibleInt(json['id']) ?? 0, - uuid: optString('uuid') ?? '', - username: optString('username') ?? '', - title: optString('title') ?? '', - email: optString('email') ?? '', - friendlyName: optString('friendlyName'), - locale: optString('locale'), - confirmed: flexibleBool(json['confirmed']), - joinedAt: flexibleInt(json['joinedAt']) ?? 0, - emailOnlyAuth: flexibleBool(json['emailOnlyAuth']), - hasPassword: flexibleBool(json['hasPassword']), - protected: flexibleBool(json['protected']), - thumb: optString('thumb') ?? '', - authToken: authToken, - mailingListActive: flexibleBoolNullable(json['mailingListActive']), - scrobbleTypes: optString('scrobbleTypes') ?? '', - country: optString('country') ?? '', - restricted: flexibleBool(json['restricted']), - anonymous: flexibleBoolNullable(json['anonymous']), - home: flexibleBool(json['home']), - guest: flexibleBool(json['guest']), - homeSize: flexibleInt(json['homeSize']) ?? 1, - homeAdmin: flexibleBool(json['homeAdmin']), - maxHomeSize: flexibleInt(json['maxHomeSize']) ?? 1, - profile: profile, - twoFactorEnabled: flexibleBool(json['twoFactorEnabled']), - backupCodesCreated: flexibleBool(json['backupCodesCreated']), - attributionPartner: optString('attributionPartner'), - ); +/// INVARIANT (#1488): a successful token mint must never be lost to parsing +/// of decorative fields. `authToken` is the only field any caller consumes +/// (see plex_home_switch.dart), so nothing else on the `/switch` body is +/// read. Plex has changed field shapes on this endpoint before (July 2026: +/// profile language lists became CSV strings), and each drift used to brick +/// token minting outright. +String parsePlexSwitchAuthToken(Map json) { + final authToken = json['authToken']; + if (authToken is! String || authToken.isEmpty) { + throw const FormatException('Plex /switch response has no usable authToken'); } + return authToken; } diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 37512204..ebd9fd50 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -145,6 +145,21 @@ class PlayerAndroid extends PlayerBase { } } + // A setting requested before the core is up is applied by _doInitialize from + // the stored fields; one requested while an init is in flight has to be + // replayed afterwards, but only if no newer request superseded it. + Future _applyWhenInitialized(Future Function() apply, bool Function() stillRequested) async { + final initFuture = _initFuture; + if (initialized) { + await apply(); + } else if (initFuture != null) { + await initFuture; + if (!disposed && initialized && stillRequested()) { + await apply(); + } + } + } + @override Future open( Media media, { @@ -279,15 +294,10 @@ class PlayerAndroid extends PlayerBase { break; case 'dv-conversion-mode': _dvConversionMode = value; - final initFuture = _initFuture; - if (initialized) { - await invoke('setDvConversionMode', {'mode': value}); - } else if (initFuture != null) { - await initFuture; - if (!disposed && initialized && _dvConversionMode == value) { - await invoke('setDvConversionMode', {'mode': value}); - } - } + await _applyWhenInitialized( + () => invoke('setDvConversionMode', {'mode': value}), + () => _dvConversionMode == value, + ); break; case 'sub-visibility': if (value == 'no') { @@ -316,15 +326,10 @@ class PlayerAndroid extends PlayerBase { Future setAudioNormalization(bool enabled) async { if (disposed) return; _audioNormalizationEnabled = enabled; - final initFuture = _initFuture; - if (initialized) { - await invoke('setAudioNormalization', {'enabled': enabled}); - } else if (initFuture != null) { - await initFuture; - if (!disposed && initialized && _audioNormalizationEnabled == enabled) { - await invoke('setAudioNormalization', {'enabled': enabled}); - } - } + await _applyWhenInitialized( + () => invoke('setAudioNormalization', {'enabled': enabled}), + () => _audioNormalizationEnabled == enabled, + ); // Keep the mpv af property flowing through setMpvProperty so the plugin's // pendingMpvProperties replay applies loudnorm if exo falls back to mpv. await super.setAudioNormalization(enabled); @@ -336,21 +341,10 @@ class PlayerAndroid extends PlayerBase { _downmixEnabled = enabled; _downmixCenterBoostDb = centerBoostDb; _downmixNormalize = normalize; - Future invokeNative() => - invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}); - final initFuture = _initFuture; - if (initialized) { - await invokeNative(); - } else if (initFuture != null) { - await initFuture; - if (!disposed && - initialized && - _downmixEnabled == enabled && - _downmixCenterBoostDb == centerBoostDb && - _downmixNormalize == normalize) { - await invokeNative(); - } - } + await _applyWhenInitialized( + () => invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}), + () => _downmixEnabled == enabled && _downmixCenterBoostDb == centerBoostDb && _downmixNormalize == normalize, + ); // Keep the mpv properties flowing through setMpvProperty so the plugin's // pendingMpvProperties replay applies downmix if exo falls back to mpv. await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize); @@ -360,15 +354,10 @@ class PlayerAndroid extends PlayerBase { Future setAudioPassthrough(bool enabled) async { if (disposed) return; _audioPassthroughEnabled = enabled; - final initFuture = _initFuture; - if (initialized) { - await invoke('setAudioPassthrough', {'enabled': enabled}); - } else if (initFuture != null) { - await initFuture; - if (!disposed && initialized && _audioPassthroughEnabled == enabled) { - await invoke('setAudioPassthrough', {'enabled': enabled}); - } - } + await _applyWhenInitialized( + () => invoke('setAudioPassthrough', {'enabled': enabled}), + () => _audioPassthroughEnabled == enabled, + ); await setProperty('audio-spdif', enabled ? _passthroughCodecs : ''); } diff --git a/lib/profiles/plex_home_switch.dart b/lib/profiles/plex_home_switch.dart index 1f101fa4..c772494b 100644 --- a/lib/profiles/plex_home_switch.dart +++ b/lib/profiles/plex_home_switch.dart @@ -51,8 +51,8 @@ Future switchPlexHomeUserWithPin({ if (pin == null) return const PlexHomeSwitchResult._(PlexHomeSwitchStatus.cancelled, null); } try { - final response = await auth.switchToUser(homeUserUuid, accountToken, pin: pin); - return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, response.authToken); + final userToken = await auth.switchToUser(homeUserUuid, accountToken, pin: pin); + return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, userToken); } on MediaServerHttpException catch (e) { if (e.statusCode == 403 && _isInvalidPin(e)) { error = t.profiles.incorrectPinTryAgain; diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 41a3ab23..a1834bd7 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -889,6 +889,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Check if an item is in the queue /// For shows/seasons, checks if any episodes are queued + @visibleForTesting bool isQueued(String globalKey) { final progress = getProgress(globalKey); return progress?.status == DownloadStatus.queued; diff --git a/lib/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart index 4f52aac7..7d64550d 100644 --- a/lib/providers/hidden_libraries_provider.dart +++ b/lib/providers/hidden_libraries_provider.dart @@ -76,6 +76,7 @@ class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifi } /// Check if a specific library is hidden + @visibleForTesting bool isLibraryHidden(String libraryKey) => _hiddenLibraryKeys.contains(libraryKey); /// Refresh hidden libraries from storage diff --git a/lib/providers/libraries_provider.dart b/lib/providers/libraries_provider.dart index 533980c5..cf5c0757 100644 --- a/lib/providers/libraries_provider.dart +++ b/lib/providers/libraries_provider.dart @@ -65,9 +65,11 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi bool get isLoading => _loadState == LibrariesLoadState.loading; /// Whether libraries have been loaded at least once + @visibleForTesting bool get hasLoaded => _loadState == LibrariesLoadState.loaded; /// Current load state + @visibleForTesting LibrariesLoadState get loadState => _loadState; /// Error message if loading failed diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index c9541bc8..81b3c52a 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -75,9 +75,11 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi } /// Whether there is network connectivity (WiFi, mobile data, etc.) + @visibleForTesting bool get hasNetworkConnection => _hasNetworkConnection; /// Whether at least one media server (Plex or Jellyfin) is reachable + @visibleForTesting bool get hasServerConnection => _hasServerConnection; bool get _hasKnownVisibleServers => diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index d0fd9857..c4abd3f5 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -71,6 +71,7 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// 2. Metadata from download provider /// /// Returns null if no position is available. + @visibleForTesting Future getViewOffset(String globalKey) async { // First check local offline progress final localOffset = await _syncService.getLocalViewOffset(globalKey); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 6a25bedb..25cfacd6 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -88,6 +88,7 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin, W static const _themeChannel = MethodChannel('com.plezy/theme'); + @visibleForTesting Future setThemeMode(settings.ThemeMode mode) async { if (_themeMode == mode) return; final service = _settingsBinding.settings ?? await settings.SettingsService.getInstance(); diff --git a/lib/providers/watch_state_store.dart b/lib/providers/watch_state_store.dart index a02c1949..f4cac153 100644 --- a/lib/providers/watch_state_store.dart +++ b/lib/providers/watch_state_store.dart @@ -97,6 +97,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin return _exactEntryFor(globalKey); } + @visibleForTesting WatchStateSnapshot? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; WatchStateSnapshot? patchForItem(MediaItem item) { diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index b279e896..22e3e2f2 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -11,9 +11,7 @@ class SliverErrorState extends StatelessWidget { final String? retryLabel; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; final bool actionAutofocus; final bool actionUseBackgroundFocus; @@ -25,9 +23,7 @@ class SliverErrorState extends StatelessWidget { this.retryLabel, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, this.actionAutofocus = false, this.actionUseBackgroundFocus = false, @@ -42,9 +38,7 @@ class SliverErrorState extends StatelessWidget { retryLabel: retryLabel, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, actionAutofocus: actionAutofocus, actionUseBackgroundFocus: actionUseBackgroundFocus, @@ -62,9 +56,7 @@ class SliverEmptyState extends StatelessWidget { final IconData? actionIcon; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; const SliverEmptyState({ @@ -77,9 +69,7 @@ class SliverEmptyState extends StatelessWidget { this.actionIcon, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, }); @@ -94,9 +84,7 @@ class SliverEmptyState extends StatelessWidget { actionIcon: actionIcon, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, ), ); diff --git a/lib/screens/libraries/state_messages.dart b/lib/screens/libraries/state_messages.dart index cf6bf3fe..a1b113fe 100644 --- a/lib/screens/libraries/state_messages.dart +++ b/lib/screens/libraries/state_messages.dart @@ -39,9 +39,7 @@ class StateMessageWidget extends StatelessWidget { final IconData? actionIcon; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; /// Whether the action button should request focus when it appears. @@ -63,9 +61,7 @@ class StateMessageWidget extends StatelessWidget { this.actionLabel, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, this.actionIcon, this.actionAutofocus = false, @@ -110,9 +106,7 @@ class StateMessageWidget extends StatelessWidget { FocusableButton( focusNode: actionFocusNode, onNavigateUp: onActionNavigateUp, - onNavigateDown: onActionNavigateDown, onNavigateLeft: onActionNavigateLeft, - onNavigateRight: onActionNavigateRight, onBack: onActionBack, onPressed: onAction, autofocus: actionAutofocus, @@ -155,9 +149,7 @@ class EmptyStateWidget extends StatelessWidget { final IconData? actionIcon; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; const EmptyStateWidget({ @@ -171,9 +163,7 @@ class EmptyStateWidget extends StatelessWidget { this.actionIcon, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, }); @@ -189,9 +179,7 @@ class EmptyStateWidget extends StatelessWidget { actionIcon: actionIcon ?? Symbols.add_rounded, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, ); } @@ -218,9 +206,7 @@ class ErrorStateWidget extends StatelessWidget { final String? retryLabel; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; const ErrorStateWidget({ @@ -231,9 +217,7 @@ class ErrorStateWidget extends StatelessWidget { this.retryLabel, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, this.actionAutofocus = false, this.actionUseBackgroundFocus = false, @@ -251,9 +235,7 @@ class ErrorStateWidget extends StatelessWidget { actionIcon: Symbols.refresh_rounded, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, actionAutofocus: actionAutofocus, actionUseBackgroundFocus: actionUseBackgroundFocus, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 44776ddc..0418be05 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -3562,6 +3562,42 @@ class _MediaDetailScreenState extends State ); } + /// The ordered metadata fields the TV detail line renders and its announcement reads, + /// built through [text] for plain fields and [rating] for the rating slot. + List _tvDetailMetadataParts( + MediaItem metadata, { + required T Function(String value) text, + required T? Function(MediaItem item) rating, + }) { + final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata; + final parts = []; + + void add(T? part) { + if (part != null) parts.add(part); + } + + final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); + if (lineMetadata.isEpisode && episodeLabel != null) add(text(episodeLabel)); + if (lineMetadata.isMovie) { + add(text(t.discover.movie)); + } else if (lineMetadata.isShow) { + add(text(t.discover.tvShow)); + } + add(rating(lineMetadata)); + if (lineMetadata.contentRating != null) add(text(formatContentRating(lineMetadata.contentRating!))); + if (lineMetadata.durationMs != null) add(text(formatDurationTextual(lineMetadata.durationMs!))); + if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) { + add(text(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!))); + } else if (lineMetadata.year != null) { + add(text(lineMetadata.year.toString())); + } + for (final label in buildMediaQualityLabels(lineMetadata)) { + add(text(label)); + } + + return parts; + } + String _tvDetailInformationSemanticLabel( MediaItem metadata, { required String? description, @@ -3578,23 +3614,13 @@ class _MediaDetailScreenState extends State add(metadata.displayTitle); if (!identical(lineMetadata, metadata)) add(lineMetadata.displayTitle); - final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); - if (lineMetadata.isEpisode) add(episodeLabel); - if (lineMetadata.isMovie) { - add(t.discover.movie); - } else if (lineMetadata.isShow) { - add(t.discover.tvShow); - } - add(MediaRatingBadge.semanticLabelForMedia(lineMetadata, fallbackItem: metadata)); - if (lineMetadata.contentRating != null) add(formatContentRating(lineMetadata.contentRating!)); - if (lineMetadata.durationMs != null) add(formatDurationTextual(lineMetadata.durationMs!)); - if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) { - add(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!)); - } else if (lineMetadata.year != null) { - add(lineMetadata.year.toString()); - } - for (final label in buildMediaQualityLabels(lineMetadata)) { - add(label); + final fields = _tvDetailMetadataParts( + metadata, + text: (value) => value, + rating: (item) => MediaRatingBadge.semanticLabelForMedia(item, fallbackItem: metadata), + ); + for (final field in fields) { + add(field); } if (genres.isNotEmpty) add(genres.join(', ')); add(description); @@ -3679,60 +3705,32 @@ class _MediaDetailScreenState extends State } Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) { - final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata; - final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); - final qualityLabels = buildMediaQualityLabels(lineMetadata); final textStyle = TextStyle( color: _tvDetailForegroundColor(context), fontSize: 18 * scale, fontWeight: .w700, letterSpacing: 0.1, ); - final children = []; - - void addSeparator() { - if (children.isNotEmpty) children.add(Text(' • ', maxLines: 1, style: textStyle)); - } - - void addTextPart(String text) { - addSeparator(); - children.add(Text(text, maxLines: 1, style: textStyle)); - } - - void addWidgetPart(Widget widget) { - addSeparator(); - children.add(widget); - } - - if (lineMetadata.isEpisode && episodeLabel != null) addTextPart(episodeLabel); - if (lineMetadata.isMovie) { - addTextPart(t.discover.movie); - } else if (lineMetadata.isShow) { - addTextPart(t.discover.tvShow); - } - final ratingBadge = MediaRatingBadge.inlineForMedia( - item: lineMetadata, - fallbackItem: metadata, - foregroundColor: textStyle.color, - iconSize: textStyle.fontSize, - spacing: 4 * scale, - textStyle: textStyle, + final fields = _tvDetailMetadataParts( + metadata, + text: (value) => Text(value, maxLines: 1, style: textStyle), + rating: (item) => MediaRatingBadge.inlineForMedia( + item: item, + fallbackItem: metadata, + foregroundColor: textStyle.color, + iconSize: textStyle.fontSize, + spacing: 4 * scale, + textStyle: textStyle, + ), ); - if (ratingBadge != null) { - addWidgetPart(ratingBadge); - } - if (lineMetadata.contentRating != null) addTextPart(formatContentRating(lineMetadata.contentRating!)); - if (lineMetadata.durationMs != null) addTextPart(formatDurationTextual(lineMetadata.durationMs!)); - if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) { - addTextPart(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!)); - } else if (lineMetadata.year != null) { - addTextPart(lineMetadata.year.toString()); - } - for (final label in qualityLabels) { - addTextPart(label); - } - if (children.isEmpty) return const SizedBox.shrink(); + if (fields.isEmpty) return const SizedBox.shrink(); + + final children = []; + for (final field in fields) { + if (children.isNotEmpty) children.add(Text(' • ', maxLines: 1, style: textStyle)); + children.add(field); + } return SingleChildScrollView( scrollDirection: Axis.horizontal, diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 86d7c143..e7a34eea 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -1223,82 +1223,17 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { // Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the // home rows from Latest plus optional playback rows. The richer Plex Discover surface // is intentionally left untranslated — see ServerCapabilities.richHubs. - final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'IncludeItemTypes': 'Movie,Series,Episode', - ...jellyfinImageQueryParameters, - }, retry: _homeHubRetry); - - if (!includePlaybackHubs) { - final latest = await latestFuture; - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.recent', - title: t.discover.recentlyAdded, - type: 'mixed', - items: latest, - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); - } - - final results = await Future.wait([ - latestFuture, - _safeFetchItemsArray('/UserItems/Resume', { - 'userId': connection.userId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'MediaTypes': 'Video', - 'Recursive': 'true', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }, retry: _homeHubRetry), - _safeFetchItemsArray('/Shows/NextUp', { - 'userId': connection.userId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'EnableResumable': 'false', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }, retry: _homeHubRetry), - ]); - - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.continue', - title: t.discover.continueWatching, - type: 'mixed', - items: results[1], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.nextup', - title: t.discover.nextUp, - type: 'episode', - items: results[2], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.recent', - title: t.discover.recentlyAdded, - type: 'mixed', - items: results.first, - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); + return _playbackHubSet( + idPrefix: 'home', + limit: limit, + includePlaybackHubs: includePlaybackHubs, + includeNextUp: true, + retry: _homeHubRetry, + latestItemTypes: 'Movie,Series,Episode', + continueTitle: t.discover.continueWatching, + nextUpTitle: t.discover.nextUp, + recentTitle: t.discover.recentlyAdded, + ); } @override @@ -1330,86 +1265,92 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { // Issued in parallel so the recommended tab loads in one round-trip. // When the caller knows the library kind, skip NextUp for movie libraries; // Jellyfin can otherwise spend time scanning TV state only to return []. + return _playbackHubSet( + parentId: libraryId, + idPrefix: 'library.$libraryId', + limit: limit, + includePlaybackHubs: includePlaybackHubs, + includeNextUp: libraryKind == null || libraryKind == MediaKind.show, + retry: _libraryHubRetry, + continueTitle: t.discover.continueWatchingIn(library: libraryName), + nextUpTitle: t.discover.nextUpIn(library: libraryName), + recentTitle: t.discover.recentlyAddedIn(library: libraryName), + ); + } + + /// Latest + Continue Watching + Next Up row set shared by the home and + /// per-library surfaces. Both scopes issue the same three requests in the + /// same order and synthesise the same three rows; they differ only in + /// [parentId], the row identifier prefix, the titles, and the transport + /// policy. The Latest request fires before the [includePlaybackHubs] + /// short-circuit so callers that only want Recently Added still get it in + /// one round-trip. + Future> _playbackHubSet({ + required String idPrefix, + required int limit, + required bool includePlaybackHubs, + required bool includeNextUp, + required _HubRetryPolicy retry, + required String continueTitle, + required String nextUpTitle, + required String recentTitle, + String? parentId, + String? latestItemTypes, + }) async { final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { 'Limit': limit.toString(), - 'ParentId': libraryId, + 'ParentId': ?parentId, 'Fields': _browseFields, + 'IncludeItemTypes': ?latestItemTypes, ...jellyfinImageQueryParameters, - }, retry: _libraryHubRetry); + }, retry: retry); - if (!includePlaybackHubs) { - final latest = await latestFuture; - return [ + MediaHub hub(String suffix, String title, String type, List> items) => JellyfinMappers.syntheticHub( mapItem: _mapItem, - identifier: 'library.$libraryId.recent', - title: t.discover.recentlyAddedIn(library: libraryName), - type: 'mixed', - items: latest, + identifier: '$idPrefix.$suffix', + title: title, + type: type, + items: items, previewLimit: limit, serverId: serverId, serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); + ); + + if (!includePlaybackHubs) { + final latest = await latestFuture; + return [hub('recent', recentTitle, 'mixed', latest)].where((h) => h.items.isNotEmpty).toList(); } - final includeNextUp = libraryKind == null || libraryKind == MediaKind.show; final results = await Future.wait([ latestFuture, _safeFetchItemsArray('/UserItems/Resume', { 'userId': connection.userId, - 'ParentId': libraryId, + 'ParentId': ?parentId, 'Limit': limit.toString(), 'Fields': _browseFields, 'MediaTypes': 'Video', 'Recursive': 'true', 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, - }, retry: _libraryHubRetry), + }, retry: retry), includeNextUp ? _safeFetchItemsArray('/Shows/NextUp', { 'userId': connection.userId, - 'ParentId': libraryId, + 'ParentId': ?parentId, 'Limit': limit.toString(), 'Fields': _browseFields, 'EnableResumable': 'false', 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, - }, retry: _libraryHubRetry) + }, retry: retry) : Future.value(const >[]), ]); return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.continue', - title: t.discover.continueWatchingIn(library: libraryName), - type: 'mixed', - items: results[1], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.nextup', - title: t.discover.nextUpIn(library: libraryName), - type: 'episode', - items: results[2], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.recent', - title: t.discover.recentlyAddedIn(library: libraryName), - type: 'mixed', - items: results.first, - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), + hub('continue', continueTitle, 'mixed', results[1]), + hub('nextup', nextUpTitle, 'episode', results[2]), + hub('recent', recentTitle, 'mixed', results.first), ].where((h) => h.items.isNotEmpty).toList(); } diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 448794c8..43011acf 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -103,6 +103,7 @@ class KeyboardShortcutsService extends ChangeNotifier { Map get hotkeys => Map.from(_hotkeys); + @visibleForTesting HotKey? getHotkey(String action) { return _hotkeys[action]; } diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 50ddf00b..2235c380 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -271,12 +271,6 @@ class MultiServerManager { _emitStatus(); } - /// Plex-specific server config (name, machineId, connection candidates, - /// `owned` flag). Returns `null` for Jellyfin server ids — Jellyfin has no - /// `PlexServer` analogue. For "is this server registered?" use - /// [getClient] (works for both backends). - PlexServer? getPlexServer(ServerId serverId) => _plexServers[serverId]; - String serverDisplayName(ServerId serverId) => _clients[serverId]?.serverName ?? _plexServers[serverId]?.name ?? serverId; @@ -311,12 +305,6 @@ class MultiServerManager { return result; } - /// Plex servers known to the manager. Jellyfin servers are NOT included - /// here — they have no `PlexServer` analogue (single-URL connections, - /// not connection-raced multi-endpoint structs). For an all-backends - /// view of online servers use [serverIds] or [onlineClients]. - Map get plexServers => Map.unmodifiable(_plexServers); - /// Check if a server is online bool isServerOnline(ServerId serverId) => _serverStatus[serverId] ?? false; diff --git a/lib/services/playback_subtitle_resolver.dart b/lib/services/playback_subtitle_resolver.dart index 3f1ed4a6..bd7e14c4 100644 --- a/lib/services/playback_subtitle_resolver.dart +++ b/lib/services/playback_subtitle_resolver.dart @@ -275,13 +275,6 @@ class PlaybackSubtitleResolver { return findMpvTrackForPlexSubtitle(sourceTrack, nativeTracks, allPlexTracks: allSourceTracks); } - static PlaybackSourceSubtitleChoice nextSourceChoice( - List tracks, - PlaybackSourceSubtitleChoice currentChoice, - ) { - return advanceSourceChoice(tracks, currentChoice, 1); - } - static PlaybackSourceSubtitleChoice advanceSourceChoice( List tracks, PlaybackSourceSubtitleChoice currentChoice, diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 4db143bc..04df153f 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -262,8 +262,9 @@ class PlexAuthService { return PlexHome.fromJson(response.data as Map); } - /// Switch to a different user in the home - Future switchToUser(String userUUID, String currentToken, {String? pin}) async { + /// Switch to a different user in the home, returning the freshly minted + /// user-level token + Future switchToUser(String userUUID, String currentToken, {String? pin}) async { final queryParams = { 'includeSubscriptions': '1', 'includeProviders': '1', @@ -286,7 +287,7 @@ class PlexAuthService { ); _checkStatus(response); - return UserSwitchResponse.fromJson(response.data as Map); + return parsePlexSwitchAuthToken(response.data as Map); } } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 0de09dd3..13cf3630 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -915,6 +915,7 @@ class PlexClient ); } + @visibleForTesting Future> getServerIdentity() async { final response = await _getWithFailover('/identity'); return response.data; diff --git a/lib/services/seerr/seerr_client.dart b/lib/services/seerr/seerr_client.dart index 39080475..60f87e34 100644 --- a/lib/services/seerr/seerr_client.dart +++ b/lib/services/seerr/seerr_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../models/seerr/seerr_details.dart'; @@ -65,6 +66,7 @@ class SeerrClient { // ---------- Auth ---------- + @visibleForTesting Future getMe() async { final data = await _request('GET', '/auth/me'); return SeerrUser.fromJson(data as Map); diff --git a/lib/services/sleep_timer_service.dart b/lib/services/sleep_timer_service.dart index 0ed53bd8..5d27d6f7 100644 --- a/lib/services/sleep_timer_service.dart +++ b/lib/services/sleep_timer_service.dart @@ -127,6 +127,7 @@ class SleepTimerService extends ChangeNotifier { } /// Execute the completion callback directly (fallback path) + @visibleForTesting void executeCompletion() { _executeCallback(); } diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index c30e56fb..e09adf5b 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; + import '../mpv/mpv.dart'; import '../media/media_item.dart'; @@ -82,6 +84,7 @@ class TrackManager { } /// Cached external subtitles for re-use after backend fallback. + @visibleForTesting List get lastExternalSubtitles => _lastExternalSubtitles; TrackManager({ diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index 613a520f..7e1b3f9a 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -103,10 +103,6 @@ class VideoFilterManager { return _zoomScale; } - double adjustZoom(double delta) => setZoomScale(_zoomScale + delta); - - double resetZoom() => setZoomScale(1.0); - /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) void cycleBoxFitMode() { _boxFitMode = (_boxFitMode + 1) % 3; diff --git a/lib/utils/content_utils.dart b/lib/utils/content_utils.dart index 4f8e7f66..d02b8744 100644 --- a/lib/utils/content_utils.dart +++ b/lib/utils/content_utils.dart @@ -26,17 +26,6 @@ class ContentTypeHelper { static bool isVideoContent(String type) => ContentTypes.videoTypes.contains(type.toLowerCase()); - static bool isMusicLibrary(dynamic lib) { - if (lib == null) return false; - try { - // ignore: avoid_dynamic_calls — duck-typed across library shapes - final type = (lib as dynamic).kind?.id as String?; - return type?.toLowerCase() == ContentTypes.artist; - } catch (e) { - return false; - } - } - static IconData getLibraryIcon(String type) { switch (type.toLowerCase()) { case ContentTypes.movie: diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index 002a101b..a3fef3d2 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -18,12 +18,8 @@ class ScreenBreakpoints { static bool isTablet(double width) => width >= mobile && width < desktop; - static bool isWideTablet(double width) => width >= wideTablet && width < desktop; - static bool isDesktop(double width) => width >= desktop && width < largeDesktop; - static bool isLargeDesktop(double width) => width >= largeDesktop; - static bool isDesktopOrLarger(double width) => width >= desktop; static bool isWideTabletOrLarger(double width) => width >= wideTablet; diff --git a/lib/utils/plex_cache_parser.dart b/lib/utils/plex_cache_parser.dart index a88d1de5..c47e39c7 100644 --- a/lib/utils/plex_cache_parser.dart +++ b/lib/utils/plex_cache_parser.dart @@ -18,10 +18,4 @@ class PlexCacheParser { if (list == null || list.isEmpty) return null; return list.first as Map; } - - static List? extractChapters(Map? cached) { - final metadata = extractFirstMetadata(cached); - if (metadata == null) return null; - return metadata['Chapter'] as List?; - } } diff --git a/lib/widgets/focus_builders.dart b/lib/widgets/focus_builders.dart index b38a99a4..12eb3113 100644 --- a/lib/widgets/focus_builders.dart +++ b/lib/widgets/focus_builders.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import '../focus/card_focus_scope.dart'; -import '../focus/focus_glow_overlay.dart'; +import '../focus/focus_chrome.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; import 'clickable_cursor.dart'; @@ -63,96 +62,13 @@ class FocusBuilders { ); } - /// Builds a card-style focusable widget with scale and border decoration. + /// Builds a card-style wrapper with scale and border decoration but no [Focus] + /// node — focus lives on an enclosing rail or screen that passes [isFocused] + /// down. /// - /// Used by FocusableMediaCard and _LockedHubItemWrapper. - /// - /// Parameters: - /// - [context]: Build context for theming - /// - [focusNode]: The focus node for this widget (optional for locked wrappers) - /// - [isFocused]: Whether this widget currently has focus - /// - [onKeyEvent]: Callback for handling key events (optional for locked wrappers) - /// - [onTap]: Callback for tap/click events - /// - [onLongPress]: Callback for long press events - /// - [borderRadius]: Border radius for the focus decoration - /// - [child]: The content to display inside the card - static Widget buildFocusableCard({ - required BuildContext context, - FocusNode? focusNode, - required bool isFocused, - KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent, - VoidCallback? onTap, - VoidCallback? onLongPress, - double borderRadius = FocusTheme.defaultBorderRadius, - double focusScale = FocusTheme.focusScale, - bool useFocusGlow = false, - bool delegateFocusBorder = false, - Size? glowSize, - required Widget child, - }) { - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - // In touch mode, no item ever shows focus effects — skip animated wrappers - // entirely. This saves ~2 element levels per card on ARM32 Android phones. - if (!isKeyboardMode) { - final gestureWidget = (onTap != null || onLongPress != null) - ? ClickableCursor( - child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child), - ) - : child; - if (focusNode != null && onKeyEvent != null) { - return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget); - } - return gestureWidget; - } - - final duration = FocusTheme.getAnimationDuration(context); - final showFocus = isFocused && isKeyboardMode; - // Glow (full-bleed cards) renders in an overlay above siblings so it stays - // symmetric; the in-card decoration only carries the border. - Widget card = delegateFocusBorder - ? CardFocusScope(showFocus: showFocus, child: child) - : AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - decoration: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: borderRadius), - child: child, - ); - if (useFocusGlow) { - card = FocusGlowOverlay( - isFocused: showFocus, - borderRadius: borderRadius, - color: FocusTheme.getFocusBorderColor(context), - glowSize: glowSize, - child: card, - ); - } - - final focusedWidget = AnimatedScale( - scale: showFocus ? focusScale : 1.0, - duration: duration, - curve: Curves.easeOutCubic, - child: card, - ); - - // Wrap in GestureDetector if tap/long press handlers provided - final gestureWidget = (onTap != null || onLongPress != null) - ? ClickableCursor( - child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget), - ) - : focusedWidget; - - // Wrap in Focus if focus node and key event handler provided - if (focusNode != null && onKeyEvent != null) { - return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget); - } - - return gestureWidget; - } - - /// Builds a simple locked wrapper (no Focus widget) with scale and border decoration. - /// - /// Used by _LockedHubItemWrapper where focus is managed at a higher level. + /// Used by the hub row, the TV browse rail, the cast strip and the extras row. + /// Cards that own their focus node use [FocusableWrapper] instead; both share + /// the same chrome through [buildFocusChrome]. /// /// Parameters: /// - [context]: Build context for theming @@ -173,19 +89,40 @@ class FocusBuilders { Size? glowSize, required Widget child, }) { - return buildFocusableCard( - context: context, - focusNode: null, - isFocused: isFocused, - onKeyEvent: null, - onTap: onTap, - onLongPress: onLongPress, - borderRadius: borderRadius, - focusScale: focusScale, - useFocusGlow: useFocusGlow, - delegateFocusBorder: delegateFocusBorder, - glowSize: glowSize, - child: child, + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + // In touch mode, no item ever shows focus effects — skip animated wrappers + // entirely. This saves ~2 element levels per card on ARM32 Android phones. + if (!isKeyboardMode) { + return (onTap != null || onLongPress != null) + ? ClickableCursor( + child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child), + ) + : child; + } + + final duration = FocusTheme.getAnimationDuration(context); + final focusedWidget = AnimatedScale( + scale: isFocused ? focusScale : 1.0, + duration: duration, + curve: Curves.easeOutCubic, + child: buildFocusChrome( + context, + showFocus: isFocused, + duration: duration, + borderRadius: borderRadius, + useFocusGlow: useFocusGlow, + delegateFocusBorder: delegateFocusBorder, + glowSize: glowSize, + child: child, + ), ); + + // Wrap in GestureDetector if tap/long press handlers provided + return (onTap != null || onLongPress != null) + ? ClickableCursor( + child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget), + ) + : focusedWidget; } } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 53b96d54..dabc5cf7 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -54,6 +54,20 @@ final class _LibraryItemRow extends _LibraryNavRow { const _LibraryItemRow({required super.section, required this.library, this.showServerName = false}); } +/// SELECT activates the rail row, RIGHT hands off to the content area. +KeyEventResult _handleRailItemKey(KeyEvent event, {required VoidCallback onSelect, VoidCallback? onNavigateRight}) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey.isSelectKey) { + onSelect(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) { + onNavigateRight(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +} + /// Reusable navigation rail item widget that handles focus, selection, and interaction class NavigationRailItem extends StatelessWidget { final IconData icon; @@ -63,6 +77,9 @@ class NavigationRailItem extends StatelessWidget { /// Playing item's equalizer). Should be at most [iconSize] tall/wide. final Widget? iconWidget; final Widget label; + + /// Widget rendered after the [label] (e.g. a section header's chevron). + final Widget? trailing; final bool isSelected; final bool isCollapsed; final bool useSimpleLayout; @@ -74,6 +91,11 @@ class NavigationRailItem extends StatelessWidget { final double horizontalPadding; final bool suppressSelectedBackground; + /// Background tint while keyboard-focused, and its stronger variant used + /// when the item also shows its selected background. + final double focusAlpha; + final double selectedFocusAlpha; + /// Called when RIGHT arrow is pressed to navigate to content area. final VoidCallback? onNavigateRight; @@ -83,6 +105,7 @@ class NavigationRailItem extends StatelessWidget { this.selectedIcon, this.iconWidget, required this.label, + this.trailing, required this.isSelected, this.isCollapsed = false, this.useSimpleLayout = false, @@ -93,6 +116,8 @@ class NavigationRailItem extends StatelessWidget { this.iconSize = 22, this.horizontalPadding = 17, this.suppressSelectedBackground = false, + this.focusAlpha = 0.12, + this.selectedFocusAlpha = 0.15, this.onNavigateRight, }); @@ -108,18 +133,7 @@ class NavigationRailItem extends StatelessWidget { return Focus( focusNode: focusNode, autofocus: autofocus, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - onTap(); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) { - onNavigateRight!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, + onKeyEvent: (node, event) => _handleRailItemKey(event, onSelect: onTap, onNavigateRight: onNavigateRight), child: Material( color: Colors.transparent, child: InkWell( @@ -129,8 +143,10 @@ class NavigationRailItem extends StatelessWidget { child: Container( decoration: BoxDecoration( color: () { - if (isCollapsed) return focused ? t.text.withValues(alpha: 0.12) : null; - if (focused) return t.text.withValues(alpha: showSelectedBackground ? 0.15 : 0.12); + if (isCollapsed) return focused ? t.text.withValues(alpha: focusAlpha) : null; + if (focused) { + return t.text.withValues(alpha: showSelectedBackground ? selectedFocusAlpha : focusAlpha); + } if (showSelectedBackground) return t.text.withValues(alpha: 0.1); return null; }(), @@ -162,6 +178,7 @@ class NavigationRailItem extends StatelessWidget { return AnimatedOpacity(opacity: opacity, duration: t.fast, child: label); }(), ), + ?trailing, ], ), ), @@ -975,107 +992,44 @@ class SideNavigationRailState extends State with MountedSetS }) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading; - final isLibrariesSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == null; - final librariesFocusNode = _focusTracker.get(_kLibraries); - final showLibrariesSelectedBackground = isLibrariesSelected && !widget.isSidebarFocused; + final isLibrariesTabSelected = widget.selectedTab == NavigationTabId.libraries; final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0; return Column( crossAxisAlignment: .start, children: [ - ListenableBuilder( - listenable: librariesFocusNode, - builder: (context, _) => Focus( - focusNode: librariesFocusNode, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - setState(() { - _librariesExpanded = !_librariesExpanded; - }); - return KeyEventResult.handled; - } - // RIGHT arrow navigates to content area - if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) { - widget.onNavigateToContent!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Material( - color: Colors.transparent, - child: InkWell( - canRequestFocus: false, - onTap: () { - setState(() { - _librariesExpanded = !_librariesExpanded; - }); - }, - borderRadius: BorderRadius.circular(tokens(context).radiusMd), - child: Container( - decoration: BoxDecoration( - color: () { - final showFocus = librariesFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); - if (isCollapsed) return showFocus ? t.text.withValues(alpha: 0.08) : null; - if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1); - if (showFocus) return t.text.withValues(alpha: 0.08); - return null; - }(), - borderRadius: BorderRadius.circular(tokens(context).radiusMd), - ), - clipBehavior: Clip.hardEdge, - child: UnconstrainedBox( - alignment: .centerLeft, - constrainedAxis: Axis.vertical, - clipBehavior: Clip.hardEdge, - child: SizedBox( - width: expandedWidth - 24, - child: Padding( - padding: .symmetric(vertical: 12, horizontal: itemHorizontalPadding), - child: Row( - children: [ - AppIcon( - Symbols.video_library_rounded, - fill: 1, - size: 22, - color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted, - ), - const SizedBox(width: 11), - Expanded( - child: AnimatedOpacity( - opacity: isCollapsed ? 0.0 : 1.0, - duration: tokens(context).fast, - child: Text( - Translations.of(context).navigation.libraries, - style: TextStyle( - fontSize: 14, - fontWeight: widget.selectedTab == NavigationTabId.libraries - ? FontWeight.w600 - : FontWeight.w400, - color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted, - ), - ), - ), - ), - AnimatedOpacity( - opacity: isCollapsed ? 0.0 : 1.0, - duration: tokens(context).fast, - child: AppIcon( - _librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, - fill: 1, - size: 20, - color: t.textMuted, - ), - ), - ], - ), - ), - ), - ), - ), - ), + NavigationRailItem( + icon: Symbols.video_library_rounded, + label: Text( + Translations.of(context).navigation.libraries, + style: TextStyle( + fontSize: 14, + fontWeight: isLibrariesTabSelected ? FontWeight.w600 : FontWeight.w400, + color: isLibrariesTabSelected ? t.text : t.textMuted, ), ), + trailing: AnimatedOpacity( + opacity: isCollapsed ? 0.0 : 1.0, + duration: tokens(context).fast, + child: AppIcon( + _librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, + fill: 1, + size: 20, + color: t.textMuted, + ), + ), + isSelected: isLibrariesTabSelected, + isCollapsed: isCollapsed, + onTap: () => setState(() => _librariesExpanded = !_librariesExpanded), + focusNode: _focusTracker.get(_kLibraries), + borderRadius: BorderRadius.circular(tokens(context).radiusMd), + horizontalPadding: itemHorizontalPadding, + // A selected library owns the highlight; the header only shows it + // for the bare Libraries tab. + suppressSelectedBackground: widget.isSidebarFocused || widget.selectedLibraryKey != null, + focusAlpha: 0.08, + selectedFocusAlpha: 0.1, + onNavigateRight: widget.onNavigateToContent, ), TweenAnimationBuilder( @@ -1222,18 +1176,8 @@ class SideNavigationRailState extends State with MountedSetS listenable: focusNode, builder: (context, _) => Focus( focusNode: focusNode, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - onToggle(); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) { - widget.onNavigateToContent!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, + onKeyEvent: (node, event) => + _handleRailItemKey(event, onSelect: onToggle, onNavigateRight: widget.onNavigateToContent), child: Material( color: Colors.transparent, child: InkWell( diff --git a/lib/widgets/tv_color_picker.dart b/lib/widgets/tv_color_picker.dart index 84846f1c..29cfd1f0 100644 --- a/lib/widgets/tv_color_picker.dart +++ b/lib/widgets/tv_color_picker.dart @@ -3,14 +3,9 @@ import 'package:flutter/services.dart'; import '../i18n/strings.g.dart'; import '../focus/dpad_navigator.dart'; -import '../focus/focus_theme.dart'; import '../focus/focusable_text_field.dart'; -import '../focus/input_mode_tracker.dart'; -import '../focus/key_repeat_helper.dart'; import '../mixins/controller_disposer_mixin.dart'; -import '../theme/mono_tokens.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'app_icon.dart'; +import 'tv_number_spinner.dart'; /// A TV-friendly color picker using HSV sliders for D-pad navigation. /// @@ -106,6 +101,31 @@ class _TvColorPickerState extends State with ControllerDisposerMi widget.onColorChanged(color); } + Widget _channelRow({ + required String label, + required String semanticLabel, + required int value, + required int max, + required String suffix, + required ValueChanged onChanged, + bool autofocus = false, + }) { + return TvNumberSpinner( + label: label, + semanticLabel: semanticLabel, + value: value, + min: 0, + max: max, + step: 5, + suffix: suffix, + autofocus: autofocus, + onConfirm: widget.onConfirm, + onChanged: onChanged, + verticalKeysAdjustValue: false, + density: TvNumberSpinnerDensity.compact, + ); + } + @override Widget build(BuildContext context) { final currentColor = _currentColor(); @@ -123,46 +143,37 @@ class _TvColorPickerState extends State with ControllerDisposerMi ), ), const SizedBox(height: 16), - _ColorChannelRow( + _channelRow( label: 'H', semanticLabel: Translations.of(context).accessibility.hue, value: _hue, - min: 0, max: 360, - step: 5, suffix: '°', autofocus: true, - onConfirm: widget.onConfirm, onChanged: (v) { setState(() => _hue = v); _onChannelChanged(); }, ), const SizedBox(height: 8), - _ColorChannelRow( + _channelRow( label: 'S', semanticLabel: Translations.of(context).accessibility.saturation, value: _saturation, - min: 0, max: 100, - step: 5, suffix: '%', - onConfirm: widget.onConfirm, onChanged: (v) { setState(() => _saturation = v); _onChannelChanged(); }, ), const SizedBox(height: 8), - _ColorChannelRow( + _channelRow( label: 'V', semanticLabel: Translations.of(context).accessibility.brightness, value: _value, - min: 0, max: 100, - step: 5, suffix: '%', - onConfirm: widget.onConfirm, onChanged: (v) { setState(() => _value = v); _onChannelChanged(); @@ -185,211 +196,3 @@ class _TvColorPickerState extends State with ControllerDisposerMi ); } } - -/// A horizontal channel row for a single HSV component. -/// -/// LEFT/RIGHT adjust the value (with repeat timer for held keys). -/// UP/DOWN are ignored so focus traverses normally between rows. -class _ColorChannelRow extends StatefulWidget { - final String label; - final String semanticLabel; - final int value; - final int min; - final int max; - final int step; - final String suffix; - final bool autofocus; - final ValueChanged onChanged; - - /// Called when the user presses SELECT to confirm. - final VoidCallback? onConfirm; - - const _ColorChannelRow({ - required this.label, - required this.semanticLabel, - required this.value, - required this.min, - required this.max, - required this.step, - required this.suffix, - required this.onChanged, - this.autofocus = false, - this.onConfirm, - }); - - @override - State<_ColorChannelRow> createState() => _ColorChannelRowState(); -} - -class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper<_ColorChannelRow> { - late FocusNode _focusNode; - bool _isFocused = false; - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(debugLabel: 'ColorChannel_${widget.label}'); - } - - @override - void dispose() { - stopRepeat(); - _focusNode.dispose(); - super.dispose(); - } - - void _increment() { - final newValue = widget.value + widget.step; - if (newValue <= widget.max) { - widget.onChanged(newValue); - } - } - - void _decrement() { - final newValue = widget.value - widget.step; - if (newValue >= widget.min) { - widget.onChanged(newValue); - } - } - - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - // Let UP/DOWN pass through for focus traversal between rows - if (key.isUpKey || key.isDownKey) { - return KeyEventResult.ignored; - } - - if (event is KeyDownEvent) { - if (key.isSelectKey && widget.onConfirm != null) { - widget.onConfirm!(); - return KeyEventResult.handled; - } - if (key.isRightKey) { - startRepeat(_increment); - return KeyEventResult.handled; - } else if (key.isLeftKey) { - startRepeat(_decrement); - return KeyEventResult.handled; - } - } else if (event is KeyRepeatEvent) { - // Consume repeat events for LEFT/RIGHT so they don't escape - // to the focus system as traversal actions. The repeat timer - // from KeyDown already handles value repetition. - if (key.isRightKey || key.isLeftKey) { - return KeyEventResult.handled; - } - } else if (event is KeyUpEvent) { - if (key.isRightKey || key.isLeftKey) { - stopRepeat(); - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final tokens = theme.extension(); - final canDecrement = widget.value > widget.min; - final canIncrement = widget.value < widget.max; - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - return Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - descendantsAreFocusable: false, - onFocusChange: (hasFocus) { - setState(() => _isFocused = hasFocus); - if (!hasFocus) stopRepeat(); - }, - onKeyEvent: _handleKeyEvent, - child: AnimatedContainer( - duration: tokens?.fast ?? const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)), - border: Border.fromBorderSide( - BorderSide( - color: _isFocused && isKeyboardMode ? FocusTheme.getFocusBorderColor(context) : Colors.transparent, - width: FocusTheme.focusBorderWidth, - ), - ), - ), - child: Row( - children: [ - SizedBox( - width: 24, - child: Text(widget.label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)), - ), - const SizedBox(width: 8), - _ChannelButton( - icon: Symbols.remove_rounded, - onPressed: canDecrement ? _decrement : null, - semanticLabel: Translations.of(context).accessibility.decreaseValue(label: widget.semanticLabel), - ), - const SizedBox(width: 8), - Container( - constraints: const BoxConstraints(minWidth: 56), - alignment: .center, - child: Text('${widget.value}${widget.suffix}', style: theme.textTheme.titleMedium), - ), - const SizedBox(width: 8), - _ChannelButton( - icon: Symbols.add_rounded, - onPressed: canIncrement ? _increment : null, - semanticLabel: Translations.of(context).accessibility.increaseValue(label: widget.semanticLabel), - ), - ], - ), - ), - ); - } -} - -class _ChannelButton extends StatelessWidget { - final IconData icon; - final VoidCallback? onPressed; - final String semanticLabel; - - const _ChannelButton({required this.icon, required this.onPressed, required this.semanticLabel}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isEnabled = onPressed != null; - - return Semantics( - label: semanticLabel, - button: true, - enabled: isEnabled, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: const BorderRadius.all(Radius.circular(20)), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, - ), - child: Center( - child: AppIcon( - icon, - size: 18, - fill: 1, - color: isEnabled - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/tv_number_spinner.dart b/lib/widgets/tv_number_spinner.dart index 3b6b0b9b..234802f6 100644 --- a/lib/widgets/tv_number_spinner.dart +++ b/lib/widgets/tv_number_spinner.dart @@ -11,10 +11,20 @@ import 'app_icon.dart'; import '../theme/mono_tokens.dart'; import 'package:material_symbols_icons/symbols.dart'; +/// Size variant for [TvNumberSpinner]. +enum TvNumberSpinnerDensity { + /// Large buttons with long-press repeat, for a spinner that owns the dialog. + standard, + + /// Smaller buttons sized to sit in a stack of labelled rows. + compact, +} + /// A TV-friendly number spinner with +/- buttons for D-pad navigation. /// -/// Displays a value with decrement/increment buttons on either side. -/// Supports keyboard repeat for faster value changes when holding arrows. +/// Displays a value with decrement/increment buttons on either side, optionally +/// behind a leading [label]. Supports keyboard repeat for faster value changes +/// when holding arrows. class TvNumberSpinner extends StatefulWidget { final int value; @@ -27,6 +37,13 @@ class TvNumberSpinner extends StatefulWidget { /// Optional suffix text (e.g., "s" for seconds). final String? suffix; + /// Optional leading label shown before the buttons (e.g., "H" for hue). + final String? label; + + /// When set, the +/- buttons announce themselves as adjusting this value + /// instead of using the generic increase/decrease labels. + final String? semanticLabel; + final ValueChanged onChanged; /// Called when the user presses SELECT to confirm. @@ -39,6 +56,13 @@ class TvNumberSpinner extends StatefulWidget { final bool autofocus; + /// When false, UP/DOWN are left alone so focus traverses between rows, and + /// held LEFT/RIGHT repeat events are consumed so they don't escape to the + /// focus system as traversal actions. + final bool verticalKeysAdjustValue; + + final TvNumberSpinnerDensity density; + const TvNumberSpinner({ super.key, required this.value, @@ -47,9 +71,13 @@ class TvNumberSpinner extends StatefulWidget { required this.onChanged, this.step = 1, this.suffix, + this.label, + this.semanticLabel, this.autofocus = false, this.onConfirm, this.onCancel, + this.verticalKeysAdjustValue = true, + this.density = TvNumberSpinnerDensity.standard, }); @override @@ -63,7 +91,8 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< @override void initState() { super.initState(); - _focusNode = FocusNode(debugLabel: 'TvNumberSpinner'); + final label = widget.label; + _focusNode = FocusNode(debugLabel: label == null ? 'TvNumberSpinner' : 'TvNumberSpinner_$label'); } @override @@ -89,6 +118,7 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; + final vertical = widget.verticalKeysAdjustValue; if (widget.onCancel != null) { final backResult = handleBackKeyAction(event, widget.onCancel!); @@ -97,20 +127,32 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< } } + // Let UP/DOWN pass through for focus traversal between rows. + if (!vertical && (key.isUpKey || key.isDownKey)) { + return KeyEventResult.ignored; + } + if (event is KeyDownEvent) { if (key.isSelectKey && widget.onConfirm != null) { widget.onConfirm!(); return KeyEventResult.handled; } - if (key.isUpKey || key.isRightKey) { + if ((vertical && key.isUpKey) || key.isRightKey) { startRepeat(_increment); return KeyEventResult.handled; - } else if (key.isDownKey || key.isLeftKey) { + } else if ((vertical && key.isDownKey) || key.isLeftKey) { startRepeat(_decrement); return KeyEventResult.handled; } + } else if (event is KeyRepeatEvent) { + // The repeat timer from KeyDown already handles value repetition, so + // swallow the OS repeats that would otherwise traverse focus. Only + // needed when UP/DOWN traverse — otherwise no direction escapes. + if (!vertical && (key.isRightKey || key.isLeftKey)) { + return KeyEventResult.handled; + } } else if (event is KeyUpEvent) { - if (key.isUpKey || key.isRightKey || key.isDownKey || key.isLeftKey) { + if ((vertical && (key.isUpKey || key.isDownKey)) || key.isRightKey || key.isLeftKey) { stopRepeat(); return KeyEventResult.handled; } @@ -126,6 +168,11 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< final canDecrement = widget.value > widget.min; final canIncrement = widget.value < widget.max; final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final isCompact = widget.density == TvNumberSpinnerDensity.compact; + final gap = isCompact ? const SizedBox(width: 8) : const SizedBox(width: 16); + final label = widget.label; + final semanticLabel = widget.semanticLabel; + final a11y = Translations.of(context).accessibility; return Focus( focusNode: _focusNode, @@ -149,32 +196,43 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< ), ), child: Row( - mainAxisSize: .min, - mainAxisAlignment: .center, + mainAxisSize: isCompact ? .max : .min, + mainAxisAlignment: isCompact ? .start : .center, children: [ + if (label != null) ...[ + SizedBox( + width: 24, + child: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)), + ), + gap, + ], _SpinnerButton( icon: Symbols.remove_rounded, onPressed: canDecrement ? _decrement : null, - onLongPressStart: canDecrement ? () => startRepeat(_decrement) : null, - onLongPressEnd: stopRepeat, - semanticLabel: Translations.of(context).accessibility.decrease, + onLongPressStart: !isCompact && canDecrement ? () => startRepeat(_decrement) : null, + onLongPressEnd: isCompact ? null : stopRepeat, + semanticLabel: semanticLabel != null ? a11y.decreaseValue(label: semanticLabel) : a11y.decrease, + compact: isCompact, ), - const SizedBox(width: 16), + gap, Container( - constraints: const BoxConstraints(minWidth: 60), + constraints: BoxConstraints(minWidth: isCompact ? 56 : 60), alignment: .center, child: Text( - widget.suffix != null ? '${widget.value}${widget.suffix}' : '${widget.value}', - style: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold), + '${widget.value}${widget.suffix ?? ''}', + style: isCompact + ? theme.textTheme.titleMedium + : theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold), ), ), - const SizedBox(width: 16), + gap, _SpinnerButton( icon: Symbols.add_rounded, onPressed: canIncrement ? _increment : null, - onLongPressStart: canIncrement ? () => startRepeat(_increment) : null, - onLongPressEnd: stopRepeat, - semanticLabel: Translations.of(context).accessibility.increase, + onLongPressStart: !isCompact && canIncrement ? () => startRepeat(_increment) : null, + onLongPressEnd: isCompact ? null : stopRepeat, + semanticLabel: semanticLabel != null ? a11y.increaseValue(label: semanticLabel) : a11y.increase, + compact: isCompact, ), ], ), @@ -190,6 +248,7 @@ class _SpinnerButton extends StatelessWidget { final VoidCallback? onLongPressStart; final VoidCallback? onLongPressEnd; final String semanticLabel; + final bool compact; const _SpinnerButton({ required this.icon, @@ -197,45 +256,49 @@ class _SpinnerButton extends StatelessWidget { this.onLongPressStart, this.onLongPressEnd, required this.semanticLabel, + this.compact = false, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final isEnabled = onPressed != null; + final size = compact ? 36.0 : 48.0; - return Semantics( - label: semanticLabel, - button: true, - enabled: isEnabled, - child: GestureDetector( - onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null, - onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: const BorderRadius.all(Radius.circular(24)), - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, - ), - child: Center( - child: AppIcon( - icon, - fill: 1, - color: isEnabled - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), + Widget button = Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.all(Radius.circular(compact ? 20 : 24)), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, + ), + child: Center( + child: AppIcon( + icon, + size: compact ? 18 : null, + fill: 1, + color: isEnabled + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), ), ), ), ); + + if (onLongPressStart != null || onLongPressEnd != null) { + button = GestureDetector( + onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null, + onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null, + child: button, + ); + } + + return Semantics(label: semanticLabel, button: true, enabled: isEnabled, child: button); } } diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart index af167a82..4a470ad3 100644 --- a/test/media/media_item_test.dart +++ b/test/media/media_item_test.dart @@ -95,21 +95,18 @@ void main() { final movie = _movie(artPath: '/art', backgroundSquarePath: '/square'); expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/art']); - expect(movie.heroArt(containerAspectRatio: 1.0), '/square'); }); test('near-square containers fall back to wide cover art when square art is missing', () { final movie = _movie(artPath: '/art'); expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/art']); - expect(movie.heroArt(containerAspectRatio: 1.0), '/art'); }); test('wide containers prefer wide cover art before square art', () { final movie = _movie(artPath: '/art', backgroundSquarePath: '/square'); expect(movie.heroArtCandidates(containerAspectRatio: 16 / 9), ['/art', '/square']); - expect(movie.heroArt(containerAspectRatio: 16 / 9), '/art'); }); test('episodes prefer show art before episode art for wide hero containers', () { @@ -126,7 +123,6 @@ void main() { ); expect(episode.heroArtCandidates(containerAspectRatio: 16 / 9), ['/show-art', '/episode-art', '/square']); - expect(episode.heroArt(containerAspectRatio: 16 / 9), '/show-art'); expect(episode.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/show-art', '/episode-art']); }); diff --git a/test/media/media_playlist_test.dart b/test/media/media_playlist_test.dart index 2ef0931f..441b48ce 100644 --- a/test/media/media_playlist_test.dart +++ b/test/media/media_playlist_test.dart @@ -126,16 +126,6 @@ void main() { }); }); - group('MediaPlaylist.isEditable', () { - test('smart playlists are read-only (Plex semantics)', () { - expect(_playlist(smart: true).isEditable, isFalse); - }); - - test('manual playlists are editable', () { - expect(_playlist(smart: false).isEditable, isTrue); - }); - }); - group('MediaPlaylist.globalKey', () { test('uses ":" when serverId is set', () { final pl = _playlist(id: 'pl-42', serverId: 'srv-9'); @@ -166,7 +156,6 @@ void main() { expect(minimal.serverName, isNull); expect(minimal.displayImagePath, isNull); expect(minimal.displayTitle, 'Min'); - expect(minimal.isEditable, isTrue); // Without a serverId, globalKey reduces to the bare id. expect(minimal.globalKey, 'pl'); }); diff --git a/test/models/plex_user_profile_test.dart b/test/models/plex_user_profile_test.dart index be75576c..5c5a2c26 100644 --- a/test/models/plex_user_profile_test.dart +++ b/test/models/plex_user_profile_test.dart @@ -70,23 +70,5 @@ void main() { expect(profile.watchedIndicator, 2); expect(profile.defaultSubtitleForced, 1); }); - - test('defaults() matches parsing an empty map', () { - final parsed = PlexUserProfile.fromJson(const {}); - final defaults = PlexUserProfile.defaults(); - - expect(defaults.autoSelectAudio, parsed.autoSelectAudio); - expect(defaults.defaultAudioAccessibility, parsed.defaultAudioAccessibility); - expect(defaults.defaultAudioLanguage, parsed.defaultAudioLanguage); - expect(defaults.defaultAudioLanguages, parsed.defaultAudioLanguages); - expect(defaults.defaultSubtitleLanguage, parsed.defaultSubtitleLanguage); - expect(defaults.defaultSubtitleLanguages, parsed.defaultSubtitleLanguages); - expect(defaults.autoSelectSubtitle, parsed.autoSelectSubtitle); - expect(defaults.defaultSubtitleAccessibility, parsed.defaultSubtitleAccessibility); - expect(defaults.defaultSubtitleForced, parsed.defaultSubtitleForced); - expect(defaults.watchedIndicator, parsed.watchedIndicator); - expect(defaults.mediaReviewsVisibility, parsed.mediaReviewsVisibility); - expect(defaults.mediaReviewsLanguages, parsed.mediaReviewsLanguages); - }); }); } diff --git a/test/models/user_switch_response_test.dart b/test/models/user_switch_response_test.dart index 1d0aa50b..6d3b47f4 100644 --- a/test/models/user_switch_response_test.dart +++ b/test/models/user_switch_response_test.dart @@ -49,34 +49,17 @@ Map driftedSwitchJson() => { }; void main() { - group('UserSwitchResponse.fromJson', () { - test('parses a realistic drifted 201 body, preserving the token', () { - final response = UserSwitchResponse.fromJson(driftedSwitchJson()); - - expect(response.authToken, 'minted-user-token'); - expect(response.uuid, 'e443d57860076fc3'); - expect(response.protected, isTrue); - expect(response.homeAdmin, isTrue); - expect(response.profile.defaultAudioLanguages, ['en', 'sv']); - expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']); + group('parsePlexSwitchAuthToken', () { + test('takes the token out of a realistic drifted 201 body', () { + expect(parsePlexSwitchAuthToken(driftedSwitchJson()), 'minted-user-token'); }); - test('parses a token-only body with defaults everywhere else', () { - final response = UserSwitchResponse.fromJson({'authToken': 'tok'}); - - expect(response.authToken, 'tok'); - expect(response.id, 0); - expect(response.uuid, ''); - expect(response.title, ''); - expect(response.confirmed, isFalse); - expect(response.homeSize, 1); - expect(response.maxHomeSize, 1); - expect(response.profile.autoSelectAudio, isTrue); - expect(response.profile.defaultAudioLanguages, isNull); + test('takes the token out of a token-only body', () { + expect(parsePlexSwitchAuthToken({'authToken': 'tok'}), 'tok'); }); test('never loses the token to wrong-typed decorative fields', () { - final response = UserSwitchResponse.fromJson({ + final token = parsePlexSwitchAuthToken({ 'authToken': 'tok', 'id': {}, 'uuid': 42, @@ -92,20 +75,13 @@ void main() { 'twoFactorEnabled': {}, }); - expect(response.authToken, 'tok'); - expect(response.id, 0); - expect(response.uuid, '42'); - expect(response.title, '7'); - expect(response.confirmed, isFalse); - expect(response.homeSize, 1); - expect(response.profile.autoSelectAudio, isTrue); - expect(response.profile.defaultAudioLanguages, isNull); + expect(token, 'tok'); }); test('throws when authToken is missing, empty, or not a string', () { - expect(() => UserSwitchResponse.fromJson(const {}), throwsFormatException); - expect(() => UserSwitchResponse.fromJson({'authToken': ''}), throwsFormatException); - expect(() => UserSwitchResponse.fromJson({'authToken': 12345}), throwsFormatException); + expect(() => parsePlexSwitchAuthToken(const {}), throwsFormatException); + expect(() => parsePlexSwitchAuthToken({'authToken': ''}), throwsFormatException); + expect(() => parsePlexSwitchAuthToken({'authToken': 12345}), throwsFormatException); }); }); } diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index 55ae4f51..e85b14ef 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -123,27 +123,16 @@ void main() { expect(m.serverIds, isEmpty); expect(m.onlineServerIds, isEmpty); expect(m.offlineServerIds, isEmpty); - expect(m.plexServers, isEmpty); expect(m.onlineClients, isEmpty); }); - test('getClient/getPlexServer return null for unknown ids', () { + test('getClient returns null for unknown ids', () { final m = MultiServerManager(); addTearDown(m.dispose); expect(m.getClient(ServerId('nope')), isNull); - expect(m.getPlexServer(ServerId('nope')), isNull); expect(m.isServerOnline(ServerId('nope')), isFalse); }); - - test('plexServers map is unmodifiable', () { - final m = MultiServerManager(); - addTearDown(m.dispose); - - // Map.unmodifiable rejects every mutating operation — clear() is the - // simplest no-arg one to exercise the wrapper. - expect(() => m.plexServers.clear(), throwsUnsupportedError); - }); }); // ============================================================ diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index f98eb4e6..72afe30e 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -113,15 +113,15 @@ void main() { final tracks = [_sourceSubtitle(0), _sourceSubtitle(2)]; expect( - PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off()), + PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 1), const PlaybackSourceSubtitleChoice.source(0), ); expect( - PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0)), + PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0), 1), const PlaybackSourceSubtitleChoice.source(2), ); expect( - PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2)), + PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2), 1), const PlaybackSourceSubtitleChoice.off(), ); expect( diff --git a/test/services/plex_auth_service_test.dart b/test/services/plex_auth_service_test.dart index ea1dd78c..61de93eb 100644 --- a/test/services/plex_auth_service_test.dart +++ b/test/services/plex_auth_service_test.dart @@ -73,11 +73,9 @@ void main() { addTearDown(client.close); final auth = PlexAuthService.forTesting(http: client); - final response = await auth.switchToUser('uuid-1', 'account-token'); + final token = await auth.switchToUser('uuid-1', 'account-token'); - expect(response.authToken, 'minted-user-token'); - expect(response.profile.defaultAudioLanguages, ['en', 'sv']); - expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']); + expect(token, 'minted-user-token'); }); test('fetchServers tolerates scalar drift in server and connection fields', () async { diff --git a/test/services/video_filter_manager_test.dart b/test/services/video_filter_manager_test.dart index bc4e5379..d861a52b 100644 --- a/test/services/video_filter_manager_test.dart +++ b/test/services/video_filter_manager_test.dart @@ -13,7 +13,7 @@ void main() { expect(manager.setZoomScale(1.234), 1.23); expect(manager.zoomScale, 1.23); - expect(manager.adjustZoom(VideoFilterManager.zoomStep), 1.24); + expect(manager.setZoomScale(manager.zoomScale + VideoFilterManager.zoomStep), 1.24); expect(manager.zoomScale, 1.24); }); @@ -26,7 +26,7 @@ void main() { expect(manager.setZoomScale(1.00008), 1.0); expect(manager.zoomScale, 1.0); - expect(manager.resetZoom(), 1.0); + expect(manager.setZoomScale(1.0), 1.0); }); test('video zoom property is exact zero at normalized default', () async { diff --git a/test/utils/content_utils_test.dart b/test/utils/content_utils_test.dart index 49c9256c..f64d9592 100644 --- a/test/utils/content_utils_test.dart +++ b/test/utils/content_utils_test.dart @@ -95,10 +95,6 @@ void main() { expect(ContentTypeHelper.isVideoContent('artist'), isFalse); }); - test('isMusicLibrary returns false for null and non-matching types', () { - expect(ContentTypeHelper.isMusicLibrary(null), isFalse); - }); - test('getLibraryIcon normalizes type and falls back to folder', () { expect(ContentTypeHelper.getLibraryIcon('MOVIE'), Symbols.movie_rounded); expect(ContentTypeHelper.getLibraryIcon('show'), Symbols.tv_rounded); diff --git a/test/utils/layout_constants_test.dart b/test/utils/layout_constants_test.dart index 0bdfde51..4d4822ac 100644 --- a/test/utils/layout_constants_test.dart +++ b/test/utils/layout_constants_test.dart @@ -18,13 +18,6 @@ void main() { expect(ScreenBreakpoints.isTablet(1200), isFalse); }); - test('isWideTablet: 900 ≤ w < 1200', () { - expect(ScreenBreakpoints.isWideTablet(899.9), isFalse); - expect(ScreenBreakpoints.isWideTablet(900), isTrue); - expect(ScreenBreakpoints.isWideTablet(1199.9), isTrue); - expect(ScreenBreakpoints.isWideTablet(1200), isFalse); - }); - test('isDesktop: 1200 ≤ w < 1600', () { expect(ScreenBreakpoints.isDesktop(1199.9), isFalse); expect(ScreenBreakpoints.isDesktop(1200), isTrue); @@ -32,12 +25,6 @@ void main() { expect(ScreenBreakpoints.isDesktop(1600), isFalse); }); - test('isLargeDesktop: w ≥ 1600', () { - expect(ScreenBreakpoints.isLargeDesktop(1599.9), isFalse); - expect(ScreenBreakpoints.isLargeDesktop(1600), isTrue); - expect(ScreenBreakpoints.isLargeDesktop(10000), isTrue); - }); - test('isDesktopOrLarger: w ≥ 1200', () { expect(ScreenBreakpoints.isDesktopOrLarger(1199.9), isFalse); expect(ScreenBreakpoints.isDesktopOrLarger(1200), isTrue); diff --git a/test/utils/plex_cache_parser_test.dart b/test/utils/plex_cache_parser_test.dart index 0fbc4a24..118af79d 100644 --- a/test/utils/plex_cache_parser_test.dart +++ b/test/utils/plex_cache_parser_test.dart @@ -61,47 +61,4 @@ void main() { expect(result, equals(first)); }); }); - - group('PlexCacheParser.extractChapters', () { - test('returns null for null input', () { - expect(PlexCacheParser.extractChapters(null), isNull); - }); - - test('returns null when no metadata', () { - expect( - PlexCacheParser.extractChapters({ - 'MediaContainer': {'Metadata': []}, - }), - isNull, - ); - }); - - test('returns null when first metadata has no Chapter key', () { - expect( - PlexCacheParser.extractChapters({ - 'MediaContainer': { - 'Metadata': [ - {'ratingKey': '1'}, - ], - }, - }), - isNull, - ); - }); - - test('returns chapter list when present', () { - final chapters = [ - {'tag': 'Chapter 1'}, - {'tag': 'Chapter 2'}, - ]; - final result = PlexCacheParser.extractChapters({ - 'MediaContainer': { - 'Metadata': [ - {'ratingKey': '1', 'Chapter': chapters}, - ], - }, - }); - expect(result, equals(chapters)); - }); - }); }