refactor: share focus chrome and simplify the TV picker and browse paths

Focus chrome was implemented twice, once in the focusable wrapper and once
in the focus builders; both now go through FocusChrome. TvColorPicker's
channel row was a copy of TvNumberSpinner and is now that widget in compact
density.

Also trims unused helpers and fields and simplifies the Jellyfin browse
paths.
This commit is contained in:
edde746
2026-07-26 06:09:49 +02:00
parent c68ffe9ed0
commit 4eaf4423a1
47 changed files with 526 additions and 1133 deletions
+2 -2
View File
@@ -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
+59
View File
@@ -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;
}
+13 -35
View File
@@ -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<FocusableWrapper> 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!),
);
}
-7
View File
@@ -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<String> heroArtCandidates({required double containerAspectRatio}) {
final own = resolvedBackdropPaths;
-5
View File
@@ -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({
+1 -15
View File
@@ -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<String, dynamic> json) {
final envelope = json['profile'];
final profile = envelope is Map<String, dynamic> ? envelope : json;
+11 -118
View File
@@ -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<String, dynamic> 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<String, dynamic> json) {
final authToken = json['authToken'];
if (authToken is! String || authToken.isEmpty) {
throw const FormatException('Plex /switch response has no usable authToken');
}
return authToken;
}
+31 -42
View File
@@ -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<void> _applyWhenInitialized(Future<void> 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<void> 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<void> 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<void> 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<void> 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 : '');
}
+2 -2
View File
@@ -51,8 +51,8 @@ Future<PlexHomeSwitchResult> 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;
+1
View File
@@ -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;
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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 =>
@@ -71,6 +71,7 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
/// 2. Metadata from download provider
///
/// Returns null if no position is available.
@visibleForTesting
Future<int?> getViewOffset(String globalKey) async {
// First check local offline progress
final localOffset = await _syncService.getLocalViewOffset(globalKey);
+1
View File
@@ -88,6 +88,7 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin, W
static const _themeChannel = MethodChannel('com.plezy/theme');
@visibleForTesting
Future<void> setThemeMode(settings.ThemeMode mode) async {
if (_themeMode == mode) return;
final service = _settingsBinding.settings ?? await settings.SettingsService.getInstance();
+1
View File
@@ -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) {
@@ -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,
),
);
-18
View File
@@ -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,
+61 -63
View File
@@ -3562,6 +3562,42 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
);
}
/// 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<T> _tvDetailMetadataParts<T extends Object>(
MediaItem metadata, {
required T Function(String value) text,
required T? Function(MediaItem item) rating,
}) {
final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata;
final parts = <T>[];
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<MediaDetailScreen>
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<String>(
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<MediaDetailScreen>
}
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 = <Widget>[];
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<Widget>(
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 = <Widget>[];
for (final field in fields) {
if (children.isNotEmpty) children.add(Text('', maxLines: 1, style: textStyle));
children.add(field);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
+63 -122
View File
@@ -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<List<MediaHub>> _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<Map<String, dynamic>> 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 <Map<String, dynamic>>[]),
]);
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();
}
@@ -103,6 +103,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
Map<String, HotKey?> get hotkeys => Map.from(_hotkeys);
@visibleForTesting
HotKey? getHotkey(String action) {
return _hotkeys[action];
}
-12
View File
@@ -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<String, PlexServer> get plexServers => Map.unmodifiable(_plexServers);
/// Check if a server is online
bool isServerOnline(ServerId serverId) => _serverStatus[serverId] ?? false;
@@ -275,13 +275,6 @@ class PlaybackSubtitleResolver {
return findMpvTrackForPlexSubtitle(sourceTrack, nativeTracks, allPlexTracks: allSourceTracks);
}
static PlaybackSourceSubtitleChoice nextSourceChoice(
List<MediaSubtitleTrack> tracks,
PlaybackSourceSubtitleChoice currentChoice,
) {
return advanceSourceChoice(tracks, currentChoice, 1);
}
static PlaybackSourceSubtitleChoice advanceSourceChoice(
List<MediaSubtitleTrack> tracks,
PlaybackSourceSubtitleChoice currentChoice,
+4 -3
View File
@@ -262,8 +262,9 @@ class PlexAuthService {
return PlexHome.fromJson(response.data as Map<String, dynamic>);
}
/// Switch to a different user in the home
Future<UserSwitchResponse> switchToUser(String userUUID, String currentToken, {String? pin}) async {
/// Switch to a different user in the home, returning the freshly minted
/// user-level token
Future<String> 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<String, dynamic>);
return parsePlexSwitchAuthToken(response.data as Map<String, dynamic>);
}
}
+1
View File
@@ -915,6 +915,7 @@ class PlexClient
);
}
@visibleForTesting
Future<Map<String, dynamic>> getServerIdentity() async {
final response = await _getWithFailover('/identity');
return response.data;
+2
View File
@@ -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<SeerrUser> getMe() async {
final data = await _request('GET', '/auth/me');
return SeerrUser.fromJson(data as Map<String, dynamic>);
+1
View File
@@ -127,6 +127,7 @@ class SleepTimerService extends ChangeNotifier {
}
/// Execute the completion callback directly (fallback path)
@visibleForTesting
void executeCompletion() {
_executeCallback();
}
+3
View File
@@ -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<SubtitleTrack> get lastExternalSubtitles => _lastExternalSubtitles;
TrackManager({
-4
View File
@@ -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;
-11
View File
@@ -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:
-4
View File
@@ -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;
-6
View File
@@ -18,10 +18,4 @@ class PlexCacheParser {
if (list == null || list.isEmpty) return null;
return list.first as Map<String, dynamic>;
}
static List<dynamic>? extractChapters(Map<String, dynamic>? cached) {
final metadata = extractFirstMetadata(cached);
if (metadata == null) return null;
return metadata['Chapter'] as List?;
}
}
+41 -104
View File
@@ -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;
}
}
+64 -120
View File
@@ -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<SideNavigationRail> with MountedSetS
}) {
final librariesProvider = context.watch<LibrariesProvider>();
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<double>(
@@ -1222,18 +1176,8 @@ class SideNavigationRailState extends State<SideNavigationRail> 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(
+29 -226
View File
@@ -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<TvColorPicker> with ControllerDisposerMi
widget.onColorChanged(color);
}
Widget _channelRow({
required String label,
required String semanticLabel,
required int value,
required int max,
required String suffix,
required ValueChanged<int> 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<TvColorPicker> 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<TvColorPicker> 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<int> 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<MonoTokens>();
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),
),
),
),
),
),
);
}
}
+110 -47
View File
@@ -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<int> 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<TvNumberSpinner> 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<TvNumberSpinner> 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<TvNumberSpinner> 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<TvNumberSpinner> 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<TvNumberSpinner> 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);
}
}