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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 : '');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -915,6 +915,7 @@ class PlexClient
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<Map<String, dynamic>> getServerIdentity() async {
|
||||
final response = await _getWithFailover('/identity');
|
||||
return response.data;
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -127,6 +127,7 @@ class SleepTimerService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Execute the completion callback directly (fallback path)
|
||||
@visibleForTesting
|
||||
void executeCompletion() {
|
||||
_executeCallback();
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user