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].
|
/// 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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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 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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user