refactor: reduce model and focus boilerplate

This commit is contained in:
edde746
2026-05-06 00:26:44 +02:00
parent 18a5041334
commit 91c23786f2
36 changed files with 624 additions and 364 deletions
+21 -35
View File
@@ -49,49 +49,35 @@ class MediaServerHttpException extends MediaServerException {
/// Map a caught exception to a [MediaServerHttpException]. /// Map a caught exception to a [MediaServerHttpException].
factory MediaServerHttpException.from(Object error, {Uri? uri}) { factory MediaServerHttpException.from(Object error, {Uri? uri}) {
if (error is MediaServerHttpException) return error; return switch (error) {
MediaServerHttpException() => error,
if (error is RequestAbortedException) { RequestAbortedException(:final message, uri: final errorUri) => MediaServerHttpException(
return MediaServerHttpException(
type: MediaServerHttpErrorType.cancelled, type: MediaServerHttpErrorType.cancelled,
message: error.message, message: message,
requestUri: error.uri ?? uri, requestUri: errorUri ?? uri,
); ),
} TimeoutException(:final message) => MediaServerHttpException(
if (error is TimeoutException) {
return MediaServerHttpException(
type: MediaServerHttpErrorType.connectionTimeout, type: MediaServerHttpErrorType.connectionTimeout,
message: error.message, message: message,
requestUri: uri, requestUri: uri,
); ),
} SocketException(:final message) => MediaServerHttpException(
if (error is SocketException) {
return MediaServerHttpException(
type: MediaServerHttpErrorType.connectionError, type: MediaServerHttpErrorType.connectionError,
message: error.message, message: message,
requestUri: uri, requestUri: uri,
); ),
} HttpException(:final message) => MediaServerHttpException(
if (error is HttpException) {
return MediaServerHttpException(
type: MediaServerHttpErrorType.connectionError, type: MediaServerHttpErrorType.connectionError,
message: error.message, message: message,
requestUri: uri, requestUri: uri,
); ),
} ClientException(:final message, uri: final errorUri) => MediaServerHttpException(
if (error is ClientException) {
return MediaServerHttpException(
type: MediaServerHttpErrorType.connectionError, type: MediaServerHttpErrorType.connectionError,
message: error.message, message: message,
requestUri: error.uri ?? uri, requestUri: errorUri ?? uri,
); ),
} _ => MediaServerHttpException(type: MediaServerHttpErrorType.unknown, message: error.toString(), requestUri: uri),
};
return MediaServerHttpException(type: MediaServerHttpErrorType.unknown, message: error.toString(), requestUri: uri);
} }
/// Whether the error looks transient (network/timeout) and worth retrying. /// Whether the error looks transient (network/timeout) and worth retrying.
+1
View File
@@ -1248,6 +1248,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
statusIcon = const Icon(Icons.cancel, size: 14, color: failColor); statusIcon = const Icon(Icons.cancel, size: 14, color: failColor);
} }
return Padding( return Padding(
key: ValueKey(entry.key),
padding: const EdgeInsets.symmetric(vertical: 2), padding: const EdgeInsets.symmetric(vertical: 2),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
+2 -2
View File
@@ -7,7 +7,7 @@ class LiveTvHubResult {
final String hubKey; final String hubKey;
final List<LiveTvHubEntry> entries; final List<LiveTvHubEntry> 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. /// 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 MediaItem metadata;
final LiveTvProgram program; final LiveTvProgram program;
LiveTvHubEntry({required this.metadata, required this.program}); const LiveTvHubEntry({required this.metadata, required this.program});
} }
+7 -8
View File
@@ -1,3 +1,8 @@
import 'package:json_annotation/json_annotation.dart';
part 'mpv_config_models.g.dart';
@JsonSerializable()
class MpvPreset { class MpvPreset {
final String name; final String name;
final String text; final String text;
@@ -5,13 +10,7 @@ class MpvPreset {
const MpvPreset({required this.name, required this.text, required this.createdAt}); const MpvPreset({required this.name, required this.text, required this.createdAt});
factory MpvPreset.fromJson(Map<String, dynamic> json) { factory MpvPreset.fromJson(Map<String, dynamic> json) => _$MpvPresetFromJson(json);
return MpvPreset(
name: json['name'] as String,
text: json['text'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
Map<String, dynamic> toJson() => {'name': name, 'text': text, 'createdAt': createdAt.toIso8601String()}; Map<String, dynamic> toJson() => _$MpvPresetToJson(this);
} }
+19
View File
@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mpv_config_models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MpvPreset _$MpvPresetFromJson(Map<String, dynamic> json) => MpvPreset(
name: json['name'] as String,
text: json['text'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$MpvPresetToJson(MpvPreset instance) => <String, dynamic>{
'name': instance.name,
'text': instance.text,
'createdAt': instance.createdAt.toIso8601String(),
};
+7 -14
View File
@@ -1,9 +1,14 @@
import 'package:json_annotation/json_annotation.dart';
import '../../utils/external_ids.dart'; import '../../utils/external_ids.dart';
part 'trakt_ids.g.dart';
/// External IDs for matching Plex items against Trakt's catalog. /// External IDs for matching Plex items against Trakt's catalog.
/// ///
/// Trakt prefers (in order): trakt > slug > imdb > tmdb > tvdb. Movies use /// 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. /// imdb/tmdb; episodes use the show's tvdb/tmdb/imdb plus season/episode index.
@JsonSerializable(includeIfNull: false)
class TraktIds { class TraktIds {
final int? trakt; final int? trakt;
final String? slug; final String? slug;
@@ -16,21 +21,9 @@ class TraktIds {
/// True when at least one external ID is set (i.e. usable for Trakt matching). /// 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; bool get hasAny => imdb != null || tmdb != null || tvdb != null || trakt != null || slug != null;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => _$TraktIdsToJson(this);
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,
};
factory TraktIds.fromJson(Map<String, dynamic> json) => TraktIds( factory TraktIds.fromJson(Map<String, dynamic> json) => _$TraktIdsFromJson(json);
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.fromExternal(ExternalIds ids) => TraktIds(imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb); factory TraktIds.fromExternal(ExternalIds ids) => TraktIds(imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb);
} }
+23
View File
@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trakt_ids.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TraktIds _$TraktIdsFromJson(Map<String, dynamic> 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<String, dynamic> _$TraktIdsToJson(TraktIds instance) => <String, dynamic>{
'trakt': ?instance.trakt,
'slug': ?instance.slug,
'imdb': ?instance.imdb,
'tmdb': ?instance.tmdb,
'tvdb': ?instance.tvdb,
};
+2 -1
View File
@@ -47,6 +47,7 @@ import '../mixins/watch_state_aware.dart';
import '../utils/watch_state_notifier.dart'; import '../utils/watch_state_notifier.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/dialogs.dart'; import '../utils/dialogs.dart';
import '../utils/formatters.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart'; import '../utils/video_player_navigation.dart';
import '../utils/layout_constants.dart'; import '../utils/layout_constants.dart';
@@ -1609,7 +1610,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
Text( Text(
[ [
contentTypeLabel, 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.contentRating != null) formatContentRating(heroItem.contentRating!),
if (heroItem.year != null) heroItem.year.toString(), if (heroItem.year != null) heroItem.year.toString(),
].join(''), ].join(''),
@@ -646,6 +646,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
children: options.map((grouping) { children: options.map((grouping) {
final isSelected = _selectedGrouping == grouping; final isSelected = _selectedGrouping == grouping;
return FocusableListTile( return FocusableListTile(
key: ValueKey(grouping),
dense: true, dense: true,
leading: AppIcon( leading: AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
+83 -70
View File
@@ -75,6 +75,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
int _gridChannelIndex = 0; int _gridChannelIndex = 0;
int _gridColumn = 0; // 0=channel, 1=program int _gridColumn = 0; // 0=channel, 1=program
bool _hasFocus = false; bool _hasFocus = false;
final ValueNotifier<bool> _hasFocusNotifier = ValueNotifier(false);
LiveTvProgram? _focusedProgram; LiveTvProgram? _focusedProgram;
bool _pendingFocus = false; bool _pendingFocus = false;
@@ -144,9 +145,16 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
_gridHorizontalController.dispose(); _gridHorizontalController.dispose();
_channelVerticalController.dispose(); _channelVerticalController.dispose();
_timeIndicatorTimer?.cancel(); _timeIndicatorTimer?.cancel();
_hasFocusNotifier.dispose();
super.dispose(); super.dispose();
} }
void _handleGuideFocusChange(bool hasFocus) {
if (_hasFocus == hasFocus) return;
_hasFocus = hasFocus;
_hasFocusNotifier.value = hasFocus;
}
void _syncGridToHeader() { void _syncGridToHeader() {
if (_syncingScroll) return; if (_syncingScroll) return;
_syncingScroll = true; _syncingScroll = true;
@@ -506,7 +514,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
return OverlaySheetHost( return OverlaySheetHost(
child: Focus( child: Focus(
focusNode: _guideFocusNode, focusNode: _guideFocusNode,
onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), onFocusChange: _handleGuideFocusChange,
onKeyEvent: _handleKeyEvent, onKeyEvent: _handleKeyEvent,
child: _buildGuideGrid(theme), child: _buildGuideGrid(theme),
), ),
@@ -514,87 +522,92 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
} }
Widget _buildGuideGrid(ThemeData theme) { Widget _buildGuideGrid(ThemeData theme) {
return Column( return ValueListenableBuilder<bool>(
children: [ valueListenable: _hasFocusNotifier,
_buildTimeNavigation(theme), builder: (context, hasFocus, child) {
Expanded( return Column(
child: ListenableBuilder( children: [
listenable: _gridHorizontalController, _buildTimeNavigation(theme),
builder: (context, child) { Expanded(
return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]); child: ListenableBuilder(
}, listenable: _gridHorizontalController,
child: Column( builder: (context, child) {
children: [ return Stack(children: [child!, _buildNowIndicatorOverlay(theme)]);
Row( },
child: Column(
children: [ children: [
const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), Row(
Expanded( children: [
child: SingleChildScrollView( const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight),
controller: _headerHorizontalController, Expanded(
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<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollUpdateNotification &&
notification.metrics.axis == Axis.vertical) {
if (_channelVerticalController.hasClients) {
_channelVerticalController.jumpTo(notification.metrics.pixels);
}
}
return false;
},
child: SingleChildScrollView( child: SingleChildScrollView(
controller: _gridHorizontalController, controller: _headerHorizontalController,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
child: SizedBox( child: SizedBox(
width: _totalGridWidth(), width: _totalGridWidth(),
child: ListView.builder( height: _timeHeaderHeight,
controller: _gridVerticalController, child: _buildTimeHeader(theme),
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);
},
),
), ),
), ),
), ),
],
),
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<ScrollNotification>(
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);
},
),
),
),
),
),
],
), ),
], ),
), ],
), ),
], ),
), ),
), ],
), );
], },
); );
} }
+171 -154
View File
@@ -599,13 +599,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final isNumeric = mediaClient?.capabilities.numericUserRating ?? true; final isNumeric = mediaClient?.capabilities.numericUserRating ?? true;
final hasRating = metadata.userRating != null && metadata.userRating! > 0; final hasRating = metadata.userRating != null && metadata.userRating! > 0;
final starValue = hasRating ? metadata.userRating! / 2.0 : 0.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 activate = isNumeric ? () => _showRatingDialog(metadata, starValue) : () => _toggleLike(metadata);
final iconData = isNumeric ? Symbols.star_rounded : Symbols.thumb_up_rounded; final iconData = isNumeric ? Symbols.star_rounded : Symbols.thumb_up_rounded;
@@ -615,50 +608,60 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// "Rate" label as the action prompt either way. // "Rate" label as the action prompt either way.
final label = isNumeric && hasRating ? formatRating(starValue) : t.mediaMenu.rate; final label = isNumeric && hasRating ? formatRating(starValue) : t.mediaMenu.rate;
return FocusableWrapper( return ListenableBuilder(
focusNode: _ratingChipFocusNode, listenable: _ratingChipFocusNode,
onSelect: activate, builder: (context, _) {
borderRadius: 100, final colorScheme = Theme.of(context).colorScheme;
disableScale: true, final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
focusColor: Colors.transparent, final showFocus = _ratingChipFocusNode.hasFocus && isKeyboardMode;
onFocusChange: (_) => setState(() {}), final bgColor = showFocus ? colorScheme.inverseSurface : colorScheme.secondaryContainer.withValues(alpha: 0.8);
onKeyEvent: (_, event) { final fgColor = showFocus ? colorScheme.onInverseSurface : colorScheme.onSecondaryContainer;
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey; return FocusableWrapper(
if (key.isDownKey) { focusNode: _ratingChipFocusNode,
_playButtonFocusNode.requestFocus(); onSelect: activate,
return KeyEventResult.handled; borderRadius: 100,
} disableScale: true,
if (key.isUpKey) { focusColor: Colors.transparent,
return KeyEventResult.handled; // consume — nothing above onKeyEvent: (_, event) {
} if (!event.isActionable) return KeyEventResult.ignored;
return KeyEventResult.ignored; final key = event.logicalKey;
}, if (key.isDownKey) {
child: GestureDetector( _playButtonFocusNode.requestFocus();
onTap: activate, return KeyEventResult.handled;
child: AnimatedContainer( }
duration: const Duration(milliseconds: 150), if (key.isUpKey) {
curve: Curves.easeOutCubic, return KeyEventResult.handled; // consume — nothing above
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), }
decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(100))), return KeyEventResult.ignored;
child: Row( },
mainAxisSize: MainAxisSize.min, child: GestureDetector(
children: [ onTap: activate,
AppIcon( child: AnimatedContainer(
iconData, duration: const Duration(milliseconds: 150),
fill: hasRating ? 1 : 0, curve: Curves.easeOutCubic,
color: showFocus ? fgColor : (hasRating ? activeIconColor : fgColor), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
size: 16, 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<MediaDetailScreen>
Focus( Focus(
focusNode: _overviewFocusNode, focusNode: _overviewFocusNode,
onKeyEvent: _handleOverviewKeyEvent, onKeyEvent: _handleOverviewKeyEvent,
onFocusChange: (_) => setState(() {}), child: ListenableBuilder(
child: Builder( listenable: _overviewFocusNode,
builder: (context) { builder: (context, _) {
final innerTheme = Theme.of(context);
final showFocus = final showFocus =
_overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); _overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
return AnimatedContainer( return AnimatedContainer(
@@ -2203,13 +2205,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all( border: Border.all(
color: showFocus color: showFocus
? innerTheme.colorScheme.primary.withValues(alpha: 0.5) ? theme.colorScheme.primary.withValues(alpha: 0.5)
: Colors.transparent, : Colors.transparent,
width: 2, width: 2,
), ),
), ),
child: () { child: () {
final summaryStyle = innerTheme.textTheme.bodyLarge?.copyWith(height: 1.6); final summaryStyle = theme.textTheme.bodyLarge?.copyWith(height: 1.6);
if (isTv) { if (isTv) {
return Text(metadata.summary!, style: summaryStyle); return Text(metadata.summary!, style: summaryStyle);
} }
@@ -2601,7 +2603,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// image + inner padding + text area + outer list padding + focus scale headroom // image + inner padding + text area + outer list padding + focus scale headroom
final containerHeight = imageSize + innerPadding * 2 + 66 + 16; final containerHeight = imageSize + innerPadding * 2 + 66 + 16;
final hasFocus = _castFocusNode.hasFocus;
final theme = Theme.of(context); final theme = Theme.of(context);
final actorNameStyle = theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600); final actorNameStyle = theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600);
final actorRoleStyle = theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant); final actorRoleStyle = theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant);
@@ -2609,74 +2610,85 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return Focus( return Focus(
focusNode: _castFocusNode, focusNode: _castFocusNode,
onKeyEvent: _handleCastKeyEvent, onKeyEvent: _handleCastKeyEvent,
onFocusChange: (_) => setState(() {}), child: ListenableBuilder(
child: SizedBox( listenable: _castFocusNode,
height: containerHeight, builder: (context, _) {
child: HorizontalScrollWithArrows( final hasFocus = _castFocusNode.hasFocus;
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( return SizedBox(
padding: const EdgeInsets.only(right: 4), height: containerHeight,
child: FocusBuilders.buildLockedFocusWrapper( child: HorizontalScrollWithArrows(
context: context, controller: _castScrollController,
isFocused: isFocused, builder: (scrollController) => ListView.builder(
borderRadius: tokens(context).radiusSm, controller: scrollController,
onTap: () => _navigateToActorMedia(actor), scrollDirection: Axis.horizontal,
child: Padding( clipBehavior: Clip.none,
padding: const EdgeInsets.all(innerPadding), padding: const EdgeInsets.symmetric(vertical: 5),
child: SizedBox( itemCount: metadata.roles!.length,
width: cardWidth, itemBuilder: (context, index) {
child: Column( final actor = metadata.roles![index];
crossAxisAlignment: CrossAxisAlignment.start, final isFocused = hasFocus && index == _focusedCastIndex;
children: [
ClipRRect( return Padding(
borderRadius: BorderRadius.circular(tokens(context).radiusSm), padding: const EdgeInsets.only(right: 4),
child: OptimizedMediaImage( child: FocusBuilders.buildLockedFocusWrapper(
client: getServerBoundMediaClient(context), context: context,
imagePath: actor.thumbPath, isFocused: isFocused,
width: imageSize, borderRadius: tokens(context).radiusSm,
height: imageSize, onTap: () => _navigateToActorMedia(actor),
fit: BoxFit.cover, child: Padding(
imageType: ImageType.avatar, padding: const EdgeInsets.all(innerPadding),
fallbackIcon: Symbols.person_rounded, 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<MediaDetailScreen>
final posterHeight = (cardWidth - 16) * (9 / 16); final posterHeight = (cardWidth - 16) * (9 / 16);
final containerHeight = posterHeight + 66; final containerHeight = posterHeight + 66;
final hasFocus = _extrasFocusNode.hasFocus;
return Focus( return Focus(
focusNode: _extrasFocusNode, focusNode: _extrasFocusNode,
onKeyEvent: _handleExtrasKeyEvent, onKeyEvent: _handleExtrasKeyEvent,
child: SizedBox( child: ListenableBuilder(
height: containerHeight, listenable: _extrasFocusNode,
child: HorizontalScrollWithArrows( builder: (context, _) {
controller: _extrasScrollController, final hasFocus = _extrasFocusNode.hasFocus;
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<MediaCardState>());
return Padding( return SizedBox(
padding: const EdgeInsets.only(right: 4), height: containerHeight,
child: FocusBuilders.buildLockedFocusWrapper( child: HorizontalScrollWithArrows(
context: context, controller: _extrasScrollController,
isFocused: isFocused, builder: (scrollController) => ListView.builder(
onTap: () => navigateToVideoPlayer(context, metadata: extra), controller: scrollController,
child: MediaCard( scrollDirection: Axis.horizontal,
key: cardKey, clipBehavior: Clip.none,
item: extra, padding: const EdgeInsets.symmetric(vertical: 5),
width: cardWidth, itemCount: _extras!.length,
height: posterHeight, itemBuilder: (context, index) {
forceGridMode: true, final extra = _extras![index];
), final isFocused = hasFocus && index == _focusedExtraIndex;
), final cardKey = _extraCardKeys.putIfAbsent(index, () => GlobalKey<MediaCardState>());
);
}, 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,
),
),
);
},
),
),
);
},
), ),
); );
} }
+5 -1
View File
@@ -328,7 +328,11 @@ class _PlexMetadataEditScreenState extends State<PlexMetadataEditScreen> {
child: ListView( child: ListView(
shrinkWrap: true, shrinkWrap: true,
children: options.map((option) { children: options.map((option) {
return FocusableRadioListTile<String>(title: Text(option.label), value: option.value); return FocusableRadioListTile<String>(
key: ValueKey(option.value),
title: Text(option.label),
value: option.value,
);
}).toList(), }).toList(),
), ),
), ),
+1
View File
@@ -90,6 +90,7 @@ Future<T?> showSelectionDialog<T>({
children: options.map((option) { children: options.map((option) {
final selected = option.value == currentValue; final selected = option.value == currentValue;
return FocusableListTile( return FocusableListTile(
key: ValueKey(option.value),
leading: Icon( leading: Icon(
selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, selected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
color: selected ? Theme.of(dialogContext).colorScheme.primary : null, color: selected ? Theme.of(dialogContext).colorScheme.primary : null,
+1 -1
View File
@@ -251,7 +251,7 @@ class _ConnectionCandidate {
final bool isPlexDirectUri; final bool isPlexDirectUri;
final bool isHttps; 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 /// Represents a Plex Media Server
+1 -1
View File
@@ -345,7 +345,7 @@ class TrackSelectionResult<T> {
final T track; final T track;
final TrackSelectionPriority priority; 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 /// Service for selecting and applying audio and subtitle tracks based on
@@ -1,10 +1,15 @@
import 'package:json_annotation/json_annotation.dart';
import '../oauth_proxy_client.dart'; import '../oauth_proxy_client.dart';
import '../tracker_session_utils.dart'; import '../tracker_session_utils.dart';
part 'anilist_session.g.dart';
/// Immutable AniList OAuth session. /// Immutable AniList OAuth session.
/// ///
/// Implicit grant — no refresh token. Tokens are valid for 1 year; on expiry /// Implicit grant — no refresh token. Tokens are valid for 1 year; on expiry
/// the user must re-auth. /// the user must re-auth.
@JsonSerializable(fieldRename: FieldRename.snake)
class AnilistSession with EncodedTrackerSession { class AnilistSession with EncodedTrackerSession {
final String accessToken; final String accessToken;
final int expiresAt; final int expiresAt;
@@ -25,19 +30,9 @@ class AnilistSession with EncodedTrackerSession {
} }
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => _$AnilistSessionToJson(this);
'access_token': accessToken,
'expires_at': expiresAt,
'username': username,
'created_at': createdAt,
};
factory AnilistSession.fromJson(Map<String, dynamic> json) => AnilistSession( factory AnilistSession.fromJson(Map<String, dynamic> json) => _$AnilistSessionFromJson(json);
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(),
);
/// Build a session from the OAuth-proxy result. AniList tokens last 1 year /// 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 /// and have no refresh; when the proxy doesn't echo an explicit expiry we
@@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'anilist_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AnilistSession _$AnilistSessionFromJson(Map<String, dynamic> 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<String, dynamic> _$AnilistSessionToJson(AnilistSession instance) => <String, dynamic>{
'access_token': instance.accessToken,
'expires_at': instance.expiresAt,
'username': instance.username,
'created_at': instance.createdAt,
};
+7 -14
View File
@@ -1,10 +1,15 @@
import 'package:json_annotation/json_annotation.dart';
import '../oauth_proxy_client.dart'; import '../oauth_proxy_client.dart';
import '../tracker_session_utils.dart'; import '../tracker_session_utils.dart';
part 'mal_session.g.dart';
/// Immutable MyAnimeList OAuth session. /// Immutable MyAnimeList OAuth session.
/// ///
/// Access tokens expire in ~31 days. Refresh token rotates with each refresh /// Access tokens expire in ~31 days. Refresh token rotates with each refresh
/// (rare but documented in MAL's API contract). /// (rare but documented in MAL's API contract).
@JsonSerializable(fieldRename: FieldRename.snake)
class MalSession with EncodedTrackerSession { class MalSession with EncodedTrackerSession {
final String accessToken; final String accessToken;
final String refreshToken; final String refreshToken;
@@ -34,21 +39,9 @@ class MalSession with EncodedTrackerSession {
} }
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => _$MalSessionToJson(this);
'access_token': accessToken,
'refresh_token': refreshToken,
'expires_at': expiresAt,
'username': username,
'created_at': createdAt,
};
factory MalSession.fromJson(Map<String, dynamic> json) => MalSession( factory MalSession.fromJson(Map<String, dynamic> json) => _$MalSessionFromJson(json);
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(),
);
/// Build a session from MAL's `/oauth2/token` response. /// Build a session from MAL's `/oauth2/token` response.
factory MalSession.fromTokenResponse(Map<String, dynamic> json) { factory MalSession.fromTokenResponse(Map<String, dynamic> json) {
@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mal_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MalSession _$MalSessionFromJson(Map<String, dynamic> 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<String, dynamic> _$MalSessionToJson(MalSession instance) => <String, dynamic>{
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'expires_at': instance.expiresAt,
'username': instance.username,
'created_at': instance.createdAt,
};
@@ -1,9 +1,14 @@
import 'package:json_annotation/json_annotation.dart';
import '../tracker_session_utils.dart'; import '../tracker_session_utils.dart';
part 'simkl_session.g.dart';
/// Immutable Simkl OAuth session. /// Immutable Simkl OAuth session.
/// ///
/// Simkl access tokens don't expire (per their docs), so there's no /// 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. /// refresh_token — just the bearer and a display name for the settings UI.
@JsonSerializable(fieldRename: FieldRename.snake)
class SimklSession with EncodedTrackerSession { class SimklSession with EncodedTrackerSession {
final String accessToken; final String accessToken;
final String? username; final String? username;
@@ -18,13 +23,9 @@ class SimklSession with EncodedTrackerSession {
); );
@override @override
Map<String, dynamic> toJson() => {'access_token': accessToken, 'username': username, 'created_at': createdAt}; Map<String, dynamic> toJson() => _$SimklSessionToJson(this);
factory SimklSession.fromJson(Map<String, dynamic> json) => SimklSession( factory SimklSession.fromJson(Map<String, dynamic> json) => _$SimklSessionFromJson(json);
accessToken: json['access_token'] as String,
username: json['username'] as String?,
createdAt: (json['created_at'] as num).toInt(),
);
/// Build a session from Simkl's device-code `/oauth/pin/<code>` response. /// Build a session from Simkl's device-code `/oauth/pin/<code>` response.
/// Simkl doesn't expose a creation timestamp so we stamp "now". /// Simkl doesn't expose a creation timestamp so we stamp "now".
@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'simkl_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SimklSession _$SimklSessionFromJson(Map<String, dynamic> json) => SimklSession(
accessToken: json['access_token'] as String,
createdAt: (json['created_at'] as num).toInt(),
username: json['username'] as String?,
);
Map<String, dynamic> _$SimklSessionToJson(SimklSession instance) => <String, dynamic>{
'access_token': instance.accessToken,
'username': instance.username,
'created_at': instance.createdAt,
};
+8 -18
View File
@@ -1,10 +1,15 @@
import 'package:json_annotation/json_annotation.dart';
import '../trackers/tracker_session_utils.dart'; import '../trackers/tracker_session_utils.dart';
part 'trakt_session.g.dart';
/// Immutable Trakt OAuth session. /// Immutable Trakt OAuth session.
/// ///
/// Persisted as a JSON blob under `user_{uuid}_trakt_session` in /// Persisted as a JSON blob under `user_{uuid}_trakt_session` in
/// `SharedPreferences`. Tokens are stored in plaintext, matching the security /// `SharedPreferences`. Tokens are stored in plaintext, matching the security
/// model of the existing Plex token. /// model of the existing Plex token.
@JsonSerializable(fieldRename: FieldRename.snake)
class TraktSession { class TraktSession {
final String accessToken; final String accessToken;
final String refreshToken; final String refreshToken;
@@ -15,6 +20,7 @@ class TraktSession {
/// Trakt username (`@handle`), populated after `getUserSettings`. /// Trakt username (`@handle`), populated after `getUserSettings`.
final String? username; final String? username;
@JsonKey(defaultValue: 'public')
final String scope; final String scope;
/// Epoch seconds at which the session was first created. /// Epoch seconds at which the session was first created.
@@ -53,25 +59,9 @@ class TraktSession {
); );
} }
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => _$TraktSessionToJson(this);
'access_token': accessToken,
'refresh_token': refreshToken,
'expires_at': expiresAt,
'username': username,
'scope': scope,
'created_at': createdAt,
};
factory TraktSession.fromJson(Map<String, dynamic> json) { factory TraktSession.fromJson(Map<String, dynamic> json) => _$TraktSessionFromJson(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(),
);
}
/// Build a session from Trakt's `/oauth/token` or `/oauth/device/token` response, /// Build a session from Trakt's `/oauth/token` or `/oauth/device/token` response,
/// which uses `expires_in` (relative seconds) rather than `expires_at`. /// which uses `expires_in` (relative seconds) rather than `expires_at`.
+25
View File
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trakt_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TraktSession _$TraktSessionFromJson(Map<String, dynamic> 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<String, dynamic> _$TraktSessionToJson(TraktSession instance) => <String, dynamic>{
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'expires_at': instance.expiresAt,
'username': instance.username,
'scope': instance.scope,
'created_at': instance.createdAt,
};
+1 -1
View File
@@ -30,7 +30,7 @@ class LogEntry {
final Object? error; final Object? error;
final StackTrace? stackTrace; 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 /// Estimate the memory size of this log entry in bytes
int get estimatedSize { int get estimatedSize {
+3
View File
@@ -173,6 +173,9 @@ String toBulletedString(List<String> parts) {
return parts.join(' · '); return parts.join(' · ');
} }
String formatRating(double value) =>
value == value.truncateToDouble() ? value.toInt().toString() : value.toStringAsFixed(1);
final RegExp _trailingZeroPattern = RegExp(r'\.?0+$'); final RegExp _trailingZeroPattern = RegExp(r'\.?0+$');
/// Format a playback rate for display (e.g. 1.25 → "1.25x", 2.0 → "2x"). /// Format a playback rate for display (e.g. 1.25 → "1.25x", 2.0 → "2x").
@@ -1,32 +1,26 @@
import 'dart:convert'; import 'dart:convert';
import 'package:json_annotation/json_annotation.dart';
import '../../services/settings_service.dart'; import '../../services/settings_service.dart';
import '../models/watch_session.dart'; import '../models/watch_session.dart';
part 'recent_rooms_service.g.dart';
@JsonSerializable(includeIfNull: false)
class RecentRoom { class RecentRoom {
final String code; final String code;
final String? name; final String? name;
@JsonKey(fromJson: _dateTimeFromMillis, toJson: _dateTimeToMillis)
final DateTime lastUsed; final DateTime lastUsed;
@JsonKey(fromJson: _controlModeFromIndex, toJson: _controlModeToIndex)
final ControlMode? controlMode; final ControlMode? controlMode;
const RecentRoom({required this.code, this.name, required this.lastUsed, this.controlMode}); const RecentRoom({required this.code, this.name, required this.lastUsed, this.controlMode});
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => _$RecentRoomToJson(this);
'code': code,
if (name != null) 'name': name,
'lastUsed': lastUsed.millisecondsSinceEpoch,
if (controlMode != null) 'controlMode': controlMode!.index,
};
factory RecentRoom.fromJson(Map<String, dynamic> json) { factory RecentRoom.fromJson(Map<String, dynamic> json) => _$RecentRoomFromJson(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,
);
}
RecentRoom copyWith({ RecentRoom copyWith({
String? code, 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 { class RecentRoomsService {
static const int _maxRooms = 20; static const int _maxRooms = 20;
@@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'recent_rooms_service.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
RecentRoom _$RecentRoomFromJson(Map<String, dynamic> 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<String, dynamic> _$RecentRoomToJson(RecentRoom instance) => <String, dynamic>{
'code': instance.code,
'name': ?instance.name,
'lastUsed': _dateTimeToMillis(instance.lastUsed),
'controlMode': ?_controlModeToIndex(instance.controlMode),
};
@@ -343,6 +343,7 @@ class _ParticipantNotificationOverlayState extends State<ParticipantNotification
ParticipantEventType.buffering => t.watchTogether.participantBuffering(name: n.event.displayName), ParticipantEventType.buffering => t.watchTogether.participantBuffering(name: n.event.displayName),
}; };
return Container( return Container(
key: ValueKey(n.id),
margin: const EdgeInsets.only(bottom: 4), margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: const BoxDecoration( decoration: const BoxDecoration(
+1 -1
View File
@@ -80,7 +80,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
Text( Text(
(widget.episode.userRating! / 2) == (widget.episode.userRating! / 2).truncateToDouble() (widget.episode.userRating! / 2) == (widget.episode.userRating! / 2).truncateToDouble()
? '${(widget.episode.userRating! / 2).toInt()}' ? '${(widget.episode.userRating! / 2).toInt()}'
: (widget.episode.userRating! / 2).toStringAsFixed(1), : formatRating(widget.episode.userRating! / 2),
style: mutedStyle, style: mutedStyle,
), ),
], ],
+1 -1
View File
@@ -428,7 +428,7 @@ class _MediaCardList extends StatelessWidget {
} }
if (mi.rating != null) { if (mi.rating != null) {
parts.add('${mi.rating!.toStringAsFixed(1)}'); parts.add('${formatRating(mi.rating!)}');
} }
if (mi.studio != null && mi.studio!.isNotEmpty) { if (mi.studio != null && mi.studio!.isNotEmpty) {
+2
View File
@@ -1674,6 +1674,7 @@ class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet>
final index = entry.key; final index = entry.key;
final action = entry.value; final action = entry.value;
return FocusableListTile( return FocusableListTile(
key: ValueKey(action.value),
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
leading: AppIcon(action.icon, fill: 1), leading: AppIcon(action.icon, fill: 1),
title: Text(action.label), title: Text(action.label),
@@ -1798,6 +1799,7 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
final index = entry.key; final index = entry.key;
final action = entry.value; final action = entry.value;
return FocusableListTile( return FocusableListTile(
key: ValueKey(action.value),
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
leading: AppIcon(action.icon, fill: 1, size: 20), leading: AppIcon(action.icon, fill: 1, size: 20),
title: Text(action.label), title: Text(action.label),
+1 -3
View File
@@ -6,6 +6,7 @@ import '../widgets/overlay_sheet.dart';
import '../focus/dpad_navigator.dart'; import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart'; import '../focus/input_mode_tracker.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../utils/formatters.dart';
class RatingBottomSheet extends StatefulWidget { class RatingBottomSheet extends StatefulWidget {
final double currentRating; final double currentRating;
@@ -18,9 +19,6 @@ class RatingBottomSheet extends StatefulWidget {
State<RatingBottomSheet> createState() => _RatingBottomSheetState(); State<RatingBottomSheet> createState() => _RatingBottomSheetState();
} }
String formatRating(double value) =>
value == value.truncateToDouble() ? value.toInt().toString() : value.toStringAsFixed(1);
class _RatingBottomSheetState extends State<RatingBottomSheet> { class _RatingBottomSheetState extends State<RatingBottomSheet> {
late double _selectedRating; late double _selectedRating;
late final FocusNode _starsFocusNode; late final FocusNode _starsFocusNode;
+1 -1
View File
@@ -25,7 +25,7 @@ class _ServerResult {
final String serverName; final String serverName;
final List<PlexActivity> activities; final List<PlexActivity> activities;
_ServerResult({required this.serverId, required this.serverName, required this.activities}); const _ServerResult({required this.serverId, required this.serverName, required this.activities});
} }
class _PanelData { class _PanelData {
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../models/livetv_capture_buffer.dart'; import '../../../models/livetv_capture_buffer.dart';
import '../../../mpv/mpv.dart'; import '../../../mpv/mpv.dart';
import '../../../focus/focusable_wrapper.dart'; import '../../../focus/focusable_wrapper.dart';
import '../../../utils/formatters.dart';
/// Timeline bar for live TV time-shift. /// Timeline bar for live TV time-shift.
/// ///
@@ -51,9 +51,9 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
int _displayPosition(Duration playerPosition) => _isDragging ? _dragPositionEpoch : _currentEpoch(playerPosition); int _displayPosition(Duration playerPosition) => _isDragging ? _dragPositionEpoch : _currentEpoch(playerPosition);
String _formatEpochTime(int epochSeconds) { String _formatEpochTime(BuildContext context, int epochSeconds) {
final dt = DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000); final dt = DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000);
return DateFormat.jm().format(dt); return formatClockTime(dt, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context));
} }
double _epochToFraction(int epoch) { double _epochToFraction(int epoch) {
@@ -88,7 +88,7 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
return Row( return Row(
children: [ children: [
Text( Text(
_formatEpochTime(displayPos), _formatEpochTime(context, displayPos),
style: const TextStyle(color: Colors.white70, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]), style: const TextStyle(color: Colors.white70, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -107,7 +107,7 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
_formatEpochTime(displayPos), _formatEpochTime(context, displayPos),
style: const TextStyle(color: Colors.white70, fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]), style: const TextStyle(color: Colors.white70, fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]),
), ),
), ),
@@ -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);
});
});
}
@@ -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/mal/mal_session.dart';
import 'package:plezy/services/trackers/simkl/simkl_session.dart'; import 'package:plezy/services/trackers/simkl/simkl_session.dart';
import 'package:plezy/services/trackers/tracker_session_utils.dart'; import 'package:plezy/services/trackers/tracker_session_utils.dart';
import 'package:plezy/services/trakt/trakt_session.dart';
void main() { void main() {
group('tracker token expiry helpers', () { group('tracker token expiry helpers', () {
@@ -26,6 +27,39 @@ void main() {
expect(decoded, {'access_token': 'abc', 'created_at': 123}); 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', () { test('round-trips AniList sessions through shared encode mixin', () {
const session = AnilistSession(accessToken: 'anilist-at', expiresAt: 2000, username: 'alice', createdAt: 1000); const session = AnilistSession(accessToken: 'anilist-at', expiresAt: 2000, username: 'alice', createdAt: 1000);