refactor(features): consolidate shared feature primitives

This commit is contained in:
edde746
2026-07-12 17:31:13 +02:00
parent 6506f36843
commit 7ecadffdb1
103 changed files with 1718 additions and 898 deletions
+54 -42
View File
@@ -390,6 +390,14 @@ void _defaultEditingComplete(TextInputAction? textInputAction) {
} }
} }
({int start, int end}) _normalizedSelectionRange(TextEditingValue value) {
final selection = value.selection;
if (!selection.isValid) {
return (start: value.text.length, end: value.text.length);
}
return (start: selection.start.clamp(0, value.text.length), end: selection.end.clamp(0, value.text.length));
}
void _insertText({ void _insertText({
required TextEditingController controller, required TextEditingController controller,
required String text, required String text,
@@ -398,13 +406,9 @@ void _insertText({
ValueChanged<String>? onChanged, ValueChanged<String>? onChanged,
}) { }) {
final value = controller.value; final value = controller.value;
final selection = value.selection; final range = _normalizedSelectionRange(value);
final start = selection.isValid final start = range.start;
? (selection.start < selection.end ? selection.start : selection.end) final end = range.end;
: value.text.length;
final end = selection.isValid
? (selection.start > selection.end ? selection.start : selection.end)
: value.text.length;
final newText = value.text.replaceRange(start, end, text); final newText = value.text.replaceRange(start, end, text);
_replaceTextValue( _replaceTextValue(
controller: controller, controller: controller,
@@ -426,13 +430,9 @@ void _backspace({
ValueChanged<String>? onChanged, ValueChanged<String>? onChanged,
}) { }) {
final value = controller.value; final value = controller.value;
final selection = value.selection; final range = _normalizedSelectionRange(value);
final start = selection.isValid final start = range.start;
? (selection.start < selection.end ? selection.start : selection.end) final end = range.end;
: value.text.length;
final end = selection.isValid
? (selection.start > selection.end ? selection.start : selection.end)
: value.text.length;
if (start != end) { if (start != end) {
_replaceTextRange( _replaceTextRange(
controller, controller,
@@ -462,13 +462,9 @@ void _deleteForward({
ValueChanged<String>? onChanged, ValueChanged<String>? onChanged,
}) { }) {
final value = controller.value; final value = controller.value;
final selection = value.selection; final range = _normalizedSelectionRange(value);
final start = selection.isValid final start = range.start;
? (selection.start < selection.end ? selection.start : selection.end) final end = range.end;
: value.text.length;
final end = selection.isValid
? (selection.start > selection.end ? selection.start : selection.end)
: value.text.length;
if (start != end) { if (start != end) {
_replaceTextRange( _replaceTextRange(
controller, controller,
@@ -624,6 +620,23 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
} }
} }
({
TextInputType? keyboardType,
bool readOnly,
bool? showCursor,
bool? enableInteractiveSelection,
VoidCallback? onTap,
})
_tvInputConfiguration(bool usesTvKeyboard, VoidCallback openKeyboard) {
return (
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
readOnly: usesTvKeyboard,
showCursor: usesTvKeyboard ? true : null,
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
onTap: usesTvKeyboard ? openKeyboard : null,
);
}
KeyEventResult _handleKey(BuildContext context, FocusNode node, KeyEvent event, VoidCallback openKeyboard) { KeyEventResult _handleKey(BuildContext context, FocusNode node, KeyEvent event, VoidCallback openKeyboard) {
return _handleInputKey( return _handleInputKey(
controller: controller, controller: controller,
@@ -650,7 +663,6 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
} }
Widget buildFocusableInput( Widget buildFocusableInput(
BuildContext context,
Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder, Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder,
) { ) {
return _FocusableTextInputHost(input: this, builder: builder); return _FocusableTextInputHost(input: this, builder: builder);
@@ -994,14 +1006,14 @@ class FocusableTextField extends _FocusableTextInputBase {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return buildFocusableInput( return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
context, final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
(usesTvKeyboard, effectiveFocusNode, openKeyboard) => TextField( return TextField(
controller: controller, controller: controller,
focusNode: effectiveFocusNode, focusNode: effectiveFocusNode,
enabled: enabled, enabled: enabled,
decoration: decoration, decoration: decoration,
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType, keyboardType: tvInput.keyboardType,
textInputAction: textInputAction, textInputAction: textInputAction,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
onChanged: onChanged, onChanged: onChanged,
@@ -1017,12 +1029,12 @@ class FocusableTextField extends _FocusableTextInputBase {
textAlign: textAlign, textAlign: textAlign,
textCapitalization: textCapitalization, textCapitalization: textCapitalization,
style: style, style: style,
readOnly: usesTvKeyboard, readOnly: tvInput.readOnly,
showCursor: usesTvKeyboard ? true : null, showCursor: tvInput.showCursor,
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection, enableInteractiveSelection: tvInput.enableInteractiveSelection,
onTap: usesTvKeyboard ? openKeyboard : null, onTap: tvInput.onTap,
), );
); });
} }
} }
@@ -1071,14 +1083,14 @@ class FocusableTextFormField extends _FocusableTextInputBase {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return buildFocusableInput( return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
context, final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
(usesTvKeyboard, effectiveFocusNode, openKeyboard) => TextFormField( return TextFormField(
controller: controller, controller: controller,
focusNode: effectiveFocusNode, focusNode: effectiveFocusNode,
enabled: enabled, enabled: enabled,
decoration: decoration, decoration: decoration,
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType, keyboardType: tvInput.keyboardType,
textInputAction: textInputAction, textInputAction: textInputAction,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
onChanged: onChanged, onChanged: onChanged,
@@ -1097,11 +1109,11 @@ class FocusableTextFormField extends _FocusableTextInputBase {
textAlign: textAlign, textAlign: textAlign,
textCapitalization: textCapitalization, textCapitalization: textCapitalization,
style: style, style: style,
readOnly: usesTvKeyboard, readOnly: tvInput.readOnly,
showCursor: usesTvKeyboard ? true : null, showCursor: tvInput.showCursor,
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection, enableInteractiveSelection: tvInput.enableInteractiveSelection,
onTap: usesTvKeyboard ? openKeyboard : null, onTap: tvInput.onTap,
), );
); });
} }
} }
+26 -42
View File
@@ -3,20 +3,17 @@ import 'media_item.dart';
import 'media_kind.dart'; import 'media_kind.dart';
import 'media_server_client.dart'; import 'media_server_client.dart';
/// Collect every episode of a show into [out] using the backend's one-shot /// Collect every episode below a show or season into [out] using the backend's
/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants] /// one-shot recursive-leaves call ([MediaServerClient.fetchPlayableDescendants]
/// Plex's `/library/metadata/{id}/allLeaves`, Jellyfin's /// Plex's `/library/metadata/{id}/allLeaves`, Jellyfin's
/// `/Items?Recursive=true&IncludeItemTypes=Movie,Episode`). Avoids walking /// `/Items?Recursive=true&IncludeItemTypes=Movie,Episode`). This avoids walking
/// show → seasons → episodes client-side, so large series come back in one /// show → seasons → episodes client-side and is not capped by a page size.
/// trip and aren't capped by any per-page Limit.
/// ///
/// A failure of the underlying call propagates to the caller — both /// A failure propagates to the caller so download and sync transactions can
/// `DownloadProvider.queueDownload` and the sync rule executor wrap their /// surface or roll back the operation.
/// invocations so the user-facing error surfaces / the rule run is rolled Future<void> collectEpisodes(
/// back.
Future<void> collectEpisodesForShow(
MediaServerClient client, MediaServerClient client,
String showRatingKey, { String parentId, {
required bool unwatchedOnly, required bool unwatchedOnly,
required List<MediaItem> out, required List<MediaItem> out,
MediaItem? fallback, MediaItem? fallback,
@@ -24,28 +21,7 @@ Future<void> collectEpisodesForShow(
}) { }) {
return _collectPlayable( return _collectPlayable(
client, client,
showRatingKey, parentId,
unwatchedOnly: unwatchedOnly,
out: out,
fallback: fallback,
includeSpecials: includeSpecials,
);
}
/// Collect every episode of a single season into [out] via the same
/// one-shot endpoint. On a season the leaves *are* the episodes, so the
/// shape matches the show case.
Future<void> collectEpisodesForSeason(
MediaServerClient client,
String seasonRatingKey, {
required bool unwatchedOnly,
required List<MediaItem> out,
MediaItem? fallback,
bool includeSpecials = true,
}) {
return _collectPlayable(
client,
seasonRatingKey,
unwatchedOnly: unwatchedOnly, unwatchedOnly: unwatchedOnly,
out: out, out: out,
fallback: fallback, fallback: fallback,
@@ -60,10 +36,7 @@ Future<MediaItem?> fetchFirstEpisodeForSeason(
String seasonRatingKey, { String seasonRatingKey, {
String? seriesId, String? seriesId,
}) async { }) async {
final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null; final page = await _fetchSeasonPage(client, seasonId: seasonRatingKey, seriesId: seriesId, start: 0, size: 1);
final page = seriesId != null && seasonPagingClient != null
? await seasonPagingClient.fetchSeasonEpisodesPage(seriesId, seasonRatingKey, start: 0, size: 1)
: await client.fetchChildrenPage(seasonRatingKey, start: 0, size: 1);
for (final item in page.items) { for (final item in page.items) {
if (item.kind == MediaKind.episode) return item; if (item.kind == MediaKind.episode) return item;
} }
@@ -259,10 +232,7 @@ Future<LibraryPage<MediaItem>> fetchSeasonEpisodePage(
required int start, required int start,
required int size, required int size,
}) async { }) async {
final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null; final page = await _fetchSeasonPage(client, seriesId: show.id, seasonId: season.id, start: start, size: size);
final page = seasonPagingClient != null
? await seasonPagingClient.fetchSeasonEpisodesPage(show.id, season.id, start: start, size: size)
: await client.fetchChildrenPage(season.id, start: start, size: size);
return LibraryPage<MediaItem>( return LibraryPage<MediaItem>(
items: normalizeSeasonEpisodes(page.items, show: show, season: season), items: normalizeSeasonEpisodes(page.items, show: show, season: season),
totalCount: page.totalCount, totalCount: page.totalCount,
@@ -270,6 +240,20 @@ Future<LibraryPage<MediaItem>> fetchSeasonEpisodePage(
); );
} }
Future<LibraryPage<MediaItem>> _fetchSeasonPage(
MediaServerClient client, {
required String seasonId,
required int start,
required int size,
String? seriesId,
}) {
final pagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null;
if (seriesId != null && pagingClient != null) {
return pagingClient.fetchSeasonEpisodesPage(seriesId, seasonId, start: start, size: size);
}
return client.fetchChildrenPage(seasonId, start: start, size: size);
}
List<MediaItem> normalizeSeasonEpisodes( List<MediaItem> normalizeSeasonEpisodes(
List<MediaItem> episodes, { List<MediaItem> episodes, {
required MediaItem show, required MediaItem show,
+112
View File
@@ -0,0 +1,112 @@
import 'dart:async';
/// Acceleration tier shared by video and music timeline key-repeat seeking.
double steppedSeekMultiplier(int repeatCount) {
if (repeatCount <= 5) return 1.5;
if (repeatCount <= 15) return 3.0;
if (repeatCount <= 30) return 6.0;
return 10.0;
}
/// Coalesces a burst of relative timeline steps into one absolute seek.
///
/// The pending target remains pinned until playback reaches it (or the settle
/// ceiling expires), so a slow seek cannot make the next burst rebase from a
/// stale player position.
class DebouncedSeekAccumulator {
DebouncedSeekAccumulator({
required this.currentPosition,
required this.duration,
required this.seek,
this.onChanged,
this.debounce = const Duration(milliseconds: 800),
this.settlePoll = const Duration(seconds: 2),
this.settleTolerance = const Duration(seconds: 3),
this.settleCeiling = const Duration(seconds: 10),
});
final Duration Function() currentPosition;
final Duration Function() duration;
final void Function(Duration target) seek;
final void Function()? onChanged;
final Duration debounce;
final Duration settlePoll;
final Duration settleTolerance;
final Duration settleCeiling;
Duration? _pendingPosition;
Duration? _lastFlushedPosition;
Timer? _debounceTimer;
Timer? _settleTimer;
bool _disposed = false;
Duration? get pendingPosition => _pendingPosition;
void seekBy(Duration delta) {
if (_disposed) return;
final maximum = duration();
if (maximum <= Duration.zero) return;
final base = _pendingPosition ?? currentPosition();
final targetMs = (base + delta).inMilliseconds.clamp(0, maximum.inMilliseconds);
final target = Duration(milliseconds: targetMs);
if (target != _pendingPosition) {
_settleTimer?.cancel();
_settleTimer = null;
_pendingPosition = target;
_lastFlushedPosition = null;
onChanged?.call();
}
_debounceTimer?.cancel();
_debounceTimer = Timer(debounce, flush);
}
void flush() {
if (_disposed) return;
_debounceTimer?.cancel();
_debounceTimer = null;
final target = _pendingPosition;
if (target == null || target == _lastFlushedPosition) return;
_lastFlushedPosition = target;
seek(target);
_scheduleClear(target);
}
void _scheduleClear(Duration target) {
_settleTimer?.cancel();
var elapsed = Duration.zero;
void poll() {
if (_disposed || _pendingPosition != target) return;
elapsed += settlePoll;
if ((currentPosition() - target).abs() <= settleTolerance || elapsed >= settleCeiling) {
_pendingPosition = null;
_lastFlushedPosition = null;
_settleTimer = null;
onChanged?.call();
return;
}
_settleTimer = Timer(settlePoll, poll);
}
_settleTimer = Timer(settlePoll, poll);
}
void cancel() {
_debounceTimer?.cancel();
_debounceTimer = null;
_settleTimer?.cancel();
_settleTimer = null;
_lastFlushedPosition = null;
if (_pendingPosition != null) {
_pendingPosition = null;
onChanged?.call();
}
}
void dispose() {
_disposed = true;
_debounceTimer?.cancel();
_settleTimer?.cancel();
}
}
+5 -9
View File
@@ -1,3 +1,5 @@
import '../utils/json_utils.dart';
/// Represents the seekable capture buffer for a live TV transcode session. /// Represents the seekable capture buffer for a live TV transcode session.
/// ///
/// Extracted from the `TranscodeSession` element in the tune response: /// Extracted from the `TranscodeSession` element in the tune response:
@@ -23,19 +25,13 @@ class CaptureBuffer {
/// Parse from a TranscodeSession JSON map. Returns null if required fields are missing. /// Parse from a TranscodeSession JSON map. Returns null if required fields are missing.
/// Values may be num or String depending on whether the server returned JSON or XML. /// Values may be num or String depending on whether the server returned JSON or XML.
static CaptureBuffer? fromTranscodeSession(Map<String, dynamic> session) { static CaptureBuffer? fromTranscodeSession(Map<String, dynamic> session) {
final timeStamp = _parseDouble(session['timeStamp']); final timeStamp = flexibleDouble(session['timeStamp']);
final minOffset = _parseDouble(session['minOffsetAvailable']); final minOffset = flexibleDouble(session['minOffsetAvailable']);
final maxOffset = _parseDouble(session['maxOffsetAvailable']); final maxOffset = flexibleDouble(session['maxOffsetAvailable']);
if (timeStamp == null || minOffset == null || maxOffset == null) return null; if (timeStamp == null || minOffset == null || maxOffset == null) return null;
return CaptureBuffer(startedAt: timeStamp, seekStartSeconds: minOffset, seekEndSeconds: maxOffset); return CaptureBuffer(startedAt: timeStamp, seekStartSeconds: minOffset, seekEndSeconds: maxOffset);
} }
static double? _parseDouble(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
@override @override
String toString() => String toString() =>
'CaptureBuffer(startedAt: $startedAt, seek: $seekStartSeconds..$seekEndSeconds, ' 'CaptureBuffer(startedAt: $startedAt, seek: $seekStartSeconds..$seekEndSeconds, '
+3 -24
View File
@@ -5,32 +5,11 @@ import 'media_subscription.dart';
part 'livetv_dvr.g.dart'; part 'livetv_dvr.g.dart';
List<ChannelMapping> _parseChannelMappings(Object? raw) { List<ChannelMapping> _parseChannelMappings(Object? raw) => parseFlexibleJsonList(raw, ChannelMapping.fromJson);
final result = <ChannelMapping>[];
final list = flexibleList(raw) ?? const [];
for (final item in list) {
try {
result.add(ChannelMapping.fromJson(item as Map<String, dynamic>));
} catch (_) {}
}
return result;
}
List<SubscriptionSetting> _parseSettings(Object? raw) { List<SubscriptionSetting> _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) SubscriptionSetting.fromJson(item),
];
}
List<Map<String, dynamic>> _parseRawMaps(Object? raw) { List<Map<String, dynamic>> _parseRawMaps(Object? raw) => flexibleMapList(raw);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) item,
];
}
/// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider) /// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider)
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
+1 -7
View File
@@ -5,13 +5,7 @@ import 'livetv_channel.dart';
part 'livetv_lineup.g.dart'; part 'livetv_lineup.g.dart';
List<LiveTvChannel> _parseChannels(Object? raw) { List<LiveTvChannel> _parseChannels(Object? raw) => parseFlexibleJsonList(raw, LiveTvChannel.fromJson);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) LiveTvChannel.fromJson(item),
];
}
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class LiveTvCountry { class LiveTvCountry {
+5 -27
View File
@@ -7,36 +7,14 @@ import 'media_grab_operation.dart';
part 'livetv_session.g.dart'; part 'livetv_session.g.dart';
Map<String, dynamic>? _firstMap(Object? raw) { LiveTvProgram? _programFromRaw(Object? raw) => parseFlexibleJsonObject(raw, LiveTvProgram.fromJson);
if (raw is Map<String, dynamic>) return raw;
if (raw is List && raw.isNotEmpty && raw.first is Map<String, dynamic>) return raw.first as Map<String, dynamic>;
return null;
}
LiveTvProgram? _programFromRaw(Object? raw) { MediaGrabOperation? _grabOperationFromRaw(Object? raw) => parseFlexibleJsonObject(raw, MediaGrabOperation.fromJson);
final map = _firstMap(raw);
if (map == null) return null;
try {
return LiveTvProgram.fromJson(map);
} catch (_) {
return null;
}
}
MediaGrabOperation? _grabOperationFromRaw(Object? raw) {
final map = _firstMap(raw);
if (map == null) return null;
try {
return MediaGrabOperation.fromJson(map);
} catch (_) {
return null;
}
}
CaptureBuffer? _captureBufferFromRaw(Object? raw) { CaptureBuffer? _captureBufferFromRaw(Object? raw) {
final map = _firstMap(raw); final map = firstFlexibleMap(raw);
if (map == null) return null; if (map == null) return null;
final session = _firstMap(map['TranscodeSession']) ?? map; final session = firstFlexibleMap(map['TranscodeSession']) ?? map;
return CaptureBuffer.fromTranscodeSession(session); return CaptureBuffer.fromTranscodeSession(session);
} }
@@ -60,7 +38,7 @@ class LiveTvSession {
final CaptureBuffer? captureBuffer; final CaptureBuffer? captureBuffer;
@JsonKey(name: 'MediaGrabOperation', fromJson: _grabOperationFromRaw) @JsonKey(name: 'MediaGrabOperation', fromJson: _grabOperationFromRaw)
final MediaGrabOperation? grabOperation; final MediaGrabOperation? grabOperation;
@JsonKey(name: 'Timeline', fromJson: _firstMap) @JsonKey(name: 'Timeline', fromJson: firstFlexibleMap)
final Map<String, dynamic>? timeline; final Map<String, dynamic>? timeline;
@JsonKey(name: 'AiringMetadataItem', fromJson: _programFromRaw) @JsonKey(name: 'AiringMetadataItem', fromJson: _programFromRaw)
final LiveTvProgram? airingMetadataItem; final LiveTvProgram? airingMetadataItem;
+1 -1
View File
@@ -19,7 +19,7 @@ LiveTvSession _$LiveTvSessionFromJson(Map<String, dynamic> json) =>
startedAt: flexibleInt(json['startedAt']), startedAt: flexibleInt(json['startedAt']),
captureBuffer: _captureBufferFromRaw(json['CaptureBuffer']), captureBuffer: _captureBufferFromRaw(json['CaptureBuffer']),
grabOperation: _grabOperationFromRaw(json['MediaGrabOperation']), grabOperation: _grabOperationFromRaw(json['MediaGrabOperation']),
timeline: _firstMap(json['Timeline']), timeline: firstFlexibleMap(json['Timeline']),
airingMetadataItem: _programFromRaw(json['AiringMetadataItem']), airingMetadataItem: _programFromRaw(json['AiringMetadataItem']),
upNextMetadataItem: _programFromRaw(json['UpNextMetadataItem']), upNextMetadataItem: _programFromRaw(json['UpNextMetadataItem']),
); );
+2 -16
View File
@@ -5,23 +5,9 @@ import 'livetv_program.dart';
part 'media_grab_operation.g.dart'; part 'media_grab_operation.g.dart';
Map<String, dynamic>? _metadataFromJson(Object? raw) { Map<String, dynamic>? _metadataFromJson(Object? raw) => firstFlexibleMap(raw);
if (raw is Map<String, dynamic>) return raw;
if (raw is List && raw.isNotEmpty && raw.first is Map<String, dynamic>) {
return raw.first as Map<String, dynamic>;
}
return null;
}
LiveTvProgram? _programFromMetadata(Object? raw) { LiveTvProgram? _programFromMetadata(Object? raw) => parseFlexibleJsonObject(raw, LiveTvProgram.fromJson);
final metadata = _metadataFromJson(raw);
if (metadata == null) return null;
try {
return LiveTvProgram.fromJson(metadata);
} catch (_) {
return null;
}
}
/// A scheduled or active Plex DVR grab operation. /// A scheduled or active Plex DVR grab operation.
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
+2 -14
View File
@@ -6,21 +6,9 @@ import 'media_subscription.dart';
part 'media_grabber_device.g.dart'; part 'media_grabber_device.g.dart';
List<ChannelMapping> _parseChannelMappings(Object? raw) { List<ChannelMapping> _parseChannelMappings(Object? raw) => parseFlexibleJsonList(raw, ChannelMapping.fromJson);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) ChannelMapping.fromJson(item),
];
}
List<SubscriptionSetting> _parseSettings(Object? raw) { List<SubscriptionSetting> _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) SubscriptionSetting.fromJson(item),
];
}
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class MediaGrabber { class MediaGrabber {
+2 -14
View File
@@ -4,21 +4,9 @@ import '../utils/json_utils.dart';
part 'media_provider_info.g.dart'; part 'media_provider_info.g.dart';
List<MediaProviderFeature> _parseFeatures(Object? raw) { List<MediaProviderFeature> _parseFeatures(Object? raw) => parseFlexibleJsonList(raw, MediaProviderFeature.fromJson);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) MediaProviderFeature.fromJson(item),
];
}
List<Map<String, dynamic>> _parseRawMaps(Object? raw) { List<Map<String, dynamic>> _parseRawMaps(Object? raw) => flexibleMapList(raw);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) item,
];
}
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class MediaProviderInfo { class MediaProviderInfo {
+4 -22
View File
@@ -5,31 +5,13 @@ import 'media_grab_operation.dart';
part 'media_subscription.g.dart'; part 'media_subscription.g.dart';
List<MediaSubscription> _parseSubscriptions(Object? raw) { List<MediaSubscription> _parseSubscriptions(Object? raw) => parseFlexibleJsonList(raw, MediaSubscription.fromJson);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) MediaSubscription.fromJson(item),
];
}
List<SubscriptionSetting> _parseSettings(Object? raw) { List<SubscriptionSetting> _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) SubscriptionSetting.fromJson(item),
];
}
List<MediaGrabOperation> _parseGrabOperations(Object? raw) { List<MediaGrabOperation> _parseGrabOperations(Object? raw) => parseFlexibleJsonList(raw, MediaGrabOperation.fromJson);
final list = flexibleList(raw) ?? const [];
return [
for (final item in list)
if (item is Map<String, dynamic>) MediaGrabOperation.fromJson(item),
];
}
Map<String, dynamic>? _mapFromJson(Object? raw) => raw is Map<String, dynamic> ? raw : null; Map<String, dynamic>? _mapFromJson(Object? raw) => firstFlexibleMap(raw);
/// Template wrapper returned by `/media/subscriptions/template`. /// Template wrapper returned by `/media/subscriptions/template`.
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
+1 -2
View File
@@ -98,8 +98,7 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
} }
if (_lastSessionActiveId == activeId) return; if (_lastSessionActiveId == activeId) return;
_lastSessionActiveId = activeId; _lastSessionActiveId = activeId;
final cache = ApiCache.maybeInstance; unawaited(ApiCache.clearRegisteredVolatile());
if (cache != null) unawaited(cache.clearVolatile());
} }
@override @override
+10 -27
View File
@@ -980,15 +980,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
await queueItem(item); await queueItem(item);
} else if (item.isShow || item.isSeason) { } else if (item.isShow || item.isSeason) {
if (!expandShows) continue; if (!expandShows) continue;
// One-shot recursive expansion (Plex /grandchildren, Jellyfin // One-shot recursive expansion for both shows and seasons.
// Recursive=true) — the per-season walk that used to live here
// was the same pattern as collectEpisodes*, just inlined.
final episodes = <MediaItem>[]; final episodes = <MediaItem>[];
if (item.isShow) { await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
} else {
await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
}
for (final ep in episodes) { for (final ep in episodes) {
await queueItem(ep); await queueItem(ep);
} }
@@ -1264,25 +1258,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
includeSpecials || (container.kind == MediaKind.season && isSpecialSeasonNumber(container.index)); includeSpecials || (container.kind == MediaKind.season && isSpecialSeasonNumber(container.index));
final relatedContext = _RelatedMetadataDownloadContext(); final relatedContext = _RelatedMetadataDownloadContext();
final episodes = <MediaItem>[]; final episodes = <MediaItem>[];
if (container.kind == MediaKind.show) { await collectEpisodes(
await collectEpisodesForShow( client,
client, container.id,
container.id, unwatchedOnly: unwatchedOnly,
unwatchedOnly: unwatchedOnly, out: episodes,
out: episodes, fallback: container,
fallback: container, includeSpecials: effectiveIncludeSpecials,
includeSpecials: effectiveIncludeSpecials, );
);
} else {
await collectEpisodesForSeason(
client,
container.id,
unwatchedOnly: unwatchedOnly,
out: episodes,
fallback: container,
includeSpecials: effectiveIncludeSpecials,
);
}
int count = 0; int count = 0;
for (final episode in episodes) { for (final episode in episodes) {
+1 -1
View File
@@ -1430,7 +1430,7 @@ class _MainScreenState extends State<MainScreen>
// Drop volatile API cache rows before screens kick off their refetch. // Drop volatile API cache rows before screens kick off their refetch.
// Pinned rows back offline downloads and must survive profile switches. // Pinned rows back offline downloads and must survive profile switches.
try { try {
await ApiCache.instance.clearVolatile(); await ApiCache.clearRegisteredVolatile();
} catch (e, st) { } catch (e, st) {
appLogger.w('Failed to clear ApiCache on profile switch', error: e, stackTrace: st); appLogger.w('Failed to clear ApiCache on profile switch', error: e, stackTrace: st);
} }
+8 -33
View File
@@ -19,7 +19,6 @@ import '../../theme/mono_tokens.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/formatters.dart';
import '../../utils/layout_constants.dart';
import '../../utils/media_image_helper.dart'; import '../../utils/media_image_helper.dart';
import '../../utils/music_navigation.dart'; import '../../utils/music_navigation.dart';
import '../../utils/platform_detector.dart'; import '../../utils/platform_detector.dart';
@@ -31,6 +30,7 @@ import '../../widgets/download_status_icon.dart';
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../../widgets/media_context_menu.dart'; import '../../widgets/media_context_menu.dart';
import '../../widgets/music/mini_player.dart'; import '../../widgets/music/mini_player.dart';
import '../../widgets/music/music_detail_header.dart';
import '../../widgets/music/music_actions.dart'; import '../../widgets/music/music_actions.dart';
import '../../widgets/music/track_row.dart'; import '../../widgets/music/track_row.dart';
import '../../widgets/optimized_media_image.dart'; import '../../widgets/optimized_media_image.dart';
@@ -315,38 +315,13 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScree
onBack: () => Navigator.pop(context), onBack: () => Navigator.pop(context),
); );
return Padding( return MusicDetailHeader(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), artworkBuilder: cover,
child: LayoutBuilder( infoBuilder: info,
builder: (context, constraints) { actionBar: actionRow,
final narrow = constraints.maxWidth < ScreenBreakpoints.mobile; compactArtworkSize: 200,
if (narrow) { compactArtworkSpacing: 16,
return Column( wideAlignment: CrossAxisAlignment.end,
children: [
cover(200),
const SizedBox(height: 16),
info(centered: true),
const SizedBox(height: 16),
actionRow,
],
);
}
return Row(
crossAxisAlignment: .end,
children: [
cover(180),
const SizedBox(width: 24),
Expanded(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [info(centered: false), const SizedBox(height: 16), actionRow],
),
),
],
);
},
),
); );
} }
+8 -37
View File
@@ -13,7 +13,6 @@ import '../../mixins/grid_focus_node_mixin.dart';
import '../../services/music/music_playback_service.dart'; import '../../services/music/music_playback_service.dart';
import '../../theme/mono_tokens.dart'; import '../../theme/mono_tokens.dart';
import '../../utils/formatters.dart'; import '../../utils/formatters.dart';
import '../../utils/layout_constants.dart';
import '../../utils/media_image_helper.dart'; import '../../utils/media_image_helper.dart';
import '../../utils/music_navigation.dart'; import '../../utils/music_navigation.dart';
import '../../utils/platform_detector.dart'; import '../../utils/platform_detector.dart';
@@ -23,6 +22,7 @@ import '../../widgets/collapsible_text.dart';
import '../../widgets/desktop_app_bar.dart'; import '../../widgets/desktop_app_bar.dart';
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../../widgets/music/mini_player.dart'; import '../../widgets/music/mini_player.dart';
import '../../widgets/music/music_detail_header.dart';
import '../../widgets/music/music_actions.dart'; import '../../widgets/music/music_actions.dart';
import '../../widgets/optimized_media_image.dart'; import '../../widgets/optimized_media_image.dart';
import '../../widgets/overlay_sheet.dart'; import '../../widgets/overlay_sheet.dart';
@@ -179,42 +179,13 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
onBack: () => Navigator.pop(context), onBack: () => Navigator.pop(context),
); );
return Padding( return MusicDetailHeader(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), artworkBuilder: portrait,
child: LayoutBuilder( infoBuilder: info,
builder: (context, constraints) { actionBar: actionRow,
final narrow = constraints.maxWidth < ScreenBreakpoints.mobile; compactArtworkSize: 140,
if (narrow) { compactArtworkSpacing: 12,
return Column( compactBottomSpacing: 8,
children: [
portrait(140),
const SizedBox(height: 12),
info(centered: true),
const SizedBox(height: 16),
actionRow,
const SizedBox(height: 8),
],
);
}
// Wide/desktop: portrait left, left-aligned text + actions beside
// it — mirrors the album header so the two screens read as one
// family (and the grid below starts at the same left inset).
return Row(
crossAxisAlignment: .center,
children: [
portrait(180),
const SizedBox(width: 24),
Expanded(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [info(centered: false), const SizedBox(height: 16), actionRow],
),
),
],
);
},
),
); );
} }
+40 -23
View File
@@ -16,6 +16,7 @@ import '../../i18n/strings.g.dart';
import '../../media/ids.dart'; import '../../media/ids.dart';
import '../../media/lyrics.dart'; import '../../media/lyrics.dart';
import '../../media/media_item.dart'; import '../../media/media_item.dart';
import '../../media/stepped_seek.dart';
import '../../media/media_server_client.dart'; import '../../media/media_server_client.dart';
import '../../mixins/context_menu_tap_mixin.dart'; import '../../mixins/context_menu_tap_mixin.dart';
import '../../services/device_performance.dart'; import '../../services/device_performance.dart';
@@ -1004,27 +1005,37 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
int _seekRepeatCount = 0; int _seekRepeatCount = 0;
LogicalKeyboardKey? _seekDirection; LogicalKeyboardKey? _seekDirection;
Duration? _keySeekTarget; late final DebouncedSeekAccumulator _keySeek;
/// Stepped acceleration tiers, mirroring the video timeline's key-repeat @override
/// scrubbing. void initState() {
double _seekMultiplier() { super.initState();
if (_seekRepeatCount <= 5) return 1.5; _keySeek = DebouncedSeekAccumulator(
if (_seekRepeatCount <= 15) return 3.0; currentPosition: () => context.read<MusicPlaybackService>().position,
if (_seekRepeatCount <= 30) return 6.0; duration: () => context.read<MusicPlaybackService>().duration ?? Duration.zero,
return 10.0; seek: (target) => unawaited(context.read<MusicPlaybackService>().seek(target)),
onChanged: () {
if (mounted) setState(() {});
},
);
}
@override
void dispose() {
_keySeek.dispose();
super.dispose();
} }
void _resetSeekState() { void _resetSeekState() {
_seekRepeatCount = 0; _seekRepeatCount = 0;
_seekDirection = null; _seekDirection = null;
_keySeekTarget = null;
} }
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey; final key = event.logicalKey;
if (event is KeyUpEvent && (key.isLeftKey || key.isRightKey)) { if (event is KeyUpEvent && (key.isLeftKey || key.isRightKey)) {
_keySeek.flush();
_resetSeekState(); _resetSeekState();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1054,16 +1065,11 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
_seekRepeatCount = 0; _seekRepeatCount = 0;
} }
if (event is KeyRepeatEvent) _seekRepeatCount++; if (event is KeyRepeatEvent) _seekRepeatCount++;
final multiplier = event is KeyRepeatEvent ? _seekMultiplier() : 1.0; final multiplier = event is KeyRepeatEvent ? steppedSeekMultiplier(_seekRepeatCount) : 1.0;
final stepMs = (_baseStepMs * multiplier).round(); final stepMs = (_baseStepMs * multiplier).round();
// Step from the in-flight target during a held burst — the position final step = Duration(milliseconds: stepMs);
// stream lags behind the seeks. _keySeek.seekBy(key.isRightKey ? step : -step);
final base = _keySeekTarget ?? service.position;
final targetMs = (base.inMilliseconds + (key.isRightKey ? stepMs : -stepMs)).clamp(0, duration.inMilliseconds);
final target = Duration(milliseconds: targetMs);
_keySeekTarget = target;
unawaited(service.seek(target));
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1082,7 +1088,10 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
final duration = service.duration ?? Duration.zero; final duration = service.duration ?? Duration.zero;
final durationMs = duration.inMilliseconds.toDouble(); final durationMs = duration.inMilliseconds.toDouble();
final hasDuration = durationMs > 0; final hasDuration = durationMs > 0;
final rawPositionMs = _dragValueMs ?? (snapshot.data ?? service.position).inMilliseconds.toDouble(); final rawPositionMs =
_dragValueMs ??
_keySeek.pendingPosition?.inMilliseconds.toDouble() ??
(snapshot.data ?? service.position).inMilliseconds.toDouble();
final positionMs = hasDuration ? rawPositionMs.clamp(0.0, durationMs) : 0.0; final positionMs = hasDuration ? rawPositionMs.clamp(0.0, durationMs) : 0.0;
final dragging = _dragValueMs != null; final dragging = _dragValueMs != null;
@@ -1103,7 +1112,12 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
child: Slider( child: Slider(
max: hasDuration ? durationMs : 1, max: hasDuration ? durationMs : 1,
value: positionMs, value: positionMs,
onChangeStart: hasDuration ? (value) => setState(() => _dragValueMs = value) : null, onChangeStart: hasDuration
? (value) {
_keySeek.cancel();
setState(() => _dragValueMs = value);
}
: null,
onChanged: hasDuration ? (value) => setState(() => _dragValueMs = value) : null, onChanged: hasDuration ? (value) => setState(() => _dragValueMs = value) : null,
onChangeEnd: hasDuration onChangeEnd: hasDuration
? (value) { ? (value) {
@@ -1135,10 +1149,13 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
focusNode: widget.focusNode, focusNode: widget.focusNode,
descendantsAreFocusable: false, descendantsAreFocusable: false,
onKeyEvent: _handleKeyEvent, onKeyEvent: _handleKeyEvent,
onFocusChange: (hasFocus) => setState(() { onFocusChange: (hasFocus) {
_focused = hasFocus; if (!hasFocus) {
if (!hasFocus) _resetSeekState(); _keySeek.flush();
}), _resetSeekState();
}
setState(() => _focused = hasFocus);
},
child: AnimatedContainer( child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context), duration: FocusTheme.getAnimationDuration(context),
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
+1 -1
View File
@@ -251,7 +251,7 @@ Future<void> logoutAllProfiles(BuildContext context) async {
await scope.database.clearAllSyncRules(); await scope.database.clearAllSyncRules();
// Preserve pinned rows backing offline downloads; all session/API data is // Preserve pinned rows backing offline downloads; all session/API data is
// volatile and must not cross into the next sign-in. // volatile and must not cross into the next sign-in.
await ApiCache.instance.clearVolatile(); await ApiCache.clearRegisteredVolatile();
await scope.hiddenLibraries?.refresh(); await scope.hiddenLibraries?.refresh();
playbackState.clearShuffle(); playbackState.clearShuffle();
+58 -17
View File
@@ -20,32 +20,59 @@ import '../utils/isolate_helper.dart';
/// implement the abstract [getMetadata] / [pinForOffline] / [deleteForItem] /// implement the abstract [getMetadata] / [pinForOffline] / [deleteForItem]
/// methods so callers can dispatch via [forBackend] instead of switching on /// methods so callers can dispatch via [forBackend] instead of switching on
/// the backend type at every call site. /// the backend type at every call site.
abstract class ApiCache { class ApiCacheSingleton<T extends ApiCache> {
static ApiCache? _instance; ApiCacheSingleton(this.backend, this.typeName);
/// Returns the most recently registered cache instance — used by callers final MediaBackend backend;
/// that don't care which backend's helpers they're hitting (e.g. plain final String typeName;
/// `get`/`put` from `JellyfinClient`). Backend-specific operations should T? _instance;
/// route through [forBackend] instead.
static ApiCache get instance { T get instance {
if (_instance == null) { final value = _instance;
throw StateError('ApiCache not initialized. Call initialize() on a backend cache first.'); if (value == null) {
throw StateError('$typeName not initialized. Call $typeName.initialize() first.');
} }
return _instance!; return value;
} }
/// Like [instance], but `null` before any backend cache registered — void install(T instance) {
/// for best-effort callers (nothing cached yet means nothing to clear). _instance = instance;
static ApiCache? get maybeInstance => _instance; ApiCache.registerInstance(backend, instance);
}
}
/// Decodes independent cached JSON rows, dropping only the malformed row.
Map<String, MediaItem> decodeCachedMediaRows<T>(
Iterable<T> rows, {
required String Function(T row) serializedData,
required MapEntry<String, MediaItem>? Function(T row, Map<String, dynamic> json) decode,
}) {
final result = <String, MediaItem>{};
for (final row in rows) {
try {
final json = jsonDecode(serializedData(row)) as Map<String, dynamic>;
final decoded = decode(row, json);
if (decoded != null) {
result[decoded.key] = decoded.value;
}
} catch (_) {
// A malformed cache row does not invalidate its siblings.
}
}
return result;
}
abstract class ApiCache {
static final Map<MediaBackend, ApiCache> _byBackend = {}; static final Map<MediaBackend, ApiCache> _byBackend = {};
/// Subclasses call this from their own `initialize` to register themselves /// Registers a backend cache. A new database marks a new application/test
/// for backend dispatch. Also seeds [instance] so the legacy singleton /// lifecycle, so registrations tied to the previous database are discarded
/// surface keeps working. /// instead of leaving backend dispatch pointed at a closed connection.
static void registerInstance(MediaBackend backend, ApiCache cache) { static void registerInstance(MediaBackend backend, ApiCache cache) {
if (_byBackend.values.any((registered) => !identical(registered.database, cache.database))) {
_byBackend.clear();
}
_byBackend[backend] = cache; _byBackend[backend] = cache;
_instance = cache;
} }
/// Pick the cache for [backend]. Plex is the legacy default — covers items /// Pick the cache for [backend]. Plex is the legacy default — covers items
@@ -58,6 +85,20 @@ abstract class ApiCache {
return picked; return picked;
} }
/// Clears volatile rows for every distinct registered database.
///
/// Production backend caches share one [AppDatabase], while focused tests
/// may register only one backend. This operation is therefore independent
/// of backend initialization order and is a no-op before registration.
static Future<void> clearRegisteredVolatile() async {
final cleared = <AppDatabase>{};
for (final cache in _byBackend.values) {
if (cleared.add(cache.database)) {
await cache.clearVolatile();
}
}
}
final AppDatabase _db; final AppDatabase _db;
ApiCache(this._db); ApiCache(this._db);
@@ -340,23 +340,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
unawaited(socket.close(4002, 'Authentication required')); unawaited(socket.close(4002, 'Authentication required'));
} }
} else { } else {
// Encrypted command — data is binary await _handleEncryptedCommand(data);
final decrypted = await _decryptIncoming(data);
if (decrypted == null) return;
final json = jsonDecode(decrypted) as Map<String, dynamic>;
final command = RemoteCommand.fromJson(json);
appLogger.d('CompanionRemote: Received command: ${command.type}');
if (_shouldSendAck(command)) {
_sendAck(command);
}
_commandReceivedController.add(command);
if (command.type == RemoteCommandType.ping) {
_sendPong();
}
} }
} catch (e) { } catch (e) {
appLogger.e('CompanionRemote: Failed to process message', error: e); appLogger.e('CompanionRemote: Failed to process message', error: e);
@@ -443,23 +427,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
(data) async { (data) async {
try { try {
if (_isAuthenticated) { if (_isAuthenticated) {
// Post-auth: all messages are encrypted binary await _handleEncryptedCommand(data);
final decrypted = await _decryptIncoming(data);
if (decrypted == null) return;
final json = jsonDecode(decrypted) as Map<String, dynamic>;
final command = RemoteCommand.fromJson(json);
appLogger.d('CompanionRemote: Received command: ${command.type}');
if (_shouldSendAck(command)) {
_sendAck(command);
}
_commandReceivedController.add(command);
if (command.type == RemoteCommandType.ping) {
_sendPong();
}
} else if (_sessionEncKey != null) { } else if (_sessionEncKey != null) {
// Keys derived, waiting for encrypted authSuccess // Keys derived, waiting for encrypted authSuccess
final decrypted = await _decryptIncoming(data); final decrypted = await _decryptIncoming(data);
@@ -781,18 +749,25 @@ class CompanionRemotePeerService with KeepaliveMixin {
// ── Encrypted send/receive ── // ── Encrypted send/receive ──
// Serializes async sends to prevent counter interleaving // Serialize cryptographic operations so implicit nonce counters cannot
// interleave when stream callbacks overlap.
Future<void>? _encryptChain;
Future<void>? _decryptChain;
Future<void>? _sendChain; Future<void>? _sendChain;
Future<List<int>> _encryptOutgoing(String plaintext) async { Future<List<int>> _encryptOutgoing(String plaintext) {
final encrypted = await RemoteAuthService.instance.encrypt( final result = (_encryptChain ?? Future<void>.value()).then((_) async {
_sessionEncKey!, final encrypted = await RemoteAuthService.instance.encrypt(
utf8.encode(plaintext), _sessionEncKey!,
isHost: _role == RemoteSessionRole.host, utf8.encode(plaintext),
counter: _sendCounter, isHost: _role == RemoteSessionRole.host,
); counter: _sendCounter,
_sendCounter++; );
return encrypted; _sendCounter++;
return encrypted;
});
_encryptChain = result.then<void>((_) {});
return result;
} }
Future<void> _sendEncryptedToSocket(WebSocket socket, String plaintext) async { Future<void> _sendEncryptedToSocket(WebSocket socket, String plaintext) async {
@@ -801,15 +776,20 @@ class CompanionRemotePeerService with KeepaliveMixin {
socket.add(encrypted); socket.add(encrypted);
} }
Future<String?> _decryptIncoming(dynamic data) async { Future<String?> _decryptIncoming(dynamic data) {
if (_sessionEncKey == null) return null; if (_sessionEncKey == null) return Future<String?>.value();
final result = (_decryptChain ?? Future<void>.value()).then((_) => _decryptIncomingNow(data));
_decryptChain = result.then<void>((_) {});
return result;
}
Future<String?> _decryptIncomingNow(dynamic data) async {
try { try {
final auth = RemoteAuthService.instance;
final bytes = data is List<int> ? data : utf8.encode(data as String); final bytes = data is List<int> ? data : utf8.encode(data as String);
final decrypted = await auth.decrypt( final decrypted = await RemoteAuthService.instance.decrypt(
bytes, bytes,
_sessionEncKey!, _sessionEncKey!,
fromHost: _role == RemoteSessionRole.remote, // If we're remote, incoming is from host fromHost: _role == RemoteSessionRole.remote,
expectedCounter: _recvCounter, expectedCounter: _recvCounter,
); );
_recvCounter++; _recvCounter++;
@@ -820,6 +800,23 @@ class CompanionRemotePeerService with KeepaliveMixin {
} }
} }
Future<void> _handleEncryptedCommand(dynamic data) async {
final decrypted = await _decryptIncoming(data);
if (decrypted == null) return;
final command = RemoteCommand.fromJson(jsonDecode(decrypted) as Map<String, dynamic>);
appLogger.d('CompanionRemote: Received command: ${command.type}');
if (_shouldSendAck(command)) {
_sendAck(command);
}
_commandReceivedController.add(command);
if (command.type == RemoteCommandType.ping) {
_sendPong();
}
}
// ── Commands ── // ── Commands ──
@override @override
@@ -894,6 +891,13 @@ class CompanionRemotePeerService with KeepaliveMixin {
stopKeepalive(); stopKeepalive();
// Flush commands already decoded by the overlapping stream callbacks
// before closing their transport, then reject any later send request.
await _decryptChain;
await _sendChain;
await _encryptChain;
_isAuthenticated = false;
if (_clientSocket != null) { if (_clientSocket != null) {
try { try {
await _clientSocket!.close(); await _clientSocket!.close();
@@ -925,8 +929,9 @@ class CompanionRemotePeerService with KeepaliveMixin {
_sessionEncKey = null; _sessionEncKey = null;
_sendCounter = 0; _sendCounter = 0;
_recvCounter = 0; _recvCounter = 0;
_isAuthenticated = false;
_sendChain = null; _sendChain = null;
_encryptChain = null;
_decryptChain = null;
_failedAuthAttempts.clear(); _failedAuthAttempts.clear();
_authLockouts.clear(); _authLockouts.clear();
+16 -28
View File
@@ -21,23 +21,15 @@ import 'jellyfin_mappers.dart';
/// the bare machine id; the compound prefix only isolates local user-scoped /// the bare machine id; the compound prefix only isolates local user-scoped
/// state such as `UserData`. /// state such as `UserData`.
class JellyfinApiCache extends ApiCache { class JellyfinApiCache extends ApiCache {
static JellyfinApiCache? _instance; static final _singleton = ApiCacheSingleton<JellyfinApiCache>(MediaBackend.jellyfin, 'JellyfinApiCache');
static JellyfinApiCache get instance { static JellyfinApiCache get instance => _singleton.instance;
if (_instance == null) {
throw StateError('JellyfinApiCache not initialized. Call JellyfinApiCache.initialize() first.');
}
return _instance!;
}
JellyfinApiCache._(super.db); JellyfinApiCache._(super.db);
/// Initialize the singleton with an [AppDatabase] instance. Also registers /// Initialize the singleton with an [AppDatabase] instance. Also registers
/// this instance with the [ApiCache] backend dispatch so callers using /// this instance with the [ApiCache] backend dispatch so callers using
/// `ApiCache.forBackend(MediaBackend.jellyfin)` resolve here. /// `ApiCache.forBackend(MediaBackend.jellyfin)` resolve here.
static void initialize(AppDatabase db) { static void initialize(AppDatabase db) => _singleton.install(JellyfinApiCache._(db));
_instance = JellyfinApiCache._(db);
ApiCache.registerInstance(MediaBackend.jellyfin, _instance!);
}
JellyfinCacheResolver get _resolver => JellyfinCacheResolver(database); JellyfinCacheResolver get _resolver => JellyfinCacheResolver(database);
@@ -211,29 +203,25 @@ class JellyfinApiCache extends ApiCache {
} }
} }
return await tryIsolateRun(() { return await tryIsolateRun(
final result = <String, MediaItem>{}; () => decodeCachedMediaRows(
for (final entry in entries) { entries,
final ctx = contexts[entry.connection.id]; serializedData: (entry) => entry.cacheRow.data,
final absolutizer = absolutizers[entry.connection.id]; decode: (entry, data) {
if (ctx == null || absolutizer == null) continue; final ctx = contexts[entry.connection.id];
try { final absolutizer = absolutizers[entry.connection.id];
final data = jsonDecode(entry.cacheRow.data) as Map<String, dynamic>; if (ctx == null || absolutizer == null) return null;
final mapped = JellyfinMappers.mediaItem( final mapped = JellyfinMappers.mediaItem(
data, data,
serverId: ServerId(ctx.machineId), serverId: ServerId(ctx.machineId),
serverName: ctx.name, serverName: ctx.name,
absolutizer: absolutizer, absolutizer: absolutizer,
); );
if (mapped != null) { if (mapped == null) return null;
result[buildGlobalKey(ServerId(entry.key.scopeId), entry.key.itemId)] = mapped; return MapEntry(buildGlobalKey(ServerId(entry.key.scopeId), entry.key.itemId), mapped);
} },
} catch (_) { ),
// Skip malformed entries );
}
}
return result;
});
} }
/// Resolve the connection context (server name + base URL + access token) /// Resolve the connection context (server name + base URL + access token)
+15 -27
View File
@@ -1,4 +1,3 @@
import 'dart:convert';
import '../media/ids.dart'; import '../media/ids.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
@@ -20,23 +19,15 @@ import 'plex_mappers.dart';
/// endpoint shape and parse cached JSON into [MediaItem] via /// endpoint shape and parse cached JSON into [MediaItem] via
/// [PlexMappers.mediaItemFromCacheJson]. /// [PlexMappers.mediaItemFromCacheJson].
class PlexApiCache extends ApiCache { class PlexApiCache extends ApiCache {
static PlexApiCache? _instance; static final _singleton = ApiCacheSingleton<PlexApiCache>(MediaBackend.plex, 'PlexApiCache');
static PlexApiCache get instance { static PlexApiCache get instance => _singleton.instance;
if (_instance == null) {
throw StateError('PlexApiCache not initialized. Call PlexApiCache.initialize() first.');
}
return _instance!;
}
PlexApiCache._(super.db); PlexApiCache._(super.db);
/// Initialize the singleton with an [AppDatabase] instance. Also registers /// Initialize the singleton with an [AppDatabase] instance. Also registers
/// this instance with the [ApiCache] backend dispatch so callers using /// this instance with the [ApiCache] backend dispatch so callers using
/// `ApiCache.forBackend(MediaBackend.plex)` resolve here. /// `ApiCache.forBackend(MediaBackend.plex)` resolve here.
static void initialize(AppDatabase db) { static void initialize(AppDatabase db) => _singleton.install(PlexApiCache._(db));
_instance = PlexApiCache._(db);
ApiCache.registerInstance(MediaBackend.plex, _instance!);
}
/// Delete cached data for a specific item (when removing a download). /// Delete cached data for a specific item (when removing a download).
@override @override
@@ -144,23 +135,20 @@ class PlexApiCache extends ApiCache {
final entries = await listPinnedRowsByPattern(_metadataKeyPattern); final entries = await listPinnedRowsByPattern(_metadataKeyPattern);
if (entries.isEmpty) return {}; if (entries.isEmpty) return {};
return await tryIsolateRun(() { return await tryIsolateRun(
final result = <String, MediaItem>{}; () => decodeCachedMediaRows(
for (final entry in entries) { entries,
try { serializedData: (entry) => entry.data,
final data = jsonDecode(entry.data) as Map<String, dynamic>; decode: (entry, data) {
final container = PlexCacheParser.extractMediaContainer(data); final container = PlexCacheParser.extractMediaContainer(data);
final json = PlexCacheParser.extractFirstMetadata(data); final json = PlexCacheParser.extractFirstMetadata(data);
if (json == null) continue; if (json == null) return null;
result[buildGlobalKey(ServerId(entry.serverId), entry.id)] = PlexMappers.mediaItemFromCacheJson( return MapEntry(
_withContainerLibrary(json, container), buildGlobalKey(ServerId(entry.serverId), entry.id),
serverId: entry.serverId, PlexMappers.mediaItemFromCacheJson(_withContainerLibrary(json, container), serverId: entry.serverId),
); );
} catch (_) { },
// Skip malformed entries ),
} );
}
return result;
});
} }
} }
+9 -20
View File
@@ -259,24 +259,14 @@ class SyncRuleExecutor {
}) async { }) async {
final fromServer = <MediaItem>[]; final fromServer = <MediaItem>[];
final sourceMetadata = metadata[rule.globalKey]; final sourceMetadata = metadata[rule.globalKey];
if (rule.targetType == ContentTypes.show) { await collectEpisodes(
await collectEpisodesForShow( client,
client, rule.ratingKey,
rule.ratingKey, unwatchedOnly: true,
unwatchedOnly: true, out: fromServer,
out: fromServer, fallback: sourceMetadata,
fallback: sourceMetadata, includeSpecials: rule.targetType != ContentTypes.show || rule.includeSpecials,
includeSpecials: rule.includeSpecials, );
);
} else {
await collectEpisodesForSeason(
client,
rule.ratingKey,
unwatchedOnly: true,
out: fromServer,
fallback: sourceMetadata,
);
}
final unwatchedEpisodes = await _excludeLocallyWatched( final unwatchedEpisodes = await _excludeLocallyWatched(
episodes: fromServer, episodes: fromServer,
@@ -442,9 +432,8 @@ class SyncRuleExecutor {
if (unwatchedOnly && !item.isUnwatchedOrInProgress) break; if (unwatchedOnly && !item.isUnwatchedOrInProgress) break;
out.add(item); out.add(item);
case MediaKind.show: case MediaKind.show:
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
case MediaKind.season: case MediaKind.season:
await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item); await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
case MediaKind.album: case MediaKind.album:
case MediaKind.artist: case MediaKind.artist:
// One recursive-leaves call per container on both backends // One recursive-leaves call per container on both backends
+2 -10
View File
@@ -142,11 +142,7 @@ class TrackerCoordinator {
} }
final episodes = <MediaItem>[]; final episodes = <MediaItem>[];
if (kind == MediaKind.show) { await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
await collectEpisodesForShow(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
} else {
await collectEpisodesForSeason(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
}
appLogger.d('Trackers: manual ${kind.name} ${item.id} expanded to ${episodes.length} episodes'); appLogger.d('Trackers: manual ${kind.name} ${item.id} expanded to ${episodes.length} episodes');
await _markContainerEpisodesWatched(episodes, resolver); await _markContainerEpisodesWatched(episodes, resolver);
@@ -169,11 +165,7 @@ class TrackerCoordinator {
} }
final episodes = <MediaItem>[]; final episodes = <MediaItem>[];
if (kind == MediaKind.show) { await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
await collectEpisodesForShow(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
} else {
await collectEpisodesForSeason(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
}
appLogger.d('Trackers: manual ${kind.name} ${item.id} unwatched expanded to ${episodes.length} episodes'); appLogger.d('Trackers: manual ${kind.name} ${item.id} unwatched expanded to ${episodes.length} episodes');
await _markContainerEpisodesUnwatched(episodes, resolver); await _markContainerEpisodesUnwatched(episodes, resolver);
+1 -11
View File
@@ -183,17 +183,7 @@ class TraktSyncService {
parentId: event.mediaType == 'season' && event.parentChain.isNotEmpty ? event.parentChain.first : null, parentId: event.mediaType == 'season' && event.parentChain.isNotEmpty ? event.parentChain.first : null,
); );
final episodes = <MediaItem>[]; final episodes = <MediaItem>[];
if (fallback.kind == MediaKind.show) { await collectEpisodes(mediaClient, event.itemId, unwatchedOnly: false, out: episodes, fallback: fallback);
await collectEpisodesForShow(mediaClient, event.itemId, unwatchedOnly: false, out: episodes, fallback: fallback);
} else {
await collectEpisodesForSeason(
mediaClient,
event.itemId,
unwatchedOnly: false,
out: episodes,
fallback: fallback,
);
}
for (final episode in episodes) { for (final episode in episodes) {
if (episode.kind != MediaKind.episode) continue; if (episode.kind != MediaKind.episode) continue;
+42
View File
@@ -54,6 +54,48 @@ List<dynamic>? flexibleList(Object? v) => switch (v) {
_ => <dynamic>[v], _ => <dynamic>[v],
}; };
/// Return only JSON object entries from a value that may be one object, a
/// heterogeneous list, or null.
List<Map<String, dynamic>> flexibleMapList(Object? value) {
return [
for (final item in flexibleList(value) ?? const <dynamic>[])
if (item is Map<String, dynamic>) item,
];
}
/// Return the first JSON object from a single object or heterogeneous list.
Map<String, dynamic>? firstFlexibleMap(Object? value) {
for (final item in flexibleList(value) ?? const <dynamic>[]) {
if (item is Map<String, dynamic>) return item;
}
return null;
}
/// Parse every valid JSON object independently, dropping malformed entries
/// instead of letting one row discard an otherwise usable response.
List<T> parseFlexibleJsonList<T>(Object? value, T Function(Map<String, dynamic> json) parse) {
final result = <T>[];
for (final json in flexibleMapList(value)) {
try {
result.add(parse(json));
} catch (_) {
// A malformed row does not invalidate its siblings.
}
}
return result;
}
/// Parse the first JSON object, returning null for missing or malformed data.
T? parseFlexibleJsonObject<T>(Object? value, T Function(Map<String, dynamic> json) parse) {
final json = firstFlexibleMap(value);
if (json == null) return null;
try {
return parse(json);
} catch (_) {
return null;
}
}
/// Coerce a single String, a List of Strings, or null into `List<String>?`. /// Coerce a single String, a List of Strings, or null into `List<String>?`.
/// Non-string elements are dropped; an empty result (or null input) yields /// Non-string elements are dropped; an empty result (or null input) yields
/// `null`. Typed sibling of [flexibleList] — a bare String is wrapped into a /// `null`. Typed sibling of [flexibleList] — a bare String is wrapped into a
+2 -5
View File
@@ -64,14 +64,11 @@ Future<void> navigateToLiveTv(
appLogger.w('Live TV launch channel was not present in navigation list; prepending ${channel.key}'); appLogger.w('Live TV launch channel was not present in navigation list; prepending ${channel.key}');
} }
final route = PageRouteBuilder<bool>( final route = buildVideoPlayerRoute(
settings: const RouteSettings(name: kVideoPlayerRouteName), builder: (_) => VideoPlayerScreen(
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: placeholder, metadata: placeholder,
live: LiveTvSessionArgs(channel: channel, channels: normalizedChannels, currentChannelIndex: currentChannelIndex), live: LiveTvSessionArgs(channel: channel, channels: normalizedChannels, currentChannelIndex: currentChannelIndex),
), ),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
); );
unawaited(navigator.push<bool>(route)); unawaited(navigator.push<bool>(route));
+16 -5
View File
@@ -25,6 +25,20 @@ import 'platform_detector.dart';
const String kVideoPlayerRouteName = '/video_player'; const String kVideoPlayerRouteName = '/video_player';
/// The route contract shared by VOD and Live TV playback.
///
/// The stable route name drives player lifecycle observation, while the
/// opaque zero-duration route prevents the underlying detail screen flashing
/// during player startup and teardown.
PageRouteBuilder<bool> buildVideoPlayerRoute({required WidgetBuilder builder}) {
return PageRouteBuilder<bool>(
settings: const RouteSettings(name: kVideoPlayerRouteName),
pageBuilder: (context, _, _) => builder(context),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
);
}
class VideoPlayerNavigationInFlightGuard { class VideoPlayerNavigationInFlightGuard {
final Set<String> _keys = <String>{}; final Set<String> _keys = <String>{};
@@ -324,9 +338,8 @@ Future<bool?> navigateToVideoPlayer(
return null; return null;
} }
final route = PageRouteBuilder<bool>( final route = buildVideoPlayerRoute(
settings: const RouteSettings(name: kVideoPlayerRouteName), builder: (_) => VideoPlayerScreen(
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: metadata, metadata: metadata,
preferredAudioTrack: preferredAudioTrack, preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack, preferredSubtitleTrack: preferredSubtitleTrack,
@@ -337,8 +350,6 @@ Future<bool?> navigateToVideoPlayer(
selectedQualityPreset: selectedQualityPreset, selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline, isOffline: isOffline,
), ),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
); );
return usePushReplacement ? navigator.pushReplacement<bool, bool>(route) : navigator.push<bool>(route); return usePushReplacement ? navigator.pushReplacement<bool, bool>(route) : navigator.push<bool>(route);
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import '../../utils/layout_constants.dart';
typedef MusicDetailHeaderArtworkBuilder = Widget Function(double size);
typedef MusicDetailHeaderInfoBuilder = Widget Function({required bool centered});
/// Responsive artwork, metadata, and focusable actions shared by music detail
/// screens.
class MusicDetailHeader extends StatelessWidget {
const MusicDetailHeader({
super.key,
required this.artworkBuilder,
required this.infoBuilder,
required this.actionBar,
required this.compactArtworkSize,
required this.compactArtworkSpacing,
this.compactBottomSpacing = 0,
this.wideArtworkSize = 180,
this.wideAlignment = CrossAxisAlignment.center,
});
final MusicDetailHeaderArtworkBuilder artworkBuilder;
final MusicDetailHeaderInfoBuilder infoBuilder;
final Widget actionBar;
final double compactArtworkSize;
final double compactArtworkSpacing;
final double compactBottomSpacing;
final double wideArtworkSize;
final CrossAxisAlignment wideAlignment;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < ScreenBreakpoints.mobile) {
return Column(
children: [
artworkBuilder(compactArtworkSize),
SizedBox(height: compactArtworkSpacing),
infoBuilder(centered: true),
const SizedBox(height: 16),
actionBar,
if (compactBottomSpacing > 0) SizedBox(height: compactBottomSpacing),
],
);
}
return Row(
crossAxisAlignment: wideAlignment,
children: [
artworkBuilder(wideArtworkSize),
const SizedBox(width: 24),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [infoBuilder(centered: false), const SizedBox(height: 16), actionBar],
),
),
],
);
},
),
);
}
}
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
import '../../focus/dpad_navigator.dart'; import '../../focus/dpad_navigator.dart';
import '../../media/media_item.dart'; import '../../media/media_item.dart';
import '../../media/stepped_seek.dart';
import '../../mpv/mpv.dart'; import '../../mpv/mpv.dart';
import '../../media/media_source_info.dart'; import '../../media/media_source_info.dart';
import '../../services/fullscreen_state_manager.dart'; import '../../services/fullscreen_state_manager.dart';
@@ -193,18 +194,8 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// Preview thumbnail during sustained dpad/keyboard seeking // Preview thumbnail during sustained dpad/keyboard seeking
bool _showKeyRepeatThumbnail = false; bool _showKeyRepeatThumbnail = false;
Timer? _keyRepeatThumbnailTimer; Timer? _keyRepeatThumbnailTimer;
Timer? _timelineSeekDebounceTimer; late final DebouncedSeekAccumulator _timelineSeek;
Timer? _timelinePreviewClearTimer;
Duration? _timelinePreviewPosition;
Duration? _lastFlushedTimelinePreviewPosition;
static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400); static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400);
// Must exceed the OS initial key-repeat delay (~400-500ms on Android/TV),
// or a held key commits an extra seek in the gap before repeats begin.
// Release still flushes synchronously, so this adds no tap latency.
static const _timelineSeekDebounce = Duration(milliseconds: 800);
static const _timelinePreviewClearDelay = Duration(seconds: 2);
static const _timelinePreviewSettleTolerance = Duration(seconds: 3);
static const _timelinePreviewClearCeiling = Duration(seconds: 10);
// Content strip state // Content strip state
bool _contentStripVisible = false; bool _contentStripVisible = false;
@@ -246,6 +237,14 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
_goToLiveFocusNode, _goToLiveFocusNode,
]; ];
widget.chromeController?.addListener(_onChromeControllerChanged); widget.chromeController?.addListener(_onChromeControllerChanged);
_timelineSeek = DebouncedSeekAccumulator(
currentPosition: () => widget.player.state.position,
duration: () => widget.player.state.duration,
seek: widget.onSeekEnd,
onChanged: () {
if (mounted) setState(() {});
},
);
} }
@override @override
@@ -261,8 +260,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
void dispose() { void dispose() {
widget.chromeController?.removeListener(_onChromeControllerChanged); widget.chromeController?.removeListener(_onChromeControllerChanged);
_keyRepeatThumbnailTimer?.cancel(); _keyRepeatThumbnailTimer?.cancel();
_timelineSeekDebounceTimer?.cancel(); _timelineSeek.dispose();
_timelinePreviewClearTimer?.cancel();
_prevItemFocusNode.dispose(); _prevItemFocusNode.dispose();
_prevChapterFocusNode.dispose(); _prevChapterFocusNode.dispose();
_skipBackFocusNode.dispose(); _skipBackFocusNode.dispose();
@@ -334,7 +332,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
widget.onFocusActivity?.call(); widget.onFocusActivity?.call();
} else { } else {
// Reset progressive seek state when timeline loses focus // Reset progressive seek state when timeline loses focus
_flushTimelinePreviewSeek(); _timelineSeek.flush();
_resetSeekState(); _resetSeekState();
} }
} }
@@ -488,56 +486,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
} }
} }
void _clearTimelinePreviewIfStill(Duration target, Duration elapsed) {
if (!mounted || _timelinePreviewPosition != target) return;
// Hold the preview until playback has actually reached the committed
// target: clearing while a slow device is still buffering re-bases the
// next key-seek off the stale live position, silently discarding the
// seek that was just committed. The ceiling is a backstop for streams
// that never settle (mirrors LiveSeekAccumulator._scheduleClear).
final live = widget.player.state.position;
if ((live - target).abs() > _timelinePreviewSettleTolerance && elapsed < _timelinePreviewClearCeiling) {
_timelinePreviewClearTimer = Timer(
_timelinePreviewClearDelay,
() => _clearTimelinePreviewIfStill(target, elapsed + _timelinePreviewClearDelay),
);
return;
}
setState(() => _timelinePreviewPosition = null);
}
void _scheduleTimelinePreviewClear(Duration target) {
_timelinePreviewClearTimer?.cancel();
_timelinePreviewClearTimer = Timer(
_timelinePreviewClearDelay,
() => _clearTimelinePreviewIfStill(target, Duration.zero),
);
}
void _flushTimelinePreviewSeek() {
final target = _timelinePreviewPosition;
_timelineSeekDebounceTimer?.cancel();
_timelineSeekDebounceTimer = null;
if (target == null) return;
if (_lastFlushedTimelinePreviewPosition == target) return;
_lastFlushedTimelinePreviewPosition = target;
widget.onSeekEnd(target);
_scheduleTimelinePreviewClear(target);
}
void _scheduleTimelinePreviewSeekFlush() {
_timelineSeekDebounceTimer?.cancel();
_timelineSeekDebounceTimer = Timer(_timelineSeekDebounce, _flushTimelinePreviewSeek);
}
void _setTimelinePreviewPosition(Duration position) {
_timelinePreviewClearTimer?.cancel();
_timelinePreviewClearTimer = null;
if (_timelinePreviewPosition == position) return;
_lastFlushedTimelinePreviewPosition = null;
setState(() => _timelinePreviewPosition = position);
}
/// Show the timeline preview thumbnail during sustained key-repeat seeking. /// Show the timeline preview thumbnail during sustained key-repeat seeking.
/// Arms a short timer that hides the thumbnail once repeats stop. /// Arms a short timer that hides the thumbnail once repeats stop.
void _triggerKeyRepeatThumbnail() { void _triggerKeyRepeatThumbnail() {
@@ -551,19 +499,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
}); });
} }
/// Calculate seek multiplier based on repeat count (stepped tiers)
double _getSeekMultiplier() {
if (_seekRepeatCount <= 5) {
return 1.5;
} else if (_seekRepeatCount <= 15) {
return 3.0;
} else if (_seekRepeatCount <= 30) {
return 6.0;
} else {
return 10.0;
}
}
/// Handle key events for timeline navigation /// Handle key events for timeline navigation
KeyEventResult _handleTimelineKeyEvent(FocusNode _, KeyEvent event) { KeyEventResult _handleTimelineKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey; final key = event.logicalKey;
@@ -572,7 +507,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// seek (a no-op when nothing is pending) and reset progressive seek state. // seek (a no-op when nothing is pending) and reset progressive seek state.
if (event is KeyUpEvent) { if (event is KeyUpEvent) {
if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) {
_flushTimelinePreviewSeek(); _timelineSeek.flush();
_resetSeekState(); _resetSeekState();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -588,7 +523,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// UP arrow - hide controls and reset seek state // UP arrow - hide controls and reset seek state
if (key == LogicalKeyboardKey.arrowUp) { if (key == LogicalKeyboardKey.arrowUp) {
_flushTimelinePreviewSeek(); _timelineSeek.flush();
_resetSeekState(); _resetSeekState();
widget.onHideControls?.call(); widget.onHideControls?.call();
return KeyEventResult.handled; return KeyEventResult.handled;
@@ -596,7 +531,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// DOWN arrow - move focus to play/pause button and reset seek state // DOWN arrow - move focus to play/pause button and reset seek state
if (key == LogicalKeyboardKey.arrowDown) { if (key == LogicalKeyboardKey.arrowDown) {
_flushTimelinePreviewSeek(); _timelineSeek.flush();
_resetSeekState(); _resetSeekState();
_playPauseFocusNode.requestFocus(); _playPauseFocusNode.requestFocus();
widget.onFocusActivity?.call(); widget.onFocusActivity?.call();
@@ -619,7 +554,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
} }
final isForward = key == LogicalKeyboardKey.arrowRight; final isForward = key == LogicalKeyboardKey.arrowRight;
final effectiveMultiplier = event is KeyRepeatEvent ? _getSeekMultiplier() : 1.0; final effectiveMultiplier = event is KeyRepeatEvent ? steppedSeekMultiplier(_seekRepeatCount) : 1.0;
// Live TV: relative epoch-based seeking via the parent accumulator, which // Live TV: relative epoch-based seeking via the parent accumulator, which
// coalesces a rapid/held burst into one transcode re-open (#1253). The // coalesces a rapid/held burst into one transcode re-open (#1253). The
@@ -638,13 +573,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
final stepMs = (baseStepMs * effectiveMultiplier).clamp(500, 120_000).toInt(); final stepMs = (baseStepMs * effectiveMultiplier).clamp(500, 120_000).toInt();
final step = Duration(milliseconds: stepMs); final step = Duration(milliseconds: stepMs);
// Accumulate the scrub target from a stable base — the in-flight preview _timelineSeek.seekBy(isForward ? step : -step);
// when a burst is already running, otherwise the live position — so the
// marker never snaps back when a real seek lands mid-burst.
final previewBase = _timelinePreviewPosition ?? position;
final rawTarget = isForward ? previewBase + step : previewBase - step;
final target = Duration(milliseconds: rawTarget.inMilliseconds.clamp(0, duration.inMilliseconds));
_setTimelinePreviewPosition(target);
// Move only the preview while the key is held; commit a single seek once // Move only the preview while the key is held; commit a single seek once
// the burst pauses (debounce) or the key is released. Firing a real seek // the burst pauses (debounce) or the key is released. Firing a real seek
@@ -653,7 +582,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// The player's `buffering` flag lags the key-repeat rate, so it can't gate // The player's `buffering` flag lags the key-repeat rate, so it can't gate
// this reliably — coalescing unconditionally matches the existing transcode // this reliably — coalescing unconditionally matches the existing transcode
// path and cannot flood regardless of hardware. // path and cannot flood regardless of hardware.
_scheduleTimelinePreviewSeekFlush();
widget.onFocusActivity?.call(); widget.onFocusActivity?.call();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -823,7 +751,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
enabled: canInteract, enabled: canInteract,
thumbnailDataBuilder: widget.thumbnailDataBuilder, thumbnailDataBuilder: widget.thumbnailDataBuilder,
showKeyRepeatThumbnail: _showKeyRepeatThumbnail, showKeyRepeatThumbnail: _showKeyRepeatThumbnail,
previewPosition: _timelinePreviewPosition, previewPosition: _timelineSeek.pendingPosition,
), ),
], ],
// Row 2: Playback controls and options // Row 2: Playback controls and options
+5 -4
View File
@@ -4,9 +4,10 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_item_merge.dart'; import 'package:plezy/media/media_item_merge.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
MediaItem item({String? serverId, String? serverName, String? libraryId, String? libraryTitle}) => MediaItem( MediaItem item({String? serverId, String? serverName, String? libraryId, String? libraryTitle}) => testMediaItem(
id: 'item', id: 'item',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -17,14 +18,14 @@ void main() {
); );
test('uses the authoritative fallback when both items omit server identity', () { test('uses the authoritative fallback when both items omit server identity', () {
final merged = mergeFetchedMediaItem(fetched: item(), fallbackServerId: ServerId('fallback')); final merged = mergeFetchedtestMediaItem(fetched: item(), fallbackServerId: ServerId('fallback'));
expect(merged.serverId, 'fallback'); expect(merged.serverId, 'fallback');
expect(merged.globalKey, 'fallback:item'); expect(merged.globalKey, 'fallback:item');
}); });
test('preserves existing identity while preferring fetched library context', () { test('preserves existing identity while preferring fetched library context', () {
final merged = mergeFetchedMediaItem( final merged = mergeFetchedtestMediaItem(
fetched: item(serverId: 'fetched', serverName: 'Fetched', libraryId: 'new-lib', libraryTitle: 'New'), fetched: item(serverId: 'fetched', serverName: 'Fetched', libraryId: 'new-lib', libraryTitle: 'New'),
existing: item(serverId: 'existing', serverName: 'Existing', libraryId: 'old-lib', libraryTitle: 'Old'), existing: item(serverId: 'existing', serverName: 'Existing', libraryId: 'old-lib', libraryTitle: 'Old'),
fallbackServerId: ServerId('fallback'), fallbackServerId: ServerId('fallback'),
@@ -37,7 +38,7 @@ void main() {
}); });
test('fills missing fetched library context from the existing item', () { test('fills missing fetched library context from the existing item', () {
final merged = mergeFetchedMediaItem( final merged = mergeFetchedtestMediaItem(
fetched: item(), fetched: item(),
existing: item(libraryId: 'old-lib', libraryTitle: 'Old'), existing: item(libraryId: 'old-lib', libraryTitle: 'Old'),
fallbackServerId: ServerId('fallback'), fallbackServerId: ServerId('fallback'),
+26 -14
View File
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_part.dart'; import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_role.dart'; import 'package:plezy/media/media_role.dart';
import 'package:plezy/media/media_version.dart'; import 'package:plezy/media/media_version.dart';
import '../test_helpers/media_items.dart';
/// Backend-agnostic [MediaItem] tests. Existing coverage is split between /// Backend-agnostic [MediaItem] tests. Existing coverage is split between
/// `plex_mappers_test` and `jellyfin_mappers_test` — those exercise the /// `plex_mappers_test` and `jellyfin_mappers_test` — those exercise the
@@ -22,7 +23,7 @@ MediaItem _movie({
String? artPath, String? artPath,
String? backgroundSquarePath, String? backgroundSquarePath,
MediaBackend backend = MediaBackend.plex, MediaBackend backend = MediaBackend.plex,
}) => MediaItem( }) => testMediaItem(
id: id, id: id,
backend: backend, backend: backend,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -50,7 +51,7 @@ void main() {
}); });
test('show with all leaves watched is watched', () { test('show with all leaves watched is watched', () {
final show = MediaItem( final show = testMediaItem(
id: 's', id: 's',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -62,7 +63,7 @@ void main() {
}); });
test('show with viewedLeafCount > leafCount is still watched (defensive)', () { test('show with viewedLeafCount > leafCount is still watched (defensive)', () {
final show = MediaItem( final show = testMediaItem(
id: 's', id: 's',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -74,7 +75,13 @@ void main() {
}); });
test('show with no leaf info falls back to viewCount', () { test('show with no leaf info falls back to viewCount', () {
final show = MediaItem(id: 's', backend: MediaBackend.plex, kind: MediaKind.show, viewCount: 1, serverId: 's1'); final show = testMediaItem(
id: 's',
backend: MediaBackend.plex,
kind: MediaKind.show,
viewCount: 1,
serverId: 's1',
);
expect(show.isWatched, isTrue); expect(show.isWatched, isTrue);
}); });
}); });
@@ -102,7 +109,7 @@ void main() {
}); });
test('episodes prefer show art before episode art for wide hero containers', () { test('episodes prefer show art before episode art for wide hero containers', () {
final episode = MediaItem( final episode = testMediaItem(
id: 'e1', id: 'e1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -122,7 +129,7 @@ void main() {
group('MediaItem.isPartiallyWatched', () { group('MediaItem.isPartiallyWatched', () {
test('show with some leaves watched is partially watched', () { test('show with some leaves watched is partially watched', () {
final show = MediaItem( final show = testMediaItem(
id: 's', id: 's',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -134,7 +141,7 @@ void main() {
}); });
test('show with zero leaves watched is NOT partially watched', () { test('show with zero leaves watched is NOT partially watched', () {
final show = MediaItem( final show = testMediaItem(
id: 's', id: 's',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -146,7 +153,7 @@ void main() {
}); });
test('show with all leaves watched is NOT partially watched', () { test('show with all leaves watched is NOT partially watched', () {
final show = MediaItem( final show = testMediaItem(
id: 's', id: 's',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -210,7 +217,7 @@ void main() {
}); });
test('preserves Plex-only fields when omitted', () { test('preserves Plex-only fields when omitted', () {
const original = PlexMediaItem( const original = PlextestMediaItem(
id: 'p1', id: 'p1',
kind: MediaKind.movie, kind: MediaKind.movie,
title: 'Old', title: 'Old',
@@ -244,7 +251,7 @@ void main() {
}); });
test('preserves Jellyfin playlist item id when omitted', () { test('preserves Jellyfin playlist item id when omitted', () {
const original = JellyfinMediaItem( const original = JellyfintestMediaItem(
id: 'j1', id: 'j1',
kind: MediaKind.movie, kind: MediaKind.movie,
title: 'Old', title: 'Old',
@@ -270,7 +277,7 @@ void main() {
group('MediaItem JSON', () { group('MediaItem JSON', () {
test('round-trips Plex-only fields', () { test('round-trips Plex-only fields', () {
const original = PlexMediaItem( const original = PlextestMediaItem(
id: 'p1', id: 'p1',
kind: MediaKind.movie, kind: MediaKind.movie,
title: 'Movie', title: 'Movie',
@@ -321,7 +328,12 @@ void main() {
}); });
test('round-trips Jellyfin playlist item id', () { test('round-trips Jellyfin playlist item id', () {
const original = JellyfinMediaItem(id: 'j1', kind: MediaKind.movie, title: 'Movie', playlistItemId: 'entry-1'); const original = JellyfintestMediaItem(
id: 'j1',
kind: MediaKind.movie,
title: 'Movie',
playlistItemId: 'entry-1',
);
final json = original.toJson(); final json = original.toJson();
final decoded = MediaItem.fromJson(json); final decoded = MediaItem.fromJson(json);
@@ -343,7 +355,7 @@ void main() {
group('MediaItem.displayTitle', () { group('MediaItem.displayTitle', () {
test('episode prefers grandparent (show) title', () { test('episode prefers grandparent (show) title', () {
final ep = MediaItem( final ep = testMediaItem(
id: 'e1', id: 'e1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -357,7 +369,7 @@ void main() {
}); });
test('season prefers grandparent over parent (when both present)', () { test('season prefers grandparent over parent (when both present)', () {
final season = MediaItem( final season = testMediaItem(
id: 'sn1', id: 'sn1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
+67
View File
@@ -0,0 +1,67 @@
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/stepped_seek.dart';
void main() {
test('stepped multiplier preserves shared acceleration tiers', () {
expect(steppedSeekMultiplier(0), 1.5);
expect(steppedSeekMultiplier(5), 1.5);
expect(steppedSeekMultiplier(6), 3.0);
expect(steppedSeekMultiplier(15), 3.0);
expect(steppedSeekMultiplier(16), 6.0);
expect(steppedSeekMultiplier(30), 6.0);
expect(steppedSeekMultiplier(31), 10.0);
});
test('rapid steps accumulate and debounce into one seek', () {
fakeAsync((async) {
var position = const Duration(seconds: 20);
final seeks = <Duration>[];
final accumulator = DebouncedSeekAccumulator(
currentPosition: () => position,
duration: () => const Duration(minutes: 2),
seek: seeks.add,
);
accumulator.seekBy(const Duration(seconds: 10));
accumulator.seekBy(const Duration(seconds: 15));
accumulator.seekBy(const Duration(seconds: -5));
expect(accumulator.pendingPosition, const Duration(seconds: 40));
async.elapse(const Duration(milliseconds: 799));
expect(seeks, isEmpty);
async.elapse(const Duration(milliseconds: 1));
expect(seeks, [const Duration(seconds: 40)]);
// A slow player still reports the old position. The next burst must use
// the pinned target rather than silently dropping the committed seek.
accumulator.seekBy(const Duration(seconds: 10));
accumulator.flush();
expect(seeks, [const Duration(seconds: 40), const Duration(seconds: 50)]);
position = const Duration(seconds: 50);
async.elapse(const Duration(seconds: 2));
expect(accumulator.pendingPosition, isNull);
accumulator.dispose();
});
});
test('clamps targets and cancel prevents a pending seek', () {
fakeAsync((async) {
final seeks = <Duration>[];
final accumulator = DebouncedSeekAccumulator(
currentPosition: () => const Duration(seconds: 5),
duration: () => const Duration(seconds: 30),
seek: seeks.add,
);
accumulator.seekBy(const Duration(minutes: 1));
expect(accumulator.pendingPosition, const Duration(seconds: 30));
accumulator.cancel();
async.elapse(const Duration(seconds: 1));
expect(seeks, isEmpty);
expect(accumulator.pendingPosition, isNull);
accumulator.dispose();
});
});
}
@@ -11,6 +11,7 @@ import 'package:plezy/metadata_edit/jellyfin_metadata_edit_adapter.dart';
import 'package:plezy/metadata_edit/metadata_edit_models.dart'; import 'package:plezy/metadata_edit/metadata_edit_models.dart';
import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/utils/media_image_helper.dart'; import 'package:plezy/utils/media_image_helper.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
test('load fails when the full editable Jellyfin DTO is unavailable', () async { test('load fails when the full editable Jellyfin DTO is unavailable', () async {
@@ -21,7 +22,7 @@ void main() {
addTearDown(client.close); addTearDown(client.close);
final adapter = JellyfinMetadataEditAdapter(client); final adapter = JellyfinMetadataEditAdapter(client);
final item = MediaItem( final item = testMediaItem(
id: 'item-1', id: 'item-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -40,7 +41,7 @@ void main() {
final adapter = JellyfinMetadataEditAdapter(client); final adapter = JellyfinMetadataEditAdapter(client);
MetadataArtworkConfig posterConfig(MediaKind kind) { MetadataArtworkConfig posterConfig(MediaKind kind) {
final item = MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: kind); final item = testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: kind);
final draft = MetadataEditDraft(sourceItem: item, currentItem: item, values: {}); final draft = MetadataEditDraft(sourceItem: item, currentItem: item, values: {});
final artwork = adapter.buildSchema(draft).singleWhere((section) => section.id == 'artwork'); final artwork = adapter.buildSchema(draft).singleWhere((section) => section.id == 'artwork');
return artwork.fields.singleWhere((field) => field.id == 'artwork:Primary').artwork!; return artwork.fields.singleWhere((field) => field.id == 'artwork:Primary').artwork!;
@@ -74,7 +75,7 @@ void main() {
addTearDown(client.close); addTearDown(client.close);
final adapter = JellyfinMetadataEditAdapter(client); final adapter = JellyfinMetadataEditAdapter(client);
final item = MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie); final item = testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie);
final draft = await adapter.load(item); final draft = await adapter.load(item);
draft.setValue('director', ['Alice', 'Charlie']); draft.setValue('director', ['Alice', 'Charlie']);
@@ -4,10 +4,11 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/metadata_edit/metadata_edit_models.dart'; import 'package:plezy/metadata_edit/metadata_edit_models.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
test('adapter dirty tracking ignores immediate fields', () { test('adapter dirty tracking ignores immediate fields', () {
final item = MediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.movie); final item = testMediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.movie);
final adapter = _TestMetadataEditAdapter(); final adapter = _TestMetadataEditAdapter();
final draft = MetadataEditDraft( final draft = MetadataEditDraft(
sourceItem: item, sourceItem: item,
@@ -23,7 +24,7 @@ void main() {
}); });
test('adapter dirty tracking compares string lists as sets', () { test('adapter dirty tracking compares string lists as sets', () {
final item = MediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.movie); final item = testMediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.movie);
final adapter = _TestMetadataEditAdapter(); final adapter = _TestMetadataEditAdapter();
final draft = MetadataEditDraft( final draft = MetadataEditDraft(
sourceItem: item, sourceItem: item,
+2 -1
View File
@@ -4,6 +4,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/mixins/item_updatable.dart'; import 'package:plezy/mixins/item_updatable.dart';
import '../test_helpers/media_items.dart';
/// Probe that mixes in [ItemUpdatable]. These tests exercise the /// Probe that mixes in [ItemUpdatable]. These tests exercise the
/// `updateItemInLists` contract directly — the override-point screens /// `updateItemInLists` contract directly — the override-point screens
@@ -46,7 +47,7 @@ class _ProbeState extends State<_Probe> with ItemUpdatable {
} }
MediaItem _meta(String id, {String? title}) => MediaItem _meta(String id, {String? title}) =>
MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, title: title); testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, title: title);
void main() { void main() {
group('ItemUpdatable', () { group('ItemUpdatable', () {
+2 -1
View File
@@ -9,6 +9,7 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/mixins/paginated_item_loader.dart'; import 'package:plezy/mixins/paginated_item_loader.dart';
import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart';
import '../test_helpers/media_items.dart';
/// Test probe wired with a controllable `fetchPage` so individual tests can /// Test probe wired with a controllable `fetchPage` so individual tests can
/// stage successes, failures, and slow responses. /// stage successes, failures, and slow responses.
@@ -58,7 +59,7 @@ class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoad
Widget build(BuildContext context) => const SizedBox.shrink(); Widget build(BuildContext context) => const SizedBox.shrink();
} }
MediaItem _meta(int i) => MediaItem(id: 'k$i', backend: MediaBackend.plex, kind: MediaKind.movie, title: 't$i'); MediaItem _meta(int i) => testMediaItem(id: 'k$i', backend: MediaBackend.plex, kind: MediaKind.movie, title: 't$i');
LibraryPage<MediaItem> _result({required int start, required int size, required int totalSize}) { LibraryPage<MediaItem> _result({required int start, required int size, required int totalSize}) {
return LibraryPage<MediaItem>( return LibraryPage<MediaItem>(
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/mixins/server_bound_media_mixin.dart'; import 'package:plezy/mixins/server_bound_media_mixin.dart';
import '../test_helpers/media_items.dart';
/// Probe widget exposing the mixin's surface so tests can read its getters /// Probe widget exposing the mixin's surface so tests can read its getters
/// and call its helpers against a real BuildContext. /// and call its helpers against a real BuildContext.
@@ -38,7 +39,7 @@ class _ProbeState extends State<_Probe> with ServerBoundMediaMixin<_Probe> {
} }
MediaItem _meta({ServerId? serverId, String ratingKey = 'rk1'}) => MediaItem _meta({ServerId? serverId, String ratingKey = 'rk1'}) =>
MediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.movie, serverId: serverId); testMediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.movie, serverId: serverId);
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/livetv_dvr.dart';
import 'package:plezy/models/livetv_lineup.dart';
import 'package:plezy/models/media_grabber_device.dart';
import 'package:plezy/models/media_provider_info.dart';
import 'package:plezy/models/media_subscription.dart';
void main() {
test('Live TV collection models skip malformed entries and keep valid siblings', () {
final dvr = LiveTvDvr.fromJson({
'key': 'dvr-1',
'ChannelMapping': [
{'channelKey': 7},
{'channelKey': 'channel-1'},
],
'Setting': [
{'id': 7},
{'id': 'setting-1'},
],
'Device': [
'invalid',
{'uuid': 'device-1'},
],
});
expect(dvr.channelMappings.map((entry) => entry.channelKey), ['channel-1']);
expect(dvr.settings.map((entry) => entry.id), ['setting-1']);
expect(dvr.devices, [
{'uuid': 'device-1'},
]);
final grabber = MediaGrabberDevice.fromJson({
'key': 'device-1',
'uuid': 'device-1',
'ChannelMapping': [
{'channelKey': 7},
{'channelKey': 'channel-1'},
],
'Setting': [
{'id': 7},
{'id': 'setting-1'},
],
});
expect(grabber.channelMappings.map((entry) => entry.channelKey), ['channel-1']);
expect(grabber.settings.map((entry) => entry.id), ['setting-1']);
final lineup = LiveTvLineup.fromJson({
'uuid': 'lineup-1',
'Channel': [
{'callSign': 7},
{'key': 'channel-1', 'callSign': 'ONE'},
],
});
expect(lineup.channels.map((entry) => entry.callSign), ['ONE']);
final provider = MediaProviderInfo.fromJson({
'identifier': 'provider-1',
'Feature': [
{'type': 7},
{
'type': 'livetv',
'Directory': [
'invalid',
{'key': 'guide'},
],
},
],
});
expect(provider.features.map((entry) => entry.type), ['livetv']);
expect(provider.features.single.directories, [
{'key': 'guide'},
]);
final template = SubscriptionTemplate.fromJson({
'MediaSubscription': [
{'title': 7},
{'key': 'subscription-1', 'title': 'Recordings'},
],
});
expect(template.subscriptions.map((entry) => entry.key), ['subscription-1']);
});
}
+2 -1
View File
@@ -20,6 +20,7 @@ import 'package:plezy/utils/deletion_notifier.dart';
import 'package:plezy/utils/watch_state_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
MediaItem _item( MediaItem _item(
String id, { String id, {
@@ -27,7 +28,7 @@ MediaItem _item(
String? grandparentId, String? grandparentId,
MediaKind kind = MediaKind.episode, MediaKind kind = MediaKind.episode,
String serverId = 'server_1', String serverId = 'server_1',
}) => MediaItem( }) => testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: kind, kind: kind,
+24 -24
View File
@@ -17,10 +17,10 @@ import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/utils/watch_state_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/media_items.dart';
/// Implements only [fetchPlayableDescendants] (the surface queueDownload /// Implements only [fetchPlayableDescendants], the surface [collectEpisodes]
/// reaches via [collectEpisodesForShow] / [collectEpisodesForSeason]); /// uses. Every other call reaches [noSuchMethod] and throws.
/// every other call falls through to noSuchMethod and trips a NoSuchMethodError.
class _ThrowingClient implements MediaServerClient { class _ThrowingClient implements MediaServerClient {
@override @override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async { Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
@@ -389,7 +389,7 @@ void main() {
// Collection rule with stashed metadata (the "no underlying episode // Collection rule with stashed metadata (the "no underlying episode
// download to populate _metadata" case from createSyncRule's docs). // download to populate _metadata" case from createSyncRule's docs).
final target = MediaItem( final target = testMediaItem(
id: '20', id: '20',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.collection, kind: MediaKind.collection,
@@ -415,7 +415,7 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized(); await p.ensureInitialized();
final target = MediaItem( final target = testMediaItem(
id: '30', id: '30',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -493,7 +493,7 @@ void main() {
}); });
group('DownloadProvider — profile-scoped download ownership', () { group('DownloadProvider — profile-scoped download ownership', () {
final movie = MediaItem( final movie = testMediaItem(
id: '1', id: '1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -591,14 +591,14 @@ void main() {
}); });
test('queueDownload expands an album into its tracks via fetchPlayableDescendants', () async { test('queueDownload expands an album into its tracks via fetchPlayableDescendants', () async {
final album = MediaItem( final album = testMediaItem(
id: 'album-1', id: 'album-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.album, kind: MediaKind.album,
title: 'Album', title: 'Album',
serverId: ServerId('srv'), serverId: ServerId('srv'),
); );
MediaItem track(String id) => MediaItem( MediaItem track(String id) => testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.track, kind: MediaKind.track,
@@ -631,7 +631,7 @@ void main() {
}); });
test('album aggregates, downloadedAlbums, and per-album track order come from track downloads', () async { test('album aggregates, downloadedAlbums, and per-album track order come from track downloads', () async {
MediaItem track(String id, {required int disc, required int number}) => MediaItem( MediaItem track(String id, {required int disc, required int number}) => testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.track, kind: MediaKind.track,
@@ -656,7 +656,7 @@ void main() {
metadata: { metadata: {
'srv:t1': track('t1', disc: 2, number: 1), 'srv:t1': track('t1', disc: 2, number: 1),
'srv:t2': track('t2', disc: 1, number: 2), 'srv:t2': track('t2', disc: 1, number: 2),
'srv:album-1': MediaItem( 'srv:album-1': testMediaItem(
id: 'album-1', id: 'album-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.album, kind: MediaKind.album,
@@ -1003,7 +1003,7 @@ void main() {
fetchItemHandler: (id) async { fetchItemHandler: (id) async {
fetchStarted.complete(); fetchStarted.complete();
await releaseFetch.future; await releaseFetch.future;
return MediaItem( return testMediaItem(
id: id, id: id,
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -1035,7 +1035,7 @@ void main() {
activeClient = _ScopedTestClient( activeClient = _ScopedTestClient(
serverId: ServerId('jf-machine'), serverId: ServerId('jf-machine'),
scopedServerId: 'jf-machine/user-b', scopedServerId: 'jf-machine/user-b',
fetchItemHandler: (id) async => MediaItem( fetchItemHandler: (id) async => testMediaItem(
id: id, id: id,
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -1067,7 +1067,7 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized(); await p.ensureInitialized();
final item = MediaItem( final item = testMediaItem(
id: '42', id: '42',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -1093,7 +1093,7 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized(); await p.ensureInitialized();
final item = MediaItem( final item = testMediaItem(
id: '42', id: '42',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -1119,7 +1119,7 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized(); await p.ensureInitialized();
final show = MediaItem( final show = testMediaItem(
id: 'show-1', id: 'show-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -1127,7 +1127,7 @@ void main() {
leafCount: 3, leafCount: 3,
viewedLeafCount: 0, viewedLeafCount: 0,
); );
final season1 = MediaItem( final season1 = testMediaItem(
id: 'season-1', id: 'season-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
@@ -1136,7 +1136,7 @@ void main() {
leafCount: 2, leafCount: 2,
viewedLeafCount: 0, viewedLeafCount: 0,
); );
final episode1 = MediaItem( final episode1 = testMediaItem(
id: 'episode-1', id: 'episode-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -1199,7 +1199,7 @@ void main() {
actionType: 'unwatched', actionType: 'unwatched',
); );
final episode1 = MediaItem( final episode1 = testMediaItem(
id: 'episode-1', id: 'episode-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -1249,7 +1249,7 @@ void main() {
ratingKey: 'show-1', ratingKey: 'show-1',
actionType: 'unwatched', actionType: 'unwatched',
); );
final episode = MediaItem( final episode = testMediaItem(
id: 'episode-1', id: 'episode-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -1291,7 +1291,7 @@ void main() {
ratingKey: 'show-1', ratingKey: 'show-1',
actionType: 'unwatched', actionType: 'unwatched',
); );
final episode = MediaItem( final episode = testMediaItem(
id: 'episode-1', id: 'episode-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -1337,7 +1337,7 @@ void main() {
p.debugSeedState( p.debugSeedState(
downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.queued)}, downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.queued)},
metadata: { metadata: {
key: MediaItem( key: testMediaItem(
id: '42', id: '42',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -1410,7 +1410,7 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized(); await p.ensureInitialized();
final season = MediaItem( final season = testMediaItem(
id: '7', id: '7',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
@@ -1433,7 +1433,7 @@ void main() {
// Pre-existing metadata under the same key (e.g. from a prior sync rule's // Pre-existing metadata under the same key (e.g. from a prior sync rule's
// targetMetadata). The rollback must not delete it on queue failure. // targetMetadata). The rollback must not delete it on queue failure.
final preexisting = MediaItem( final preexisting = testMediaItem(
id: '7', id: '7',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
@@ -1442,7 +1442,7 @@ void main() {
); );
p.debugSeedState(metadata: {'srv:7': preexisting}); p.debugSeedState(metadata: {'srv:7': preexisting});
final season = MediaItem( final season = testMediaItem(
id: '7', id: '7',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
@@ -7,8 +7,9 @@ import 'package:plezy/media/media_version.dart';
import 'package:plezy/media/play_queue.dart'; import 'package:plezy/media/play_queue.dart';
import 'package:plezy/models/plex/play_queue_response.dart'; import 'package:plezy/models/plex/play_queue_response.dart';
import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart';
import '../test_helpers/media_items.dart';
PlexMediaItem _item(String ratingKey, int playQueueItemID) => PlexMediaItem( PlexMediaItem _item(String ratingKey, int playQueueItemID) => PlextestMediaItem(
id: ratingKey, id: ratingKey,
kind: MediaKind.episode, kind: MediaKind.episode,
playQueueItemId: playQueueItemID, playQueueItemId: playQueueItemID,
@@ -18,7 +19,7 @@ PlexMediaItem _item(String ratingKey, int playQueueItemID) => PlexMediaItem(
/// Episode queue entry carrying file identity, as Plex play-queue items do. /// Episode queue entry carrying file identity, as Plex play-queue items do.
/// Episodes of a multi-episode file (`S02E24-E25.mkv`) get *distinct* part /// Episodes of a multi-episode file (`S02E24-E25.mkv`) get *distinct* part
/// ids (`part-<ratingKey>` here, mirroring real servers) but share [file]. /// ids (`part-<ratingKey>` here, mirroring real servers) but share [file].
PlexMediaItem _itemWithFile(String ratingKey, int playQueueItemID, String file) => PlexMediaItem( PlexMediaItem _itemWithFile(String ratingKey, int playQueueItemID, String file) => PlextestMediaItem(
id: ratingKey, id: ratingKey,
kind: MediaKind.episode, kind: MediaKind.episode,
playQueueItemId: playQueueItemID, playQueueItemId: playQueueItemID,
@@ -32,7 +33,7 @@ PlexMediaItem _itemWithFile(String ratingKey, int playQueueItemID, String file)
); );
PlexMediaItem _miItem(String id, int playQueueItemId) => PlexMediaItem _miItem(String id, int playQueueItemId) =>
PlexMediaItem(id: id, kind: MediaKind.episode, playQueueItemId: playQueueItemId); PlextestMediaItem(id: id, kind: MediaKind.episode, playQueueItemId: playQueueItemId);
PlayQueueResponse _queue({ PlayQueueResponse _queue({
int playQueueID = 1, int playQueueID = 1,
@@ -155,7 +156,7 @@ void main() {
expect(notified, preNotify + 1); expect(notified, preNotify + 1);
// Item without playQueueItemId → no update, no notify // Item without playQueueItemId → no update, no notify
p.setCurrentItem(MediaItem(id: 'd', backend: MediaBackend.plex, kind: MediaKind.episode)); p.setCurrentItem(testMediaItem(id: 'd', backend: MediaBackend.plex, kind: MediaKind.episode));
expect(p.currentPlayQueueItemID, 2002); expect(p.currentPlayQueueItemID, 2002);
p.dispose(); p.dispose();
@@ -269,9 +270,9 @@ void main() {
final p = PlaybackStateProvider(); final p = PlaybackStateProvider();
addTearDown(p.dispose); addTearDown(p.dispose);
final ep1 = MediaItem(id: 'ep1', backend: MediaBackend.jellyfin, kind: MediaKind.episode); final ep1 = testMediaItem(id: 'ep1', backend: MediaBackend.jellyfin, kind: MediaKind.episode);
final ep2 = MediaItem(id: 'ep2', backend: MediaBackend.jellyfin, kind: MediaKind.episode); final ep2 = testMediaItem(id: 'ep2', backend: MediaBackend.jellyfin, kind: MediaKind.episode);
final outsider = MediaItem(id: 'ep-other', backend: MediaBackend.jellyfin, kind: MediaKind.episode); final outsider = testMediaItem(id: 'ep-other', backend: MediaBackend.jellyfin, kind: MediaKind.episode);
p.setPlaybackFromLocalQueue( p.setPlaybackFromLocalQueue(
LocalPlayQueue(id: 'jellyfin:playlist-X', items: [ep1, ep2], currentIndex: 0, backendId: 'jellyfin'), LocalPlayQueue(id: 'jellyfin:playlist-X', items: [ep1, ep2], currentIndex: 0, backendId: 'jellyfin'),
@@ -298,7 +299,7 @@ void main() {
// A real-world non-queue item (e.g. tapped from media detail) carries // A real-world non-queue item (e.g. tapped from media detail) carries
// no `playQueueItemId` — that's how the helper distinguishes it from // no `playQueueItemId` — that's how the helper distinguishes it from
// a launcher-seeded queue member. // a launcher-seeded queue member.
final outsider = PlexMediaItem(id: 'ep-different-show', kind: MediaKind.episode); final outsider = PlextestMediaItem(id: 'ep-different-show', kind: MediaKind.episode);
await p.setPlaybackFromPlayQueue( await p.setPlaybackFromPlayQueue(
_queue( _queue(
+3 -2
View File
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/watch_state_store.dart'; import 'package:plezy/providers/watch_state_store.dart';
import 'package:plezy/utils/watch_state_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/media_items.dart';
Future<void> _emit(WatchStateEvent event) async { Future<void> _emit(WatchStateEvent event) async {
WatchStateNotifier().notify(event); WatchStateNotifier().notify(event);
@@ -33,7 +34,7 @@ WatchStateEvent _event({
); );
} }
final _episode = MediaItem( final _episode = testMediaItem(
id: 'episode-1', id: 'episode-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -144,7 +145,7 @@ void main() {
await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true, itemId: 'season-1')); await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true, itemId: 'season-1'));
final season = MediaItem( final season = testMediaItem(
id: 'season-1', id: 'season-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
+3 -2
View File
@@ -45,6 +45,7 @@ import 'package:plezy/widgets/tv_spotlight_background.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -70,7 +71,7 @@ void main() {
tester.view.resetPhysicalSize(); tester.view.resetPhysicalSize();
}); });
final item = MediaItem( final item = testMediaItem(
id: 'movie_1', id: 'movie_1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -249,7 +250,7 @@ void main() {
final onDeck = [ final onDeck = [
for (var i = 0; i < 3; i++) for (var i = 0; i < 3; i++)
MediaItem( testMediaItem(
id: 'movie_$i', id: 'movie_$i',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -26,6 +26,7 @@ import 'package:plezy/services/plex_auth_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart'; import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
PlexConnection _plexConnection() { PlexConnection _plexConnection() {
return PlexConnection( return PlexConnection(
@@ -75,7 +76,13 @@ JellyfinClient _jellyfinClient(JellyfinConnection connection) {
} }
MediaItem _show(ServerId serverId, String ratingKey, String title) { MediaItem _show(ServerId serverId, String ratingKey, String title) {
return MediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.show, title: title, serverId: serverId); return testMediaItem(
id: ratingKey,
backend: MediaBackend.plex,
kind: MediaKind.show,
title: title,
serverId: serverId,
);
} }
class _FakeConnectionRegistry extends ConnectionRegistry { class _FakeConnectionRegistry extends ConnectionRegistry {
+2 -1
View File
@@ -20,6 +20,7 @@ import 'package:provider/provider.dart';
import '../test_helpers/paged_fakes.dart'; import '../test_helpers/paged_fakes.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -96,7 +97,7 @@ void main() {
}); });
} }
MediaItem _item(int index, {required MediaBackend backend, String? libraryId}) => MediaItem( MediaItem _item(int index, {required MediaBackend backend, String? libraryId}) => testMediaItem(
id: 'item_$index', id: 'item_$index',
backend: backend, backend: backend,
kind: MediaKind.movie, kind: MediaKind.movie,
+28 -27
View File
@@ -42,6 +42,7 @@ import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/profile_navigation.dart'; import '../test_helpers/profile_navigation.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -65,7 +66,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
const title = 'The Surprisingly Long Movie Title That Needs Two Whole Lines'; const title = 'The Surprisingly Long Movie Title That Needs Two Whole Lines';
final movie = MediaItem( final movie = testMediaItem(
id: 'movie_1', id: 'movie_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -99,7 +100,7 @@ void main() {
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
final movie = MediaItem( final movie = testMediaItem(
id: 'semantic_movie', id: 'semantic_movie',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -141,7 +142,7 @@ void main() {
testWidgets('TV detail reveals without waiting for directional input', (tester) async { testWidgets('TV detail reveals without waiting for directional input', (tester) async {
await SettingsService.getInstance(); await SettingsService.getInstance();
final movie = MediaItem( final movie = testMediaItem(
id: 'movie_1', id: 'movie_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -240,7 +241,7 @@ void main() {
testWidgets('TV detail defaults to first regular season when specials precede it', (tester) async { testWidgets('TV detail defaults to first regular season when specials precede it', (tester) async {
await SettingsService.getInstance(); await SettingsService.getInstance();
final show = MediaItem( final show = testMediaItem(
id: 'show_1', id: 'show_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.show, kind: MediaKind.show,
@@ -248,7 +249,7 @@ void main() {
serverId: 'server_1', serverId: 'server_1',
serverName: 'Server', serverName: 'Server',
); );
final specials = MediaItem( final specials = testMediaItem(
id: 'season_0', id: 'season_0',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -258,7 +259,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final season1 = MediaItem( final season1 = testMediaItem(
id: 'season_1', id: 'season_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -268,7 +269,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final specialEpisode = MediaItem( final specialEpisode = testMediaItem(
id: 'episode_special_1', id: 'episode_special_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -280,7 +281,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final episode1 = MediaItem( final episode1 = testMediaItem(
id: 'episode_1', id: 'episode_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -339,7 +340,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
const summary = 'Light theme detail text should stay readable.'; const summary = 'Light theme detail text should stay readable.';
final movie = MediaItem( final movie = testMediaItem(
id: 'movie_1', id: 'movie_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -368,7 +369,7 @@ void main() {
testWidgets('TV detail shows every season tab and prefetches adjacent first page', (tester) async { testWidgets('TV detail shows every season tab and prefetches adjacent first page', (tester) async {
await SettingsService.getInstance(); await SettingsService.getInstance();
final show = MediaItem( final show = testMediaItem(
id: 'show_1', id: 'show_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.show, kind: MediaKind.show,
@@ -376,7 +377,7 @@ void main() {
serverId: 'server_1', serverId: 'server_1',
serverName: 'Server', serverName: 'Server',
); );
final season1 = MediaItem( final season1 = testMediaItem(
id: 'season_1', id: 'season_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -386,7 +387,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final season2 = MediaItem( final season2 = testMediaItem(
id: 'season_2', id: 'season_2',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -396,7 +397,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final episode1 = MediaItem( final episode1 = testMediaItem(
id: 'episode_1', id: 'episode_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -408,7 +409,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final episode2 = MediaItem( final episode2 = testMediaItem(
id: 'episode_2', id: 'episode_2',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -464,7 +465,7 @@ void main() {
testWidgets('TV detail keeps every season tab when a season episode load fails', (tester) async { testWidgets('TV detail keeps every season tab when a season episode load fails', (tester) async {
await SettingsService.getInstance(); await SettingsService.getInstance();
final show = MediaItem( final show = testMediaItem(
id: 'show_1', id: 'show_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.show, kind: MediaKind.show,
@@ -472,7 +473,7 @@ void main() {
serverId: 'server_1', serverId: 'server_1',
serverName: 'Server', serverName: 'Server',
); );
final season1 = MediaItem( final season1 = testMediaItem(
id: 'season_1', id: 'season_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -482,7 +483,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final season2 = MediaItem( final season2 = testMediaItem(
id: 'season_2', id: 'season_2',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -492,7 +493,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final episode1 = MediaItem( final episode1 = testMediaItem(
id: 'episode_1', id: 'episode_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -504,7 +505,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final episode2 = MediaItem( final episode2 = testMediaItem(
id: 'episode_2', id: 'episode_2',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -556,7 +557,7 @@ void main() {
testWidgets('TV detail completes adjacent prefetch after focus moves to that season', (tester) async { testWidgets('TV detail completes adjacent prefetch after focus moves to that season', (tester) async {
await SettingsService.getInstance(); await SettingsService.getInstance();
final show = MediaItem( final show = testMediaItem(
id: 'show_1', id: 'show_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.show, kind: MediaKind.show,
@@ -564,7 +565,7 @@ void main() {
serverId: 'server_1', serverId: 'server_1',
serverName: 'Server', serverName: 'Server',
); );
final season1 = MediaItem( final season1 = testMediaItem(
id: 'season_1', id: 'season_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -574,7 +575,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final season2 = MediaItem( final season2 = testMediaItem(
id: 'season_2', id: 'season_2',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -584,7 +585,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final episode1 = MediaItem( final episode1 = testMediaItem(
id: 'episode_1', id: 'episode_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -596,7 +597,7 @@ void main() {
serverId: show.serverId, serverId: show.serverId,
serverName: show.serverName, serverName: show.serverName,
); );
final episode2 = MediaItem( final episode2 = testMediaItem(
id: 'episode_2', id: 'episode_2',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -656,7 +657,7 @@ void main() {
}); });
group('watch state freshness (phone layout)', () { group('watch state freshness (phone layout)', () {
MediaItem buildShow() => MediaItem( MediaItem buildShow() => testMediaItem(
id: 'show_1', id: 'show_1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.show, kind: MediaKind.show,
@@ -667,7 +668,7 @@ void main() {
serverName: 'Server', serverName: 'Server',
); );
MediaItem buildSeason(MediaItem show, int index) => MediaItem( MediaItem buildSeason(MediaItem show, int index) => testMediaItem(
id: 'season_$index', id: 'season_$index',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -680,7 +681,7 @@ void main() {
serverName: show.serverName, serverName: show.serverName,
); );
MediaItem buildEpisode(MediaItem show, MediaItem season, int index) => MediaItem( MediaItem buildEpisode(MediaItem show, MediaItem season, int index) => testMediaItem(
id: '${season.id}_episode_$index', id: '${season.id}_episode_$index',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -18,6 +18,7 @@ import 'package:plezy/widgets/music/track_row.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart'; import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -79,7 +80,7 @@ const _album = MediaItem.plex(
List<MediaItem> _multiDiscTracks() { List<MediaItem> _multiDiscTracks() {
MediaItem track({required String id, required String title, required int disc, required int number}) { MediaItem track({required String id, required String title, required int disc, required int number}) {
return MediaItem( return testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.track, kind: MediaKind.track,
+2 -1
View File
@@ -15,8 +15,9 @@ import 'package:plezy/widgets/music/track_row.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart'; import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
MediaItem _track(String id, String title) => MediaItem( MediaItem _track(String id, String title) => testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.track, kind: MediaKind.track,
@@ -29,6 +29,7 @@ import 'package:plezy/utils/media_server_http_client.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -42,7 +43,7 @@ void main() {
testWidgets('loads playlist continuation pages from an unmodifiable first page', (tester) async { testWidgets('loads playlist continuation pages from an unmodifiable first page', (tester) async {
final items = List.generate( final items = List.generate(
playlistItemsPageSize + 5, playlistItemsPageSize + 5,
(index) => MediaItem( (index) => testMediaItem(
id: 'item_$index', id: 'item_$index',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -178,7 +179,7 @@ const _playlist = MediaPlaylist(
List<MediaItem> _mediaItems(int count) { List<MediaItem> _mediaItems(int count) {
return List.generate( return List.generate(
count, count,
(index) => MediaItem( (index) => testMediaItem(
id: 'item_$index', id: 'item_$index',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
+2 -1
View File
@@ -20,6 +20,7 @@ import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -216,7 +217,7 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
items: items:
items ?? items ??
[ [
MediaItem( testMediaItem(
id: 'movie_1', id: 'movie_1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -10,6 +10,7 @@ import 'package:plezy/screens/video_player/widgets/player_prompt_overlays.dart';
import 'package:plezy/services/pip_service.dart'; import 'package:plezy/services/pip_service.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart'; import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
void main() { void main() {
testWidgets('play next prompt tracks chrome visibility for vertical position', (tester) async { testWidgets('play next prompt tracks chrome visibility for vertical position', (tester) async {
@@ -163,7 +164,7 @@ AnimatedPositioned _promptPosition(WidgetTester tester) {
} }
MediaItem _episode() { MediaItem _episode() {
return MediaItem( return testMediaItem(
id: 'episode-2', id: 'episode-2',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -10,6 +10,7 @@ import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/trakt_catalog_source.dart'; import 'package:plezy/services/catalog/trakt_catalog_source.dart';
import 'package:plezy/services/trackers/tracker_session.dart'; import 'package:plezy/services/trackers/tracker_session.dart';
import 'package:plezy/services/trakt/trakt_client.dart'; import 'package:plezy/services/trakt/trakt_client.dart';
import '../../test_helpers/media_items.dart';
TrackerSession _session() { TrackerSession _session() {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
@@ -105,7 +106,7 @@ void main() {
expect(show.rating, 8.5); expect(show.rating, 8.5);
expect(page.items[0].airStatus, isNull); expect(page.items[0].airStatus, isNull);
final rendered = page.items[0].toMediaItem(); final rendered = page.items[0].totestMediaItem();
expect(rendered.serverId, isNull); expect(rendered.serverId, isNull);
expect(rendered.title, 'The Matrix'); expect(rendered.title, 'The Matrix');
expect(rendered.isCatalogItem, isTrue); expect(rendered.isCatalogItem, isTrue);
@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/companion_remote/remote_command.dart';
import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart';
import 'package:plezy/services/companion_remote/remote_auth_context.dart';
void main() {
test('host and remote dispatch encrypted commands through the same contract', () async {
final host = CompanionRemotePeerService();
final remote = CompanionRemotePeerService();
addTearDown(() async {
await remote.dispose();
await host.dispose();
});
final context = RemoteAuthContext(
id: 'context-1',
backend: 'plex',
connectionId: 'connection-1',
homeSecret: List<int>.generate(32, (index) => index),
discoveryKey: List<int>.generate(32, (index) => 255 - index),
clientIdentifier: 'host-client',
userUuid: 'user-1',
allowedUserUuids: const ['user-1'],
);
final session = await host.createSessionForContexts('Test Host', 'macos', [context]);
await remote.joinSessionWithContexts(
'Test Remote',
'ios',
'127.0.0.1:${session.port}',
[context],
authContextId: context.id,
expectedHostClientId: context.clientIdentifier,
);
final hostCommand = host.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.play);
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}));
expect(
await hostCommand.timeout(const Duration(seconds: 5)),
const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}),
);
final remoteCommand = remote.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.pause);
host.sendCommand(const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}));
expect(
await remoteCommand.timeout(const Duration(seconds: 5)),
const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}),
);
});
}
@@ -20,6 +20,7 @@ import 'package:plezy/utils/media_server_http_client.dart';
import '../test_helpers/io_fakes.dart'; import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
class _DelayedCountingHttpClient extends http.BaseClient { class _DelayedCountingHttpClient extends http.BaseClient {
_DelayedCountingHttpClient(this.body); _DelayedCountingHttpClient(this.body);
@@ -54,7 +55,7 @@ void main() {
}); });
test('buildArtworkSpecs includes all standard artwork with sanitized local keys', () { test('buildArtworkSpecs includes all standard artwork with sanitized local keys', () {
final item = MediaItem( final item = testMediaItem(
id: 'item-1', id: 'item-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -29,6 +29,7 @@ import 'package:saf_util/saf_util_platform_interface.dart';
import '../test_helpers/io_fakes.dart'; import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
group('downloadExtensionFromUrl', () { group('downloadExtensionFromUrl', () {
@@ -140,7 +141,7 @@ void main() {
clientResolver: (serverId, {clientScopeId}) => null, clientResolver: (serverId, {clientScopeId}) => null,
); );
final year = await manager.debugResolveSafRecoveryShowYear( final year = await manager.debugResolveSafRecoveryShowYear(
MediaItem( testMediaItem(
id: 'ep-1', id: 'ep-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -245,7 +246,7 @@ void main() {
final client = _ArtworkRepairClient( final client = _ArtworkRepairClient(
serverId: ServerId('srv'), serverId: ServerId('srv'),
items: { items: {
'show-1': MediaItem( 'show-1': testMediaItem(
id: 'show-1', id: 'show-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -562,7 +563,7 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe
JellyfinApiCache.initialize(db); JellyfinApiCache.initialize(db);
final serverId = ServerId('srv'); final serverId = ServerId('srv');
const globalKey = 'srv:episode-1'; const globalKey = 'srv:episode-1';
final episode = MediaItem( final episode = testMediaItem(
id: 'episode-1', id: 'episode-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -674,7 +675,7 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind,
final serverId = ServerId('srv'); final serverId = ServerId('srv');
final id = '${kind.id}-1'; final id = '${kind.id}-1';
final globalKey = 'srv:$id'; final globalKey = 'srv:$id';
final metadata = MediaItem( final metadata = testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: kind, kind: kind,
@@ -913,7 +914,7 @@ DownloadTask _downloadTask(String taskId, String globalKey) {
} }
MediaItem _movie({String? thumbPath}) { MediaItem _movie({String? thumbPath}) {
return MediaItem( return testMediaItem(
id: 'item-1', id: 'item-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -12,6 +12,7 @@ import 'package:plezy/services/settings_service.dart';
import '../test_helpers/io_fakes.dart'; import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
late Directory tmpRoot; late Directory tmpRoot;
@@ -558,7 +559,7 @@ void main() {
// ============================================================ // ============================================================
MediaItem _movie({required String title, int? year}) { MediaItem _movie({required String title, int? year}) {
return MediaItem( return testMediaItem(
id: 'm-${title.hashCode}', id: 'm-${title.hashCode}',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -568,7 +569,7 @@ MediaItem _movie({required String title, int? year}) {
} }
MediaItem _show({required String title, int? year}) { MediaItem _show({required String title, int? year}) {
return MediaItem( return testMediaItem(
id: 's-${title.hashCode}', id: 's-${title.hashCode}',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -578,7 +579,7 @@ MediaItem _show({required String title, int? year}) {
} }
MediaItem _season({required String showTitle, int? showYear, required int seasonNumber}) { MediaItem _season({required String showTitle, int? showYear, required int seasonNumber}) {
return MediaItem( return testMediaItem(
id: 'season-$showTitle-$seasonNumber', id: 'season-$showTitle-$seasonNumber',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
@@ -596,7 +597,7 @@ MediaItem _episode({
required int episodeNumber, required int episodeNumber,
required String episodeTitle, required String episodeTitle,
}) { }) {
return MediaItem( return testMediaItem(
id: 'ep-$showTitle-$seasonNumber-$episodeNumber', id: 'ep-$showTitle-$seasonNumber-$episodeNumber',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -12,6 +12,7 @@ import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/episode_navigation_service.dart'; import 'package:plezy/services/episode_navigation_service.dart';
import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/multi_server_manager.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
// NOTE on coverage scope: // NOTE on coverage scope:
// `EpisodeNavigationService` has two methods: // `EpisodeNavigationService` has two methods:
@@ -31,9 +32,9 @@ import 'package:provider/provider.dart';
// the public surface callers depend on. // the public surface callers depend on.
MediaItem _meta(String id, {String? title}) => MediaItem _meta(String id, {String? title}) =>
MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id'); testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id');
MediaItem _jfEpisode(String id, {required String seriesId, ServerId? serverId}) => MediaItem( MediaItem _jfEpisode(String id, {required String seriesId, ServerId? serverId}) => testMediaItem(
id: id, id: id,
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -12,6 +12,7 @@ import 'package:plezy/services/external_player_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/services/offline_watch_sync_service.dart';
import '../test_helpers/media_items.dart';
class _RecordingClient implements MediaServerClient { class _RecordingClient implements MediaServerClient {
_RecordingClient({this.backend = MediaBackend.plex}); _RecordingClient({this.backend = MediaBackend.plex});
@@ -77,7 +78,7 @@ class _RecordingClient implements MediaServerClient {
} }
MediaItem _item({int? durationMs}) { MediaItem _item({int? durationMs}) {
return MediaItem( return testMediaItem(
id: 'item-1', id: 'item-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
+85 -19
View File
@@ -16,6 +16,7 @@ import 'package:plezy/utils/device_identity.dart';
import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/paged_fakes.dart'; import '../test_helpers/paged_fakes.dart';
import '../test_helpers/media_items.dart';
JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) => JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) =>
testJellyfinConnection( testJellyfinConnection(
@@ -423,7 +424,7 @@ void main() {
addTearDown(scoped.close); addTearDown(scoped.close);
final resolution = await scoped.resolveDownload( final resolution = await scoped.resolveDownload(
MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
mediaIndex: 1, mediaIndex: 1,
); );
@@ -470,7 +471,7 @@ void main() {
addTearDown(scoped.close); addTearDown(scoped.close);
final url = await scoped.resolveExternalPlaybackUrl( final url = await scoped.resolveExternalPlaybackUrl(
MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
mediaIndex: 0, mediaIndex: 0,
mediaSourceId: 'item-1', mediaSourceId: 'item-1',
); );
@@ -535,7 +536,7 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem( metadata: testMediaItem(
id: 'item-1', id: 'item-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -611,7 +612,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
qualityPreset: TranscodeQualityPreset.p720_2mbps, qualityPreset: TranscodeQualityPreset.p720_2mbps,
), ),
@@ -694,7 +700,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
), ),
); );
@@ -795,7 +806,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
), ),
); );
@@ -857,7 +873,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
), ),
); );
@@ -918,7 +939,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
selectedAudioStreamId: 4, selectedAudioStreamId: 4,
), ),
@@ -986,7 +1012,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 1, selectedMediaIndex: 1,
selectedAudioStreamId: 4, selectedAudioStreamId: 4,
), ),
@@ -1048,7 +1079,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
selectedMediaSourceId: 'src-1080', selectedMediaSourceId: 'src-1080',
), ),
@@ -1108,7 +1144,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
selectedMediaSourceId: 'item-1', selectedMediaSourceId: 'item-1',
), ),
@@ -1176,7 +1217,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
selectedMediaSourceId: 'src-1080', selectedMediaSourceId: 'src-1080',
), ),
@@ -1265,7 +1311,7 @@ void main() {
); );
addTearDown(scoped.close); addTearDown(scoped.close);
final item = MediaItem( final item = testMediaItem(
id: 'folder/item #1?x', id: 'folder/item #1?x',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -1303,7 +1349,12 @@ void main() {
); );
addTearDown(scoped.close); addTearDown(scoped.close);
final item = MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'); final item = testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
);
await expectLater(scoped.removeFromContinueWatching(item), throwsA(isA<UnsupportedError>())); await expectLater(scoped.removeFromContinueWatching(item), throwsA(isA<UnsupportedError>()));
expect(requested, isFalse); expect(requested, isFalse);
@@ -1345,7 +1396,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
qualityPreset: TranscodeQualityPreset.p720_2mbps, qualityPreset: TranscodeQualityPreset.p720_2mbps,
), ),
@@ -1386,7 +1442,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
), ),
); );
@@ -1578,7 +1639,12 @@ void main() {
final result = await scoped.getPlaybackInitialization( final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0, selectedMediaIndex: 0,
qualityPreset: TranscodeQualityPreset.p720_2mbps, qualityPreset: TranscodeQualityPreset.p720_2mbps,
), ),
@@ -1883,7 +1949,7 @@ void main() {
addTearDown(scoped.close); addTearDown(scoped.close);
final items = await scoped.fetchFolderChildren( final items = await scoped.fetchFolderChildren(
MediaItem(id: 'folder-1', backend: MediaBackend.jellyfin, kind: MediaKind.folder), testMediaItem(id: 'folder-1', backend: MediaBackend.jellyfin, kind: MediaKind.folder),
onPage: pages.add, onPage: pages.add,
); );
@@ -1925,7 +1991,7 @@ void main() {
addTearDown(scoped.close); addTearDown(scoped.close);
final items = await scoped.fetchFolderChildren( final items = await scoped.fetchFolderChildren(
MediaItem(id: 'season-1', backend: MediaBackend.jellyfin, kind: MediaKind.season), testMediaItem(id: 'season-1', backend: MediaBackend.jellyfin, kind: MediaKind.season),
onPage: pages.add, onPage: pages.add,
); );
@@ -14,6 +14,7 @@ import 'package:plezy/services/playlist_items_loader.dart';
import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_http_client.dart';
import '../test_helpers/paged_fakes.dart'; import '../test_helpers/paged_fakes.dart';
import '../test_helpers/media_items.dart';
/// Recording fake that satisfies [JellyfinClient] via `implements` + /// Recording fake that satisfies [JellyfinClient] via `implements` +
/// `noSuchMethod`. The launcher only needs the /// `noSuchMethod`. The launcher only needs the
@@ -76,7 +77,7 @@ class _RecordingJellyfinClient implements JellyfinClient {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
} }
MediaItem _ep(String id, {ServerId? serverId}) => MediaItem( MediaItem _ep(String id, {ServerId? serverId}) => testMediaItem(
id: id, id: id,
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -84,7 +85,7 @@ MediaItem _ep(String id, {ServerId? serverId}) => MediaItem(
serverId: serverId ?? ServerId('srv-jf'), serverId: serverId ?? ServerId('srv-jf'),
); );
MediaItem _movie(String id, {ServerId? serverId}) => MediaItem( MediaItem _movie(String id, {ServerId? serverId}) => testMediaItem(
id: id, id: id,
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -92,7 +93,7 @@ MediaItem _movie(String id, {ServerId? serverId}) => MediaItem(
serverId: serverId ?? ServerId('srv-jf'), serverId: serverId ?? ServerId('srv-jf'),
); );
MediaItem _clip(String id, {ServerId? serverId}) => MediaItem( MediaItem _clip(String id, {ServerId? serverId}) => testMediaItem(
id: id, id: id,
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.clip, kind: MediaKind.clip,
@@ -100,7 +101,7 @@ MediaItem _clip(String id, {ServerId? serverId}) => MediaItem(
serverId: serverId ?? ServerId('srv-jf'), serverId: serverId ?? ServerId('srv-jf'),
); );
MediaItem _track(String id, {ServerId? serverId}) => MediaItem( MediaItem _track(String id, {ServerId? serverId}) => testMediaItem(
id: id, id: id,
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.track, kind: MediaKind.track,
@@ -146,7 +147,7 @@ void main() {
final ctx = await pumpContext(tester); final ctx = await pumpContext(tester);
final launcher = JellyfinSequentialLauncher(context: ctx); final launcher = JellyfinSequentialLauncher(context: ctx);
final orphan = MediaItem( final orphan = testMediaItem(
id: 'col-1', id: 'col-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.collection, kind: MediaKind.collection,
@@ -173,7 +174,7 @@ void main() {
navigateForTesting: (m) async => navigated.add(m), navigateForTesting: (m) async => navigated.add(m),
); );
final collection = MediaItem( final collection = testMediaItem(
id: 'col-99', id: 'col-99',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.collection, kind: MediaKind.collection,
@@ -273,7 +274,12 @@ void main() {
// container. If a future change reverts to fetchChildren the test // container. If a future change reverts to fetchChildren the test
// fails because a Series row would leak into the queue. // fails because a Series row would leak into the queue.
final ctx = await pumpContext(tester); final ctx = await pumpContext(tester);
final movie = MediaItem(id: 'movie-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-jf'); final movie = testMediaItem(
id: 'movie-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-jf',
);
final ep1 = _ep('series-A-ep1'); final ep1 = _ep('series-A-ep1');
final ep2 = _ep('series-A-ep2'); final ep2 = _ep('series-A-ep2');
final fakeClient = _RecordingJellyfinClient(playableDescendantsResponse: [movie, ep1, ep2]); final fakeClient = _RecordingJellyfinClient(playableDescendantsResponse: [movie, ep1, ep2]);
@@ -287,7 +293,7 @@ void main() {
navigateForTesting: (m) async => navigated.add(m), navigateForTesting: (m) async => navigated.add(m),
); );
final collection = MediaItem( final collection = testMediaItem(
id: 'col-mixed', id: 'col-mixed',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.collection, kind: MediaKind.collection,
@@ -323,7 +329,7 @@ void main() {
navigateForTesting: (_) async {}, navigateForTesting: (_) async {},
); );
final collection = MediaItem( final collection = testMediaItem(
id: 'col-1', id: 'col-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.collection, kind: MediaKind.collection,
@@ -360,7 +366,7 @@ void main() {
navigateForTesting: (m) async => navigated.add(m), navigateForTesting: (m) async => navigated.add(m),
); );
final collection = MediaItem( final collection = testMediaItem(
id: 'col-start', id: 'col-start',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.collection, kind: MediaKind.collection,
@@ -394,7 +400,7 @@ void main() {
navigateForTesting: (m) async => navigated.add(m), navigateForTesting: (m) async => navigated.add(m),
); );
final collection = MediaItem( final collection = testMediaItem(
id: 'col', id: 'col',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.collection, kind: MediaKind.collection,
@@ -427,7 +433,7 @@ void main() {
navigateForTesting: (m) async => navigated.add(m), navigateForTesting: (m) async => navigated.add(m),
); );
final folder = MediaItem( final folder = testMediaItem(
id: 'folder-1', id: 'folder-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.unknown, kind: MediaKind.unknown,
@@ -465,7 +471,7 @@ void main() {
navigateForTesting: (_) async {}, navigateForTesting: (_) async {},
); );
final folder = MediaItem( final folder = testMediaItem(
id: 'folder-shuffle', id: 'folder-shuffle',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.unknown, kind: MediaKind.unknown,
@@ -497,7 +503,7 @@ void main() {
}, },
); );
final folder = MediaItem( final folder = testMediaItem(
id: 'music-folder', id: 'music-folder',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.unknown, kind: MediaKind.unknown,
@@ -515,7 +521,7 @@ void main() {
final ctx = await pumpContext(tester); final ctx = await pumpContext(tester);
final launcher = JellyfinSequentialLauncher(context: ctx); final launcher = JellyfinSequentialLauncher(context: ctx);
final movie = MediaItem(id: 'm1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-jf'); final movie = testMediaItem(id: 'm1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-jf');
final result = await launcher.launchShuffledShow(metadata: movie, showLoadingIndicator: false); final result = await launcher.launchShuffledShow(metadata: movie, showLoadingIndicator: false);
@@ -527,7 +533,12 @@ void main() {
final ctx = await pumpContext(tester); final ctx = await pumpContext(tester);
final launcher = JellyfinSequentialLauncher(context: ctx); final launcher = JellyfinSequentialLauncher(context: ctx);
final season = MediaItem(id: 's1', backend: MediaBackend.jellyfin, kind: MediaKind.season, serverId: 'srv-jf'); final season = testMediaItem(
id: 's1',
backend: MediaBackend.jellyfin,
kind: MediaKind.season,
serverId: 'srv-jf',
);
final result = await launcher.launchShuffledShow(metadata: season, showLoadingIndicator: false); final result = await launcher.launchShuffledShow(metadata: season, showLoadingIndicator: false);
@@ -539,7 +550,7 @@ void main() {
final ctx = await pumpContext(tester); final ctx = await pumpContext(tester);
final launcher = JellyfinSequentialLauncher(context: ctx); final launcher = JellyfinSequentialLauncher(context: ctx);
final orphan = MediaItem(id: 'show-orphan', backend: MediaBackend.jellyfin, kind: MediaKind.show); final orphan = testMediaItem(id: 'show-orphan', backend: MediaBackend.jellyfin, kind: MediaKind.show);
final result = await launcher.launchShuffledShow(metadata: orphan, showLoadingIndicator: false); final result = await launcher.launchShuffledShow(metadata: orphan, showLoadingIndicator: false);
@@ -563,7 +574,7 @@ void main() {
navigateForTesting: (m) async => navigated.add(m), navigateForTesting: (m) async => navigated.add(m),
); );
final show = MediaItem( final show = testMediaItem(
id: 'show-1', id: 'show-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.show, kind: MediaKind.show,
@@ -599,7 +610,7 @@ void main() {
navigateForTesting: (_) async {}, navigateForTesting: (_) async {},
); );
final season = MediaItem( final season = testMediaItem(
id: 'season-2', id: 'season-2',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -628,7 +639,7 @@ void main() {
}, },
); );
final show = MediaItem( final show = testMediaItem(
id: 'show-empty', id: 'show-empty',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.show, kind: MediaKind.show,
@@ -657,7 +668,7 @@ void main() {
}, },
); );
final collection = MediaItem( final collection = testMediaItem(
id: 'col-empty', id: 'col-empty',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.collection, kind: MediaKind.collection,
@@ -6,6 +6,7 @@ import 'package:plezy/services/local_playback_history.dart';
import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/settings_service.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
setUp(() { setUp(() {
@@ -15,7 +16,7 @@ void main() {
}); });
test('recordPlayback writes item and series keys for an episode', () async { test('recordPlayback writes item and series keys for an episode', () async {
final episode = MediaItem( final episode = testMediaItem(
id: 'ep-1', id: 'ep-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -32,7 +33,7 @@ void main() {
}); });
test('recordPlayback writes only the item key for a movie', () async { test('recordPlayback writes only the item key for a movie', () async {
final movie = MediaItem( final movie = testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -47,7 +48,7 @@ void main() {
}); });
test('repeat writes for the same item within the rewrite window are skipped', () async { test('repeat writes for the same item within the rewrite window are skipped', () async {
final movie = MediaItem( final movie = testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -67,7 +68,7 @@ void main() {
final settings = await SettingsService.getInstance(); final settings = await SettingsService.getInstance();
await settings.write(SettingsService.localLastPlayedAt, {for (var i = 0; i < 400; i++) 'srv-1:old-$i': i + 1}); await settings.write(SettingsService.localLastPlayedAt, {for (var i = 0; i < 400; i++) 'srv-1:old-$i': i + 1});
final movie = MediaItem( final movie = testMediaItem(
id: 'fresh', id: 'fresh',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -19,10 +19,11 @@ import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/music/music_playback_service_impl.dart'; import 'package:plezy/services/music/music_playback_service_impl.dart';
import 'package:plezy/services/music/music_source_resolver.dart'; import 'package:plezy/services/music/music_source_resolver.dart';
import 'package:plezy/services/playback_coordinator.dart'; import 'package:plezy/services/playback_coordinator.dart';
import '../../test_helpers/media_items.dart';
const _trackDuration = Duration(minutes: 3); const _trackDuration = Duration(minutes: 3);
MediaItem _track(String id) => MediaItem( MediaItem _track(String id) => testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.track, kind: MediaKind.track,
@@ -6,9 +6,10 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/music/music_queue_controller.dart'; import 'package:plezy/services/music/music_queue_controller.dart';
import '../../test_helpers/media_items.dart';
MediaItem _track(String id) => MediaItem _track(String id) =>
MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: 'Track $id', serverId: 'srv'); testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: 'Track $id', serverId: 'srv');
List<String> _ids(List<MediaItem> items) => [for (final i in items) i.id]; List<String> _ids(List<MediaItem> items) => [for (final i in items) i.id];
@@ -21,6 +21,7 @@ import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
// NOTE on coverage scope: // NOTE on coverage scope:
// The actual sync-to-server path (`syncPendingItems`, `syncWatchStatesFromServer`, // The actual sync-to-server path (`syncPendingItems`, `syncWatchStatesFromServer`,
@@ -93,7 +94,7 @@ class _RecordingMediaClient implements MediaServerClient {
@override @override
Future<MediaItem?> fetchItem(String id) async => Future<MediaItem?> fetchItem(String id) async =>
MediaItem(id: id, backend: backend, kind: MediaKind.movie, serverId: serverId); testMediaItem(id: id, backend: backend, kind: MediaKind.movie, serverId: serverId);
@override @override
Future<void> reportPlaybackStarted({ Future<void> reportPlaybackStarted({
+2 -1
View File
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/play_queue_launcher.dart'; import 'package:plezy/services/play_queue_launcher.dart';
import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/plex_client.dart';
import '../test_helpers/media_items.dart';
// NOTE on coverage scope: // NOTE on coverage scope:
// `PlayQueueLauncher` is almost entirely network/UI glue: // `PlayQueueLauncher` is almost entirely network/UI glue:
@@ -82,7 +83,7 @@ void main() {
final launcher = PlexPlayQueueLauncher(context: capturedContext, client: _StubPlexClient()); final launcher = PlexPlayQueueLauncher(context: capturedContext, client: _StubPlexClient());
final result = await launcher.launchShuffledShow( final result = await launcher.launchShuffledShow(
// movie is not show / season. // movie is not show / season.
metadata: MediaItem(id: 'rk1', backend: MediaBackend.plex, kind: MediaKind.movie), metadata: testMediaItem(id: 'rk1', backend: MediaBackend.plex, kind: MediaKind.movie),
showLoadingIndicator: false, showLoadingIndicator: false,
); );
@@ -23,6 +23,7 @@ import 'package:plezy/services/settings_service.dart';
import '../test_helpers/io_fakes.dart'; import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -60,7 +61,7 @@ void main() {
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope()); await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope());
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem( metadata: testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -89,7 +90,7 @@ void main() {
final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
metadata: MediaItem( metadata: testMediaItem(
id: 'track-1', id: 'track-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.track, kind: MediaKind.track,
@@ -116,7 +117,7 @@ void main() {
final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
metadata: MediaItem( metadata: testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -148,7 +149,7 @@ void main() {
); );
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem( metadata: testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -181,7 +182,7 @@ void main() {
); );
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem( metadata: testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -208,7 +209,7 @@ void main() {
); );
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem( metadata: testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -238,7 +239,7 @@ void main() {
final client = _StreamingPlaybackClient(serverId: ServerId('srv-1')); final client = _StreamingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
metadata: MediaItem( metadata: testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -292,7 +293,7 @@ void main() {
); );
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem( metadata: testMediaItem(
id: 'item-1', id: 'item-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -320,7 +321,7 @@ void main() {
await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello'); await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello');
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem( metadata: testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -17,6 +17,7 @@ import 'package:plezy/services/plex_client.dart';
import 'package:plezy/utils/watch_state_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
// NOTE on coverage scope: // NOTE on coverage scope:
// `PlaybackProgressTracker` periodically samples the player's position and // `PlaybackProgressTracker` periodically samples the player's position and
@@ -291,13 +292,14 @@ class _StopMarksWatchedClient extends _FakePlexClient {
const Object _defaultServerId = Object(); const Object _defaultServerId = Object();
MediaItem _meta({String ratingKey = '42', Object? serverId = _defaultServerId, String? type = 'movie'}) => MediaItem( MediaItem _meta({String ratingKey = '42', Object? serverId = _defaultServerId, String? type = 'movie'}) =>
id: ratingKey, testMediaItem(
backend: MediaBackend.plex, id: ratingKey,
kind: MediaKind.fromString(type), backend: MediaBackend.plex,
title: 'Test Item', kind: MediaKind.fromString(type),
serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?, title: 'Test Item',
); serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?,
);
void main() { void main() {
setUp(resetSharedPreferencesForTest); setUp(resetSharedPreferencesForTest);
@@ -593,7 +595,7 @@ void main() {
); );
final tracker = PlaybackProgressTracker( final tracker = PlaybackProgressTracker(
client: client, client: client,
metadata: MediaItem(id: '42', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), metadata: testMediaItem(id: '42', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'),
player: player, player: player,
isOffline: false, isOffline: false,
mediaInfo: mediaInfo, mediaInfo: mediaInfo,
+2 -1
View File
@@ -7,10 +7,11 @@ import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/services/playback_context.dart'; import 'package:plezy/services/playback_context.dart';
import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/playback_session.dart'; import 'package:plezy/services/playback_session.dart';
import '../test_helpers/media_items.dart';
PlaybackContext _context(PlaybackInitializationResult result) { PlaybackContext _context(PlaybackInitializationResult result) {
return PlaybackContext( return PlaybackContext(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
result: result, result: result,
sourceKind: result.usesLocalMedia ? PlaybackSourceKind.localFile : PlaybackSourceKind.remoteDirect, sourceKind: result.usesLocalMedia ? PlaybackSourceKind.localFile : PlaybackSourceKind.remoteDirect,
reportingMode: PlaybackReportingMode.online, reportingMode: PlaybackReportingMode.online,
@@ -11,6 +11,7 @@ import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/playback_context.dart'; import 'package:plezy/services/playback_context.dart';
import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/playback_source_resolver.dart'; import 'package:plezy/services/playback_source_resolver.dart';
import '../test_helpers/media_items.dart';
class _PlaybackClient implements MediaServerClient { class _PlaybackClient implements MediaServerClient {
_PlaybackClient({this.clientBackend = MediaBackend.plex, PlaybackInitializationResult? result}) _PlaybackClient({this.clientBackend = MediaBackend.plex, PlaybackInitializationResult? result})
@@ -56,7 +57,7 @@ void main() {
manager.debugRegisterClientForTesting(client, online: false); manager.debugRegisterClientForTesting(client, online: false);
final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
selectedMediaIndex: 0, selectedMediaIndex: 0,
offlineLibraryMode: false, offlineLibraryMode: false,
qualityPreset: TranscodeQualityPreset.original, qualityPreset: TranscodeQualityPreset.original,
@@ -79,7 +80,7 @@ void main() {
manager.debugRegisterClientForTesting(client, online: true); manager.debugRegisterClientForTesting(client, online: true);
final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
selectedMediaIndex: 0, selectedMediaIndex: 0,
offlineLibraryMode: false, offlineLibraryMode: false,
qualityPreset: TranscodeQualityPreset.original, qualityPreset: TranscodeQualityPreset.original,
@@ -103,7 +104,7 @@ void main() {
manager.debugRegisterClientForTesting(client, online: true); manager.debugRegisterClientForTesting(client, online: true);
final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'),
selectedMediaIndex: 0, selectedMediaIndex: 0,
offlineLibraryMode: false, offlineLibraryMode: false,
qualityPreset: TranscodeQualityPreset.original, qualityPreset: TranscodeQualityPreset.original,
+45
View File
@@ -5,7 +5,11 @@ import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/services/api_cache.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
late AppDatabase db; late AppDatabase db;
@@ -54,6 +58,47 @@ void main() {
test('database getter exposes the underlying AppDatabase', () { test('database getter exposes the underlying AppDatabase', () {
expect(identical(cache.database, db), isTrue); expect(identical(cache.database, db), isTrue);
}); });
test('registered cleanup ignores backend initialization order and preserves pinned rows', () async {
await cache.put(ServerId('srv'), '/volatile', {'value': 1});
await cache.put(ServerId('srv'), '/pinned', {'value': 2});
await cache.pin(ServerId('srv'), '/pinned');
// Register the other backend last; cleanup must not depend on whichever
// concrete singleton happened to initialize most recently.
JellyfinApiCache.initialize(db);
await ApiCache.clearRegisteredVolatile();
expect(await cache.get(ServerId('srv'), '/volatile'), isNull);
expect(await cache.get(ServerId('srv'), '/pinned'), {'value': 2});
});
test('registering a new database drops stale backend dispatch entries', () async {
final newDb = AppDatabase.forTesting(NativeDatabase.memory());
JellyfinApiCache.initialize(newDb);
expect(() => ApiCache.forBackend(MediaBackend.plex), throwsStateError);
expect(identical(ApiCache.forBackend(MediaBackend.jellyfin).database, newDb), isTrue);
await newDb.close();
});
});
group('shared row decoding', () {
test('drops malformed rows without discarding valid siblings', () {
final decoded = decodeCachedMediaRows(
['{"id":"first"}', 'not json', '[]', '{"id":"last"}'],
serializedData: (row) => row,
decode: (_, json) {
final id = json['id'] as String;
return MapEntry(id, testMediaItem(id: id));
},
);
expect(decoded.keys, ['first', 'last']);
expect(decoded['first']?.id, 'first');
expect(decoded['last']?.id, 'last');
});
}); });
// ============================================================ // ============================================================
@@ -16,6 +16,7 @@ import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/plex_client.dart';
import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
late AppDatabase db; late AppDatabase db;
@@ -315,7 +316,7 @@ void main() {
final result = await client.getPlaybackInitialization( final result = await client.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'), metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
selectedMediaIndex: 0, selectedMediaIndex: 0,
), ),
); );
@@ -14,6 +14,7 @@ import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/plex_client.dart';
import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
/// Regression coverage for the Plex transcode reporting bug: while /// Regression coverage for the Plex transcode reporting bug: while
/// transcoding, the `/:/timeline` reports must carry the playback's /// transcoding, the `/:/timeline` reports must carry the playback's
@@ -165,7 +166,7 @@ void main() {
final result = await client.getPlaybackInitialization( final result = await client.getPlaybackInitialization(
PlaybackInitializationOptions( PlaybackInitializationOptions(
metadata: MediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'), metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
selectedMediaIndex: 0, selectedMediaIndex: 0,
// Original preset stays on the direct-play branch (no transcode // Original preset stays on the direct-play branch (no transcode
// decision round-trip needed for this assertion). // decision round-trip needed for this assertion).
+9 -8
View File
@@ -19,6 +19,7 @@ import 'package:plezy/services/sync_rule_executor.dart';
import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection( JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection(
machineId: 'jf-machine', machineId: 'jf-machine',
@@ -314,7 +315,7 @@ void main() {
manager.debugRegisterClientForTesting(client); manager.debugRegisterClientForTesting(client);
const ruleKey = 'profile-a|plex-machine:show-1'; const ruleKey = 'profile-a|plex-machine:show-1';
final show = MediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show'); final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
await db.insertSyncRule( await db.insertSyncRule(
profileId: 'profile-a', profileId: 'profile-a',
serverId: ServerId('plex-machine'), serverId: ServerId('plex-machine'),
@@ -357,7 +358,7 @@ void main() {
manager.debugRegisterClientForTesting(client); manager.debugRegisterClientForTesting(client);
const ruleKey = 'profile-a|plex-machine:collection-1'; const ruleKey = 'profile-a|plex-machine:collection-1';
final collection = MediaItem( final collection = testMediaItem(
id: 'collection-1', id: 'collection-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.collection, kind: MediaKind.collection,
@@ -406,10 +407,10 @@ void main() {
final items = [ final items = [
_track('loose-track'), _track('loose-track'),
MediaItem(id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album'), testMediaItem(id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album'),
MediaItem(id: 'artist-1', backend: MediaBackend.plex, kind: MediaKind.artist, title: 'Artist'), testMediaItem(id: 'artist-1', backend: MediaBackend.plex, kind: MediaKind.artist, title: 'Artist'),
// Still skipped: nested lists / unplayable kinds. // Still skipped: nested lists / unplayable kinds.
MediaItem(id: 'photo-1', backend: MediaBackend.plex, kind: MediaKind.photo, title: 'Photo'), testMediaItem(id: 'photo-1', backend: MediaBackend.plex, kind: MediaKind.photo, title: 'Photo'),
]; ];
final out = <MediaItem>[]; final out = <MediaItem>[];
@@ -431,11 +432,11 @@ void main() {
} }
MediaItem _track(String id, {bool played = false}) { MediaItem _track(String id, {bool played = false}) {
return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: id, viewCount: played ? 1 : 0); return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: id, viewCount: played ? 1 : 0);
} }
MediaItem _episode(String id, {required int parentIndex, required int index, String? originallyAvailableAt}) { MediaItem _episode(String id, {required int parentIndex, required int index, String? originallyAvailableAt}) {
return MediaItem( return testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -526,7 +527,7 @@ class _CollectionPagingClient implements MediaServerClient {
collectionPageCalls.add((start: start, size: size)); collectionPageCalls.add((start: start, size: size));
expect(collectionId, 'collection-1'); expect(collectionId, 'collection-1');
return LibraryPage( return LibraryPage(
items: [MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie')], items: [testMediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie')],
totalCount: 1, totalCount: 1,
offset: start ?? 0, offset: start ?? 0,
); );
+2 -1
View File
@@ -11,6 +11,7 @@ import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/track_manager.dart'; import 'package:plezy/services/track_manager.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
// NOTE on coverage scope: // NOTE on coverage scope:
// `TrackManager` orchestrates the player + Plex client + SettingsService // `TrackManager` orchestrates the player + Plex client + SettingsService
@@ -42,7 +43,7 @@ import '../test_helpers/prefs.dart';
// therefore gated on the same SettingsService dependency. // therefore gated on the same SettingsService dependency.
// - `resumeAfterSubtitleLoad` — schedules a real wall-clock fallback Timer. // - `resumeAfterSubtitleLoad` — schedules a real wall-clock fallback Timer.
MediaItem _meta({String id = 'rk1'}) => MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie); MediaItem _meta({String id = 'rk1'}) => testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie);
/// Player that records calls and can be configured per-test. /// Player that records calls and can be configured per-test.
class _FakePlayer with PlayerStreamControllersMixin implements Player { class _FakePlayer with PlayerStreamControllersMixin implements Player {
@@ -8,6 +8,7 @@ import 'package:plezy/models/jellyfin/jellyfin_user_profile.dart';
import 'package:plezy/models/plex/plex_user_profile.dart'; import 'package:plezy/models/plex/plex_user_profile.dart';
import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/track_selection_service.dart'; import 'package:plezy/services/track_selection_service.dart';
import '../test_helpers/media_items.dart';
// NOTE on coverage scope: // NOTE on coverage scope:
// `TrackSelectionService` is a large pure logic surface with one async // `TrackSelectionService` is a large pure logic surface with one async
@@ -44,7 +45,7 @@ import 'package:plezy/services/track_selection_service.dart';
// ============================================================ // ============================================================
MediaItem _meta({MediaBackend backend = MediaBackend.plex, String? audioLanguage, String? subtitleLanguage}) => MediaItem _meta({MediaBackend backend = MediaBackend.plex, String? audioLanguage, String? subtitleLanguage}) =>
MediaItem( testMediaItem(
id: 'rk1', id: 'rk1',
backend: backend, backend: backend,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/trackers/anime_lists_mapping.dart'; import 'package:plezy/models/trackers/anime_lists_mapping.dart';
import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart'; import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart';
import '../../test_helpers/media_items.dart';
class _FakeMediaServerClient implements MediaServerClient { class _FakeMediaServerClient implements MediaServerClient {
final Map<String, List<MediaItem>> childrenByParent; final Map<String, List<MediaItem>> childrenByParent;
@@ -33,7 +34,7 @@ class _FakeMediaServerClient implements MediaServerClient {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
} }
MediaItem _season(int number, {int? watched, int? total}) => MediaItem( MediaItem _season(int number, {int? watched, int? total}) => testMediaItem(
id: 'season-$number', id: 'season-$number',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
@@ -43,7 +44,7 @@ MediaItem _season(int number, {int? watched, int? total}) => MediaItem(
viewedLeafCount: watched, viewedLeafCount: watched,
); );
MediaItem _episode({int season = 2, int number = 6, String showId = 'show-1', int? viewCount}) => MediaItem( MediaItem _episode({int season = 2, int number = 6, String showId = 'show-1', int? viewCount}) => testMediaItem(
id: 'episode-$season-$number', id: 'episode-$season-$number',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -18,6 +18,7 @@ import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
import 'package:plezy/services/trackers/tracker_coordinator.dart'; import 'package:plezy/services/trackers/tracker_coordinator.dart';
import 'package:plezy/services/trackers/tracker_session.dart'; import 'package:plezy/services/trackers/tracker_session.dart';
import 'package:plezy/utils/external_ids.dart'; import 'package:plezy/utils/external_ids.dart';
import '../../test_helpers/media_items.dart';
class _FakeMediaServerClient implements MediaServerClient { class _FakeMediaServerClient implements MediaServerClient {
@override @override
@@ -88,7 +89,7 @@ class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{}; Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
} }
MediaItem _season() => MediaItem( MediaItem _season() => testMediaItem(
id: 'season-1', id: 'season-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
@@ -99,7 +100,7 @@ MediaItem _season() => MediaItem(
parentId: 'show-1', parentId: 'show-1',
); );
MediaItem _episode(int number, {int season = 1}) => MediaItem( MediaItem _episode(int number, {int season = 1}) => testMediaItem(
id: 'episode-$season-$number', id: 'episode-$season-$number',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -110,7 +111,7 @@ MediaItem _episode(int number, {int season = 1}) => MediaItem(
index: number, index: number,
); );
MediaItem _show() => MediaItem( MediaItem _show() => testMediaItem(
id: 'show-1', id: 'show-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -119,7 +120,7 @@ MediaItem _show() => MediaItem(
libraryId: 'lib-1', libraryId: 'lib-1',
); );
MediaItem _movie() => MediaItem( MediaItem _movie() => testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -10,6 +10,7 @@ import 'package:plezy/services/trackers/anime_lists_mapping_store.dart';
import 'package:plezy/services/trackers/fribb_mapping_store.dart'; import 'package:plezy/services/trackers/fribb_mapping_store.dart';
import 'package:plezy/services/trackers/tracker_id_resolver.dart'; import 'package:plezy/services/trackers/tracker_id_resolver.dart';
import 'package:plezy/utils/external_ids.dart'; import 'package:plezy/utils/external_ids.dart';
import '../../test_helpers/media_items.dart';
class _FakeMediaServerClient implements MediaServerClient { class _FakeMediaServerClient implements MediaServerClient {
final Map<String, ExternalIds> externalIdsByItem; final Map<String, ExternalIds> externalIdsByItem;
@@ -94,7 +95,7 @@ class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{}; Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
} }
MediaItem _episode({int season = 23, int number = 6}) => MediaItem( MediaItem _episode({int season = 23, int number = 6}) => testMediaItem(
id: 'episode-$season-$number', id: 'episode-$season-$number',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
+2 -1
View File
@@ -6,6 +6,7 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/watch_state_resolver.dart'; import 'package:plezy/services/watch_state_resolver.dart';
import 'package:plezy/utils/watch_state_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/media_items.dart';
OfflineWatchProgressItem _action({ OfflineWatchProgressItem _action({
required String actionType, required String actionType,
@@ -86,7 +87,7 @@ void main() {
test('applying a watched snapshot patches container leaf counts so isWatched flips', () { test('applying a watched snapshot patches container leaf counts so isWatched flips', () {
const snapshot = WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0); const snapshot = WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0);
final season = MediaItem( final season = testMediaItem(
id: 'season-1', id: 'season-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
+200
View File
@@ -0,0 +1,200 @@
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_role.dart';
import 'package:plezy/media/media_version.dart';
/// Canonical backend-neutral [MediaItem] fixture.
///
/// Tests should override only fields relevant to the behavior under test. The
/// defaults deliberately form a valid Plex movie without inventing hierarchy,
/// watch-state, or library metadata that could influence derived getters.
MediaItem testMediaItem({
String id = 'item-1',
MediaBackend backend = MediaBackend.plex,
MediaKind kind = MediaKind.movie,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? userRating,
bool? isFavorite,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
String? subtitleLanguage,
int? subtitleMode,
String? serverId,
String? serverName,
String? backendFolderKey,
Map<String, Object?>? raw,
}) {
return MediaItem(
id: id,
backend: backend,
kind: kind,
guid: guid,
title: title,
titleSort: titleSort,
summary: summary,
tagline: tagline,
originalTitle: originalTitle,
studio: studio,
year: year,
originallyAvailableAt: originallyAvailableAt,
contentRating: contentRating,
parentId: parentId,
parentTitle: parentTitle,
parentThumbPath: parentThumbPath,
parentIndex: parentIndex,
index: index,
grandparentId: grandparentId,
grandparentTitle: grandparentTitle,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: grandparentArtPath,
thumbPath: thumbPath,
artPath: artPath,
clearLogoPath: clearLogoPath,
backgroundSquarePath: backgroundSquarePath,
durationMs: durationMs,
viewOffsetMs: viewOffsetMs,
viewCount: viewCount,
lastViewedAt: lastViewedAt,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
childCount: childCount,
addedAt: addedAt,
updatedAt: updatedAt,
rating: rating,
userRating: userRating,
isFavorite: isFavorite,
genres: genres,
directors: directors,
writers: writers,
producers: producers,
countries: countries,
collections: collections,
labels: labels,
styles: styles,
moods: moods,
roles: roles,
mediaVersions: mediaVersions,
libraryId: libraryId,
libraryTitle: libraryTitle,
audioLanguage: audioLanguage,
subtitleLanguage: subtitleLanguage,
subtitleMode: subtitleMode,
serverId: serverId,
serverName: serverName,
backendFolderKey: backendFolderKey,
raw: raw,
);
}
/// Season fixture with canonical show linkage.
MediaItem testSeason({
String id = 'season-1',
MediaItem? show,
int index = 1,
String? title,
MediaBackend? backend,
String? serverId,
String? libraryId,
int? leafCount,
int? viewedLeafCount,
}) {
return testMediaItem(
id: id,
backend: backend ?? show?.backend ?? MediaBackend.plex,
kind: MediaKind.season,
title: title,
parentId: show?.id,
parentTitle: show?.title,
index: index,
serverId: serverId ?? show?.serverId,
serverName: show?.serverName,
libraryId: libraryId ?? show?.libraryId,
libraryTitle: show?.libraryTitle,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
);
}
/// Episode fixture with canonical show and season linkage.
MediaItem testEpisode({
String id = 'episode-1',
MediaItem? show,
MediaItem? season,
int index = 1,
String? title,
MediaBackend? backend,
String? serverId,
String? libraryId,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
String? originallyAvailableAt,
List<MediaVersion>? mediaVersions,
}) {
return testMediaItem(
id: id,
backend: backend ?? season?.backend ?? show?.backend ?? MediaBackend.plex,
kind: MediaKind.episode,
title: title,
parentId: season?.id,
parentTitle: season?.title,
parentIndex: season?.index,
index: index,
grandparentId: show?.id,
grandparentTitle: show?.title,
serverId: serverId ?? season?.serverId ?? show?.serverId,
serverName: season?.serverName ?? show?.serverName,
libraryId: libraryId ?? season?.libraryId ?? show?.libraryId,
libraryTitle: season?.libraryTitle ?? show?.libraryTitle,
durationMs: durationMs,
viewOffsetMs: viewOffsetMs,
viewCount: viewCount,
originallyAvailableAt: originallyAvailableAt,
mediaVersions: mediaVersions,
);
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'media_items.dart';
void main() {
test('default fixture is a minimal Plex movie', () {
final item = testMediaItem();
expect(item.id, 'item-1');
expect(item.backend, MediaBackend.plex);
expect(item.kind, MediaKind.movie);
expect(item.serverId, isNull);
expect(item.parentId, isNull);
expect(item.viewCount, isNull);
});
test('season and episode fixtures preserve canonical hierarchy and scope', () {
final show = testMediaItem(
id: 'show-1',
kind: MediaKind.show,
backend: MediaBackend.jellyfin,
title: 'Show',
serverId: 'server-1',
serverName: 'Server',
libraryId: 'library-1',
libraryTitle: 'Library',
);
final season = testSeason(id: 'season-2', show: show, index: 2, title: 'Season 2');
final episode = testEpisode(id: 'episode-3', show: show, season: season, index: 3, title: 'Episode 3');
expect(season.backend, show.backend);
expect(season.parentId, show.id);
expect(season.parentTitle, show.title);
expect(episode.parentId, season.id);
expect(episode.parentTitle, season.title);
expect(episode.parentIndex, season.index);
expect(episode.grandparentId, show.id);
expect(episode.grandparentTitle, show.title);
expect(episode.serverId, show.serverId);
expect(episode.libraryId, show.libraryId);
});
}
+4 -3
View File
@@ -5,9 +5,10 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_item_types.dart'; import 'package:plezy/media/media_item_types.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/utils/content_utils.dart'; import 'package:plezy/utils/content_utils.dart';
import '../test_helpers/media_items.dart';
MediaItem _episode({int? viewOffsetMs, int? durationMs, int? viewCount, int? leafCount, int? viewedLeafCount}) { MediaItem _episode({int? viewOffsetMs, int? durationMs, int? viewCount, int? leafCount, int? viewedLeafCount}) {
return MediaItem( return testMediaItem(
id: '1', id: '1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -20,7 +21,7 @@ MediaItem _episode({int? viewOffsetMs, int? durationMs, int? viewCount, int? lea
} }
MediaItem _movie({int? viewCount}) { MediaItem _movie({int? viewCount}) {
return MediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.movie, viewCount: viewCount); return testMediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.movie, viewCount: viewCount);
} }
void main() { void main() {
@@ -58,7 +59,7 @@ void main() {
group('MediaItemTypes.shouldHideSpoiler', () { group('MediaItemTypes.shouldHideSpoiler', () {
test('false for non-episodes', () { test('false for non-episodes', () {
expect(_movie().shouldHideSpoiler, isFalse); expect(_movie().shouldHideSpoiler, isFalse);
final show = MediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.show); final show = testMediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.show);
expect(show.shouldHideSpoiler, isFalse); expect(show.shouldHideSpoiler, isFalse);
}); });
@@ -2,9 +2,10 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/app_database.dart';
import 'package:plezy/models/download_models.dart'; import 'package:plezy/models/download_models.dart';
import 'package:plezy/utils/downloaded_version_match.dart'; import 'package:plezy/utils/downloaded_version_match.dart';
import '../test_helpers/media_items.dart';
DownloadedMediaItem _row({int mediaIndex = 0, String? mediaSourceId}) { DownloadedMediaItem _row({int mediaIndex = 0, String? mediaSourceId}) {
return DownloadedMediaItem( return DownloadedtestMediaItem(
id: 1, id: 1,
serverId: 'srv', serverId: 'srv',
ratingKey: 'movie-1', ratingKey: 'movie-1',
+11 -10
View File
@@ -9,8 +9,9 @@ import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/media_version.dart'; import 'package:plezy/media/media_version.dart';
import 'package:plezy/utils/download_version_utils.dart'; import 'package:plezy/utils/download_version_utils.dart';
import 'package:plezy/media/episode_collection.dart'; import 'package:plezy/media/episode_collection.dart';
import '../test_helpers/media_items.dart';
MediaItem _season(String id, {int index = 1, int? leafCount, int? viewedLeafCount}) => MediaItem( MediaItem _season(String id, {int index = 1, int? leafCount, int? viewedLeafCount}) => testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.season, kind: MediaKind.season,
@@ -31,7 +32,7 @@ MediaItem _episode(
int? viewOffsetMs, int? viewOffsetMs,
int? durationMs, int? durationMs,
String? originallyAvailableAt, String? originallyAvailableAt,
}) => MediaItem( }) => testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -47,7 +48,7 @@ MediaItem _episode(
originallyAvailableAt: originallyAvailableAt, originallyAvailableAt: originallyAvailableAt,
); );
MediaItem _clip(String id) => MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.clip, title: 'Clip'); MediaItem _clip(String id) => testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.clip, title: 'Clip');
class _RecordingClient implements MediaServerClient { class _RecordingClient implements MediaServerClient {
_RecordingClient({this.childrenByParent = const {}, this.childrenPageByParent = const {}, this.itemsById = const {}}); _RecordingClient({this.childrenByParent = const {}, this.childrenPageByParent = const {}, this.itemsById = const {}});
@@ -111,7 +112,7 @@ class _LeavesClient implements MediaServerClient {
} }
void main() { void main() {
test('collectEpisodesForShow drops Specials when includeSpecials is false', () async { test('collectEpisodes drops Specials when includeSpecials is false', () async {
final client = _LeavesClient([ final client = _LeavesClient([
_episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'), _episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'),
_episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'), _episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'),
@@ -119,12 +120,12 @@ void main() {
]); ]);
final withoutSpecials = <MediaItem>[]; final withoutSpecials = <MediaItem>[];
await collectEpisodesForShow(client, 'show-1', unwatchedOnly: false, out: withoutSpecials, includeSpecials: false); await collectEpisodes(client, 'show-1', unwatchedOnly: false, out: withoutSpecials, includeSpecials: false);
expect(withoutSpecials.map((e) => e.id), ['s1e1', 's1e2']); expect(withoutSpecials.map((e) => e.id), ['s1e1', 's1e2']);
// Default keeps Specials, interleaved into aired order. // Default keeps Specials, interleaved into aired order.
final withSpecials = <MediaItem>[]; final withSpecials = <MediaItem>[];
await collectEpisodesForShow(client, 'show-1', unwatchedOnly: false, out: withSpecials); await collectEpisodes(client, 'show-1', unwatchedOnly: false, out: withSpecials);
expect(withSpecials.map((e) => e.id), ['s1e1', 's0e1', 's1e2']); expect(withSpecials.map((e) => e.id), ['s1e1', 's0e1', 's1e2']);
}); });
@@ -317,7 +318,7 @@ void main() {
}); });
test('fetchSeasonEpisodePage normalizes show and season identity', () async { test('fetchSeasonEpisodePage normalizes show and season identity', () async {
final show = MediaItem( final show = testMediaItem(
id: 'show-1', id: 'show-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.show, kind: MediaKind.show,
@@ -350,7 +351,7 @@ void main() {
}); });
test('fetchSeasonEpisodePage uses season episode paging when available', () async { test('fetchSeasonEpisodePage uses season episode paging when available', () async {
final show = MediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show'); final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
final season = _season('season-1'); final season = _season('season-1');
final row = _episode('episode-1'); final row = _episode('episode-1');
final client = _SeasonPagingRecordingClient( final client = _SeasonPagingRecordingClient(
@@ -367,7 +368,7 @@ void main() {
}); });
test('normalizeSeasonEpisodes ignores non-episode rows', () { test('normalizeSeasonEpisodes ignores non-episode rows', () {
final show = MediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show'); final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
final season = _season('season-1'); final season = _season('season-1');
final normalized = normalizeSeasonEpisodes([_clip('extra-1'), _episode('episode-1')], show: show, season: season); final normalized = normalizeSeasonEpisodes([_clip('extra-1'), _episode('episode-1')], show: show, season: season);
@@ -395,7 +396,7 @@ void main() {
test('fetchRepresentativeVersions keeps full season lookup but pages selected season episodes', () async { test('fetchRepresentativeVersions keeps full season lookup but pages selected season episodes', () async {
final versions = [const MediaVersion(id: '1080', videoResolution: '1080')]; final versions = [const MediaVersion(id: '1080', videoResolution: '1080')];
final show = MediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show'); final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
final special = _season('specials', index: 0); final special = _season('specials', index: 0);
final firstRegularSeason = _season('season-1'); final firstRegularSeason = _season('season-1');
final episodeRow = _episode('episode-1'); final episodeRow = _episode('episode-1');
+27
View File
@@ -227,4 +227,31 @@ void main() {
expect(flexibleCsvStringList(<dynamic>[]), isNull); expect(flexibleCsvStringList(<dynamic>[]), isNull);
}); });
}); });
group('flexible JSON objects', () {
String parseId(Map<String, dynamic> json) => json['id'] as String;
test('list parser keeps valid siblings around malformed entries', () {
final parsed = parseFlexibleJsonList([
{'id': 'first'},
{'id': 2},
'not-a-map',
{'id': 'last'},
], parseId);
expect(parsed, ['first', 'last']);
});
test('object parser finds the first map and contains parse failures', () {
expect(
parseFlexibleJsonObject([
'not-a-map',
{'id': 'value'},
], parseId),
'value',
);
expect(parseFlexibleJsonObject({'id': 2}, parseId), isNull);
expect(parseFlexibleJsonObject(null, parseId), isNull);
});
});
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_library.dart';
import 'package:plezy/utils/media_hub_ordering.dart'; import 'package:plezy/utils/media_hub_ordering.dart';
import '../test_helpers/media_items.dart';
const Object _defaultServerId = Object(); const Object _defaultServerId = Object();
@@ -20,7 +21,7 @@ MediaLibrary _library(String id, {ServerId? serverId}) {
} }
MediaItem _item(String id, {String? libraryId, Object? serverId = _defaultServerId}) { MediaItem _item(String id, {String? libraryId, Object? serverId = _defaultServerId}) {
return MediaItem( return testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
+4 -3
View File
@@ -4,10 +4,11 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/media_navigation_helper.dart'; import 'package:plezy/utils/media_navigation_helper.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
test('episode detail target opens parent show and focuses season episode', () { test('episode detail target opens parent show and focuses season episode', () {
final episode = MediaItem( final episode = testMediaItem(
id: 'episode-1', id: 'episode-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -29,7 +30,7 @@ void main() {
}); });
test('season detail target opens parent show and focuses season', () { test('season detail target opens parent show and focuses season', () {
final season = MediaItem( final season = testMediaItem(
id: 'season-3', id: 'season-3',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -50,7 +51,7 @@ void main() {
}); });
test('movie detail target keeps the movie itself', () { test('movie detail target keeps the movie itself', () {
final movie = MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); final movie = testMediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final target = mediaDetailNavigationTargetFor(movie); final target = mediaDetailNavigationTargetFor(movie);
+2 -1
View File
@@ -9,6 +9,7 @@ import 'package:plezy/media/media_version.dart';
import 'package:plezy/services/jellyfin_mappers.dart'; import 'package:plezy/services/jellyfin_mappers.dart';
import 'package:plezy/services/plex_mappers.dart'; import 'package:plezy/services/plex_mappers.dart';
import 'package:plezy/utils/media_quality_labels.dart'; import 'package:plezy/utils/media_quality_labels.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
group('buildMediaQualityLabels', () { group('buildMediaQualityLabels', () {
@@ -216,7 +217,7 @@ void main() {
} }
MediaItem _episodeWithVersion(MediaVersion? version) { MediaItem _episodeWithVersion(MediaVersion? version) {
return MediaItem( return testMediaItem(
id: 'episode-1', id: 'episode-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
+3 -2
View File
@@ -3,6 +3,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/utils/plex_season_display.dart'; import 'package:plezy/utils/plex_season_display.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
group('shouldShowPlexEpisodesDirectly', () { group('shouldShowPlexEpisodesDirectly', () {
@@ -57,9 +58,9 @@ void main() {
} }
MediaItem _show({Map<String, Object?>? raw}) { MediaItem _show({Map<String, Object?>? raw}) {
return MediaItem(id: 'show', backend: MediaBackend.plex, kind: MediaKind.show, raw: raw); return testMediaItem(id: 'show', backend: MediaBackend.plex, kind: MediaKind.show, raw: raw);
} }
MediaItem _season(String id) { MediaItem _season(String id) {
return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.season); return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.season);
} }
+14 -3
View File
@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_item.dart';
@@ -9,11 +10,21 @@ import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/video_player_navigation.dart'; import 'package:plezy/utils/video_player_navigation.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
test('VOD and Live TV route contract is opaque, named, and transition-free', () {
final route = buildVideoPlayerRoute(builder: (_) => const SizedBox());
expect(route.settings.name, kVideoPlayerRouteName);
expect(route.opaque, isTrue);
expect(route.transitionDuration, Duration.zero);
expect(route.reverseTransitionDuration, Duration.zero);
});
test('in-flight video player navigation rejects duplicate requests', () { test('in-flight video player navigation rejects duplicate requests', () {
final guard = VideoPlayerNavigationInFlightGuard(); final guard = VideoPlayerNavigationInFlightGuard();
final item = MediaItem( final item = testMediaItem(
id: 'episode_1', id: 'episode_1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -48,7 +59,7 @@ void main() {
MediaVersion(id: '102', videoResolution: '4k', videoCodec: 'hevc', container: 'mkv'), MediaVersion(id: '102', videoResolution: '4k', videoCodec: 'hevc', container: 'mkv'),
]; ];
final episode = MediaItem( final episode = testMediaItem(
id: 'ep-1', id: 'ep-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -122,7 +133,7 @@ void main() {
); );
SettingsService.resetForTesting(); SettingsService.resetForTesting();
final bare = MediaItem( final bare = testMediaItem(
id: 'ep-2', id: 'ep-2',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.episode, kind: MediaKind.episode,
+2 -1
View File
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/download_models.dart'; import 'package:plezy/models/download_models.dart';
import 'package:plezy/widgets/download_tree_view.dart'; import 'package:plezy/widgets/download_tree_view.dart';
import '../test_helpers/media_items.dart';
DownloadTreeNode _episodeNode(String globalKey) => DownloadTreeNode( DownloadTreeNode _episodeNode(String globalKey) => DownloadTreeNode(
key: globalKey, key: globalKey,
@@ -34,7 +35,7 @@ MediaItem _episodeMeta({
required ServerId? serverId, required ServerId? serverId,
required String? grandparentId, required String? grandparentId,
required String? parentId, required String? parentId,
}) => MediaItem( }) => testMediaItem(
id: id, id: id,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.episode, kind: MediaKind.episode,
@@ -715,6 +715,31 @@ void main() {
expect(find.byType(Dialog), findsNothing); expect(find.byType(Dialog), findsNothing);
}); });
testWidgets('TV hardware input replaces a reversed text selection', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController(text: 'ab')
..selection = const TextSelection(baseOffset: 2, extentOffset: 0);
final fieldFocusNode = FocusNode(debugLabel: 'selection_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
),
),
);
fieldFocusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyC, character: 'c');
await tester.pump();
expect(controller.text, 'c');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
});
testWidgets('TV keyboard done resolves callbacks against the latest field widget', (tester) async { testWidgets('TV keyboard done resolves callbacks against the latest field widget', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true); await TvDetectionService.getInstance(forceTv: true);
+3 -2
View File
@@ -4,10 +4,11 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/screens/libraries/folder_tree_item.dart'; import 'package:plezy/screens/libraries/folder_tree_item.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
testWidgets('folder rows use the item title for seasons', (tester) async { testWidgets('folder rows use the item title for seasons', (tester) async {
final item = MediaItem( final item = testMediaItem(
id: 'season-1', id: 'season-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.season, kind: MediaKind.season,
@@ -28,7 +29,7 @@ void main() {
}); });
testWidgets('folder rows show play and shuffle buttons when callbacks are supplied', (tester) async { testWidgets('folder rows show play and shuffle buttons when callbacks are supplied', (tester) async {
final item = MediaItem( final item = testMediaItem(
id: 'folder-1', id: 'folder-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.unknown, kind: MediaKind.unknown,
+10 -4
View File
@@ -15,6 +15,7 @@ import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/media_grid_delegate.dart'; import 'package:plezy/widgets/media_grid_delegate.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -69,7 +70,7 @@ void main() {
}); });
testWidgets('full bleed grid media cards hide text when constrained by a grid cell', (tester) async { testWidgets('full bleed grid media cards hide text when constrained by a grid cell', (tester) async {
final item = MediaItem( final item = testMediaItem(
id: 'movie_1', id: 'movie_1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -93,7 +94,12 @@ void main() {
}); });
testWidgets('standard grid media cards still show text', (tester) async { testWidgets('standard grid media cards still show text', (tester) async {
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Visible Movie'); final item = testMediaItem(
id: 'movie_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Visible Movie',
);
await tester.pumpWidget( await tester.pumpWidget(
_TestApp( _TestApp(
@@ -105,7 +111,7 @@ void main() {
}); });
testWidgets('full bleed flag does not hide list media card text', (tester) async { testWidgets('full bleed flag does not hide list media card text', (tester) async {
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'List Movie'); final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'List Movie');
await tester.pumpWidget( await tester.pumpWidget(
_TestApp( _TestApp(
@@ -205,7 +211,7 @@ void main() {
} }
Widget _fullCardHarness({required FocusNode focusNode, required bool fullBleed}) { Widget _fullCardHarness({required FocusNode focusNode, required bool fullBleed}) {
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Focused Movie'); final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Focused Movie');
return InputModeTracker( return InputModeTracker(
child: _TestApp( child: _TestApp(
child: SizedBox( child: SizedBox(
+2 -1
View File
@@ -14,8 +14,9 @@ import 'package:plezy/widgets/optimized_media_image.dart';
import 'package:plezy/widgets/watched_indicator.dart'; import 'package:plezy/widgets/watched_indicator.dart';
import '../test_helpers/prefs.dart'; import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
MediaItem _item(MediaKind kind, {String? parentTitle, int? durationMs}) => MediaItem( MediaItem _item(MediaKind kind, {String? parentTitle, int? durationMs}) => testMediaItem(
id: '${kind.id}_1', id: '${kind.id}_1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: kind, kind: kind,
+16 -7
View File
@@ -39,6 +39,7 @@ import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/media_context_menu.dart'; import 'package:plezy/widgets/media_context_menu.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -48,7 +49,11 @@ void main() {
final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false)); final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false));
expect( expect(
isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile), isAdminActionAllowedFortestMediaItem(
isOwnerOrAdmin: true,
itemBackend: MediaBackend.plex,
activeProfile: profile,
),
isFalse, isFalse,
); );
}); });
@@ -57,7 +62,7 @@ void main() {
final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false)); final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false));
expect( expect(
isAdminActionAllowedForMediaItem( isAdminActionAllowedFortestMediaItem(
isOwnerOrAdmin: true, isOwnerOrAdmin: true,
itemBackend: MediaBackend.jellyfin, itemBackend: MediaBackend.jellyfin,
activeProfile: profile, activeProfile: profile,
@@ -70,7 +75,11 @@ void main() {
final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: true)); final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: true));
expect( expect(
isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile), isAdminActionAllowedFortestMediaItem(
isOwnerOrAdmin: true,
itemBackend: MediaBackend.plex,
activeProfile: profile,
),
isTrue, isTrue,
); );
}); });
@@ -97,14 +106,14 @@ void main() {
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final tracks = [ final tracks = [
MediaItem( testMediaItem(
id: 'track-1', id: 'track-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.track, kind: MediaKind.track,
title: 'Track One', title: 'Track One',
serverId: 'srv-1', serverId: 'srv-1',
), ),
MediaItem( testMediaItem(
id: 'track-2', id: 'track-2',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.track, kind: MediaKind.track,
@@ -222,7 +231,7 @@ void main() {
}); });
final menuKey = GlobalKey<MediaContextMenuState>(); final menuKey = GlobalKey<MediaContextMenuState>();
final item = MediaItem( final item = testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.jellyfin, backend: MediaBackend.jellyfin,
kind: MediaKind.movie, kind: MediaKind.movie,
@@ -372,7 +381,7 @@ Future<GlobalKey<MediaContextMenuState>> _pumpPlexMovieMenu(
}); });
final menuKey = GlobalKey<MediaContextMenuState>(); final menuKey = GlobalKey<MediaContextMenuState>();
final item = MediaItem( final item = testMediaItem(
id: 'movie-1', id: 'movie-1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.movie, kind: MediaKind.movie,
+2 -1
View File
@@ -15,8 +15,9 @@ import 'package:plezy/widgets/music/mini_player.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart'; import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
final _track = MediaItem( final _track = testMediaItem(
id: 'track_1', id: 'track_1',
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.track, kind: MediaKind.track,
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/music/music_detail_header.dart';
void main() {
Widget host({required double width, required MusicDetailHeader header}) {
return MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: width, child: header),
),
),
);
}
testWidgets('compact header stacks centered metadata between artwork and actions', (tester) async {
double? artworkSize;
bool? centeredValue;
await tester.pumpWidget(
host(
width: 500,
header: MusicDetailHeader(
artworkBuilder: (size) {
artworkSize = size;
return const SizedBox(key: Key('artwork'), width: 1, height: 1);
},
infoBuilder: ({required centered}) {
centeredValue = centered;
return const SizedBox(key: Key('info'), width: 1, height: 1);
},
actionBar: const SizedBox(key: Key('actions'), width: 1, height: 1),
compactArtworkSize: 140,
compactArtworkSpacing: 12,
compactBottomSpacing: 8,
),
),
);
expect(artworkSize, 140);
expect(centeredValue, isTrue);
expect(find.byType(Column), findsOneWidget);
expect(
tester.getTopLeft(find.byKey(const Key('artwork'))).dy,
lessThan(tester.getTopLeft(find.byKey(const Key('info'))).dy),
);
expect(
tester.getTopLeft(find.byKey(const Key('info'))).dy,
lessThan(tester.getTopLeft(find.byKey(const Key('actions'))).dy),
);
});
testWidgets('wide header places full-size artwork beside left-aligned metadata', (tester) async {
double? artworkSize;
bool? centeredValue;
await tester.pumpWidget(
host(
width: 800,
header: MusicDetailHeader(
artworkBuilder: (size) {
artworkSize = size;
return const SizedBox(key: Key('artwork'), width: 1, height: 1);
},
infoBuilder: ({required centered}) {
centeredValue = centered;
return const SizedBox(key: Key('info'), width: 1, height: 1);
},
actionBar: const SizedBox(key: Key('actions'), width: 1, height: 1),
compactArtworkSize: 140,
compactArtworkSpacing: 12,
),
),
);
expect(artworkSize, 180);
expect(centeredValue, isFalse);
expect(find.byType(Row), findsOneWidget);
expect(
tester.getTopLeft(find.byKey(const Key('artwork'))).dx,
lessThan(tester.getTopLeft(find.byKey(const Key('info'))).dx),
);
expect(
tester.getTopLeft(find.byKey(const Key('info'))).dy,
lessThan(tester.getTopLeft(find.byKey(const Key('actions'))).dy),
);
});
}

Some files were not shown because too many files have changed in this diff Show More