refactor(features): consolidate shared feature primitives
This commit is contained in:
@@ -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,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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']),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user