From 91c23786f2d3d095bffb64355de5f32e5848afba Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 6 May 2026 00:26:44 +0200 Subject: [PATCH] refactor: reduce model and focus boilerplate --- lib/exceptions/media_server_exceptions.dart | 56 ++- lib/main.dart | 1 + lib/models/livetv_hub_result.dart | 4 +- lib/models/mpv_config_models.dart | 15 +- lib/models/mpv_config_models.g.dart | 19 + lib/models/trakt/trakt_ids.dart | 21 +- lib/models/trakt/trakt_ids.g.dart | 23 ++ lib/screens/discover_screen.dart | 3 +- .../libraries/tabs/library_browse_tab.dart | 1 + lib/screens/livetv/tabs/guide_tab.dart | 153 +++++---- lib/screens/media_detail_screen.dart | 325 +++++++++--------- lib/screens/plex_metadata_edit_screen.dart | 6 +- lib/screens/settings/settings_utils.dart | 1 + lib/services/plex_auth_service.dart | 2 +- lib/services/track_selection_service.dart | 2 +- .../trackers/anilist/anilist_session.dart | 19 +- .../trackers/anilist/anilist_session.g.dart | 21 ++ lib/services/trackers/mal/mal_session.dart | 21 +- lib/services/trackers/mal/mal_session.g.dart | 23 ++ .../trackers/simkl/simkl_session.dart | 13 +- .../trackers/simkl/simkl_session.g.dart | 19 + lib/services/trakt/trakt_session.dart | 26 +- lib/services/trakt/trakt_session.g.dart | 25 ++ lib/utils/app_logger.dart | 2 +- lib/utils/formatters.dart | 3 + .../services/recent_rooms_service.dart | 35 +- .../services/recent_rooms_service.g.dart | 21 ++ .../widgets/watch_together_overlay.dart | 1 + lib/widgets/episode_card.dart | 2 +- lib/widgets/media_card.dart | 2 +- lib/widgets/media_context_menu.dart | 2 + lib/widgets/rating_bottom_sheet.dart | 4 +- lib/widgets/server_activities_button.dart | 2 +- .../widgets/live_timeline_bar.dart | 10 +- test/models/json_model_round_trip_test.dart | 71 ++++ .../trackers/tracker_session_utils_test.dart | 34 ++ 36 files changed, 624 insertions(+), 364 deletions(-) create mode 100644 lib/models/mpv_config_models.g.dart create mode 100644 lib/models/trakt/trakt_ids.g.dart create mode 100644 lib/services/trackers/anilist/anilist_session.g.dart create mode 100644 lib/services/trackers/mal/mal_session.g.dart create mode 100644 lib/services/trackers/simkl/simkl_session.g.dart create mode 100644 lib/services/trakt/trakt_session.g.dart create mode 100644 lib/watch_together/services/recent_rooms_service.g.dart create mode 100644 test/models/json_model_round_trip_test.dart diff --git a/lib/exceptions/media_server_exceptions.dart b/lib/exceptions/media_server_exceptions.dart index ca366d0e..316092c6 100644 --- a/lib/exceptions/media_server_exceptions.dart +++ b/lib/exceptions/media_server_exceptions.dart @@ -49,49 +49,35 @@ class MediaServerHttpException extends MediaServerException { /// Map a caught exception to a [MediaServerHttpException]. factory MediaServerHttpException.from(Object error, {Uri? uri}) { - if (error is MediaServerHttpException) return error; - - if (error is RequestAbortedException) { - return MediaServerHttpException( + return switch (error) { + MediaServerHttpException() => error, + RequestAbortedException(:final message, uri: final errorUri) => MediaServerHttpException( type: MediaServerHttpErrorType.cancelled, - message: error.message, - requestUri: error.uri ?? uri, - ); - } - - if (error is TimeoutException) { - return MediaServerHttpException( + message: message, + requestUri: errorUri ?? uri, + ), + TimeoutException(:final message) => MediaServerHttpException( type: MediaServerHttpErrorType.connectionTimeout, - message: error.message, + message: message, requestUri: uri, - ); - } - - if (error is SocketException) { - return MediaServerHttpException( + ), + SocketException(:final message) => MediaServerHttpException( type: MediaServerHttpErrorType.connectionError, - message: error.message, + message: message, requestUri: uri, - ); - } - - if (error is HttpException) { - return MediaServerHttpException( + ), + HttpException(:final message) => MediaServerHttpException( type: MediaServerHttpErrorType.connectionError, - message: error.message, + message: message, requestUri: uri, - ); - } - - if (error is ClientException) { - return MediaServerHttpException( + ), + ClientException(:final message, uri: final errorUri) => MediaServerHttpException( type: MediaServerHttpErrorType.connectionError, - message: error.message, - requestUri: error.uri ?? uri, - ); - } - - return MediaServerHttpException(type: MediaServerHttpErrorType.unknown, message: error.toString(), requestUri: uri); + message: message, + requestUri: errorUri ?? uri, + ), + _ => MediaServerHttpException(type: MediaServerHttpErrorType.unknown, message: error.toString(), requestUri: uri), + }; } /// Whether the error looks transient (network/timeout) and worth retrying. diff --git a/lib/main.dart b/lib/main.dart index e3941178..e65d236a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1248,6 +1248,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { statusIcon = const Icon(Icons.cancel, size: 14, color: failColor); } return Padding( + key: ValueKey(entry.key), padding: const EdgeInsets.symmetric(vertical: 2), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/models/livetv_hub_result.dart b/lib/models/livetv_hub_result.dart index 44edfbd0..6977ba4c 100644 --- a/lib/models/livetv_hub_result.dart +++ b/lib/models/livetv_hub_result.dart @@ -7,7 +7,7 @@ class LiveTvHubResult { final String hubKey; final List entries; - LiveTvHubResult({required this.title, required this.hubKey, required this.entries}); + const LiveTvHubResult({required this.title, required this.hubKey, required this.entries}); } /// A single item in a live TV hub, holding both display metadata and EPG timing. @@ -15,5 +15,5 @@ class LiveTvHubEntry { final MediaItem metadata; final LiveTvProgram program; - LiveTvHubEntry({required this.metadata, required this.program}); + const LiveTvHubEntry({required this.metadata, required this.program}); } diff --git a/lib/models/mpv_config_models.dart b/lib/models/mpv_config_models.dart index b42470a8..aaae67e1 100644 --- a/lib/models/mpv_config_models.dart +++ b/lib/models/mpv_config_models.dart @@ -1,3 +1,8 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'mpv_config_models.g.dart'; + +@JsonSerializable() class MpvPreset { final String name; final String text; @@ -5,13 +10,7 @@ class MpvPreset { const MpvPreset({required this.name, required this.text, required this.createdAt}); - factory MpvPreset.fromJson(Map json) { - return MpvPreset( - name: json['name'] as String, - text: json['text'] as String, - createdAt: DateTime.parse(json['createdAt'] as String), - ); - } + factory MpvPreset.fromJson(Map json) => _$MpvPresetFromJson(json); - Map toJson() => {'name': name, 'text': text, 'createdAt': createdAt.toIso8601String()}; + Map toJson() => _$MpvPresetToJson(this); } diff --git a/lib/models/mpv_config_models.g.dart b/lib/models/mpv_config_models.g.dart new file mode 100644 index 00000000..405595f1 --- /dev/null +++ b/lib/models/mpv_config_models.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mpv_config_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MpvPreset _$MpvPresetFromJson(Map json) => MpvPreset( + name: json['name'] as String, + text: json['text'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), +); + +Map _$MpvPresetToJson(MpvPreset instance) => { + 'name': instance.name, + 'text': instance.text, + 'createdAt': instance.createdAt.toIso8601String(), +}; diff --git a/lib/models/trakt/trakt_ids.dart b/lib/models/trakt/trakt_ids.dart index 8542508a..7ce20971 100644 --- a/lib/models/trakt/trakt_ids.dart +++ b/lib/models/trakt/trakt_ids.dart @@ -1,9 +1,14 @@ +import 'package:json_annotation/json_annotation.dart'; + import '../../utils/external_ids.dart'; +part 'trakt_ids.g.dart'; + /// External IDs for matching Plex items against Trakt's catalog. /// /// Trakt prefers (in order): trakt > slug > imdb > tmdb > tvdb. Movies use /// imdb/tmdb; episodes use the show's tvdb/tmdb/imdb plus season/episode index. +@JsonSerializable(includeIfNull: false) class TraktIds { final int? trakt; final String? slug; @@ -16,21 +21,9 @@ class TraktIds { /// True when at least one external ID is set (i.e. usable for Trakt matching). bool get hasAny => imdb != null || tmdb != null || tvdb != null || trakt != null || slug != null; - Map toJson() => { - if (trakt != null) 'trakt': trakt, - if (slug != null) 'slug': slug, - if (imdb != null) 'imdb': imdb, - if (tmdb != null) 'tmdb': tmdb, - if (tvdb != null) 'tvdb': tvdb, - }; + Map toJson() => _$TraktIdsToJson(this); - factory TraktIds.fromJson(Map json) => TraktIds( - trakt: (json['trakt'] as num?)?.toInt(), - slug: json['slug'] as String?, - imdb: json['imdb'] as String?, - tmdb: (json['tmdb'] as num?)?.toInt(), - tvdb: (json['tvdb'] as num?)?.toInt(), - ); + factory TraktIds.fromJson(Map json) => _$TraktIdsFromJson(json); factory TraktIds.fromExternal(ExternalIds ids) => TraktIds(imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb); } diff --git a/lib/models/trakt/trakt_ids.g.dart b/lib/models/trakt/trakt_ids.g.dart new file mode 100644 index 00000000..b5dc973b --- /dev/null +++ b/lib/models/trakt/trakt_ids.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trakt_ids.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TraktIds _$TraktIdsFromJson(Map json) => TraktIds( + trakt: (json['trakt'] as num?)?.toInt(), + slug: json['slug'] as String?, + imdb: json['imdb'] as String?, + tmdb: (json['tmdb'] as num?)?.toInt(), + tvdb: (json['tvdb'] as num?)?.toInt(), +); + +Map _$TraktIdsToJson(TraktIds instance) => { + 'trakt': ?instance.trakt, + 'slug': ?instance.slug, + 'imdb': ?instance.imdb, + 'tmdb': ?instance.tmdb, + 'tvdb': ?instance.tvdb, +}; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 49bb0d8a..956ca9f3 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -47,6 +47,7 @@ import '../mixins/watch_state_aware.dart'; import '../utils/watch_state_notifier.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; +import '../utils/formatters.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/layout_constants.dart'; @@ -1609,7 +1610,7 @@ class _DiscoverScreenState extends State Text( [ contentTypeLabel, - if (heroItem.rating != null) '★ ${heroItem.rating!.toStringAsFixed(1)}', + if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}', if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!), if (heroItem.year != null) heroItem.year.toString(), ].join(' • '), diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 3180c30b..d304d49a 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -646,6 +646,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState with MountedSetStateMixin { int _gridChannelIndex = 0; int _gridColumn = 0; // 0=channel, 1=program bool _hasFocus = false; + final ValueNotifier _hasFocusNotifier = ValueNotifier(false); LiveTvProgram? _focusedProgram; bool _pendingFocus = false; @@ -144,9 +145,16 @@ class GuideTabState extends State with MountedSetStateMixin { _gridHorizontalController.dispose(); _channelVerticalController.dispose(); _timeIndicatorTimer?.cancel(); + _hasFocusNotifier.dispose(); super.dispose(); } + void _handleGuideFocusChange(bool hasFocus) { + if (_hasFocus == hasFocus) return; + _hasFocus = hasFocus; + _hasFocusNotifier.value = hasFocus; + } + void _syncGridToHeader() { if (_syncingScroll) return; _syncingScroll = true; @@ -506,7 +514,7 @@ class GuideTabState extends State with MountedSetStateMixin { return OverlaySheetHost( child: Focus( focusNode: _guideFocusNode, - onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), + onFocusChange: _handleGuideFocusChange, onKeyEvent: _handleKeyEvent, child: _buildGuideGrid(theme), ), @@ -514,87 +522,92 @@ class GuideTabState extends State with MountedSetStateMixin { } Widget _buildGuideGrid(ThemeData theme) { - return Column( - children: [ - _buildTimeNavigation(theme), - Expanded( - child: ListenableBuilder( - listenable: _gridHorizontalController, - builder: (context, child) { - return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]); - }, - child: Column( - children: [ - Row( + return ValueListenableBuilder( + valueListenable: _hasFocusNotifier, + builder: (context, hasFocus, child) { + return Column( + children: [ + _buildTimeNavigation(theme), + Expanded( + child: ListenableBuilder( + listenable: _gridHorizontalController, + builder: (context, child) { + return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]); + }, + child: Column( children: [ - const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), - Expanded( - child: SingleChildScrollView( - controller: _headerHorizontalController, - scrollDirection: Axis.horizontal, - physics: const ClampingScrollPhysics(), - child: SizedBox( - width: _totalGridWidth(), - height: _timeHeaderHeight, - child: _buildTimeHeader(theme), - ), - ), - ), - ], - ), - Expanded( - child: Row( - children: [ - SizedBox( - width: _channelColumnWidth, - child: ListView.builder( - controller: _channelVerticalController, - physics: const NeverScrollableScrollPhysics(), - itemCount: widget.channels.length, - itemExtent: _rowHeight, - itemBuilder: (context, index) => - _buildChannelCell(widget.channels[index], theme, index: index), - ), - ), - Expanded( - child: NotificationListener( - onNotification: (notification) { - if (notification is ScrollUpdateNotification && - notification.metrics.axis == Axis.vertical) { - if (_channelVerticalController.hasClients) { - _channelVerticalController.jumpTo(notification.metrics.pixels); - } - } - return false; - }, + Row( + children: [ + const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), + Expanded( child: SingleChildScrollView( - controller: _gridHorizontalController, + controller: _headerHorizontalController, scrollDirection: Axis.horizontal, physics: const ClampingScrollPhysics(), child: SizedBox( width: _totalGridWidth(), - child: ListView.builder( - controller: _gridVerticalController, - itemCount: widget.channels.length, - itemExtent: _rowHeight, - itemBuilder: (context, index) { - final channel = widget.channels[index]; - final programs = _getProgramsForChannel(channel); - return _buildProgramRow(channel, programs, theme, channelIndex: index); - }, - ), + height: _timeHeaderHeight, + child: _buildTimeHeader(theme), ), ), ), + ], + ), + Expanded( + child: Row( + children: [ + SizedBox( + width: _channelColumnWidth, + child: ListView.builder( + controller: _channelVerticalController, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) => + _buildChannelCell(widget.channels[index], theme, index: index), + ), + ), + Expanded( + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollUpdateNotification && + notification.metrics.axis == Axis.vertical) { + if (_channelVerticalController.hasClients) { + _channelVerticalController.jumpTo(notification.metrics.pixels); + } + } + return false; + }, + child: SingleChildScrollView( + controller: _gridHorizontalController, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: SizedBox( + width: _totalGridWidth(), + child: ListView.builder( + controller: _gridVerticalController, + itemCount: widget.channels.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) { + final channel = widget.channels[index]; + final programs = _getProgramsForChannel(channel); + return _buildProgramRow(channel, programs, theme, channelIndex: index); + }, + ), + ), + ), + ), + ), + ], ), - ], - ), + ), + ], ), - ], + ), ), - ), - ), - ], + ], + ); + }, ); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 3883f058..165b8c61 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -599,13 +599,6 @@ class _MediaDetailScreenState extends State final isNumeric = mediaClient?.capabilities.numericUserRating ?? true; final hasRating = metadata.userRating != null && metadata.userRating! > 0; final starValue = hasRating ? metadata.userRating! / 2.0 : 0.0; - final colorScheme = Theme.of(context).colorScheme; - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - final showFocus = _ratingChipFocusNode.hasFocus && isKeyboardMode; - - final bgColor = showFocus ? colorScheme.inverseSurface : colorScheme.secondaryContainer.withValues(alpha: 0.8); - final fgColor = showFocus ? colorScheme.onInverseSurface : colorScheme.onSecondaryContainer; - final activate = isNumeric ? () => _showRatingDialog(metadata, starValue) : () => _toggleLike(metadata); final iconData = isNumeric ? Symbols.star_rounded : Symbols.thumb_up_rounded; @@ -615,50 +608,60 @@ class _MediaDetailScreenState extends State // "Rate" label as the action prompt either way. final label = isNumeric && hasRating ? formatRating(starValue) : t.mediaMenu.rate; - return FocusableWrapper( - focusNode: _ratingChipFocusNode, - onSelect: activate, - borderRadius: 100, - disableScale: true, - focusColor: Colors.transparent, - onFocusChange: (_) => setState(() {}), - onKeyEvent: (_, event) { - if (!event.isActionable) return KeyEventResult.ignored; - final key = event.logicalKey; - if (key.isDownKey) { - _playButtonFocusNode.requestFocus(); - return KeyEventResult.handled; - } - if (key.isUpKey) { - return KeyEventResult.handled; // consume — nothing above - } - return KeyEventResult.ignored; - }, - child: GestureDetector( - onTap: activate, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(100))), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon( - iconData, - fill: hasRating ? 1 : 0, - color: showFocus ? fgColor : (hasRating ? activeIconColor : fgColor), - size: 16, + return ListenableBuilder( + listenable: _ratingChipFocusNode, + builder: (context, _) { + final colorScheme = Theme.of(context).colorScheme; + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final showFocus = _ratingChipFocusNode.hasFocus && isKeyboardMode; + final bgColor = showFocus ? colorScheme.inverseSurface : colorScheme.secondaryContainer.withValues(alpha: 0.8); + final fgColor = showFocus ? colorScheme.onInverseSurface : colorScheme.onSecondaryContainer; + + return FocusableWrapper( + focusNode: _ratingChipFocusNode, + onSelect: activate, + borderRadius: 100, + disableScale: true, + focusColor: Colors.transparent, + onKeyEvent: (_, event) { + if (!event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + if (key.isDownKey) { + _playButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + if (key.isUpKey) { + return KeyEventResult.handled; // consume — nothing above + } + return KeyEventResult.ignored; + }, + child: GestureDetector( + onTap: activate, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(100))), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon( + iconData, + fill: hasRating ? 1 : 0, + color: showFocus ? fgColor : (hasRating ? activeIconColor : fgColor), + size: 16, + ), + const SizedBox(width: 4), + Text( + label, + style: TextStyle(color: fgColor, fontSize: 13, fontWeight: FontWeight.w500), + ), + ], ), - const SizedBox(width: 4), - Text( - label, - style: TextStyle(color: fgColor, fontSize: 13, fontWeight: FontWeight.w500), - ), - ], + ), ), - ), - ), + ); + }, ); } @@ -2190,10 +2193,9 @@ class _MediaDetailScreenState extends State Focus( focusNode: _overviewFocusNode, onKeyEvent: _handleOverviewKeyEvent, - onFocusChange: (_) => setState(() {}), - child: Builder( - builder: (context) { - final innerTheme = Theme.of(context); + child: ListenableBuilder( + listenable: _overviewFocusNode, + builder: (context, _) { final showFocus = _overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); return AnimatedContainer( @@ -2203,13 +2205,13 @@ class _MediaDetailScreenState extends State borderRadius: const BorderRadius.all(Radius.circular(8)), border: Border.all( color: showFocus - ? innerTheme.colorScheme.primary.withValues(alpha: 0.5) + ? theme.colorScheme.primary.withValues(alpha: 0.5) : Colors.transparent, width: 2, ), ), child: () { - final summaryStyle = innerTheme.textTheme.bodyLarge?.copyWith(height: 1.6); + final summaryStyle = theme.textTheme.bodyLarge?.copyWith(height: 1.6); if (isTv) { return Text(metadata.summary!, style: summaryStyle); } @@ -2601,7 +2603,6 @@ class _MediaDetailScreenState extends State // image + inner padding + text area + outer list padding + focus scale headroom final containerHeight = imageSize + innerPadding * 2 + 66 + 16; - final hasFocus = _castFocusNode.hasFocus; final theme = Theme.of(context); final actorNameStyle = theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600); final actorRoleStyle = theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant); @@ -2609,74 +2610,85 @@ class _MediaDetailScreenState extends State return Focus( focusNode: _castFocusNode, onKeyEvent: _handleCastKeyEvent, - onFocusChange: (_) => setState(() {}), - child: SizedBox( - height: containerHeight, - child: HorizontalScrollWithArrows( - controller: _castScrollController, - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - padding: const EdgeInsets.symmetric(vertical: 5), - itemCount: metadata.roles!.length, - itemBuilder: (context, index) { - final actor = metadata.roles![index]; - final isFocused = hasFocus && index == _focusedCastIndex; + child: ListenableBuilder( + listenable: _castFocusNode, + builder: (context, _) { + final hasFocus = _castFocusNode.hasFocus; - return Padding( - padding: const EdgeInsets.only(right: 4), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isFocused, - borderRadius: tokens(context).radiusSm, - onTap: () => _navigateToActorMedia(actor), - child: Padding( - padding: const EdgeInsets.all(innerPadding), - child: SizedBox( - width: cardWidth, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: OptimizedMediaImage( - client: getServerBoundMediaClient(context), - imagePath: actor.thumbPath, - width: imageSize, - height: imageSize, - fit: BoxFit.cover, - imageType: ImageType.avatar, - fallbackIcon: Symbols.person_rounded, - ), + return SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + controller: _castScrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 5), + itemCount: metadata.roles!.length, + itemBuilder: (context, index) { + final actor = metadata.roles![index]; + final isFocused = hasFocus && index == _focusedCastIndex; + + return Padding( + padding: const EdgeInsets.only(right: 4), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + borderRadius: tokens(context).radiusSm, + onTap: () => _navigateToActorMedia(actor), + child: Padding( + padding: const EdgeInsets.all(innerPadding), + child: SizedBox( + width: cardWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: OptimizedMediaImage( + client: getServerBoundMediaClient(context), + imagePath: actor.thumbPath, + width: imageSize, + height: imageSize, + fit: BoxFit.cover, + imageType: ImageType.avatar, + fallbackIcon: Symbols.person_rounded, + ), + ), + const SizedBox(height: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + actor.tag, + style: actorNameStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (actor.role != null) ...[ + const SizedBox(height: 2), + Text( + actor.role!, + style: actorRoleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], ), - const SizedBox(height: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(actor.tag, style: actorNameStyle, maxLines: 2, overflow: TextOverflow.ellipsis), - if (actor.role != null) ...[ - const SizedBox(height: 2), - Text( - actor.role!, - style: actorRoleStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ], + ), ), ), - ), - ), - ); - }, - ), - ), + ); + }, + ), + ), + ); + }, ), ); } @@ -2694,44 +2706,49 @@ class _MediaDetailScreenState extends State final posterHeight = (cardWidth - 16) * (9 / 16); final containerHeight = posterHeight + 66; - final hasFocus = _extrasFocusNode.hasFocus; - return Focus( focusNode: _extrasFocusNode, onKeyEvent: _handleExtrasKeyEvent, - child: SizedBox( - height: containerHeight, - child: HorizontalScrollWithArrows( - controller: _extrasScrollController, - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - padding: const EdgeInsets.symmetric(vertical: 5), - itemCount: _extras!.length, - itemBuilder: (context, index) { - final extra = _extras![index]; - final isFocused = hasFocus && index == _focusedExtraIndex; - final cardKey = _extraCardKeys.putIfAbsent(index, () => GlobalKey()); + child: ListenableBuilder( + listenable: _extrasFocusNode, + builder: (context, _) { + final hasFocus = _extrasFocusNode.hasFocus; - return Padding( - padding: const EdgeInsets.only(right: 4), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isFocused, - onTap: () => navigateToVideoPlayer(context, metadata: extra), - child: MediaCard( - key: cardKey, - item: extra, - width: cardWidth, - height: posterHeight, - forceGridMode: true, - ), - ), - ); - }, - ), - ), + return SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + controller: _extrasScrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 5), + itemCount: _extras!.length, + itemBuilder: (context, index) { + final extra = _extras![index]; + final isFocused = hasFocus && index == _focusedExtraIndex; + final cardKey = _extraCardKeys.putIfAbsent(index, () => GlobalKey()); + + return Padding( + padding: const EdgeInsets.only(right: 4), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: () => navigateToVideoPlayer(context, metadata: extra), + child: MediaCard( + key: cardKey, + item: extra, + width: cardWidth, + height: posterHeight, + forceGridMode: true, + ), + ), + ); + }, + ), + ), + ); + }, ), ); } diff --git a/lib/screens/plex_metadata_edit_screen.dart b/lib/screens/plex_metadata_edit_screen.dart index 1f903454..d2ffe0dc 100644 --- a/lib/screens/plex_metadata_edit_screen.dart +++ b/lib/screens/plex_metadata_edit_screen.dart @@ -328,7 +328,11 @@ class _PlexMetadataEditScreenState extends State { child: ListView( shrinkWrap: true, children: options.map((option) { - return FocusableRadioListTile(title: Text(option.label), value: option.value); + return FocusableRadioListTile( + key: ValueKey(option.value), + title: Text(option.label), + value: option.value, + ); }).toList(), ), ), diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index e4c41ab0..97a95009 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -90,6 +90,7 @@ Future showSelectionDialog({ children: options.map((option) { final selected = option.value == currentValue; return FocusableListTile( + key: ValueKey(option.value), leading: Icon( selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: selected ? Theme.of(dialogContext).colorScheme.primary : null, diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 80c86ac4..25760a17 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -251,7 +251,7 @@ class _ConnectionCandidate { final bool isPlexDirectUri; final bool isHttps; - _ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri, this.isHttps); + const _ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri, this.isHttps); } /// Represents a Plex Media Server diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 01e34e5a..a82eed0b 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -345,7 +345,7 @@ class TrackSelectionResult { final T track; final TrackSelectionPriority priority; - TrackSelectionResult(this.track, this.priority); + const TrackSelectionResult(this.track, this.priority); } /// Service for selecting and applying audio and subtitle tracks based on diff --git a/lib/services/trackers/anilist/anilist_session.dart b/lib/services/trackers/anilist/anilist_session.dart index d1126fba..6c63d088 100644 --- a/lib/services/trackers/anilist/anilist_session.dart +++ b/lib/services/trackers/anilist/anilist_session.dart @@ -1,10 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + import '../oauth_proxy_client.dart'; import '../tracker_session_utils.dart'; +part 'anilist_session.g.dart'; + /// Immutable AniList OAuth session. /// /// Implicit grant — no refresh token. Tokens are valid for 1 year; on expiry /// the user must re-auth. +@JsonSerializable(fieldRename: FieldRename.snake) class AnilistSession with EncodedTrackerSession { final String accessToken; final int expiresAt; @@ -25,19 +30,9 @@ class AnilistSession with EncodedTrackerSession { } @override - Map toJson() => { - 'access_token': accessToken, - 'expires_at': expiresAt, - 'username': username, - 'created_at': createdAt, - }; + Map toJson() => _$AnilistSessionToJson(this); - factory AnilistSession.fromJson(Map json) => AnilistSession( - accessToken: json['access_token'] as String, - expiresAt: (json['expires_at'] as num).toInt(), - username: json['username'] as String?, - createdAt: (json['created_at'] as num).toInt(), - ); + factory AnilistSession.fromJson(Map json) => _$AnilistSessionFromJson(json); /// Build a session from the OAuth-proxy result. AniList tokens last 1 year /// and have no refresh; when the proxy doesn't echo an explicit expiry we diff --git a/lib/services/trackers/anilist/anilist_session.g.dart b/lib/services/trackers/anilist/anilist_session.g.dart new file mode 100644 index 00000000..b46c1683 --- /dev/null +++ b/lib/services/trackers/anilist/anilist_session.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'anilist_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AnilistSession _$AnilistSessionFromJson(Map json) => AnilistSession( + accessToken: json['access_token'] as String, + expiresAt: (json['expires_at'] as num).toInt(), + createdAt: (json['created_at'] as num).toInt(), + username: json['username'] as String?, +); + +Map _$AnilistSessionToJson(AnilistSession instance) => { + 'access_token': instance.accessToken, + 'expires_at': instance.expiresAt, + 'username': instance.username, + 'created_at': instance.createdAt, +}; diff --git a/lib/services/trackers/mal/mal_session.dart b/lib/services/trackers/mal/mal_session.dart index 4eaa0bec..26929c27 100644 --- a/lib/services/trackers/mal/mal_session.dart +++ b/lib/services/trackers/mal/mal_session.dart @@ -1,10 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + import '../oauth_proxy_client.dart'; import '../tracker_session_utils.dart'; +part 'mal_session.g.dart'; + /// Immutable MyAnimeList OAuth session. /// /// Access tokens expire in ~31 days. Refresh token rotates with each refresh /// (rare but documented in MAL's API contract). +@JsonSerializable(fieldRename: FieldRename.snake) class MalSession with EncodedTrackerSession { final String accessToken; final String refreshToken; @@ -34,21 +39,9 @@ class MalSession with EncodedTrackerSession { } @override - Map toJson() => { - 'access_token': accessToken, - 'refresh_token': refreshToken, - 'expires_at': expiresAt, - 'username': username, - 'created_at': createdAt, - }; + Map toJson() => _$MalSessionToJson(this); - factory MalSession.fromJson(Map json) => MalSession( - accessToken: json['access_token'] as String, - refreshToken: json['refresh_token'] as String, - expiresAt: (json['expires_at'] as num).toInt(), - username: json['username'] as String?, - createdAt: (json['created_at'] as num).toInt(), - ); + factory MalSession.fromJson(Map json) => _$MalSessionFromJson(json); /// Build a session from MAL's `/oauth2/token` response. factory MalSession.fromTokenResponse(Map json) { diff --git a/lib/services/trackers/mal/mal_session.g.dart b/lib/services/trackers/mal/mal_session.g.dart new file mode 100644 index 00000000..e680644b --- /dev/null +++ b/lib/services/trackers/mal/mal_session.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mal_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MalSession _$MalSessionFromJson(Map json) => MalSession( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + expiresAt: (json['expires_at'] as num).toInt(), + createdAt: (json['created_at'] as num).toInt(), + username: json['username'] as String?, +); + +Map _$MalSessionToJson(MalSession instance) => { + 'access_token': instance.accessToken, + 'refresh_token': instance.refreshToken, + 'expires_at': instance.expiresAt, + 'username': instance.username, + 'created_at': instance.createdAt, +}; diff --git a/lib/services/trackers/simkl/simkl_session.dart b/lib/services/trackers/simkl/simkl_session.dart index ee732d7d..0b88e911 100644 --- a/lib/services/trackers/simkl/simkl_session.dart +++ b/lib/services/trackers/simkl/simkl_session.dart @@ -1,9 +1,14 @@ +import 'package:json_annotation/json_annotation.dart'; + import '../tracker_session_utils.dart'; +part 'simkl_session.g.dart'; + /// Immutable Simkl OAuth session. /// /// Simkl access tokens don't expire (per their docs), so there's no /// refresh_token — just the bearer and a display name for the settings UI. +@JsonSerializable(fieldRename: FieldRename.snake) class SimklSession with EncodedTrackerSession { final String accessToken; final String? username; @@ -18,13 +23,9 @@ class SimklSession with EncodedTrackerSession { ); @override - Map toJson() => {'access_token': accessToken, 'username': username, 'created_at': createdAt}; + Map toJson() => _$SimklSessionToJson(this); - factory SimklSession.fromJson(Map json) => SimklSession( - accessToken: json['access_token'] as String, - username: json['username'] as String?, - createdAt: (json['created_at'] as num).toInt(), - ); + factory SimklSession.fromJson(Map json) => _$SimklSessionFromJson(json); /// Build a session from Simkl's device-code `/oauth/pin/` response. /// Simkl doesn't expose a creation timestamp so we stamp "now". diff --git a/lib/services/trackers/simkl/simkl_session.g.dart b/lib/services/trackers/simkl/simkl_session.g.dart new file mode 100644 index 00000000..8b7f9d15 --- /dev/null +++ b/lib/services/trackers/simkl/simkl_session.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'simkl_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SimklSession _$SimklSessionFromJson(Map json) => SimklSession( + accessToken: json['access_token'] as String, + createdAt: (json['created_at'] as num).toInt(), + username: json['username'] as String?, +); + +Map _$SimklSessionToJson(SimklSession instance) => { + 'access_token': instance.accessToken, + 'username': instance.username, + 'created_at': instance.createdAt, +}; diff --git a/lib/services/trakt/trakt_session.dart b/lib/services/trakt/trakt_session.dart index e22be7c7..bc8f298d 100644 --- a/lib/services/trakt/trakt_session.dart +++ b/lib/services/trakt/trakt_session.dart @@ -1,10 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + import '../trackers/tracker_session_utils.dart'; +part 'trakt_session.g.dart'; + /// Immutable Trakt OAuth session. /// /// Persisted as a JSON blob under `user_{uuid}_trakt_session` in /// `SharedPreferences`. Tokens are stored in plaintext, matching the security /// model of the existing Plex token. +@JsonSerializable(fieldRename: FieldRename.snake) class TraktSession { final String accessToken; final String refreshToken; @@ -15,6 +20,7 @@ class TraktSession { /// Trakt username (`@handle`), populated after `getUserSettings`. final String? username; + @JsonKey(defaultValue: 'public') final String scope; /// Epoch seconds at which the session was first created. @@ -53,25 +59,9 @@ class TraktSession { ); } - Map toJson() => { - 'access_token': accessToken, - 'refresh_token': refreshToken, - 'expires_at': expiresAt, - 'username': username, - 'scope': scope, - 'created_at': createdAt, - }; + Map toJson() => _$TraktSessionToJson(this); - factory TraktSession.fromJson(Map json) { - return TraktSession( - accessToken: json['access_token'] as String, - refreshToken: json['refresh_token'] as String, - expiresAt: (json['expires_at'] as num).toInt(), - username: json['username'] as String?, - scope: json['scope'] as String? ?? 'public', - createdAt: (json['created_at'] as num).toInt(), - ); - } + factory TraktSession.fromJson(Map json) => _$TraktSessionFromJson(json); /// Build a session from Trakt's `/oauth/token` or `/oauth/device/token` response, /// which uses `expires_in` (relative seconds) rather than `expires_at`. diff --git a/lib/services/trakt/trakt_session.g.dart b/lib/services/trakt/trakt_session.g.dart new file mode 100644 index 00000000..8b376535 --- /dev/null +++ b/lib/services/trakt/trakt_session.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trakt_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TraktSession _$TraktSessionFromJson(Map json) => TraktSession( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + expiresAt: (json['expires_at'] as num).toInt(), + scope: json['scope'] as String? ?? 'public', + createdAt: (json['created_at'] as num).toInt(), + username: json['username'] as String?, +); + +Map _$TraktSessionToJson(TraktSession instance) => { + 'access_token': instance.accessToken, + 'refresh_token': instance.refreshToken, + 'expires_at': instance.expiresAt, + 'username': instance.username, + 'scope': instance.scope, + 'created_at': instance.createdAt, +}; diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 98372384..4f3a6980 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -30,7 +30,7 @@ class LogEntry { final Object? error; final StackTrace? stackTrace; - LogEntry({required this.timestamp, required this.level, required this.message, this.error, this.stackTrace}); + const LogEntry({required this.timestamp, required this.level, required this.message, this.error, this.stackTrace}); /// Estimate the memory size of this log entry in bytes int get estimatedSize { diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart index d496f0e6..3b028fe1 100644 --- a/lib/utils/formatters.dart +++ b/lib/utils/formatters.dart @@ -173,6 +173,9 @@ String toBulletedString(List parts) { return parts.join(' · '); } +String formatRating(double value) => + value == value.truncateToDouble() ? value.toInt().toString() : value.toStringAsFixed(1); + final RegExp _trailingZeroPattern = RegExp(r'\.?0+$'); /// Format a playback rate for display (e.g. 1.25 → "1.25x", 2.0 → "2x"). diff --git a/lib/watch_together/services/recent_rooms_service.dart b/lib/watch_together/services/recent_rooms_service.dart index 5a81715c..0e5291de 100644 --- a/lib/watch_together/services/recent_rooms_service.dart +++ b/lib/watch_together/services/recent_rooms_service.dart @@ -1,32 +1,26 @@ import 'dart:convert'; +import 'package:json_annotation/json_annotation.dart'; + import '../../services/settings_service.dart'; import '../models/watch_session.dart'; +part 'recent_rooms_service.g.dart'; + +@JsonSerializable(includeIfNull: false) class RecentRoom { final String code; final String? name; + @JsonKey(fromJson: _dateTimeFromMillis, toJson: _dateTimeToMillis) final DateTime lastUsed; + @JsonKey(fromJson: _controlModeFromIndex, toJson: _controlModeToIndex) final ControlMode? controlMode; const RecentRoom({required this.code, this.name, required this.lastUsed, this.controlMode}); - Map toJson() => { - 'code': code, - if (name != null) 'name': name, - 'lastUsed': lastUsed.millisecondsSinceEpoch, - if (controlMode != null) 'controlMode': controlMode!.index, - }; + Map toJson() => _$RecentRoomToJson(this); - factory RecentRoom.fromJson(Map json) { - final modeIndex = json['controlMode'] as int?; - return RecentRoom( - code: json['code'] as String, - name: json['name'] as String?, - lastUsed: DateTime.fromMillisecondsSinceEpoch(json['lastUsed'] as int), - controlMode: modeIndex != null ? ControlMode.values[modeIndex] : null, - ); - } + factory RecentRoom.fromJson(Map json) => _$RecentRoomFromJson(json); RecentRoom copyWith({ String? code, @@ -42,6 +36,17 @@ class RecentRoom { ); } +DateTime _dateTimeFromMillis(int value) => DateTime.fromMillisecondsSinceEpoch(value); + +int _dateTimeToMillis(DateTime value) => value.millisecondsSinceEpoch; + +ControlMode? _controlModeFromIndex(int? index) { + if (index == null || index < 0 || index >= ControlMode.values.length) return null; + return ControlMode.values[index]; +} + +int? _controlModeToIndex(ControlMode? value) => value?.index; + class RecentRoomsService { static const int _maxRooms = 20; diff --git a/lib/watch_together/services/recent_rooms_service.g.dart b/lib/watch_together/services/recent_rooms_service.g.dart new file mode 100644 index 00000000..8da02fde --- /dev/null +++ b/lib/watch_together/services/recent_rooms_service.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recent_rooms_service.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RecentRoom _$RecentRoomFromJson(Map json) => RecentRoom( + code: json['code'] as String, + name: json['name'] as String?, + lastUsed: _dateTimeFromMillis((json['lastUsed'] as num).toInt()), + controlMode: _controlModeFromIndex((json['controlMode'] as num?)?.toInt()), +); + +Map _$RecentRoomToJson(RecentRoom instance) => { + 'code': instance.code, + 'name': ?instance.name, + 'lastUsed': _dateTimeToMillis(instance.lastUsed), + 'controlMode': ?_controlModeToIndex(instance.controlMode), +}; diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index 9bbdcb9d..44caf873 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -343,6 +343,7 @@ class _ParticipantNotificationOverlayState extends State t.watchTogether.participantBuffering(name: n.event.displayName), }; return Container( + key: ValueKey(n.id), margin: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: const BoxDecoration( diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 698c4852..e101fd79 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -80,7 +80,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin final index = entry.key; final action = entry.value; return FocusableListTile( + key: ValueKey(action.value), focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, leading: AppIcon(action.icon, fill: 1), title: Text(action.label), @@ -1798,6 +1799,7 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { final index = entry.key; final action = entry.value; return FocusableListTile( + key: ValueKey(action.value), focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, leading: AppIcon(action.icon, fill: 1, size: 20), title: Text(action.label), diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index 5828e4f8..4cbf741f 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -6,6 +6,7 @@ import '../widgets/overlay_sheet.dart'; import '../focus/dpad_navigator.dart'; import '../focus/input_mode_tracker.dart'; import '../i18n/strings.g.dart'; +import '../utils/formatters.dart'; class RatingBottomSheet extends StatefulWidget { final double currentRating; @@ -18,9 +19,6 @@ class RatingBottomSheet extends StatefulWidget { State createState() => _RatingBottomSheetState(); } -String formatRating(double value) => - value == value.truncateToDouble() ? value.toInt().toString() : value.toStringAsFixed(1); - class _RatingBottomSheetState extends State { late double _selectedRating; late final FocusNode _starsFocusNode; diff --git a/lib/widgets/server_activities_button.dart b/lib/widgets/server_activities_button.dart index e0843f0b..b69f410a 100644 --- a/lib/widgets/server_activities_button.dart +++ b/lib/widgets/server_activities_button.dart @@ -25,7 +25,7 @@ class _ServerResult { final String serverName; final List activities; - _ServerResult({required this.serverId, required this.serverName, required this.activities}); + const _ServerResult({required this.serverId, required this.serverName, required this.activities}); } class _PanelData { diff --git a/lib/widgets/video_controls/widgets/live_timeline_bar.dart b/lib/widgets/video_controls/widgets/live_timeline_bar.dart index e614676b..43f6f259 100644 --- a/lib/widgets/video_controls/widgets/live_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/live_timeline_bar.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import '../../../models/livetv_capture_buffer.dart'; import '../../../mpv/mpv.dart'; import '../../../focus/focusable_wrapper.dart'; +import '../../../utils/formatters.dart'; /// Timeline bar for live TV time-shift. /// @@ -51,9 +51,9 @@ class _LiveTimelineBarState extends State { int _displayPosition(Duration playerPosition) => _isDragging ? _dragPositionEpoch : _currentEpoch(playerPosition); - String _formatEpochTime(int epochSeconds) { + String _formatEpochTime(BuildContext context, int epochSeconds) { final dt = DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000); - return DateFormat.jm().format(dt); + return formatClockTime(dt, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context)); } double _epochToFraction(int epoch) { @@ -88,7 +88,7 @@ class _LiveTimelineBarState extends State { return Row( children: [ Text( - _formatEpochTime(displayPos), + _formatEpochTime(context, displayPos), style: const TextStyle(color: Colors.white70, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]), ), const SizedBox(width: 8), @@ -107,7 +107,7 @@ class _LiveTimelineBarState extends State { Align( alignment: Alignment.centerLeft, child: Text( - _formatEpochTime(displayPos), + _formatEpochTime(context, displayPos), style: const TextStyle(color: Colors.white70, fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]), ), ), diff --git a/test/models/json_model_round_trip_test.dart b/test/models/json_model_round_trip_test.dart new file mode 100644 index 00000000..bcefc5b5 --- /dev/null +++ b/test/models/json_model_round_trip_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/companion_remote/remote_command.dart'; +import 'package:plezy/models/mpv_config_models.dart'; +import 'package:plezy/models/trakt/trakt_ids.dart'; +import 'package:plezy/watch_together/models/watch_session.dart'; +import 'package:plezy/watch_together/services/recent_rooms_service.dart'; + +void main() { + group('JSON model round trips', () { + test('MpvPreset preserves ISO timestamp shape', () { + final createdAt = DateTime.utc(2024, 1, 2, 3, 4, 5); + final preset = MpvPreset(name: 'Anime', text: 'profile=gpu-hq', createdAt: createdAt); + + expect(preset.toJson(), {'name': 'Anime', 'text': 'profile=gpu-hq', 'createdAt': createdAt.toIso8601String()}); + + final decoded = MpvPreset.fromJson(preset.toJson()); + expect(decoded.name, preset.name); + expect(decoded.text, preset.text); + expect(decoded.createdAt, createdAt); + }); + + test('TraktIds omits null fields and accepts numeric ids', () { + const ids = TraktIds(imdb: 'tt123', tmdb: 42); + + expect(ids.toJson(), {'imdb': 'tt123', 'tmdb': 42}); + + final decoded = TraktIds.fromJson({'trakt': 1.0, 'slug': 'movie', 'tmdb': 42.0, 'tvdb': 9}); + expect(decoded.trakt, 1); + expect(decoded.slug, 'movie'); + expect(decoded.tmdb, 42); + expect(decoded.tvdb, 9); + expect(decoded.hasAny, isTrue); + }); + + test('RecentRoom preserves epoch timestamp and control mode index', () { + final lastUsed = DateTime.fromMillisecondsSinceEpoch(1700000000000); + final room = RecentRoom(code: 'ABCD', name: 'Movie night', lastUsed: lastUsed, controlMode: ControlMode.anyone); + + expect(room.toJson(), {'code': 'ABCD', 'name': 'Movie night', 'lastUsed': 1700000000000, 'controlMode': 1}); + + final decoded = RecentRoom.fromJson(room.toJson()); + expect(decoded.code, room.code); + expect(decoded.name, room.name); + expect(decoded.lastUsed, lastUsed); + expect(decoded.controlMode, ControlMode.anyone); + }); + + test('RecentRoom omits nullable fields when absent', () { + final lastUsed = DateTime.fromMillisecondsSinceEpoch(1700000000000); + final room = RecentRoom(code: 'ABCD', lastUsed: lastUsed); + + expect(room.toJson(), {'code': 'ABCD', 'lastUsed': 1700000000000}); + }); + + test('RemoteCommand keeps compact protocol keys and unknown fallback', () { + const command = RemoteCommand(type: RemoteCommandType.volumeSet, data: {'value': 50}); + + expect(command.toJson(), { + 't': RemoteCommandType.volumeSet.index, + 'd': {'value': 50}, + }); + + final decoded = RemoteCommand.fromJson(command.toJson()); + expect(decoded.type, RemoteCommandType.volumeSet); + expect(decoded.data, {'value': 50}); + + final unknown = RemoteCommand.fromJson({'t': 999}); + expect(unknown.type, RemoteCommandType.ping); + }); + }); +} diff --git a/test/services/trackers/tracker_session_utils_test.dart b/test/services/trackers/tracker_session_utils_test.dart index 394282ad..430370e8 100644 --- a/test/services/trackers/tracker_session_utils_test.dart +++ b/test/services/trackers/tracker_session_utils_test.dart @@ -3,6 +3,7 @@ import 'package:plezy/services/trackers/anilist/anilist_session.dart'; import 'package:plezy/services/trackers/mal/mal_session.dart'; import 'package:plezy/services/trackers/simkl/simkl_session.dart'; import 'package:plezy/services/trackers/tracker_session_utils.dart'; +import 'package:plezy/services/trakt/trakt_session.dart'; void main() { group('tracker token expiry helpers', () { @@ -26,6 +27,39 @@ void main() { expect(decoded, {'access_token': 'abc', 'created_at': 123}); }); + test('round-trips Trakt sessions with snake-case keys and default scope', () { + const session = TraktSession( + accessToken: 'trakt-at', + refreshToken: 'trakt-rt', + expiresAt: 2000, + scope: 'public', + createdAt: 1000, + ); + + expect(session.toJson(), { + 'access_token': 'trakt-at', + 'refresh_token': 'trakt-rt', + 'expires_at': 2000, + 'username': null, + 'scope': 'public', + 'created_at': 1000, + }); + + final decoded = TraktSession.fromJson({ + 'access_token': 'trakt-at', + 'refresh_token': 'trakt-rt', + 'expires_at': 2000, + 'created_at': 1000, + }); + + expect(decoded.accessToken, 'trakt-at'); + expect(decoded.refreshToken, 'trakt-rt'); + expect(decoded.expiresAt, 2000); + expect(decoded.username, isNull); + expect(decoded.scope, 'public'); + expect(decoded.createdAt, 1000); + }); + test('round-trips AniList sessions through shared encode mixin', () { const session = AnilistSession(accessToken: 'anilist-at', expiresAt: 2000, username: 'alice', createdAt: 1000);