refactor: reduce model and focus boilerplate
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +522,9 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
|
||||
}
|
||||
|
||||
Widget _buildGuideGrid(ThemeData theme) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _hasFocusNotifier,
|
||||
builder: (context, hasFocus, child) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTimeNavigation(theme),
|
||||
@@ -596,6 +607,8 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNowIndicatorOverlay(ThemeData _) {
|
||||
|
||||
@@ -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,13 +608,21 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
// "Rate" label as the action prompt either way.
|
||||
final label = isNumeric && hasRating ? formatRating(starValue) : t.mediaMenu.rate;
|
||||
|
||||
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,
|
||||
onFocusChange: (_) => setState(() {}),
|
||||
onKeyEvent: (_, event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
@@ -660,6 +661,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Like/unlike toggle for backends that only support binary ratings
|
||||
@@ -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,8 +2610,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
return Focus(
|
||||
focusNode: _castFocusNode,
|
||||
onKeyEvent: _handleCastKeyEvent,
|
||||
onFocusChange: (_) => setState(() {}),
|
||||
child: SizedBox(
|
||||
child: ListenableBuilder(
|
||||
listenable: _castFocusNode,
|
||||
builder: (context, _) {
|
||||
final hasFocus = _castFocusNode.hasFocus;
|
||||
|
||||
return SizedBox(
|
||||
height: containerHeight,
|
||||
child: HorizontalScrollWithArrows(
|
||||
controller: _castScrollController,
|
||||
@@ -2655,7 +2660,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(actor.tag, style: actorNameStyle, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
Text(
|
||||
actor.tag,
|
||||
style: actorNameStyle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (actor.role != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
@@ -2677,6 +2687,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2694,12 +2706,15 @@ 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(
|
||||
child: ListenableBuilder(
|
||||
listenable: _extrasFocusNode,
|
||||
builder: (context, _) {
|
||||
final hasFocus = _extrasFocusNode.hasFocus;
|
||||
|
||||
return SizedBox(
|
||||
height: containerHeight,
|
||||
child: HorizontalScrollWithArrows(
|
||||
controller: _extrasScrollController,
|
||||
@@ -2732,6 +2747,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user