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].
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.
+1
View File
@@ -1248,6 +1248,7 @@ class _SetupScreenState extends State<SetupScreen> 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,
+2 -2
View File
@@ -7,7 +7,7 @@ class LiveTvHubResult {
final String hubKey;
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.
@@ -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});
}
+7 -8
View File
@@ -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<String, dynamic> json) {
return MpvPreset(
name: json['name'] as String,
text: json['text'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
factory MpvPreset.fromJson(Map<String, dynamic> json) => _$MpvPresetFromJson(json);
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';
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<String, dynamic> 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<String, dynamic> toJson() => _$TraktIdsToJson(this);
factory TraktIds.fromJson(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(),
);
factory TraktIds.fromJson(Map<String, dynamic> json) => _$TraktIdsFromJson(json);
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/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<DiscoverScreen>
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(''),
@@ -646,6 +646,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
children: options.map((grouping) {
final isSelected = _selectedGrouping == grouping;
return FocusableListTile(
key: ValueKey(grouping),
dense: true,
leading: AppIcon(
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 _gridColumn = 0; // 0=channel, 1=program
bool _hasFocus = false;
final ValueNotifier<bool> _hasFocusNotifier = ValueNotifier(false);
LiveTvProgram? _focusedProgram;
bool _pendingFocus = false;
@@ -144,9 +145,16 @@ class GuideTabState extends State<GuideTab> 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<GuideTab> 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<GuideTab> 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<bool>(
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<ScrollNotification>(
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<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 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<MediaDetailScreen>
// "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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaDetailScreen>
// 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<MediaDetailScreen>
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<MediaDetailScreen>
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<MediaCardState>());
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<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(
shrinkWrap: true,
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(),
),
),
+1
View File
@@ -90,6 +90,7 @@ Future<T?> showSelectionDialog<T>({
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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -345,7 +345,7 @@ class TrackSelectionResult<T> {
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
@@ -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<String, dynamic> toJson() => {
'access_token': accessToken,
'expires_at': expiresAt,
'username': username,
'created_at': createdAt,
};
Map<String, dynamic> toJson() => _$AnilistSessionToJson(this);
factory AnilistSession.fromJson(Map<String, dynamic> 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<String, dynamic> 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
@@ -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 '../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<String, dynamic> toJson() => {
'access_token': accessToken,
'refresh_token': refreshToken,
'expires_at': expiresAt,
'username': username,
'created_at': createdAt,
};
Map<String, dynamic> toJson() => _$MalSessionToJson(this);
factory MalSession.fromJson(Map<String, dynamic> 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<String, dynamic> json) => _$MalSessionFromJson(json);
/// Build a session from MAL's `/oauth2/token` response.
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';
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<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(
accessToken: json['access_token'] as String,
username: json['username'] as String?,
createdAt: (json['created_at'] as num).toInt(),
);
factory SimklSession.fromJson(Map<String, dynamic> json) => _$SimklSessionFromJson(json);
/// Build a session from Simkl's device-code `/oauth/pin/<code>` response.
/// 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';
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<String, dynamic> toJson() => {
'access_token': accessToken,
'refresh_token': refreshToken,
'expires_at': expiresAt,
'username': username,
'scope': scope,
'created_at': createdAt,
};
Map<String, dynamic> toJson() => _$TraktSessionToJson(this);
factory TraktSession.fromJson(Map<String, dynamic> 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<String, dynamic> 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`.
+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 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 {
+3
View File
@@ -173,6 +173,9 @@ String toBulletedString(List<String> 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").
@@ -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<String, dynamic> toJson() => {
'code': code,
if (name != null) 'name': name,
'lastUsed': lastUsed.millisecondsSinceEpoch,
if (controlMode != null) 'controlMode': controlMode!.index,
};
Map<String, dynamic> toJson() => _$RecentRoomToJson(this);
factory RecentRoom.fromJson(Map<String, dynamic> 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<String, dynamic> 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;
@@ -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),
};
return Container(
key: ValueKey(n.id),
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: const BoxDecoration(
+1 -1
View File
@@ -80,7 +80,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
Text(
(widget.episode.userRating! / 2) == (widget.episode.userRating! / 2).truncateToDouble()
? '${(widget.episode.userRating! / 2).toInt()}'
: (widget.episode.userRating! / 2).toStringAsFixed(1),
: formatRating(widget.episode.userRating! / 2),
style: mutedStyle,
),
],
+1 -1
View File
@@ -428,7 +428,7 @@ class _MediaCardList extends StatelessWidget {
}
if (mi.rating != null) {
parts.add('${mi.rating!.toStringAsFixed(1)}');
parts.add('${formatRating(mi.rating!)}');
}
if (mi.studio != null && mi.studio!.isNotEmpty) {
+2
View File
@@ -1674,6 +1674,7 @@ class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet>
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),
+1 -3
View File
@@ -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<RatingBottomSheet> createState() => _RatingBottomSheetState();
}
String formatRating(double value) =>
value == value.truncateToDouble() ? value.toInt().toString() : value.toStringAsFixed(1);
class _RatingBottomSheetState extends State<RatingBottomSheet> {
late double _selectedRating;
late final FocusNode _starsFocusNode;
+1 -1
View File
@@ -25,7 +25,7 @@ class _ServerResult {
final String serverName;
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 {
@@ -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<LiveTimelineBar> {
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<LiveTimelineBar> {
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<LiveTimelineBar> {
Align(
alignment: Alignment.centerLeft,
child: Text(
_formatEpochTime(displayPos),
_formatEpochTime(context, displayPos),
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/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);