From 7ecadffdb1dd8dad85542f5dbd6255ba267fbeb4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:55:52 +0200 Subject: [PATCH] refactor(features): consolidate shared feature primitives --- lib/focus/focusable_text_field.dart | 96 +++++---- lib/media/episode_collection.dart | 68 +++--- lib/media/stepped_seek.dart | 112 ++++++++++ lib/models/livetv_capture_buffer.dart | 14 +- lib/models/livetv_dvr.dart | 27 +-- lib/models/livetv_lineup.dart | 8 +- lib/models/livetv_session.dart | 32 +-- lib/models/livetv_session.g.dart | 2 +- lib/models/media_grab_operation.dart | 18 +- lib/models/media_grabber_device.dart | 16 +- lib/models/media_provider_info.dart | 16 +- lib/models/media_subscription.dart | 26 +-- lib/navigation/profile_session_screen.dart | 3 +- lib/providers/download_provider.dart | 37 +--- lib/screens/main_screen.dart | 2 +- lib/screens/music/album_detail_screen.dart | 41 +--- lib/screens/music/artist_detail_screen.dart | 45 +--- lib/screens/music/now_playing_screen.dart | 63 ++++-- lib/screens/profile/profile_teardown.dart | 2 +- lib/services/api_cache.dart | 75 +++++-- .../companion_remote_peer_service.dart | 105 ++++----- lib/services/jellyfin_api_cache.dart | 44 ++-- lib/services/plex_api_cache.dart | 42 ++-- lib/services/sync_rule_executor.dart | 29 +-- .../trackers/tracker_coordinator.dart | 12 +- lib/services/trakt/trakt_sync_service.dart | 12 +- lib/utils/json_utils.dart | 42 ++++ lib/utils/live_tv_player_navigation.dart | 7 +- lib/utils/video_player_navigation.dart | 21 +- lib/widgets/music/music_detail_header.dart | 69 ++++++ .../desktop_video_controls.dart | 108 ++-------- test/media/media_item_merge_test.dart | 9 +- test/media/media_item_test.dart | 40 ++-- test/media/stepped_seek_test.dart | 67 ++++++ .../jellyfin_metadata_edit_adapter_test.dart | 7 +- .../metadata_edit_models_test.dart | 5 +- test/mixins/item_updatable_test.dart | 3 +- test/mixins/paginated_item_loader_test.dart | 3 +- .../mixins/server_bound_media_mixin_test.dart | 3 +- test/models/livetv_flexible_parsing_test.dart | 81 +++++++ test/providers/discover_provider_test.dart | 3 +- test/providers/download_provider_test.dart | 48 ++--- .../playback_state_provider_test.dart | 17 +- test/providers/watch_state_store_test.dart | 5 +- test/screens/discover_screen_test.dart | 5 +- .../downloads/sync_rules_screen_test.dart | 9 +- test/screens/hub_detail_screen_test.dart | 3 +- test/screens/media_detail_screen_test.dart | 55 ++--- .../music/album_detail_screen_test.dart | 3 +- test/screens/music/queue_sheet_test.dart | 3 +- test/screens/playlist_detail_screen_test.dart | 5 +- test/screens/search_screen_test.dart | 3 +- .../player_prompt_overlays_test.dart | 3 +- .../catalog/trakt_catalog_source_test.dart | 3 +- .../companion_remote_peer_service_test.dart | 50 +++++ .../download_artwork_service_test.dart | 3 +- .../download_manager_service_test.dart | 11 +- .../download_storage_service_test.dart | 9 +- .../episode_navigation_service_test.dart | 5 +- .../external_player_service_test.dart | 3 +- test/services/jellyfin_client_urls_test.dart | 104 +++++++-- .../jellyfin_sequential_launcher_test.dart | 53 +++-- .../services/local_playback_history_test.dart | 9 +- .../music/music_playback_service_test.dart | 3 +- .../music/music_queue_controller_test.dart | 3 +- .../offline_watch_sync_service_test.dart | 3 +- test/services/play_queue_launcher_test.dart | 3 +- ...ack_initialization_offline_cache_test.dart | 19 +- .../playback_progress_tracker_test.dart | 18 +- test/services/playback_session_test.dart | 3 +- .../playback_source_resolver_test.dart | 7 +- test/services/plex_api_cache_test.dart | 45 ++++ .../plex_playback_data_request_test.dart | 3 +- test/services/plex_timeline_session_test.dart | 3 +- test/services/sync_rule_executor_test.dart | 17 +- test/services/track_manager_test.dart | 3 +- .../track_selection_service_test.dart | 3 +- .../anime_episode_progress_resolver_test.dart | 5 +- .../tracker_coordinator_manual_test.dart | 9 +- .../trackers/tracker_id_resolver_test.dart | 3 +- test/services/watch_state_resolver_test.dart | 3 +- test/test_helpers/media_items.dart | 200 ++++++++++++++++++ test/test_helpers/media_items_test.dart | 44 ++++ test/utils/content_utils_test.dart | 7 +- test/utils/downloaded_version_match_test.dart | 3 +- test/utils/episode_collection_test.dart | 21 +- test/utils/json_utils_test.dart | 27 +++ test/utils/media_hub_ordering_test.dart | 3 +- test/utils/media_navigation_helper_test.dart | 7 +- test/utils/media_quality_labels_test.dart | 3 +- test/utils/plex_season_display_test.dart | 5 +- test/utils/video_player_navigation_test.dart | 17 +- test/widgets/download_tree_view_test.dart | 3 +- test/widgets/focusable_text_field_test.dart | 25 +++ test/widgets/folder_tree_item_test.dart | 5 +- test/widgets/media_card_full_card_test.dart | 14 +- test/widgets/media_card_square_test.dart | 3 +- test/widgets/media_context_menu_test.dart | 23 +- test/widgets/music/mini_player_test.dart | 3 +- test/widgets/music_detail_header_test.dart | 89 ++++++++ test/widgets/player_queue_spoilers_test.dart | 5 +- test/widgets/tv_browse_rail_test.dart | 129 ++++++----- test/widgets/tv_spotlight_scaffold_test.dart | 3 +- 103 files changed, 1718 insertions(+), 898 deletions(-) create mode 100644 lib/media/stepped_seek.dart create mode 100644 lib/widgets/music/music_detail_header.dart create mode 100644 test/media/stepped_seek_test.dart create mode 100644 test/models/livetv_flexible_parsing_test.dart create mode 100644 test/services/companion_remote_peer_service_test.dart create mode 100644 test/test_helpers/media_items.dart create mode 100644 test/test_helpers/media_items_test.dart create mode 100644 test/widgets/music_detail_header_test.dart diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 8144b724..30d8dd03 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -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({ required TextEditingController controller, required String text, @@ -398,13 +406,9 @@ void _insertText({ ValueChanged? onChanged, }) { final value = controller.value; - final selection = value.selection; - final start = selection.isValid - ? (selection.start < selection.end ? selection.start : selection.end) - : value.text.length; - final end = selection.isValid - ? (selection.start > selection.end ? selection.start : selection.end) - : value.text.length; + final range = _normalizedSelectionRange(value); + final start = range.start; + final end = range.end; final newText = value.text.replaceRange(start, end, text); _replaceTextValue( controller: controller, @@ -426,13 +430,9 @@ void _backspace({ ValueChanged? onChanged, }) { final value = controller.value; - final selection = value.selection; - final start = selection.isValid - ? (selection.start < selection.end ? selection.start : selection.end) - : value.text.length; - final end = selection.isValid - ? (selection.start > selection.end ? selection.start : selection.end) - : value.text.length; + final range = _normalizedSelectionRange(value); + final start = range.start; + final end = range.end; if (start != end) { _replaceTextRange( controller, @@ -462,13 +462,9 @@ void _deleteForward({ ValueChanged? onChanged, }) { final value = controller.value; - final selection = value.selection; - final start = selection.isValid - ? (selection.start < selection.end ? selection.start : selection.end) - : value.text.length; - final end = selection.isValid - ? (selection.start > selection.end ? selection.start : selection.end) - : value.text.length; + final range = _normalizedSelectionRange(value); + final start = range.start; + final end = range.end; if (start != end) { _replaceTextRange( 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) { return _handleInputKey( controller: controller, @@ -650,7 +663,6 @@ abstract class _FocusableTextInputBase extends StatelessWidget { } Widget buildFocusableInput( - BuildContext context, Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder, ) { return _FocusableTextInputHost(input: this, builder: builder); @@ -994,14 +1006,14 @@ class FocusableTextField extends _FocusableTextInputBase { @override Widget build(BuildContext context) { - return buildFocusableInput( - context, - (usesTvKeyboard, effectiveFocusNode, openKeyboard) => TextField( + return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) { + final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard); + return TextField( controller: controller, focusNode: effectiveFocusNode, enabled: enabled, decoration: decoration, - keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType, + keyboardType: tvInput.keyboardType, textInputAction: textInputAction, inputFormatters: inputFormatters, onChanged: onChanged, @@ -1017,12 +1029,12 @@ class FocusableTextField extends _FocusableTextInputBase { textAlign: textAlign, textCapitalization: textCapitalization, style: style, - readOnly: usesTvKeyboard, - showCursor: usesTvKeyboard ? true : null, - enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection, - onTap: usesTvKeyboard ? openKeyboard : null, - ), - ); + readOnly: tvInput.readOnly, + showCursor: tvInput.showCursor, + enableInteractiveSelection: tvInput.enableInteractiveSelection, + onTap: tvInput.onTap, + ); + }); } } @@ -1071,14 +1083,14 @@ class FocusableTextFormField extends _FocusableTextInputBase { @override Widget build(BuildContext context) { - return buildFocusableInput( - context, - (usesTvKeyboard, effectiveFocusNode, openKeyboard) => TextFormField( + return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) { + final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard); + return TextFormField( controller: controller, focusNode: effectiveFocusNode, enabled: enabled, decoration: decoration, - keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType, + keyboardType: tvInput.keyboardType, textInputAction: textInputAction, inputFormatters: inputFormatters, onChanged: onChanged, @@ -1097,11 +1109,11 @@ class FocusableTextFormField extends _FocusableTextInputBase { textAlign: textAlign, textCapitalization: textCapitalization, style: style, - readOnly: usesTvKeyboard, - showCursor: usesTvKeyboard ? true : null, - enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection, - onTap: usesTvKeyboard ? openKeyboard : null, - ), - ); + readOnly: tvInput.readOnly, + showCursor: tvInput.showCursor, + enableInteractiveSelection: tvInput.enableInteractiveSelection, + onTap: tvInput.onTap, + ); + }); } } diff --git a/lib/media/episode_collection.dart b/lib/media/episode_collection.dart index a29ffe6d..cb081fa4 100644 --- a/lib/media/episode_collection.dart +++ b/lib/media/episode_collection.dart @@ -3,20 +3,17 @@ import 'media_item.dart'; import 'media_kind.dart'; import 'media_server_client.dart'; -/// Collect every episode of a show into [out] using the backend's one-shot -/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants] — -/// Plex's `/library/metadata/{id}/allLeaves`, Jellyfin's -/// `/Items?Recursive=true&IncludeItemTypes=Movie,Episode`). Avoids walking -/// show → seasons → episodes client-side, so large series come back in one -/// trip and aren't capped by any per-page Limit. +/// Collect every episode below a show or season into [out] using the backend's +/// one-shot recursive-leaves call ([MediaServerClient.fetchPlayableDescendants] +/// — Plex's `/library/metadata/{id}/allLeaves`, Jellyfin's +/// `/Items?Recursive=true&IncludeItemTypes=Movie,Episode`). This avoids walking +/// show → seasons → episodes client-side and is not capped by a page size. /// -/// A failure of the underlying call propagates to the caller — both -/// `DownloadProvider.queueDownload` and the sync rule executor wrap their -/// invocations so the user-facing error surfaces / the rule run is rolled -/// back. -Future collectEpisodesForShow( +/// A failure propagates to the caller so download and sync transactions can +/// surface or roll back the operation. +Future collectEpisodes( MediaServerClient client, - String showRatingKey, { + String parentId, { required bool unwatchedOnly, required List out, MediaItem? fallback, @@ -24,28 +21,7 @@ Future collectEpisodesForShow( }) { return _collectPlayable( client, - showRatingKey, - 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 collectEpisodesForSeason( - MediaServerClient client, - String seasonRatingKey, { - required bool unwatchedOnly, - required List out, - MediaItem? fallback, - bool includeSpecials = true, -}) { - return _collectPlayable( - client, - seasonRatingKey, + parentId, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback, @@ -60,10 +36,7 @@ Future fetchFirstEpisodeForSeason( String seasonRatingKey, { String? seriesId, }) async { - final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null; - final page = seriesId != null && seasonPagingClient != null - ? await seasonPagingClient.fetchSeasonEpisodesPage(seriesId, seasonRatingKey, start: 0, size: 1) - : await client.fetchChildrenPage(seasonRatingKey, start: 0, size: 1); + final page = await _fetchSeasonPage(client, seasonId: seasonRatingKey, seriesId: seriesId, start: 0, size: 1); for (final item in page.items) { if (item.kind == MediaKind.episode) return item; } @@ -259,10 +232,7 @@ Future> fetchSeasonEpisodePage( required int start, required int size, }) async { - final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null; - final page = seasonPagingClient != null - ? await seasonPagingClient.fetchSeasonEpisodesPage(show.id, season.id, start: start, size: size) - : await client.fetchChildrenPage(season.id, start: start, size: size); + final page = await _fetchSeasonPage(client, seriesId: show.id, seasonId: season.id, start: start, size: size); return LibraryPage( items: normalizeSeasonEpisodes(page.items, show: show, season: season), totalCount: page.totalCount, @@ -270,6 +240,20 @@ Future> fetchSeasonEpisodePage( ); } +Future> _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 normalizeSeasonEpisodes( List episodes, { required MediaItem show, diff --git a/lib/media/stepped_seek.dart b/lib/media/stepped_seek.dart new file mode 100644 index 00000000..93415b5f --- /dev/null +++ b/lib/media/stepped_seek.dart @@ -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(); + } +} diff --git a/lib/models/livetv_capture_buffer.dart b/lib/models/livetv_capture_buffer.dart index af41162b..ab98cd24 100644 --- a/lib/models/livetv_capture_buffer.dart +++ b/lib/models/livetv_capture_buffer.dart @@ -1,3 +1,5 @@ +import '../utils/json_utils.dart'; + /// Represents the seekable capture buffer for a live TV transcode session. /// /// 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. /// Values may be num or String depending on whether the server returned JSON or XML. static CaptureBuffer? fromTranscodeSession(Map session) { - final timeStamp = _parseDouble(session['timeStamp']); - final minOffset = _parseDouble(session['minOffsetAvailable']); - final maxOffset = _parseDouble(session['maxOffsetAvailable']); + final timeStamp = flexibleDouble(session['timeStamp']); + final minOffset = flexibleDouble(session['minOffsetAvailable']); + final maxOffset = flexibleDouble(session['maxOffsetAvailable']); if (timeStamp == null || minOffset == null || maxOffset == null) return null; 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 String toString() => 'CaptureBuffer(startedAt: $startedAt, seek: $seekStartSeconds..$seekEndSeconds, ' diff --git a/lib/models/livetv_dvr.dart b/lib/models/livetv_dvr.dart index 3951e9ac..45824e90 100644 --- a/lib/models/livetv_dvr.dart +++ b/lib/models/livetv_dvr.dart @@ -5,32 +5,11 @@ import 'media_subscription.dart'; part 'livetv_dvr.g.dart'; -List _parseChannelMappings(Object? raw) { - final result = []; - final list = flexibleList(raw) ?? const []; - for (final item in list) { - try { - result.add(ChannelMapping.fromJson(item as Map)); - } catch (_) {} - } - return result; -} +List _parseChannelMappings(Object? raw) => parseFlexibleJsonList(raw, ChannelMapping.fromJson); -List _parseSettings(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) SubscriptionSetting.fromJson(item), - ]; -} +List _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson); -List> _parseRawMaps(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) item, - ]; -} +List> _parseRawMaps(Object? raw) => flexibleMapList(raw); /// Represents a Plex Live TV DVR device (e.g., HDHomeRun tuner, IPTV provider) @JsonSerializable(createToJson: false) diff --git a/lib/models/livetv_lineup.dart b/lib/models/livetv_lineup.dart index ae0bcf35..524bf694 100644 --- a/lib/models/livetv_lineup.dart +++ b/lib/models/livetv_lineup.dart @@ -5,13 +5,7 @@ import 'livetv_channel.dart'; part 'livetv_lineup.g.dart'; -List _parseChannels(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) LiveTvChannel.fromJson(item), - ]; -} +List _parseChannels(Object? raw) => parseFlexibleJsonList(raw, LiveTvChannel.fromJson); @JsonSerializable(createToJson: false) class LiveTvCountry { diff --git a/lib/models/livetv_session.dart b/lib/models/livetv_session.dart index c1f34f95..05fe69d2 100644 --- a/lib/models/livetv_session.dart +++ b/lib/models/livetv_session.dart @@ -7,36 +7,14 @@ import 'media_grab_operation.dart'; part 'livetv_session.g.dart'; -Map? _firstMap(Object? raw) { - if (raw is Map) return raw; - if (raw is List && raw.isNotEmpty && raw.first is Map) return raw.first as Map; - return null; -} +LiveTvProgram? _programFromRaw(Object? raw) => parseFlexibleJsonObject(raw, LiveTvProgram.fromJson); -LiveTvProgram? _programFromRaw(Object? raw) { - 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; - } -} +MediaGrabOperation? _grabOperationFromRaw(Object? raw) => parseFlexibleJsonObject(raw, MediaGrabOperation.fromJson); CaptureBuffer? _captureBufferFromRaw(Object? raw) { - final map = _firstMap(raw); + final map = firstFlexibleMap(raw); if (map == null) return null; - final session = _firstMap(map['TranscodeSession']) ?? map; + final session = firstFlexibleMap(map['TranscodeSession']) ?? map; return CaptureBuffer.fromTranscodeSession(session); } @@ -60,7 +38,7 @@ class LiveTvSession { final CaptureBuffer? captureBuffer; @JsonKey(name: 'MediaGrabOperation', fromJson: _grabOperationFromRaw) final MediaGrabOperation? grabOperation; - @JsonKey(name: 'Timeline', fromJson: _firstMap) + @JsonKey(name: 'Timeline', fromJson: firstFlexibleMap) final Map? timeline; @JsonKey(name: 'AiringMetadataItem', fromJson: _programFromRaw) final LiveTvProgram? airingMetadataItem; diff --git a/lib/models/livetv_session.g.dart b/lib/models/livetv_session.g.dart index 415cc09b..0e1f1571 100644 --- a/lib/models/livetv_session.g.dart +++ b/lib/models/livetv_session.g.dart @@ -19,7 +19,7 @@ LiveTvSession _$LiveTvSessionFromJson(Map json) => startedAt: flexibleInt(json['startedAt']), captureBuffer: _captureBufferFromRaw(json['CaptureBuffer']), grabOperation: _grabOperationFromRaw(json['MediaGrabOperation']), - timeline: _firstMap(json['Timeline']), + timeline: firstFlexibleMap(json['Timeline']), airingMetadataItem: _programFromRaw(json['AiringMetadataItem']), upNextMetadataItem: _programFromRaw(json['UpNextMetadataItem']), ); diff --git a/lib/models/media_grab_operation.dart b/lib/models/media_grab_operation.dart index d4c7aac5..32f66e8f 100644 --- a/lib/models/media_grab_operation.dart +++ b/lib/models/media_grab_operation.dart @@ -5,23 +5,9 @@ import 'livetv_program.dart'; part 'media_grab_operation.g.dart'; -Map? _metadataFromJson(Object? raw) { - if (raw is Map) return raw; - if (raw is List && raw.isNotEmpty && raw.first is Map) { - return raw.first as Map; - } - return null; -} +Map? _metadataFromJson(Object? raw) => firstFlexibleMap(raw); -LiveTvProgram? _programFromMetadata(Object? raw) { - final metadata = _metadataFromJson(raw); - if (metadata == null) return null; - try { - return LiveTvProgram.fromJson(metadata); - } catch (_) { - return null; - } -} +LiveTvProgram? _programFromMetadata(Object? raw) => parseFlexibleJsonObject(raw, LiveTvProgram.fromJson); /// A scheduled or active Plex DVR grab operation. @JsonSerializable(createToJson: false) diff --git a/lib/models/media_grabber_device.dart b/lib/models/media_grabber_device.dart index f4f77006..5ab834e6 100644 --- a/lib/models/media_grabber_device.dart +++ b/lib/models/media_grabber_device.dart @@ -6,21 +6,9 @@ import 'media_subscription.dart'; part 'media_grabber_device.g.dart'; -List _parseChannelMappings(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) ChannelMapping.fromJson(item), - ]; -} +List _parseChannelMappings(Object? raw) => parseFlexibleJsonList(raw, ChannelMapping.fromJson); -List _parseSettings(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) SubscriptionSetting.fromJson(item), - ]; -} +List _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson); @JsonSerializable(createToJson: false) class MediaGrabber { diff --git a/lib/models/media_provider_info.dart b/lib/models/media_provider_info.dart index 10d57bfa..5e68e4db 100644 --- a/lib/models/media_provider_info.dart +++ b/lib/models/media_provider_info.dart @@ -4,21 +4,9 @@ import '../utils/json_utils.dart'; part 'media_provider_info.g.dart'; -List _parseFeatures(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) MediaProviderFeature.fromJson(item), - ]; -} +List _parseFeatures(Object? raw) => parseFlexibleJsonList(raw, MediaProviderFeature.fromJson); -List> _parseRawMaps(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) item, - ]; -} +List> _parseRawMaps(Object? raw) => flexibleMapList(raw); @JsonSerializable(createToJson: false) class MediaProviderInfo { diff --git a/lib/models/media_subscription.dart b/lib/models/media_subscription.dart index 23763bf4..317e30bf 100644 --- a/lib/models/media_subscription.dart +++ b/lib/models/media_subscription.dart @@ -5,31 +5,13 @@ import 'media_grab_operation.dart'; part 'media_subscription.g.dart'; -List _parseSubscriptions(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) MediaSubscription.fromJson(item), - ]; -} +List _parseSubscriptions(Object? raw) => parseFlexibleJsonList(raw, MediaSubscription.fromJson); -List _parseSettings(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) SubscriptionSetting.fromJson(item), - ]; -} +List _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson); -List _parseGrabOperations(Object? raw) { - final list = flexibleList(raw) ?? const []; - return [ - for (final item in list) - if (item is Map) MediaGrabOperation.fromJson(item), - ]; -} +List _parseGrabOperations(Object? raw) => parseFlexibleJsonList(raw, MediaGrabOperation.fromJson); -Map? _mapFromJson(Object? raw) => raw is Map ? raw : null; +Map? _mapFromJson(Object? raw) => firstFlexibleMap(raw); /// Template wrapper returned by `/media/subscriptions/template`. @JsonSerializable(createToJson: false) diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index 7d9ab706..cf876795 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -98,8 +98,7 @@ class _ProfileSessionScreenState extends State { } if (_lastSessionActiveId == activeId) return; _lastSessionActiveId = activeId; - final cache = ApiCache.maybeInstance; - if (cache != null) unawaited(cache.clearVolatile()); + unawaited(ApiCache.clearRegisteredVolatile()); } @override diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 7c8e0cfa..fa3ba146 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -980,15 +980,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin await queueItem(item); } else if (item.isShow || item.isSeason) { if (!expandShows) continue; - // One-shot recursive expansion (Plex /grandchildren, Jellyfin - // Recursive=true) — the per-season walk that used to live here - // was the same pattern as collectEpisodes*, just inlined. + // One-shot recursive expansion for both shows and seasons. final episodes = []; - if (item.isShow) { - await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item); - } else { - await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item); - } + await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item); for (final ep in episodes) { await queueItem(ep); } @@ -1264,25 +1258,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin includeSpecials || (container.kind == MediaKind.season && isSpecialSeasonNumber(container.index)); final relatedContext = _RelatedMetadataDownloadContext(); final episodes = []; - if (container.kind == MediaKind.show) { - await collectEpisodesForShow( - client, - container.id, - unwatchedOnly: unwatchedOnly, - out: episodes, - fallback: container, - includeSpecials: effectiveIncludeSpecials, - ); - } else { - await collectEpisodesForSeason( - client, - container.id, - unwatchedOnly: unwatchedOnly, - out: episodes, - fallback: container, - includeSpecials: effectiveIncludeSpecials, - ); - } + await collectEpisodes( + client, + container.id, + unwatchedOnly: unwatchedOnly, + out: episodes, + fallback: container, + includeSpecials: effectiveIncludeSpecials, + ); int count = 0; for (final episode in episodes) { diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index fc181ce2..31bf47cb 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1430,7 +1430,7 @@ class _MainScreenState extends State // Drop volatile API cache rows before screens kick off their refetch. // Pinned rows back offline downloads and must survive profile switches. try { - await ApiCache.instance.clearVolatile(); + await ApiCache.clearRegisteredVolatile(); } catch (e, st) { appLogger.w('Failed to clear ApiCache on profile switch', error: e, stackTrace: st); } diff --git a/lib/screens/music/album_detail_screen.dart b/lib/screens/music/album_detail_screen.dart index c3fedf28..6ed733eb 100644 --- a/lib/screens/music/album_detail_screen.dart +++ b/lib/screens/music/album_detail_screen.dart @@ -19,7 +19,6 @@ import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/formatters.dart'; -import '../../utils/layout_constants.dart'; import '../../utils/media_image_helper.dart'; import '../../utils/music_navigation.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/media_context_menu.dart'; import '../../widgets/music/mini_player.dart'; +import '../../widgets/music/music_detail_header.dart'; import '../../widgets/music/music_actions.dart'; import '../../widgets/music/track_row.dart'; import '../../widgets/optimized_media_image.dart'; @@ -315,38 +315,13 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen Navigator.pop(context), ); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: LayoutBuilder( - builder: (context, constraints) { - final narrow = constraints.maxWidth < ScreenBreakpoints.mobile; - if (narrow) { - return Column( - 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], - ), - ), - ], - ); - }, - ), + return MusicDetailHeader( + artworkBuilder: cover, + infoBuilder: info, + actionBar: actionRow, + compactArtworkSize: 200, + compactArtworkSpacing: 16, + wideAlignment: CrossAxisAlignment.end, ); } diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart index 8e392682..dd617201 100644 --- a/lib/screens/music/artist_detail_screen.dart +++ b/lib/screens/music/artist_detail_screen.dart @@ -13,7 +13,6 @@ import '../../mixins/grid_focus_node_mixin.dart'; import '../../services/music/music_playback_service.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/formatters.dart'; -import '../../utils/layout_constants.dart'; import '../../utils/media_image_helper.dart'; import '../../utils/music_navigation.dart'; import '../../utils/platform_detector.dart'; @@ -23,6 +22,7 @@ import '../../widgets/collapsible_text.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/music/mini_player.dart'; +import '../../widgets/music/music_detail_header.dart'; import '../../widgets/music/music_actions.dart'; import '../../widgets/optimized_media_image.dart'; import '../../widgets/overlay_sheet.dart'; @@ -179,42 +179,13 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen Navigator.pop(context), ); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: LayoutBuilder( - builder: (context, constraints) { - final narrow = constraints.maxWidth < ScreenBreakpoints.mobile; - if (narrow) { - return Column( - 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], - ), - ), - ], - ); - }, - ), + return MusicDetailHeader( + artworkBuilder: portrait, + infoBuilder: info, + actionBar: actionRow, + compactArtworkSize: 140, + compactArtworkSpacing: 12, + compactBottomSpacing: 8, ); } diff --git a/lib/screens/music/now_playing_screen.dart b/lib/screens/music/now_playing_screen.dart index 0a96f314..e96cfc92 100644 --- a/lib/screens/music/now_playing_screen.dart +++ b/lib/screens/music/now_playing_screen.dart @@ -16,6 +16,7 @@ import '../../i18n/strings.g.dart'; import '../../media/ids.dart'; import '../../media/lyrics.dart'; import '../../media/media_item.dart'; +import '../../media/stepped_seek.dart'; import '../../media/media_server_client.dart'; import '../../mixins/context_menu_tap_mixin.dart'; import '../../services/device_performance.dart'; @@ -1004,27 +1005,37 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> { int _seekRepeatCount = 0; LogicalKeyboardKey? _seekDirection; - Duration? _keySeekTarget; + late final DebouncedSeekAccumulator _keySeek; - /// Stepped acceleration tiers, mirroring the video timeline's key-repeat - /// scrubbing. - double _seekMultiplier() { - if (_seekRepeatCount <= 5) return 1.5; - if (_seekRepeatCount <= 15) return 3.0; - if (_seekRepeatCount <= 30) return 6.0; - return 10.0; + @override + void initState() { + super.initState(); + _keySeek = DebouncedSeekAccumulator( + currentPosition: () => context.read().position, + duration: () => context.read().duration ?? Duration.zero, + seek: (target) => unawaited(context.read().seek(target)), + onChanged: () { + if (mounted) setState(() {}); + }, + ); + } + + @override + void dispose() { + _keySeek.dispose(); + super.dispose(); } void _resetSeekState() { _seekRepeatCount = 0; _seekDirection = null; - _keySeekTarget = null; } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { final key = event.logicalKey; if (event is KeyUpEvent && (key.isLeftKey || key.isRightKey)) { + _keySeek.flush(); _resetSeekState(); return KeyEventResult.handled; } @@ -1054,16 +1065,11 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> { _seekRepeatCount = 0; } 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(); - // Step from the in-flight target during a held burst — the position - // stream lags behind the seeks. - 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)); + final step = Duration(milliseconds: stepMs); + _keySeek.seekBy(key.isRightKey ? step : -step); return KeyEventResult.handled; } @@ -1082,7 +1088,10 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> { final duration = service.duration ?? Duration.zero; final durationMs = duration.inMilliseconds.toDouble(); 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 dragging = _dragValueMs != null; @@ -1103,7 +1112,12 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> { child: Slider( max: hasDuration ? durationMs : 1, 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, onChangeEnd: hasDuration ? (value) { @@ -1135,10 +1149,13 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> { focusNode: widget.focusNode, descendantsAreFocusable: false, onKeyEvent: _handleKeyEvent, - onFocusChange: (hasFocus) => setState(() { - _focused = hasFocus; - if (!hasFocus) _resetSeekState(); - }), + onFocusChange: (hasFocus) { + if (!hasFocus) { + _keySeek.flush(); + _resetSeekState(); + } + setState(() => _focused = hasFocus); + }, child: AnimatedContainer( duration: FocusTheme.getAnimationDuration(context), padding: const EdgeInsets.symmetric(vertical: 4), diff --git a/lib/screens/profile/profile_teardown.dart b/lib/screens/profile/profile_teardown.dart index bcc8cddf..3f059bf8 100644 --- a/lib/screens/profile/profile_teardown.dart +++ b/lib/screens/profile/profile_teardown.dart @@ -251,7 +251,7 @@ Future logoutAllProfiles(BuildContext context) async { await scope.database.clearAllSyncRules(); // Preserve pinned rows backing offline downloads; all session/API data is // volatile and must not cross into the next sign-in. - await ApiCache.instance.clearVolatile(); + await ApiCache.clearRegisteredVolatile(); await scope.hiddenLibraries?.refresh(); playbackState.clearShuffle(); diff --git a/lib/services/api_cache.dart b/lib/services/api_cache.dart index 91e55637..cf4eabc1 100644 --- a/lib/services/api_cache.dart +++ b/lib/services/api_cache.dart @@ -20,32 +20,59 @@ import '../utils/isolate_helper.dart'; /// implement the abstract [getMetadata] / [pinForOffline] / [deleteForItem] /// methods so callers can dispatch via [forBackend] instead of switching on /// the backend type at every call site. -abstract class ApiCache { - static ApiCache? _instance; +class ApiCacheSingleton { + ApiCacheSingleton(this.backend, this.typeName); - /// Returns the most recently registered cache instance — used by callers - /// that don't care which backend's helpers they're hitting (e.g. plain - /// `get`/`put` from `JellyfinClient`). Backend-specific operations should - /// route through [forBackend] instead. - static ApiCache get instance { - if (_instance == null) { - throw StateError('ApiCache not initialized. Call initialize() on a backend cache first.'); + final MediaBackend backend; + final String typeName; + T? _instance; + + T get instance { + final value = _instance; + 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 — - /// for best-effort callers (nothing cached yet means nothing to clear). - static ApiCache? get maybeInstance => _instance; + void install(T instance) { + _instance = instance; + ApiCache.registerInstance(backend, instance); + } +} +/// Decodes independent cached JSON rows, dropping only the malformed row. +Map decodeCachedMediaRows( + Iterable rows, { + required String Function(T row) serializedData, + required MapEntry? Function(T row, Map json) decode, +}) { + final result = {}; + for (final row in rows) { + try { + final json = jsonDecode(serializedData(row)) as Map; + 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 _byBackend = {}; - /// Subclasses call this from their own `initialize` to register themselves - /// for backend dispatch. Also seeds [instance] so the legacy singleton - /// surface keeps working. + /// Registers a backend cache. A new database marks a new application/test + /// lifecycle, so registrations tied to the previous database are discarded + /// instead of leaving backend dispatch pointed at a closed connection. static void registerInstance(MediaBackend backend, ApiCache cache) { + if (_byBackend.values.any((registered) => !identical(registered.database, cache.database))) { + _byBackend.clear(); + } _byBackend[backend] = cache; - _instance = cache; } /// Pick the cache for [backend]. Plex is the legacy default — covers items @@ -58,6 +85,20 @@ abstract class ApiCache { 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 clearRegisteredVolatile() async { + final cleared = {}; + for (final cache in _byBackend.values) { + if (cleared.add(cache.database)) { + await cache.clearVolatile(); + } + } + } + final AppDatabase _db; ApiCache(this._db); diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart index 1dcffbb6..603cc886 100644 --- a/lib/services/companion_remote/companion_remote_peer_service.dart +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -340,23 +340,7 @@ class CompanionRemotePeerService with KeepaliveMixin { unawaited(socket.close(4002, 'Authentication required')); } } else { - // Encrypted command — data is binary - final decrypted = await _decryptIncoming(data); - if (decrypted == null) return; - - final json = jsonDecode(decrypted) as Map; - 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(); - } + await _handleEncryptedCommand(data); } } catch (e) { appLogger.e('CompanionRemote: Failed to process message', error: e); @@ -443,23 +427,7 @@ class CompanionRemotePeerService with KeepaliveMixin { (data) async { try { if (_isAuthenticated) { - // Post-auth: all messages are encrypted binary - final decrypted = await _decryptIncoming(data); - if (decrypted == null) return; - - final json = jsonDecode(decrypted) as Map; - 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(); - } + await _handleEncryptedCommand(data); } else if (_sessionEncKey != null) { // Keys derived, waiting for encrypted authSuccess final decrypted = await _decryptIncoming(data); @@ -781,18 +749,25 @@ class CompanionRemotePeerService with KeepaliveMixin { // ── Encrypted send/receive ── - // Serializes async sends to prevent counter interleaving + // Serialize cryptographic operations so implicit nonce counters cannot + // interleave when stream callbacks overlap. + Future? _encryptChain; + Future? _decryptChain; Future? _sendChain; - Future> _encryptOutgoing(String plaintext) async { - final encrypted = await RemoteAuthService.instance.encrypt( - _sessionEncKey!, - utf8.encode(plaintext), - isHost: _role == RemoteSessionRole.host, - counter: _sendCounter, - ); - _sendCounter++; - return encrypted; + Future> _encryptOutgoing(String plaintext) { + final result = (_encryptChain ?? Future.value()).then((_) async { + final encrypted = await RemoteAuthService.instance.encrypt( + _sessionEncKey!, + utf8.encode(plaintext), + isHost: _role == RemoteSessionRole.host, + counter: _sendCounter, + ); + _sendCounter++; + return encrypted; + }); + _encryptChain = result.then((_) {}); + return result; } Future _sendEncryptedToSocket(WebSocket socket, String plaintext) async { @@ -801,15 +776,20 @@ class CompanionRemotePeerService with KeepaliveMixin { socket.add(encrypted); } - Future _decryptIncoming(dynamic data) async { - if (_sessionEncKey == null) return null; + Future _decryptIncoming(dynamic data) { + if (_sessionEncKey == null) return Future.value(); + final result = (_decryptChain ?? Future.value()).then((_) => _decryptIncomingNow(data)); + _decryptChain = result.then((_) {}); + return result; + } + + Future _decryptIncomingNow(dynamic data) async { try { - final auth = RemoteAuthService.instance; final bytes = data is List ? data : utf8.encode(data as String); - final decrypted = await auth.decrypt( + final decrypted = await RemoteAuthService.instance.decrypt( bytes, _sessionEncKey!, - fromHost: _role == RemoteSessionRole.remote, // If we're remote, incoming is from host + fromHost: _role == RemoteSessionRole.remote, expectedCounter: _recvCounter, ); _recvCounter++; @@ -820,6 +800,23 @@ class CompanionRemotePeerService with KeepaliveMixin { } } + Future _handleEncryptedCommand(dynamic data) async { + final decrypted = await _decryptIncoming(data); + if (decrypted == null) return; + + final command = RemoteCommand.fromJson(jsonDecode(decrypted) as Map); + appLogger.d('CompanionRemote: Received command: ${command.type}'); + + if (_shouldSendAck(command)) { + _sendAck(command); + } + _commandReceivedController.add(command); + + if (command.type == RemoteCommandType.ping) { + _sendPong(); + } + } + // ── Commands ── @override @@ -894,6 +891,13 @@ class CompanionRemotePeerService with KeepaliveMixin { 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) { try { await _clientSocket!.close(); @@ -925,8 +929,9 @@ class CompanionRemotePeerService with KeepaliveMixin { _sessionEncKey = null; _sendCounter = 0; _recvCounter = 0; - _isAuthenticated = false; _sendChain = null; + _encryptChain = null; + _decryptChain = null; _failedAuthAttempts.clear(); _authLockouts.clear(); diff --git a/lib/services/jellyfin_api_cache.dart b/lib/services/jellyfin_api_cache.dart index 661bfcf6..e0e682ae 100644 --- a/lib/services/jellyfin_api_cache.dart +++ b/lib/services/jellyfin_api_cache.dart @@ -21,23 +21,15 @@ import 'jellyfin_mappers.dart'; /// the bare machine id; the compound prefix only isolates local user-scoped /// state such as `UserData`. class JellyfinApiCache extends ApiCache { - static JellyfinApiCache? _instance; - static JellyfinApiCache get instance { - if (_instance == null) { - throw StateError('JellyfinApiCache not initialized. Call JellyfinApiCache.initialize() first.'); - } - return _instance!; - } + static final _singleton = ApiCacheSingleton(MediaBackend.jellyfin, 'JellyfinApiCache'); + static JellyfinApiCache get instance => _singleton.instance; JellyfinApiCache._(super.db); /// Initialize the singleton with an [AppDatabase] instance. Also registers /// this instance with the [ApiCache] backend dispatch so callers using /// `ApiCache.forBackend(MediaBackend.jellyfin)` resolve here. - static void initialize(AppDatabase db) { - _instance = JellyfinApiCache._(db); - ApiCache.registerInstance(MediaBackend.jellyfin, _instance!); - } + static void initialize(AppDatabase db) => _singleton.install(JellyfinApiCache._(db)); JellyfinCacheResolver get _resolver => JellyfinCacheResolver(database); @@ -211,29 +203,25 @@ class JellyfinApiCache extends ApiCache { } } - return await tryIsolateRun(() { - final result = {}; - for (final entry in entries) { - final ctx = contexts[entry.connection.id]; - final absolutizer = absolutizers[entry.connection.id]; - if (ctx == null || absolutizer == null) continue; - try { - final data = jsonDecode(entry.cacheRow.data) as Map; + return await tryIsolateRun( + () => decodeCachedMediaRows( + entries, + serializedData: (entry) => entry.cacheRow.data, + decode: (entry, data) { + final ctx = contexts[entry.connection.id]; + final absolutizer = absolutizers[entry.connection.id]; + if (ctx == null || absolutizer == null) return null; final mapped = JellyfinMappers.mediaItem( data, serverId: ServerId(ctx.machineId), serverName: ctx.name, absolutizer: absolutizer, ); - if (mapped != null) { - result[buildGlobalKey(ServerId(entry.key.scopeId), entry.key.itemId)] = mapped; - } - } catch (_) { - // Skip malformed entries - } - } - return result; - }); + if (mapped == null) return null; + return MapEntry(buildGlobalKey(ServerId(entry.key.scopeId), entry.key.itemId), mapped); + }, + ), + ); } /// Resolve the connection context (server name + base URL + access token) diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index 517e7466..98655519 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import '../media/ids.dart'; import 'package:drift/drift.dart'; @@ -20,23 +19,15 @@ import 'plex_mappers.dart'; /// endpoint shape and parse cached JSON into [MediaItem] via /// [PlexMappers.mediaItemFromCacheJson]. class PlexApiCache extends ApiCache { - static PlexApiCache? _instance; - static PlexApiCache get instance { - if (_instance == null) { - throw StateError('PlexApiCache not initialized. Call PlexApiCache.initialize() first.'); - } - return _instance!; - } + static final _singleton = ApiCacheSingleton(MediaBackend.plex, 'PlexApiCache'); + static PlexApiCache get instance => _singleton.instance; PlexApiCache._(super.db); /// Initialize the singleton with an [AppDatabase] instance. Also registers /// this instance with the [ApiCache] backend dispatch so callers using /// `ApiCache.forBackend(MediaBackend.plex)` resolve here. - static void initialize(AppDatabase db) { - _instance = PlexApiCache._(db); - ApiCache.registerInstance(MediaBackend.plex, _instance!); - } + static void initialize(AppDatabase db) => _singleton.install(PlexApiCache._(db)); /// Delete cached data for a specific item (when removing a download). @override @@ -144,23 +135,20 @@ class PlexApiCache extends ApiCache { final entries = await listPinnedRowsByPattern(_metadataKeyPattern); if (entries.isEmpty) return {}; - return await tryIsolateRun(() { - final result = {}; - for (final entry in entries) { - try { - final data = jsonDecode(entry.data) as Map; + return await tryIsolateRun( + () => decodeCachedMediaRows( + entries, + serializedData: (entry) => entry.data, + decode: (entry, data) { final container = PlexCacheParser.extractMediaContainer(data); final json = PlexCacheParser.extractFirstMetadata(data); - if (json == null) continue; - result[buildGlobalKey(ServerId(entry.serverId), entry.id)] = PlexMappers.mediaItemFromCacheJson( - _withContainerLibrary(json, container), - serverId: entry.serverId, + if (json == null) return null; + return MapEntry( + buildGlobalKey(ServerId(entry.serverId), entry.id), + PlexMappers.mediaItemFromCacheJson(_withContainerLibrary(json, container), serverId: entry.serverId), ); - } catch (_) { - // Skip malformed entries - } - } - return result; - }); + }, + ), + ); } } diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index cb8f8885..1551297b 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -259,24 +259,14 @@ class SyncRuleExecutor { }) async { final fromServer = []; final sourceMetadata = metadata[rule.globalKey]; - if (rule.targetType == ContentTypes.show) { - await collectEpisodesForShow( - client, - rule.ratingKey, - unwatchedOnly: true, - out: fromServer, - fallback: sourceMetadata, - includeSpecials: rule.includeSpecials, - ); - } else { - await collectEpisodesForSeason( - client, - rule.ratingKey, - unwatchedOnly: true, - out: fromServer, - fallback: sourceMetadata, - ); - } + await collectEpisodes( + client, + rule.ratingKey, + unwatchedOnly: true, + out: fromServer, + fallback: sourceMetadata, + includeSpecials: rule.targetType != ContentTypes.show || rule.includeSpecials, + ); final unwatchedEpisodes = await _excludeLocallyWatched( episodes: fromServer, @@ -442,9 +432,8 @@ class SyncRuleExecutor { if (unwatchedOnly && !item.isUnwatchedOrInProgress) break; out.add(item); case MediaKind.show: - await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item); 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.artist: // One recursive-leaves call per container on both backends diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index 6f026b27..163215e6 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -142,11 +142,7 @@ class TrackerCoordinator { } final episodes = []; - if (kind == MediaKind.show) { - await collectEpisodesForShow(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - } else { - await collectEpisodesForSeason(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - } + await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); appLogger.d('Trackers: manual ${kind.name} ${item.id} expanded to ${episodes.length} episodes'); await _markContainerEpisodesWatched(episodes, resolver); @@ -169,11 +165,7 @@ class TrackerCoordinator { } final episodes = []; - if (kind == MediaKind.show) { - await collectEpisodesForShow(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - } else { - await collectEpisodesForSeason(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - } + await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); appLogger.d('Trackers: manual ${kind.name} ${item.id} unwatched expanded to ${episodes.length} episodes'); await _markContainerEpisodesUnwatched(episodes, resolver); diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index 82cb20ca..b26fd71e 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -183,17 +183,7 @@ class TraktSyncService { parentId: event.mediaType == 'season' && event.parentChain.isNotEmpty ? event.parentChain.first : null, ); final episodes = []; - if (fallback.kind == MediaKind.show) { - await collectEpisodesForShow(mediaClient, event.itemId, unwatchedOnly: false, out: episodes, fallback: fallback); - } else { - await collectEpisodesForSeason( - mediaClient, - event.itemId, - unwatchedOnly: false, - out: episodes, - fallback: fallback, - ); - } + await collectEpisodes(mediaClient, event.itemId, unwatchedOnly: false, out: episodes, fallback: fallback); for (final episode in episodes) { if (episode.kind != MediaKind.episode) continue; diff --git a/lib/utils/json_utils.dart b/lib/utils/json_utils.dart index 476c0226..31226784 100644 --- a/lib/utils/json_utils.dart +++ b/lib/utils/json_utils.dart @@ -54,6 +54,48 @@ List? flexibleList(Object? v) => switch (v) { _ => [v], }; +/// Return only JSON object entries from a value that may be one object, a +/// heterogeneous list, or null. +List> flexibleMapList(Object? value) { + return [ + for (final item in flexibleList(value) ?? const []) + if (item is Map) item, + ]; +} + +/// Return the first JSON object from a single object or heterogeneous list. +Map? firstFlexibleMap(Object? value) { + for (final item in flexibleList(value) ?? const []) { + if (item is Map) return item; + } + return null; +} + +/// Parse every valid JSON object independently, dropping malformed entries +/// instead of letting one row discard an otherwise usable response. +List parseFlexibleJsonList(Object? value, T Function(Map json) parse) { + final result = []; + 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(Object? value, T Function(Map 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?`. /// Non-string elements are dropped; an empty result (or null input) yields /// `null`. Typed sibling of [flexibleList] — a bare String is wrapped into a diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index 12c92e99..871631a0 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -64,14 +64,11 @@ Future navigateToLiveTv( appLogger.w('Live TV launch channel was not present in navigation list; prepending ${channel.key}'); } - final route = PageRouteBuilder( - settings: const RouteSettings(name: kVideoPlayerRouteName), - pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( + final route = buildVideoPlayerRoute( + builder: (_) => VideoPlayerScreen( metadata: placeholder, live: LiveTvSessionArgs(channel: channel, channels: normalizedChannels, currentChannelIndex: currentChannelIndex), ), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, ); unawaited(navigator.push(route)); diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 6292ec2b..3f1e5a99 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -25,6 +25,20 @@ import 'platform_detector.dart'; 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 buildVideoPlayerRoute({required WidgetBuilder builder}) { + return PageRouteBuilder( + settings: const RouteSettings(name: kVideoPlayerRouteName), + pageBuilder: (context, _, _) => builder(context), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ); +} + class VideoPlayerNavigationInFlightGuard { final Set _keys = {}; @@ -324,9 +338,8 @@ Future navigateToVideoPlayer( return null; } - final route = PageRouteBuilder( - settings: const RouteSettings(name: kVideoPlayerRouteName), - pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( + final route = buildVideoPlayerRoute( + builder: (_) => VideoPlayerScreen( metadata: metadata, preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, @@ -337,8 +350,6 @@ Future navigateToVideoPlayer( selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, ), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, ); return usePushReplacement ? navigator.pushReplacement(route) : navigator.push(route); diff --git a/lib/widgets/music/music_detail_header.dart b/lib/widgets/music/music_detail_header.dart new file mode 100644 index 00000000..b09bd0f6 --- /dev/null +++ b/lib/widgets/music/music_detail_header.dart @@ -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], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 96166eb4..ea78dc4f 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import '../../focus/dpad_navigator.dart'; import '../../media/media_item.dart'; +import '../../media/stepped_seek.dart'; import '../../mpv/mpv.dart'; import '../../media/media_source_info.dart'; import '../../services/fullscreen_state_manager.dart'; @@ -193,18 +194,8 @@ class DesktopVideoControlsState extends State { // Preview thumbnail during sustained dpad/keyboard seeking bool _showKeyRepeatThumbnail = false; Timer? _keyRepeatThumbnailTimer; - Timer? _timelineSeekDebounceTimer; - Timer? _timelinePreviewClearTimer; - Duration? _timelinePreviewPosition; - Duration? _lastFlushedTimelinePreviewPosition; + late final DebouncedSeekAccumulator _timelineSeek; 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 bool _contentStripVisible = false; @@ -246,6 +237,14 @@ class DesktopVideoControlsState extends State { _goToLiveFocusNode, ]; widget.chromeController?.addListener(_onChromeControllerChanged); + _timelineSeek = DebouncedSeekAccumulator( + currentPosition: () => widget.player.state.position, + duration: () => widget.player.state.duration, + seek: widget.onSeekEnd, + onChanged: () { + if (mounted) setState(() {}); + }, + ); } @override @@ -261,8 +260,7 @@ class DesktopVideoControlsState extends State { void dispose() { widget.chromeController?.removeListener(_onChromeControllerChanged); _keyRepeatThumbnailTimer?.cancel(); - _timelineSeekDebounceTimer?.cancel(); - _timelinePreviewClearTimer?.cancel(); + _timelineSeek.dispose(); _prevItemFocusNode.dispose(); _prevChapterFocusNode.dispose(); _skipBackFocusNode.dispose(); @@ -334,7 +332,7 @@ class DesktopVideoControlsState extends State { widget.onFocusActivity?.call(); } else { // Reset progressive seek state when timeline loses focus - _flushTimelinePreviewSeek(); + _timelineSeek.flush(); _resetSeekState(); } } @@ -488,56 +486,6 @@ class DesktopVideoControlsState extends State { } } - 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. /// Arms a short timer that hides the thumbnail once repeats stop. void _triggerKeyRepeatThumbnail() { @@ -551,19 +499,6 @@ class DesktopVideoControlsState extends State { }); } - /// 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 KeyEventResult _handleTimelineKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; @@ -572,7 +507,7 @@ class DesktopVideoControlsState extends State { // seek (a no-op when nothing is pending) and reset progressive seek state. if (event is KeyUpEvent) { if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { - _flushTimelinePreviewSeek(); + _timelineSeek.flush(); _resetSeekState(); return KeyEventResult.handled; } @@ -588,7 +523,7 @@ class DesktopVideoControlsState extends State { // UP arrow - hide controls and reset seek state if (key == LogicalKeyboardKey.arrowUp) { - _flushTimelinePreviewSeek(); + _timelineSeek.flush(); _resetSeekState(); widget.onHideControls?.call(); return KeyEventResult.handled; @@ -596,7 +531,7 @@ class DesktopVideoControlsState extends State { // DOWN arrow - move focus to play/pause button and reset seek state if (key == LogicalKeyboardKey.arrowDown) { - _flushTimelinePreviewSeek(); + _timelineSeek.flush(); _resetSeekState(); _playPauseFocusNode.requestFocus(); widget.onFocusActivity?.call(); @@ -619,7 +554,7 @@ class DesktopVideoControlsState extends State { } 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 // coalesces a rapid/held burst into one transcode re-open (#1253). The @@ -638,13 +573,7 @@ class DesktopVideoControlsState extends State { final stepMs = (baseStepMs * effectiveMultiplier).clamp(500, 120_000).toInt(); final step = Duration(milliseconds: stepMs); - // Accumulate the scrub target from a stable base — the in-flight preview - // 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); + _timelineSeek.seekBy(isForward ? step : -step); // 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 @@ -653,7 +582,6 @@ class DesktopVideoControlsState extends State { // The player's `buffering` flag lags the key-repeat rate, so it can't gate // this reliably — coalescing unconditionally matches the existing transcode // path and cannot flood regardless of hardware. - _scheduleTimelinePreviewSeekFlush(); widget.onFocusActivity?.call(); return KeyEventResult.handled; } @@ -823,7 +751,7 @@ class DesktopVideoControlsState extends State { enabled: canInteract, thumbnailDataBuilder: widget.thumbnailDataBuilder, showKeyRepeatThumbnail: _showKeyRepeatThumbnail, - previewPosition: _timelinePreviewPosition, + previewPosition: _timelineSeek.pendingPosition, ), ], // Row 2: Playback controls and options diff --git a/test/media/media_item_merge_test.dart b/test/media/media_item_merge_test.dart index 16af82c5..b06154e3 100644 --- a/test/media/media_item_merge_test.dart +++ b/test/media/media_item_merge_test.dart @@ -4,9 +4,10 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_item_merge.dart'; import 'package:plezy/media/media_kind.dart'; +import '../test_helpers/media_items.dart'; 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', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -17,14 +18,14 @@ void main() { ); 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.globalKey, 'fallback:item'); }); 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'), existing: item(serverId: 'existing', serverName: 'Existing', libraryId: 'old-lib', libraryTitle: 'Old'), fallbackServerId: ServerId('fallback'), @@ -37,7 +38,7 @@ void main() { }); test('fills missing fetched library context from the existing item', () { - final merged = mergeFetchedMediaItem( + final merged = mergeFetchedtestMediaItem( fetched: item(), existing: item(libraryId: 'old-lib', libraryTitle: 'Old'), fallbackServerId: ServerId('fallback'), diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart index ed145d3f..635a76c4 100644 --- a/test/media/media_item_test.dart +++ b/test/media/media_item_test.dart @@ -5,6 +5,7 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_part.dart'; import 'package:plezy/media/media_role.dart'; import 'package:plezy/media/media_version.dart'; +import '../test_helpers/media_items.dart'; /// Backend-agnostic [MediaItem] tests. Existing coverage is split between /// `plex_mappers_test` and `jellyfin_mappers_test` — those exercise the @@ -22,7 +23,7 @@ MediaItem _movie({ String? artPath, String? backgroundSquarePath, MediaBackend backend = MediaBackend.plex, -}) => MediaItem( +}) => testMediaItem( id: id, backend: backend, kind: MediaKind.movie, @@ -50,7 +51,7 @@ void main() { }); test('show with all leaves watched is watched', () { - final show = MediaItem( + final show = testMediaItem( id: 's', backend: MediaBackend.plex, kind: MediaKind.show, @@ -62,7 +63,7 @@ void main() { }); test('show with viewedLeafCount > leafCount is still watched (defensive)', () { - final show = MediaItem( + final show = testMediaItem( id: 's', backend: MediaBackend.plex, kind: MediaKind.show, @@ -74,7 +75,13 @@ void main() { }); 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); }); }); @@ -102,7 +109,7 @@ void main() { }); test('episodes prefer show art before episode art for wide hero containers', () { - final episode = MediaItem( + final episode = testMediaItem( id: 'e1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -122,7 +129,7 @@ void main() { group('MediaItem.isPartiallyWatched', () { test('show with some leaves watched is partially watched', () { - final show = MediaItem( + final show = testMediaItem( id: 's', backend: MediaBackend.plex, kind: MediaKind.show, @@ -134,7 +141,7 @@ void main() { }); test('show with zero leaves watched is NOT partially watched', () { - final show = MediaItem( + final show = testMediaItem( id: 's', backend: MediaBackend.plex, kind: MediaKind.show, @@ -146,7 +153,7 @@ void main() { }); test('show with all leaves watched is NOT partially watched', () { - final show = MediaItem( + final show = testMediaItem( id: 's', backend: MediaBackend.plex, kind: MediaKind.show, @@ -210,7 +217,7 @@ void main() { }); test('preserves Plex-only fields when omitted', () { - const original = PlexMediaItem( + const original = PlextestMediaItem( id: 'p1', kind: MediaKind.movie, title: 'Old', @@ -244,7 +251,7 @@ void main() { }); test('preserves Jellyfin playlist item id when omitted', () { - const original = JellyfinMediaItem( + const original = JellyfintestMediaItem( id: 'j1', kind: MediaKind.movie, title: 'Old', @@ -270,7 +277,7 @@ void main() { group('MediaItem JSON', () { test('round-trips Plex-only fields', () { - const original = PlexMediaItem( + const original = PlextestMediaItem( id: 'p1', kind: MediaKind.movie, title: 'Movie', @@ -321,7 +328,12 @@ void main() { }); 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 decoded = MediaItem.fromJson(json); @@ -343,7 +355,7 @@ void main() { group('MediaItem.displayTitle', () { test('episode prefers grandparent (show) title', () { - final ep = MediaItem( + final ep = testMediaItem( id: 'e1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -357,7 +369,7 @@ void main() { }); test('season prefers grandparent over parent (when both present)', () { - final season = MediaItem( + final season = testMediaItem( id: 'sn1', backend: MediaBackend.plex, kind: MediaKind.season, diff --git a/test/media/stepped_seek_test.dart b/test/media/stepped_seek_test.dart new file mode 100644 index 00000000..1e960fe3 --- /dev/null +++ b/test/media/stepped_seek_test.dart @@ -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 = []; + 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 = []; + 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(); + }); + }); +} diff --git a/test/metadata_edit/jellyfin_metadata_edit_adapter_test.dart b/test/metadata_edit/jellyfin_metadata_edit_adapter_test.dart index c214a654..b0b13104 100644 --- a/test/metadata_edit/jellyfin_metadata_edit_adapter_test.dart +++ b/test/metadata_edit/jellyfin_metadata_edit_adapter_test.dart @@ -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/services/jellyfin_client.dart'; import 'package:plezy/utils/media_image_helper.dart'; +import '../test_helpers/media_items.dart'; void main() { test('load fails when the full editable Jellyfin DTO is unavailable', () async { @@ -21,7 +22,7 @@ void main() { addTearDown(client.close); final adapter = JellyfinMetadataEditAdapter(client); - final item = MediaItem( + final item = testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -40,7 +41,7 @@ void main() { final adapter = JellyfinMetadataEditAdapter(client); 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 artwork = adapter.buildSchema(draft).singleWhere((section) => section.id == 'artwork'); return artwork.fields.singleWhere((field) => field.id == 'artwork:Primary').artwork!; @@ -74,7 +75,7 @@ void main() { addTearDown(client.close); 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); draft.setValue('director', ['Alice', 'Charlie']); diff --git a/test/metadata_edit/metadata_edit_models_test.dart b/test/metadata_edit/metadata_edit_models_test.dart index 747c3d54..9152d726 100644 --- a/test/metadata_edit/metadata_edit_models_test.dart +++ b/test/metadata_edit/metadata_edit_models_test.dart @@ -4,10 +4,11 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/metadata_edit/metadata_edit_models.dart'; +import '../test_helpers/media_items.dart'; void main() { 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 draft = MetadataEditDraft( sourceItem: item, @@ -23,7 +24,7 @@ void main() { }); 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 draft = MetadataEditDraft( sourceItem: item, diff --git a/test/mixins/item_updatable_test.dart b/test/mixins/item_updatable_test.dart index ee7f4875..9a5f6e68 100644 --- a/test/mixins/item_updatable_test.dart +++ b/test/mixins/item_updatable_test.dart @@ -4,6 +4,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/mixins/item_updatable.dart'; +import '../test_helpers/media_items.dart'; /// Probe that mixes in [ItemUpdatable]. These tests exercise the /// `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(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, title: title); + testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, title: title); void main() { group('ItemUpdatable', () { diff --git a/test/mixins/paginated_item_loader_test.dart b/test/mixins/paginated_item_loader_test.dart index b82758c4..1e5071a4 100644 --- a/test/mixins/paginated_item_loader_test.dart +++ b/test/mixins/paginated_item_loader_test.dart @@ -9,6 +9,7 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/mixins/paginated_item_loader.dart'; import 'package:plezy/utils/media_server_http_client.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 /// stage successes, failures, and slow responses. @@ -58,7 +59,7 @@ class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoad 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 _result({required int start, required int size, required int totalSize}) { return LibraryPage( diff --git a/test/mixins/server_bound_media_mixin_test.dart b/test/mixins/server_bound_media_mixin_test.dart index b4d052c1..1c58ae55 100644 --- a/test/mixins/server_bound_media_mixin_test.dart +++ b/test/mixins/server_bound_media_mixin_test.dart @@ -5,6 +5,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.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 /// 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(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.movie, serverId: serverId); + testMediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.movie, serverId: serverId); void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/test/models/livetv_flexible_parsing_test.dart b/test/models/livetv_flexible_parsing_test.dart new file mode 100644 index 00000000..39e21c6f --- /dev/null +++ b/test/models/livetv_flexible_parsing_test.dart @@ -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']); + }); +} diff --git a/test/providers/discover_provider_test.dart b/test/providers/discover_provider_test.dart index 61eef901..0755649a 100644 --- a/test/providers/discover_provider_test.dart +++ b/test/providers/discover_provider_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/utils/deletion_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; MediaItem _item( String id, { @@ -27,7 +28,7 @@ MediaItem _item( String? grandparentId, MediaKind kind = MediaKind.episode, String serverId = 'server_1', -}) => MediaItem( +}) => testMediaItem( id: id, backend: MediaBackend.plex, kind: kind, diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 2c32c420..a9cfe688 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -17,10 +17,10 @@ import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; +import '../test_helpers/media_items.dart'; -/// Implements only [fetchPlayableDescendants] (the surface queueDownload -/// reaches via [collectEpisodesForShow] / [collectEpisodesForSeason]); -/// every other call falls through to noSuchMethod and trips a NoSuchMethodError. +/// Implements only [fetchPlayableDescendants], the surface [collectEpisodes] +/// uses. Every other call reaches [noSuchMethod] and throws. class _ThrowingClient implements MediaServerClient { @override Future> fetchPlayableDescendants(String parentId) async { @@ -389,7 +389,7 @@ void main() { // Collection rule with stashed metadata (the "no underlying episode // download to populate _metadata" case from createSyncRule's docs). - final target = MediaItem( + final target = testMediaItem( id: '20', backend: MediaBackend.plex, kind: MediaKind.collection, @@ -415,7 +415,7 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - final target = MediaItem( + final target = testMediaItem( id: '30', backend: MediaBackend.plex, kind: MediaKind.show, @@ -493,7 +493,7 @@ void main() { }); group('DownloadProvider — profile-scoped download ownership', () { - final movie = MediaItem( + final movie = testMediaItem( id: '1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -591,14 +591,14 @@ void main() { }); test('queueDownload expands an album into its tracks via fetchPlayableDescendants', () async { - final album = MediaItem( + final album = testMediaItem( id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album', serverId: ServerId('srv'), ); - MediaItem track(String id) => MediaItem( + MediaItem track(String id) => testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.track, @@ -631,7 +631,7 @@ void main() { }); 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, backend: MediaBackend.plex, kind: MediaKind.track, @@ -656,7 +656,7 @@ void main() { metadata: { 'srv:t1': track('t1', disc: 2, number: 1), 'srv:t2': track('t2', disc: 1, number: 2), - 'srv:album-1': MediaItem( + 'srv:album-1': testMediaItem( id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, @@ -1003,7 +1003,7 @@ void main() { fetchItemHandler: (id) async { fetchStarted.complete(); await releaseFetch.future; - return MediaItem( + return testMediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -1035,7 +1035,7 @@ void main() { activeClient = _ScopedTestClient( serverId: ServerId('jf-machine'), scopedServerId: 'jf-machine/user-b', - fetchItemHandler: (id) async => MediaItem( + fetchItemHandler: (id) async => testMediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -1067,7 +1067,7 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - final item = MediaItem( + final item = testMediaItem( id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -1093,7 +1093,7 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - final item = MediaItem( + final item = testMediaItem( id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -1119,7 +1119,7 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - final show = MediaItem( + final show = testMediaItem( id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, @@ -1127,7 +1127,7 @@ void main() { leafCount: 3, viewedLeafCount: 0, ); - final season1 = MediaItem( + final season1 = testMediaItem( id: 'season-1', backend: MediaBackend.plex, kind: MediaKind.season, @@ -1136,7 +1136,7 @@ void main() { leafCount: 2, viewedLeafCount: 0, ); - final episode1 = MediaItem( + final episode1 = testMediaItem( id: 'episode-1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1199,7 +1199,7 @@ void main() { actionType: 'unwatched', ); - final episode1 = MediaItem( + final episode1 = testMediaItem( id: 'episode-1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1249,7 +1249,7 @@ void main() { ratingKey: 'show-1', actionType: 'unwatched', ); - final episode = MediaItem( + final episode = testMediaItem( id: 'episode-1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1291,7 +1291,7 @@ void main() { ratingKey: 'show-1', actionType: 'unwatched', ); - final episode = MediaItem( + final episode = testMediaItem( id: 'episode-1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -1337,7 +1337,7 @@ void main() { p.debugSeedState( downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.queued)}, metadata: { - key: MediaItem( + key: testMediaItem( id: '42', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1410,7 +1410,7 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - final season = MediaItem( + final season = testMediaItem( id: '7', backend: MediaBackend.plex, kind: MediaKind.season, @@ -1433,7 +1433,7 @@ void main() { // 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. - final preexisting = MediaItem( + final preexisting = testMediaItem( id: '7', backend: MediaBackend.plex, kind: MediaKind.season, @@ -1442,7 +1442,7 @@ void main() { ); p.debugSeedState(metadata: {'srv:7': preexisting}); - final season = MediaItem( + final season = testMediaItem( id: '7', backend: MediaBackend.plex, kind: MediaKind.season, diff --git a/test/providers/playback_state_provider_test.dart b/test/providers/playback_state_provider_test.dart index a40d2799..f3a2265e 100644 --- a/test/providers/playback_state_provider_test.dart +++ b/test/providers/playback_state_provider_test.dart @@ -7,8 +7,9 @@ import 'package:plezy/media/media_version.dart'; import 'package:plezy/media/play_queue.dart'; import 'package:plezy/models/plex/play_queue_response.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, kind: MediaKind.episode, 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. /// Episodes of a multi-episode file (`S02E24-E25.mkv`) get *distinct* part /// ids (`part-` 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, kind: MediaKind.episode, playQueueItemId: playQueueItemID, @@ -32,7 +33,7 @@ PlexMediaItem _itemWithFile(String ratingKey, int playQueueItemID, String file) ); PlexMediaItem _miItem(String id, int playQueueItemId) => - PlexMediaItem(id: id, kind: MediaKind.episode, playQueueItemId: playQueueItemId); + PlextestMediaItem(id: id, kind: MediaKind.episode, playQueueItemId: playQueueItemId); PlayQueueResponse _queue({ int playQueueID = 1, @@ -155,7 +156,7 @@ void main() { expect(notified, preNotify + 1); // 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); p.dispose(); @@ -269,9 +270,9 @@ void main() { final p = PlaybackStateProvider(); addTearDown(p.dispose); - final ep1 = MediaItem(id: 'ep1', backend: MediaBackend.jellyfin, kind: MediaKind.episode); - final ep2 = MediaItem(id: 'ep2', backend: MediaBackend.jellyfin, kind: MediaKind.episode); - final outsider = MediaItem(id: 'ep-other', backend: MediaBackend.jellyfin, kind: MediaKind.episode); + final ep1 = testMediaItem(id: 'ep1', backend: MediaBackend.jellyfin, kind: MediaKind.episode); + final ep2 = testMediaItem(id: 'ep2', backend: MediaBackend.jellyfin, kind: MediaKind.episode); + final outsider = testMediaItem(id: 'ep-other', backend: MediaBackend.jellyfin, kind: MediaKind.episode); p.setPlaybackFromLocalQueue( 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 // no `playQueueItemId` — that's how the helper distinguishes it from // 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( _queue( diff --git a/test/providers/watch_state_store_test.dart b/test/providers/watch_state_store_test.dart index 3bde8d69..e19e7c59 100644 --- a/test/providers/watch_state_store_test.dart +++ b/test/providers/watch_state_store_test.dart @@ -5,6 +5,7 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/watch_state_store.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; +import '../test_helpers/media_items.dart'; Future _emit(WatchStateEvent event) async { WatchStateNotifier().notify(event); @@ -33,7 +34,7 @@ WatchStateEvent _event({ ); } -final _episode = MediaItem( +final _episode = testMediaItem( id: 'episode-1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -144,7 +145,7 @@ void main() { await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true, itemId: 'season-1')); - final season = MediaItem( + final season = testMediaItem( id: 'season-1', backend: MediaBackend.jellyfin, kind: MediaKind.season, diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 8fe4a924..8b174bc8 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -45,6 +45,7 @@ import 'package:plezy/widgets/tv_spotlight_background.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -70,7 +71,7 @@ void main() { tester.view.resetPhysicalSize(); }); - final item = MediaItem( + final item = testMediaItem( id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -249,7 +250,7 @@ void main() { final onDeck = [ for (var i = 0; i < 3; i++) - MediaItem( + testMediaItem( id: 'movie_$i', backend: MediaBackend.plex, kind: MediaKind.movie, diff --git a/test/screens/downloads/sync_rules_screen_test.dart b/test/screens/downloads/sync_rules_screen_test.dart index f73c96bb..c2c7b5ed 100644 --- a/test/screens/downloads/sync_rules_screen_test.dart +++ b/test/screens/downloads/sync_rules_screen_test.dart @@ -26,6 +26,7 @@ import 'package:plezy/services/plex_auth_service.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; +import '../../test_helpers/media_items.dart'; PlexConnection _plexConnection() { return PlexConnection( @@ -75,7 +76,13 @@ JellyfinClient _jellyfinClient(JellyfinConnection connection) { } 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 { diff --git a/test/screens/hub_detail_screen_test.dart b/test/screens/hub_detail_screen_test.dart index ec63780c..9f26d1c1 100644 --- a/test/screens/hub_detail_screen_test.dart +++ b/test/screens/hub_detail_screen_test.dart @@ -20,6 +20,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { 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', backend: backend, kind: MediaKind.movie, diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 66f1031b..c774b12e 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -42,6 +42,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/profile_navigation.dart'; +import '../test_helpers/media_items.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -65,7 +66,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); const title = 'The Surprisingly Long Movie Title That Needs Two Whole Lines'; - final movie = MediaItem( + final movie = testMediaItem( id: 'movie_1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -99,7 +100,7 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final movie = MediaItem( + final movie = testMediaItem( id: 'semantic_movie', backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -141,7 +142,7 @@ void main() { testWidgets('TV detail reveals without waiting for directional input', (tester) async { await SettingsService.getInstance(); - final movie = MediaItem( + final movie = testMediaItem( id: 'movie_1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -240,7 +241,7 @@ void main() { testWidgets('TV detail defaults to first regular season when specials precede it', (tester) async { await SettingsService.getInstance(); - final show = MediaItem( + final show = testMediaItem( id: 'show_1', backend: MediaBackend.jellyfin, kind: MediaKind.show, @@ -248,7 +249,7 @@ void main() { serverId: 'server_1', serverName: 'Server', ); - final specials = MediaItem( + final specials = testMediaItem( id: 'season_0', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -258,7 +259,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final season1 = MediaItem( + final season1 = testMediaItem( id: 'season_1', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -268,7 +269,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final specialEpisode = MediaItem( + final specialEpisode = testMediaItem( id: 'episode_special_1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -280,7 +281,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final episode1 = MediaItem( + final episode1 = testMediaItem( id: 'episode_1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -339,7 +340,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); const summary = 'Light theme detail text should stay readable.'; - final movie = MediaItem( + final movie = testMediaItem( id: 'movie_1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -368,7 +369,7 @@ void main() { testWidgets('TV detail shows every season tab and prefetches adjacent first page', (tester) async { await SettingsService.getInstance(); - final show = MediaItem( + final show = testMediaItem( id: 'show_1', backend: MediaBackend.jellyfin, kind: MediaKind.show, @@ -376,7 +377,7 @@ void main() { serverId: 'server_1', serverName: 'Server', ); - final season1 = MediaItem( + final season1 = testMediaItem( id: 'season_1', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -386,7 +387,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final season2 = MediaItem( + final season2 = testMediaItem( id: 'season_2', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -396,7 +397,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final episode1 = MediaItem( + final episode1 = testMediaItem( id: 'episode_1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -408,7 +409,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final episode2 = MediaItem( + final episode2 = testMediaItem( id: 'episode_2', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -464,7 +465,7 @@ void main() { testWidgets('TV detail keeps every season tab when a season episode load fails', (tester) async { await SettingsService.getInstance(); - final show = MediaItem( + final show = testMediaItem( id: 'show_1', backend: MediaBackend.jellyfin, kind: MediaKind.show, @@ -472,7 +473,7 @@ void main() { serverId: 'server_1', serverName: 'Server', ); - final season1 = MediaItem( + final season1 = testMediaItem( id: 'season_1', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -482,7 +483,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final season2 = MediaItem( + final season2 = testMediaItem( id: 'season_2', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -492,7 +493,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final episode1 = MediaItem( + final episode1 = testMediaItem( id: 'episode_1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -504,7 +505,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final episode2 = MediaItem( + final episode2 = testMediaItem( id: 'episode_2', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -556,7 +557,7 @@ void main() { testWidgets('TV detail completes adjacent prefetch after focus moves to that season', (tester) async { await SettingsService.getInstance(); - final show = MediaItem( + final show = testMediaItem( id: 'show_1', backend: MediaBackend.jellyfin, kind: MediaKind.show, @@ -564,7 +565,7 @@ void main() { serverId: 'server_1', serverName: 'Server', ); - final season1 = MediaItem( + final season1 = testMediaItem( id: 'season_1', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -574,7 +575,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final season2 = MediaItem( + final season2 = testMediaItem( id: 'season_2', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -584,7 +585,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final episode1 = MediaItem( + final episode1 = testMediaItem( id: 'episode_1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -596,7 +597,7 @@ void main() { serverId: show.serverId, serverName: show.serverName, ); - final episode2 = MediaItem( + final episode2 = testMediaItem( id: 'episode_2', backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -656,7 +657,7 @@ void main() { }); group('watch state freshness (phone layout)', () { - MediaItem buildShow() => MediaItem( + MediaItem buildShow() => testMediaItem( id: 'show_1', backend: MediaBackend.jellyfin, kind: MediaKind.show, @@ -667,7 +668,7 @@ void main() { serverName: 'Server', ); - MediaItem buildSeason(MediaItem show, int index) => MediaItem( + MediaItem buildSeason(MediaItem show, int index) => testMediaItem( id: 'season_$index', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -680,7 +681,7 @@ void main() { 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', backend: MediaBackend.jellyfin, kind: MediaKind.episode, diff --git a/test/screens/music/album_detail_screen_test.dart b/test/screens/music/album_detail_screen_test.dart index 2996d459..83e4480b 100644 --- a/test/screens/music/album_detail_screen_test.dart +++ b/test/screens/music/album_detail_screen_test.dart @@ -18,6 +18,7 @@ import 'package:plezy/widgets/music/track_row.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; +import '../../test_helpers/media_items.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -79,7 +80,7 @@ const _album = MediaItem.plex( List _multiDiscTracks() { MediaItem track({required String id, required String title, required int disc, required int number}) { - return MediaItem( + return testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.track, diff --git a/test/screens/music/queue_sheet_test.dart b/test/screens/music/queue_sheet_test.dart index 4c4b9c7a..b3c16573 100644 --- a/test/screens/music/queue_sheet_test.dart +++ b/test/screens/music/queue_sheet_test.dart @@ -15,8 +15,9 @@ import 'package:plezy/widgets/music/track_row.dart'; import 'package:provider/provider.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, backend: MediaBackend.plex, kind: MediaKind.track, diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index 9bc2f2f2..9f27cf82 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -29,6 +29,7 @@ import 'package:plezy/utils/media_server_http_client.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -42,7 +43,7 @@ void main() { testWidgets('loads playlist continuation pages from an unmodifiable first page', (tester) async { final items = List.generate( playlistItemsPageSize + 5, - (index) => MediaItem( + (index) => testMediaItem( id: 'item_$index', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -178,7 +179,7 @@ const _playlist = MediaPlaylist( List _mediaItems(int count) { return List.generate( count, - (index) => MediaItem( + (index) => testMediaItem( id: 'item_$index', backend: MediaBackend.plex, kind: MediaKind.movie, diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index 8b9b2267..3e67c6fc 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/utils/platform_detector.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -216,7 +217,7 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc items: items ?? [ - MediaItem( + testMediaItem( id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, diff --git a/test/screens/video_player/player_prompt_overlays_test.dart b/test/screens/video_player/player_prompt_overlays_test.dart index 5345979b..deb04da0 100644 --- a/test/screens/video_player/player_prompt_overlays_test.dart +++ b/test/screens/video_player/player_prompt_overlays_test.dart @@ -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/widgets/video_controls/player_chrome_controller.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/media_items.dart'; void main() { testWidgets('play next prompt tracks chrome visibility for vertical position', (tester) async { @@ -163,7 +164,7 @@ AnimatedPositioned _promptPosition(WidgetTester tester) { } MediaItem _episode() { - return MediaItem( + return testMediaItem( id: 'episode-2', backend: MediaBackend.plex, kind: MediaKind.episode, diff --git a/test/services/catalog/trakt_catalog_source_test.dart b/test/services/catalog/trakt_catalog_source_test.dart index 4f82aece..d2ac5cb9 100644 --- a/test/services/catalog/trakt_catalog_source_test.dart +++ b/test/services/catalog/trakt_catalog_source_test.dart @@ -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/trackers/tracker_session.dart'; import 'package:plezy/services/trakt/trakt_client.dart'; +import '../../test_helpers/media_items.dart'; TrackerSession _session() { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -105,7 +106,7 @@ void main() { expect(show.rating, 8.5); expect(page.items[0].airStatus, isNull); - final rendered = page.items[0].toMediaItem(); + final rendered = page.items[0].totestMediaItem(); expect(rendered.serverId, isNull); expect(rendered.title, 'The Matrix'); expect(rendered.isCatalogItem, isTrue); diff --git a/test/services/companion_remote_peer_service_test.dart b/test/services/companion_remote_peer_service_test.dart new file mode 100644 index 00000000..b45ba2ba --- /dev/null +++ b/test/services/companion_remote_peer_service_test.dart @@ -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.generate(32, (index) => index), + discoveryKey: List.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'}), + ); + }); +} diff --git a/test/services/download_artwork_service_test.dart b/test/services/download_artwork_service_test.dart index 24eec491..d80d29a3 100644 --- a/test/services/download_artwork_service_test.dart +++ b/test/services/download_artwork_service_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; class _DelayedCountingHttpClient extends http.BaseClient { _DelayedCountingHttpClient(this.body); @@ -54,7 +55,7 @@ void main() { }); test('buildArtworkSpecs includes all standard artwork with sanitized local keys', () { - final item = MediaItem( + final item = testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index 88ea3e70..71d9a556 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -29,6 +29,7 @@ import 'package:saf_util/saf_util_platform_interface.dart'; import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { group('downloadExtensionFromUrl', () { @@ -140,7 +141,7 @@ void main() { clientResolver: (serverId, {clientScopeId}) => null, ); final year = await manager.debugResolveSafRecoveryShowYear( - MediaItem( + testMediaItem( id: 'ep-1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -245,7 +246,7 @@ void main() { final client = _ArtworkRepairClient( serverId: ServerId('srv'), items: { - 'show-1': MediaItem( + 'show-1': testMediaItem( id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, @@ -562,7 +563,7 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe JellyfinApiCache.initialize(db); final serverId = ServerId('srv'); const globalKey = 'srv:episode-1'; - final episode = MediaItem( + final episode = testMediaItem( id: 'episode-1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -674,7 +675,7 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind, final serverId = ServerId('srv'); final id = '${kind.id}-1'; final globalKey = 'srv:$id'; - final metadata = MediaItem( + final metadata = testMediaItem( id: id, backend: MediaBackend.plex, kind: kind, @@ -913,7 +914,7 @@ DownloadTask _downloadTask(String taskId, String globalKey) { } MediaItem _movie({String? thumbPath}) { - return MediaItem( + return testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, diff --git a/test/services/download_storage_service_test.dart b/test/services/download_storage_service_test.dart index a5b83085..7f5c1aac 100644 --- a/test/services/download_storage_service_test.dart +++ b/test/services/download_storage_service_test.dart @@ -12,6 +12,7 @@ import 'package:plezy/services/settings_service.dart'; import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { late Directory tmpRoot; @@ -558,7 +559,7 @@ void main() { // ============================================================ MediaItem _movie({required String title, int? year}) { - return MediaItem( + return testMediaItem( id: 'm-${title.hashCode}', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -568,7 +569,7 @@ MediaItem _movie({required String title, int? year}) { } MediaItem _show({required String title, int? year}) { - return MediaItem( + return testMediaItem( id: 's-${title.hashCode}', backend: MediaBackend.plex, kind: MediaKind.show, @@ -578,7 +579,7 @@ MediaItem _show({required String title, int? year}) { } MediaItem _season({required String showTitle, int? showYear, required int seasonNumber}) { - return MediaItem( + return testMediaItem( id: 'season-$showTitle-$seasonNumber', backend: MediaBackend.plex, kind: MediaKind.season, @@ -596,7 +597,7 @@ MediaItem _episode({ required int episodeNumber, required String episodeTitle, }) { - return MediaItem( + return testMediaItem( id: 'ep-$showTitle-$seasonNumber-$episodeNumber', backend: MediaBackend.plex, kind: MediaKind.episode, diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index 1e0902ac..f481bcf5 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -12,6 +12,7 @@ import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/episode_navigation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/media_items.dart'; // NOTE on coverage scope: // `EpisodeNavigationService` has two methods: @@ -31,9 +32,9 @@ import 'package:provider/provider.dart'; // the public surface callers depend on. 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, backend: MediaBackend.jellyfin, kind: MediaKind.episode, diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart index f0c54e23..446ac0da 100644 --- a/test/services/external_player_service_test.dart +++ b/test/services/external_player_service_test.dart @@ -12,6 +12,7 @@ import 'package:plezy/services/external_player_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; +import '../test_helpers/media_items.dart'; class _RecordingClient implements MediaServerClient { _RecordingClient({this.backend = MediaBackend.plex}); @@ -77,7 +78,7 @@ class _RecordingClient implements MediaServerClient { } MediaItem _item({int? durationMs}) { - return MediaItem( + return testMediaItem( id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 10792417..fecb0fc6 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -16,6 +16,7 @@ import 'package:plezy/utils/device_identity.dart'; import '../test_helpers/backend_client_fixtures.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'}) => testJellyfinConnection( @@ -423,7 +424,7 @@ void main() { addTearDown(scoped.close); 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, ); @@ -470,7 +471,7 @@ void main() { addTearDown(scoped.close); 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, mediaSourceId: 'item-1', ); @@ -535,7 +536,7 @@ void main() { final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( - metadata: MediaItem( + metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -611,7 +612,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, qualityPreset: TranscodeQualityPreset.p720_2mbps, ), @@ -694,7 +700,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, ), ); @@ -795,7 +806,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, ), ); @@ -857,7 +873,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, ), ); @@ -918,7 +939,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, selectedAudioStreamId: 4, ), @@ -986,7 +1012,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, selectedAudioStreamId: 4, ), @@ -1048,7 +1079,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, selectedMediaSourceId: 'src-1080', ), @@ -1108,7 +1144,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, selectedMediaSourceId: 'item-1', ), @@ -1176,7 +1217,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, selectedMediaSourceId: 'src-1080', ), @@ -1265,7 +1311,7 @@ void main() { ); addTearDown(scoped.close); - final item = MediaItem( + final item = testMediaItem( id: 'folder/item #1?x', backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -1303,7 +1349,12 @@ void main() { ); 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())); expect(requested, isFalse); @@ -1345,7 +1396,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, qualityPreset: TranscodeQualityPreset.p720_2mbps, ), @@ -1386,7 +1442,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, ), ); @@ -1578,7 +1639,12 @@ void main() { final result = await scoped.getPlaybackInitialization( 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, qualityPreset: TranscodeQualityPreset.p720_2mbps, ), @@ -1883,7 +1949,7 @@ void main() { addTearDown(scoped.close); 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, ); @@ -1925,7 +1991,7 @@ void main() { addTearDown(scoped.close); 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, ); diff --git a/test/services/jellyfin_sequential_launcher_test.dart b/test/services/jellyfin_sequential_launcher_test.dart index 17f7883f..09a05b75 100644 --- a/test/services/jellyfin_sequential_launcher_test.dart +++ b/test/services/jellyfin_sequential_launcher_test.dart @@ -14,6 +14,7 @@ import 'package:plezy/services/playlist_items_loader.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/paged_fakes.dart'; +import '../test_helpers/media_items.dart'; /// Recording fake that satisfies [JellyfinClient] via `implements` + /// `noSuchMethod`. The launcher only needs the @@ -76,7 +77,7 @@ class _RecordingJellyfinClient implements JellyfinClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -MediaItem _ep(String id, {ServerId? serverId}) => MediaItem( +MediaItem _ep(String id, {ServerId? serverId}) => testMediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -84,7 +85,7 @@ MediaItem _ep(String id, {ServerId? serverId}) => MediaItem( serverId: serverId ?? ServerId('srv-jf'), ); -MediaItem _movie(String id, {ServerId? serverId}) => MediaItem( +MediaItem _movie(String id, {ServerId? serverId}) => testMediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -92,7 +93,7 @@ MediaItem _movie(String id, {ServerId? serverId}) => MediaItem( serverId: serverId ?? ServerId('srv-jf'), ); -MediaItem _clip(String id, {ServerId? serverId}) => MediaItem( +MediaItem _clip(String id, {ServerId? serverId}) => testMediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.clip, @@ -100,7 +101,7 @@ MediaItem _clip(String id, {ServerId? serverId}) => MediaItem( serverId: serverId ?? ServerId('srv-jf'), ); -MediaItem _track(String id, {ServerId? serverId}) => MediaItem( +MediaItem _track(String id, {ServerId? serverId}) => testMediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.track, @@ -146,7 +147,7 @@ void main() { final ctx = await pumpContext(tester); final launcher = JellyfinSequentialLauncher(context: ctx); - final orphan = MediaItem( + final orphan = testMediaItem( id: 'col-1', backend: MediaBackend.jellyfin, kind: MediaKind.collection, @@ -173,7 +174,7 @@ void main() { navigateForTesting: (m) async => navigated.add(m), ); - final collection = MediaItem( + final collection = testMediaItem( id: 'col-99', backend: MediaBackend.jellyfin, kind: MediaKind.collection, @@ -273,7 +274,12 @@ void main() { // container. If a future change reverts to fetchChildren the test // fails because a Series row would leak into the queue. 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 ep2 = _ep('series-A-ep2'); final fakeClient = _RecordingJellyfinClient(playableDescendantsResponse: [movie, ep1, ep2]); @@ -287,7 +293,7 @@ void main() { navigateForTesting: (m) async => navigated.add(m), ); - final collection = MediaItem( + final collection = testMediaItem( id: 'col-mixed', backend: MediaBackend.jellyfin, kind: MediaKind.collection, @@ -323,7 +329,7 @@ void main() { navigateForTesting: (_) async {}, ); - final collection = MediaItem( + final collection = testMediaItem( id: 'col-1', backend: MediaBackend.jellyfin, kind: MediaKind.collection, @@ -360,7 +366,7 @@ void main() { navigateForTesting: (m) async => navigated.add(m), ); - final collection = MediaItem( + final collection = testMediaItem( id: 'col-start', backend: MediaBackend.jellyfin, kind: MediaKind.collection, @@ -394,7 +400,7 @@ void main() { navigateForTesting: (m) async => navigated.add(m), ); - final collection = MediaItem( + final collection = testMediaItem( id: 'col', backend: MediaBackend.jellyfin, kind: MediaKind.collection, @@ -427,7 +433,7 @@ void main() { navigateForTesting: (m) async => navigated.add(m), ); - final folder = MediaItem( + final folder = testMediaItem( id: 'folder-1', backend: MediaBackend.jellyfin, kind: MediaKind.unknown, @@ -465,7 +471,7 @@ void main() { navigateForTesting: (_) async {}, ); - final folder = MediaItem( + final folder = testMediaItem( id: 'folder-shuffle', backend: MediaBackend.jellyfin, kind: MediaKind.unknown, @@ -497,7 +503,7 @@ void main() { }, ); - final folder = MediaItem( + final folder = testMediaItem( id: 'music-folder', backend: MediaBackend.jellyfin, kind: MediaKind.unknown, @@ -515,7 +521,7 @@ void main() { final ctx = await pumpContext(tester); 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); @@ -527,7 +533,12 @@ void main() { final ctx = await pumpContext(tester); 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); @@ -539,7 +550,7 @@ void main() { final ctx = await pumpContext(tester); 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); @@ -563,7 +574,7 @@ void main() { navigateForTesting: (m) async => navigated.add(m), ); - final show = MediaItem( + final show = testMediaItem( id: 'show-1', backend: MediaBackend.jellyfin, kind: MediaKind.show, @@ -599,7 +610,7 @@ void main() { navigateForTesting: (_) async {}, ); - final season = MediaItem( + final season = testMediaItem( id: 'season-2', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -628,7 +639,7 @@ void main() { }, ); - final show = MediaItem( + final show = testMediaItem( id: 'show-empty', backend: MediaBackend.jellyfin, kind: MediaKind.show, @@ -657,7 +668,7 @@ void main() { }, ); - final collection = MediaItem( + final collection = testMediaItem( id: 'col-empty', backend: MediaBackend.jellyfin, kind: MediaKind.collection, diff --git a/test/services/local_playback_history_test.dart b/test/services/local_playback_history_test.dart index 3bc55df3..99d3a11e 100644 --- a/test/services/local_playback_history_test.dart +++ b/test/services/local_playback_history_test.dart @@ -6,6 +6,7 @@ import 'package:plezy/services/local_playback_history.dart'; import 'package:plezy/services/settings_service.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { setUp(() { @@ -15,7 +16,7 @@ void main() { }); test('recordPlayback writes item and series keys for an episode', () async { - final episode = MediaItem( + final episode = testMediaItem( id: 'ep-1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -32,7 +33,7 @@ void main() { }); test('recordPlayback writes only the item key for a movie', () async { - final movie = MediaItem( + final movie = testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -47,7 +48,7 @@ void main() { }); test('repeat writes for the same item within the rewrite window are skipped', () async { - final movie = MediaItem( + final movie = testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -67,7 +68,7 @@ void main() { final settings = await SettingsService.getInstance(); 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', backend: MediaBackend.plex, kind: MediaKind.movie, diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index a1f9d8d6..e4f7ddb7 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -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_source_resolver.dart'; import 'package:plezy/services/playback_coordinator.dart'; +import '../../test_helpers/media_items.dart'; const _trackDuration = Duration(minutes: 3); -MediaItem _track(String id) => MediaItem( +MediaItem _track(String id) => testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.track, diff --git a/test/services/music/music_queue_controller_test.dart b/test/services/music/music_queue_controller_test.dart index 2d4b9a34..7eb43152 100644 --- a/test/services/music/music_queue_controller_test.dart +++ b/test/services/music/music_queue_controller_test.dart @@ -6,9 +6,10 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/music/music_queue_controller.dart'; +import '../../test_helpers/media_items.dart'; 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 _ids(List items) => [for (final i in items) i.id]; diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 4da145b5..1ee588bf 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -21,6 +21,7 @@ import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; // NOTE on coverage scope: // The actual sync-to-server path (`syncPendingItems`, `syncWatchStatesFromServer`, @@ -93,7 +94,7 @@ class _RecordingMediaClient implements MediaServerClient { @override Future 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 Future reportPlaybackStarted({ diff --git a/test/services/play_queue_launcher_test.dart b/test/services/play_queue_launcher_test.dart index aab18496..66a7d5b9 100644 --- a/test/services/play_queue_launcher_test.dart +++ b/test/services/play_queue_launcher_test.dart @@ -5,6 +5,7 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/services/play_queue_launcher.dart'; import 'package:plezy/services/plex_client.dart'; +import '../test_helpers/media_items.dart'; // NOTE on coverage scope: // `PlayQueueLauncher` is almost entirely network/UI glue: @@ -82,7 +83,7 @@ void main() { final launcher = PlexPlayQueueLauncher(context: capturedContext, client: _StubPlexClient()); final result = await launcher.launchShuffledShow( // 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, ); diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 0985efa3..f2daf965 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -23,6 +23,7 @@ import 'package:plezy/services/settings_service.dart'; import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -60,7 +61,7 @@ void main() { await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope()); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem( + metadata: testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -89,7 +90,7 @@ void main() { final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: MediaItem( + metadata: testMediaItem( id: 'track-1', backend: MediaBackend.plex, kind: MediaKind.track, @@ -116,7 +117,7 @@ void main() { final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: MediaItem( + metadata: testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -148,7 +149,7 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem( + metadata: testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -181,7 +182,7 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem( + metadata: testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -208,7 +209,7 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem( + metadata: testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -238,7 +239,7 @@ void main() { final client = _StreamingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: MediaItem( + metadata: testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -292,7 +293,7 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem( + metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -320,7 +321,7 @@ void main() { await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello'); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem( + metadata: testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index a6a537b0..157b3640 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -17,6 +17,7 @@ import 'package:plezy/services/plex_client.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; // NOTE on coverage scope: // `PlaybackProgressTracker` periodically samples the player's position and @@ -291,13 +292,14 @@ class _StopMarksWatchedClient extends _FakePlexClient { const Object _defaultServerId = Object(); -MediaItem _meta({String ratingKey = '42', Object? serverId = _defaultServerId, String? type = 'movie'}) => MediaItem( - id: ratingKey, - backend: MediaBackend.plex, - kind: MediaKind.fromString(type), - title: 'Test Item', - serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?, -); +MediaItem _meta({String ratingKey = '42', Object? serverId = _defaultServerId, String? type = 'movie'}) => + testMediaItem( + id: ratingKey, + backend: MediaBackend.plex, + kind: MediaKind.fromString(type), + title: 'Test Item', + serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?, + ); void main() { setUp(resetSharedPreferencesForTest); @@ -593,7 +595,7 @@ void main() { ); final tracker = PlaybackProgressTracker( 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, isOffline: false, mediaInfo: mediaInfo, diff --git a/test/services/playback_session_test.dart b/test/services/playback_session_test.dart index d736e3de..1cbe713a 100644 --- a/test/services/playback_session_test.dart +++ b/test/services/playback_session_test.dart @@ -7,10 +7,11 @@ import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/services/playback_context.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/playback_session.dart'; +import '../test_helpers/media_items.dart'; PlaybackContext _context(PlaybackInitializationResult result) { 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, sourceKind: result.usesLocalMedia ? PlaybackSourceKind.localFile : PlaybackSourceKind.remoteDirect, reportingMode: PlaybackReportingMode.online, diff --git a/test/services/playback_source_resolver_test.dart b/test/services/playback_source_resolver_test.dart index 5570ea9e..76f28a76 100644 --- a/test/services/playback_source_resolver_test.dart +++ b/test/services/playback_source_resolver_test.dart @@ -11,6 +11,7 @@ import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/playback_context.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/playback_source_resolver.dart'; +import '../test_helpers/media_items.dart'; class _PlaybackClient implements MediaServerClient { _PlaybackClient({this.clientBackend = MediaBackend.plex, PlaybackInitializationResult? result}) @@ -56,7 +57,7 @@ void main() { manager.debugRegisterClientForTesting(client, online: false); 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, offlineLibraryMode: false, qualityPreset: TranscodeQualityPreset.original, @@ -79,7 +80,7 @@ void main() { manager.debugRegisterClientForTesting(client, online: true); 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, offlineLibraryMode: false, qualityPreset: TranscodeQualityPreset.original, @@ -103,7 +104,7 @@ void main() { manager.debugRegisterClientForTesting(client, online: true); 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, offlineLibraryMode: false, qualityPreset: TranscodeQualityPreset.original, diff --git a/test/services/plex_api_cache_test.dart b/test/services/plex_api_cache_test.dart index 9d200c96..d9966720 100644 --- a/test/services/plex_api_cache_test.dart +++ b/test/services/plex_api_cache_test.dart @@ -5,7 +5,11 @@ import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.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 '../test_helpers/media_items.dart'; void main() { late AppDatabase db; @@ -54,6 +58,47 @@ void main() { test('database getter exposes the underlying AppDatabase', () { 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'); + }); }); // ============================================================ diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 08bac882..431595f0 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -16,6 +16,7 @@ import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/media_items.dart'; void main() { late AppDatabase db; @@ -315,7 +316,7 @@ void main() { final result = await client.getPlaybackInitialization( 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, ), ); diff --git a/test/services/plex_timeline_session_test.dart b/test/services/plex_timeline_session_test.dart index 35f6f1ed..765091f5 100644 --- a/test/services/plex_timeline_session_test.dart +++ b/test/services/plex_timeline_session_test.dart @@ -14,6 +14,7 @@ import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/media_items.dart'; /// Regression coverage for the Plex transcode reporting bug: while /// transcoding, the `/:/timeline` reports must carry the playback's @@ -165,7 +166,7 @@ void main() { final result = await client.getPlaybackInitialization( 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, // Original preset stays on the direct-play branch (no transcode // decision round-trip needed for this assertion). diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart index 34e1157e..52f17969 100644 --- a/test/services/sync_rule_executor_test.dart +++ b/test/services/sync_rule_executor_test.dart @@ -19,6 +19,7 @@ import 'package:plezy/services/sync_rule_executor.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection( machineId: 'jf-machine', @@ -314,7 +315,7 @@ void main() { manager.debugRegisterClientForTesting(client); 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( profileId: 'profile-a', serverId: ServerId('plex-machine'), @@ -357,7 +358,7 @@ void main() { manager.debugRegisterClientForTesting(client); const ruleKey = 'profile-a|plex-machine:collection-1'; - final collection = MediaItem( + final collection = testMediaItem( id: 'collection-1', backend: MediaBackend.plex, kind: MediaKind.collection, @@ -406,10 +407,10 @@ void main() { final items = [ _track('loose-track'), - MediaItem(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: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album'), + testMediaItem(id: 'artist-1', backend: MediaBackend.plex, kind: MediaKind.artist, title: 'Artist'), // 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 = []; @@ -431,11 +432,11 @@ void main() { } 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}) { - return MediaItem( + return testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.episode, @@ -526,7 +527,7 @@ class _CollectionPagingClient implements MediaServerClient { collectionPageCalls.add((start: start, size: size)); expect(collectionId, 'collection-1'); 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, offset: start ?? 0, ); diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index b6049ec4..527888d3 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -11,6 +11,7 @@ import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/track_manager.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; // NOTE on coverage scope: // `TrackManager` orchestrates the player + Plex client + SettingsService @@ -42,7 +43,7 @@ import '../test_helpers/prefs.dart'; // therefore gated on the same SettingsService dependency. // - `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. class _FakePlayer with PlayerStreamControllersMixin implements Player { diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart index ad5e2660..2206ecca 100644 --- a/test/services/track_selection_service_test.dart +++ b/test/services/track_selection_service_test.dart @@ -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/mpv/mpv.dart'; import 'package:plezy/services/track_selection_service.dart'; +import '../test_helpers/media_items.dart'; // NOTE on coverage scope: // `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( + testMediaItem( id: 'rk1', backend: backend, kind: MediaKind.movie, diff --git a/test/services/trackers/anime_episode_progress_resolver_test.dart b/test/services/trackers/anime_episode_progress_resolver_test.dart index 7e1b5ac9..9aae8ee1 100644 --- a/test/services/trackers/anime_episode_progress_resolver_test.dart +++ b/test/services/trackers/anime_episode_progress_resolver_test.dart @@ -5,6 +5,7 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/trackers/anime_lists_mapping.dart'; import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart'; +import '../../test_helpers/media_items.dart'; class _FakeMediaServerClient implements MediaServerClient { final Map> childrenByParent; @@ -33,7 +34,7 @@ class _FakeMediaServerClient implements MediaServerClient { 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', backend: MediaBackend.plex, kind: MediaKind.season, @@ -43,7 +44,7 @@ MediaItem _season(int number, {int? watched, int? total}) => MediaItem( 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', backend: MediaBackend.plex, kind: MediaKind.episode, diff --git a/test/services/trackers/tracker_coordinator_manual_test.dart b/test/services/trackers/tracker_coordinator_manual_test.dart index efd3c608..5a043c6e 100644 --- a/test/services/trackers/tracker_coordinator_manual_test.dart +++ b/test/services/trackers/tracker_coordinator_manual_test.dart @@ -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_session.dart'; import 'package:plezy/utils/external_ids.dart'; +import '../../test_helpers/media_items.dart'; class _FakeMediaServerClient implements MediaServerClient { @override @@ -88,7 +89,7 @@ class _FakeAnimeListsLookup implements AnimeListsMappingLookup { Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const {}; } -MediaItem _season() => MediaItem( +MediaItem _season() => testMediaItem( id: 'season-1', backend: MediaBackend.plex, kind: MediaKind.season, @@ -99,7 +100,7 @@ MediaItem _season() => MediaItem( parentId: 'show-1', ); -MediaItem _episode(int number, {int season = 1}) => MediaItem( +MediaItem _episode(int number, {int season = 1}) => testMediaItem( id: 'episode-$season-$number', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -110,7 +111,7 @@ MediaItem _episode(int number, {int season = 1}) => MediaItem( index: number, ); -MediaItem _show() => MediaItem( +MediaItem _show() => testMediaItem( id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, @@ -119,7 +120,7 @@ MediaItem _show() => MediaItem( libraryId: 'lib-1', ); -MediaItem _movie() => MediaItem( +MediaItem _movie() => testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, diff --git a/test/services/trackers/tracker_id_resolver_test.dart b/test/services/trackers/tracker_id_resolver_test.dart index 2ea7a12a..8caf9ecf 100644 --- a/test/services/trackers/tracker_id_resolver_test.dart +++ b/test/services/trackers/tracker_id_resolver_test.dart @@ -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/tracker_id_resolver.dart'; import 'package:plezy/utils/external_ids.dart'; +import '../../test_helpers/media_items.dart'; class _FakeMediaServerClient implements MediaServerClient { final Map externalIdsByItem; @@ -94,7 +95,7 @@ class _FakeAnimeListsLookup implements AnimeListsMappingLookup { Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const {}; } -MediaItem _episode({int season = 23, int number = 6}) => MediaItem( +MediaItem _episode({int season = 23, int number = 6}) => testMediaItem( id: 'episode-$season-$number', backend: MediaBackend.plex, kind: MediaKind.episode, diff --git a/test/services/watch_state_resolver_test.dart b/test/services/watch_state_resolver_test.dart index e288d9ba..7662e89f 100644 --- a/test/services/watch_state_resolver_test.dart +++ b/test/services/watch_state_resolver_test.dart @@ -6,6 +6,7 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/services/watch_state_resolver.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; +import '../test_helpers/media_items.dart'; OfflineWatchProgressItem _action({ required String actionType, @@ -86,7 +87,7 @@ void main() { test('applying a watched snapshot patches container leaf counts so isWatched flips', () { const snapshot = WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0); - final season = MediaItem( + final season = testMediaItem( id: 'season-1', backend: MediaBackend.plex, kind: MediaKind.season, diff --git a/test/test_helpers/media_items.dart b/test/test_helpers/media_items.dart new file mode 100644 index 00000000..2dc9ad10 --- /dev/null +++ b/test/test_helpers/media_items.dart @@ -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? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + String? subtitleLanguage, + int? subtitleMode, + String? serverId, + String? serverName, + String? backendFolderKey, + Map? 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? 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, + ); +} diff --git a/test/test_helpers/media_items_test.dart b/test/test_helpers/media_items_test.dart new file mode 100644 index 00000000..dd5c865d --- /dev/null +++ b/test/test_helpers/media_items_test.dart @@ -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); + }); +} diff --git a/test/utils/content_utils_test.dart b/test/utils/content_utils_test.dart index 3996b7a8..49c9256c 100644 --- a/test/utils/content_utils_test.dart +++ b/test/utils/content_utils_test.dart @@ -5,9 +5,10 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_item_types.dart'; import 'package:plezy/media/media_kind.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}) { - return MediaItem( + return testMediaItem( id: '1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -20,7 +21,7 @@ MediaItem _episode({int? viewOffsetMs, int? durationMs, int? viewCount, int? lea } 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() { @@ -58,7 +59,7 @@ void main() { group('MediaItemTypes.shouldHideSpoiler', () { test('false for non-episodes', () { 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); }); diff --git a/test/utils/downloaded_version_match_test.dart b/test/utils/downloaded_version_match_test.dart index 4b0b5f3d..6afa3d5c 100644 --- a/test/utils/downloaded_version_match_test.dart +++ b/test/utils/downloaded_version_match_test.dart @@ -2,9 +2,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/utils/downloaded_version_match.dart'; +import '../test_helpers/media_items.dart'; DownloadedMediaItem _row({int mediaIndex = 0, String? mediaSourceId}) { - return DownloadedMediaItem( + return DownloadedtestMediaItem( id: 1, serverId: 'srv', ratingKey: 'movie-1', diff --git a/test/utils/episode_collection_test.dart b/test/utils/episode_collection_test.dart index 3e815afa..f1fca06f 100644 --- a/test/utils/episode_collection_test.dart +++ b/test/utils/episode_collection_test.dart @@ -9,8 +9,9 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/media_version.dart'; import 'package:plezy/utils/download_version_utils.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, backend: MediaBackend.plex, kind: MediaKind.season, @@ -31,7 +32,7 @@ MediaItem _episode( int? viewOffsetMs, int? durationMs, String? originallyAvailableAt, -}) => MediaItem( +}) => testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.episode, @@ -47,7 +48,7 @@ MediaItem _episode( 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 { _RecordingClient({this.childrenByParent = const {}, this.childrenPageByParent = const {}, this.itemsById = const {}}); @@ -111,7 +112,7 @@ class _LeavesClient implements MediaServerClient { } void main() { - test('collectEpisodesForShow drops Specials when includeSpecials is false', () async { + test('collectEpisodes drops Specials when includeSpecials is false', () async { final client = _LeavesClient([ _episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'), _episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'), @@ -119,12 +120,12 @@ void main() { ]); final withoutSpecials = []; - 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']); // Default keeps Specials, interleaved into aired order. final withSpecials = []; - 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']); }); @@ -317,7 +318,7 @@ void main() { }); test('fetchSeasonEpisodePage normalizes show and season identity', () async { - final show = MediaItem( + final show = testMediaItem( id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, @@ -350,7 +351,7 @@ void main() { }); 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 row = _episode('episode-1'); final client = _SeasonPagingRecordingClient( @@ -367,7 +368,7 @@ void main() { }); 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 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 { 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 firstRegularSeason = _season('season-1'); final episodeRow = _episode('episode-1'); diff --git a/test/utils/json_utils_test.dart b/test/utils/json_utils_test.dart index ba2e8b2d..b08b0d3e 100644 --- a/test/utils/json_utils_test.dart +++ b/test/utils/json_utils_test.dart @@ -227,4 +227,31 @@ void main() { expect(flexibleCsvStringList([]), isNull); }); }); + + group('flexible JSON objects', () { + String parseId(Map 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); + }); + }); } diff --git a/test/utils/media_hub_ordering_test.dart b/test/utils/media_hub_ordering_test.dart index 3c931bcb..c684e319 100644 --- a/test/utils/media_hub_ordering_test.dart +++ b/test/utils/media_hub_ordering_test.dart @@ -6,6 +6,7 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/utils/media_hub_ordering.dart'; +import '../test_helpers/media_items.dart'; const Object _defaultServerId = Object(); @@ -20,7 +21,7 @@ MediaLibrary _library(String id, {ServerId? serverId}) { } MediaItem _item(String id, {String? libraryId, Object? serverId = _defaultServerId}) { - return MediaItem( + return testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.movie, diff --git a/test/utils/media_navigation_helper_test.dart b/test/utils/media_navigation_helper_test.dart index 5c80c29d..40189841 100644 --- a/test/utils/media_navigation_helper_test.dart +++ b/test/utils/media_navigation_helper_test.dart @@ -4,10 +4,11 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/media_navigation_helper.dart'; +import '../test_helpers/media_items.dart'; void main() { test('episode detail target opens parent show and focuses season episode', () { - final episode = MediaItem( + final episode = testMediaItem( id: 'episode-1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -29,7 +30,7 @@ void main() { }); test('season detail target opens parent show and focuses season', () { - final season = MediaItem( + final season = testMediaItem( id: 'season-3', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -50,7 +51,7 @@ void main() { }); 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); diff --git a/test/utils/media_quality_labels_test.dart b/test/utils/media_quality_labels_test.dart index ff0aa267..b664b1a9 100644 --- a/test/utils/media_quality_labels_test.dart +++ b/test/utils/media_quality_labels_test.dart @@ -9,6 +9,7 @@ import 'package:plezy/media/media_version.dart'; import 'package:plezy/services/jellyfin_mappers.dart'; import 'package:plezy/services/plex_mappers.dart'; import 'package:plezy/utils/media_quality_labels.dart'; +import '../test_helpers/media_items.dart'; void main() { group('buildMediaQualityLabels', () { @@ -216,7 +217,7 @@ void main() { } MediaItem _episodeWithVersion(MediaVersion? version) { - return MediaItem( + return testMediaItem( id: 'episode-1', backend: MediaBackend.plex, kind: MediaKind.episode, diff --git a/test/utils/plex_season_display_test.dart b/test/utils/plex_season_display_test.dart index 7c80f55f..0d3e85a8 100644 --- a/test/utils/plex_season_display_test.dart +++ b/test/utils/plex_season_display_test.dart @@ -3,6 +3,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/utils/plex_season_display.dart'; +import '../test_helpers/media_items.dart'; void main() { group('shouldShowPlexEpisodesDirectly', () { @@ -57,9 +58,9 @@ void main() { } MediaItem _show({Map? 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) { - return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.season); + return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.season); } diff --git a/test/utils/video_player_navigation_test.dart b/test/utils/video_player_navigation_test.dart index fcbdb3a4..c3411138 100644 --- a/test/utils/video_player_navigation_test.dart +++ b/test/utils/video_player_navigation_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_backend.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 '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; 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', () { final guard = VideoPlayerNavigationInFlightGuard(); - final item = MediaItem( + final item = testMediaItem( id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -48,7 +59,7 @@ void main() { MediaVersion(id: '102', videoResolution: '4k', videoCodec: 'hevc', container: 'mkv'), ]; - final episode = MediaItem( + final episode = testMediaItem( id: 'ep-1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -122,7 +133,7 @@ void main() { ); SettingsService.resetForTesting(); - final bare = MediaItem( + final bare = testMediaItem( id: 'ep-2', backend: MediaBackend.jellyfin, kind: MediaKind.episode, diff --git a/test/widgets/download_tree_view_test.dart b/test/widgets/download_tree_view_test.dart index 157c012d..d7f1524e 100644 --- a/test/widgets/download_tree_view_test.dart +++ b/test/widgets/download_tree_view_test.dart @@ -5,6 +5,7 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/widgets/download_tree_view.dart'; +import '../test_helpers/media_items.dart'; DownloadTreeNode _episodeNode(String globalKey) => DownloadTreeNode( key: globalKey, @@ -34,7 +35,7 @@ MediaItem _episodeMeta({ required ServerId? serverId, required String? grandparentId, required String? parentId, -}) => MediaItem( +}) => testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.episode, diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 0f7a40d5..9dd43641 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -715,6 +715,31 @@ void main() { 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 { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); diff --git a/test/widgets/folder_tree_item_test.dart b/test/widgets/folder_tree_item_test.dart index e38822b3..dba0b187 100644 --- a/test/widgets/folder_tree_item_test.dart +++ b/test/widgets/folder_tree_item_test.dart @@ -4,10 +4,11 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/screens/libraries/folder_tree_item.dart'; +import '../test_helpers/media_items.dart'; void main() { testWidgets('folder rows use the item title for seasons', (tester) async { - final item = MediaItem( + final item = testMediaItem( id: 'season-1', backend: MediaBackend.jellyfin, kind: MediaKind.season, @@ -28,7 +29,7 @@ void main() { }); testWidgets('folder rows show play and shuffle buttons when callbacks are supplied', (tester) async { - final item = MediaItem( + final item = testMediaItem( id: 'folder-1', backend: MediaBackend.jellyfin, kind: MediaKind.unknown, diff --git a/test/widgets/media_card_full_card_test.dart b/test/widgets/media_card_full_card_test.dart index cbd0ecea..e944b1a0 100644 --- a/test/widgets/media_card_full_card_test.dart +++ b/test/widgets/media_card_full_card_test.dart @@ -15,6 +15,7 @@ import 'package:plezy/widgets/media_card.dart'; import 'package:plezy/widgets/media_grid_delegate.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -69,7 +70,7 @@ void main() { }); 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', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -93,7 +94,12 @@ void main() { }); 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( _TestApp( @@ -105,7 +111,7 @@ void main() { }); 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( _TestApp( @@ -205,7 +211,7 @@ void main() { } 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( child: _TestApp( child: SizedBox( diff --git a/test/widgets/media_card_square_test.dart b/test/widgets/media_card_square_test.dart index 4cc08c82..eba6954c 100644 --- a/test/widgets/media_card_square_test.dart +++ b/test/widgets/media_card_square_test.dart @@ -14,8 +14,9 @@ import 'package:plezy/widgets/optimized_media_image.dart'; import 'package:plezy/widgets/watched_indicator.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', backend: MediaBackend.plex, kind: kind, diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 24e07e18..82e6d3e1 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -39,6 +39,7 @@ import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/media_context_menu.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/media_items.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -48,7 +49,11 @@ void main() { final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false)); expect( - isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile), + isAdminActionAllowedFortestMediaItem( + isOwnerOrAdmin: true, + itemBackend: MediaBackend.plex, + activeProfile: profile, + ), isFalse, ); }); @@ -57,7 +62,7 @@ void main() { final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false)); expect( - isAdminActionAllowedForMediaItem( + isAdminActionAllowedFortestMediaItem( isOwnerOrAdmin: true, itemBackend: MediaBackend.jellyfin, activeProfile: profile, @@ -70,7 +75,11 @@ void main() { final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: true)); expect( - isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile), + isAdminActionAllowedFortestMediaItem( + isOwnerOrAdmin: true, + itemBackend: MediaBackend.plex, + activeProfile: profile, + ), isTrue, ); }); @@ -97,14 +106,14 @@ void main() { addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); final tracks = [ - MediaItem( + testMediaItem( id: 'track-1', backend: MediaBackend.jellyfin, kind: MediaKind.track, title: 'Track One', serverId: 'srv-1', ), - MediaItem( + testMediaItem( id: 'track-2', backend: MediaBackend.jellyfin, kind: MediaKind.track, @@ -222,7 +231,7 @@ void main() { }); final menuKey = GlobalKey(); - final item = MediaItem( + final item = testMediaItem( id: 'movie-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, @@ -372,7 +381,7 @@ Future> _pumpPlexMovieMenu( }); final menuKey = GlobalKey(); - final item = MediaItem( + final item = testMediaItem( id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index 73e82a20..056f4234 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -15,8 +15,9 @@ import 'package:plezy/widgets/music/mini_player.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; +import '../../test_helpers/media_items.dart'; -final _track = MediaItem( +final _track = testMediaItem( id: 'track_1', backend: MediaBackend.plex, kind: MediaKind.track, diff --git a/test/widgets/music_detail_header_test.dart b/test/widgets/music_detail_header_test.dart new file mode 100644 index 00000000..fb3dc216 --- /dev/null +++ b/test/widgets/music_detail_header_test.dart @@ -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), + ); + }); +} diff --git a/test/widgets/player_queue_spoilers_test.dart b/test/widgets/player_queue_spoilers_test.dart index dbd2e116..2fdb22b2 100644 --- a/test/widgets/player_queue_spoilers_test.dart +++ b/test/widgets/player_queue_spoilers_test.dart @@ -15,6 +15,7 @@ import 'package:plezy/widgets/video_controls/widgets/media_selector_thumbnail.da import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; const _testTokens = MonoTokens( radiusSm: 8, @@ -140,7 +141,7 @@ PlaybackStateProvider _playbackWithQueue() { items: [ _episode('spoiler-episode', title: 'Spoiler Episode'), _episode('watched-episode', title: 'Watched Episode', viewCount: 1), - MediaItem( + testMediaItem( id: 'movie', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -154,7 +155,7 @@ PlaybackStateProvider _playbackWithQueue() { } MediaItem _episode(String id, {required String title, int? viewCount}) { - return MediaItem( + return testMediaItem( id: id, backend: MediaBackend.plex, kind: MediaKind.episode, diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index ba76a77f..9c4befe7 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -23,13 +23,14 @@ import 'package:plezy/widgets/tv_browse_rail.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/media_items.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('TvBrowseRailLayout', () { test('density changes card width', () { - final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); final hub = MediaHub(id: 'hub_1', title: 'Movies', type: 'movie', items: [item], size: 1); final compact = TvBrowseRailLayout.metricsForHub( @@ -52,7 +53,7 @@ void main() { }); test('detail episode hubs can force episode thumbnails', () { - final episode = MediaItem( + final episode = testMediaItem( id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -94,8 +95,8 @@ void main() { }); test('estimated rail height is stable across mixed hub heights', () { - final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); - final episode = MediaItem( + final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final episode = testMediaItem( id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -161,7 +162,7 @@ void main() { }); test('compact tall poster scale reduces browse rail height', () { - 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 hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); const size = Size(1280, 720); @@ -183,7 +184,7 @@ void main() { }); test('empty episode thumbnail hubs reserve thumbnail row height', () { - final episode = MediaItem( + final episode = testMediaItem( id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -221,7 +222,7 @@ void main() { }); test('full card layout removes label reserve and preserves episode poster mode', () { - final episode = MediaItem( + final episode = testMediaItem( id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -255,14 +256,14 @@ void main() { }); test('compact wide poster scale makes clips match compact episode thumbnails', () { - final episode = MediaItem( + final episode = testMediaItem( id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, title: 'Episode 1', thumbPath: '/episode-thumb', ); - final clip = MediaItem( + final clip = testMediaItem( id: 'clip_1', backend: MediaBackend.plex, kind: MediaKind.clip, @@ -295,7 +296,7 @@ void main() { }); test('multi-hub estimate reserves next hub peek height', () { - 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 movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); final showHub = MediaHub(id: 'shows', title: 'Shows', type: 'show', items: [movie], size: 1); @@ -334,8 +335,8 @@ void main() { final serverManager = MultiServerManager(); final items = [ - MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'First Movie'), - MediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Second Movie'), + testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'First Movie'), + testMediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Second Movie'), ]; final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: items, size: items.length); String? activatedItemId; @@ -395,7 +396,12 @@ void main() { testWidgets('active hub header uses theme foreground in light mode', (tester) async { final serverManager = MultiServerManager(); final theme = monoTheme(dark: false); - final episode = MediaItem(id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, title: 'Episode 1'); + final episode = testMediaItem( + id: 'episode_1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 1', + ); final hub = MediaHub(id: 'season_1', title: 'Season 1', type: 'episode', items: [episode], size: 1); await tester.pumpWidget( @@ -433,8 +439,8 @@ void main() { serverId: serverId, size: 2, items: [ - MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'A', serverId: serverId), - MediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'B', serverId: serverId), + testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'A', serverId: serverId), + testMediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'B', serverId: serverId), ], ); @@ -465,8 +471,13 @@ void main() { await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true); final serverManager = MultiServerManager(); - final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Hidden Movie'); - final actor = MediaItem( + final movie = testMediaItem( + id: 'movie_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Hidden Movie', + ); + final actor = testMediaItem( id: 'actor_1', backend: MediaBackend.plex, kind: MediaKind.unknown, @@ -515,7 +526,7 @@ void main() { }); final serverManager = MultiServerManager(); - 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 hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); await tester.pumpWidget( @@ -588,8 +599,18 @@ void main() { }); final serverManager = MultiServerManager(); - final firstMovie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); - final secondMovie = MediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 2'); + final firstMovie = testMediaItem( + id: 'movie_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 1', + ); + final secondMovie = testMediaItem( + id: 'movie_2', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 2', + ); final firstHub = MediaHub(id: 'movies_1', title: 'Movies 1', type: 'movie', items: [firstMovie], size: 1); final secondHub = MediaHub(id: 'movies_2', title: 'Movies 2', type: 'movie', items: [secondMovie], size: 1); @@ -648,7 +669,12 @@ void main() { await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false); final serverManager = MultiServerManager(); - final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Visible Movie'); + final movie = testMediaItem( + id: 'movie_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Visible Movie', + ); final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); await tester.pumpWidget( @@ -683,7 +709,7 @@ void main() { }); final serverManager = MultiServerManager(); - final movie = MediaItem( + final movie = testMediaItem( id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, @@ -744,7 +770,7 @@ void main() { }); final serverManager = MultiServerManager(); - 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 hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 2, more: true); await tester.pumpWidget( @@ -801,7 +827,7 @@ void main() { }); final serverManager = MultiServerManager(); - 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 hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 2); await tester.pumpWidget( @@ -852,7 +878,7 @@ void main() { }); final serverManager = MultiServerManager(); - 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 hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); var trailing = TvRailTrailing.loading; var activations = 0; @@ -909,8 +935,13 @@ void main() { testWidgets('inactive hub contents render at reduced opacity', (tester) async { final serverManager = MultiServerManager(); - final firstItem = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); - final secondItem = MediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 2'); + final firstItem = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); + final secondItem = testMediaItem( + id: 'movie_2', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 2', + ); final firstHub = MediaHub(id: 'hub_1', title: 'First Hub', type: 'movie', items: [firstItem], size: 1); final secondHub = MediaHub(id: 'hub_2', title: 'Second Hub', type: 'movie', items: [secondItem], size: 1); @@ -1053,13 +1084,13 @@ void main() { ); } - final episode1 = MediaItem( + final episode1 = testMediaItem( id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, title: 'Episode 1', ); - final episode2 = MediaItem( + final episode2 = testMediaItem( id: 'episode_2', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1087,11 +1118,11 @@ void main() { List movieItems() => List.generate( 12, (index) => - MediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'), + testMediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'), ); List episodeItems() => List.generate( 12, - (index) => MediaItem( + (index) => testMediaItem( id: 'episode_$index', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1204,15 +1235,15 @@ void main() { }); final serverManager = MultiServerManager(); - final tallItem = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); - final wideItem = MediaItem( + final tallItem = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); + final wideItem = testMediaItem( id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, title: 'Episode 1', thumbPath: '/episode_1', ); - final activeItem = MediaItem( + final activeItem = testMediaItem( id: 'episode_2', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1284,7 +1315,7 @@ void main() { }); MediaItem episode(String id) { - return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id'); + return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id'); } final serverManager = MultiServerManager(); @@ -1357,7 +1388,7 @@ void main() { }); MediaItem episode(String id) { - return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id'); + return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id'); } final serverManager = MultiServerManager(); @@ -1457,11 +1488,11 @@ void main() { List movieItems() => List.generate( 8, (index) => - MediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'), + testMediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'), ); List episodeItems() => List.generate( 8, - (index) => MediaItem( + (index) => testMediaItem( id: 'episode_$index', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1541,7 +1572,7 @@ void main() { final episodes = List.generate( 153, - (index) => MediaItem( + (index) => testMediaItem( id: 'episode_${index + 1}', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1625,7 +1656,7 @@ void main() { const targetIndex = 419; final episodes = List.generate( episodeCount, - (index) => MediaItem( + (index) => testMediaItem( id: 'episode_${index + 1}', backend: MediaBackend.plex, kind: MediaKind.episode, @@ -1711,7 +1742,7 @@ void main() { addTearDown(SelectKeyUpSuppressor.clearSuppression); var activations = 0; - final person = MediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); + final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1); final serverManager = MultiServerManager(); @@ -1775,7 +1806,7 @@ void main() { testWidgets('suppresses transferred select activation until key up', (tester) async { var activations = 0; - final person = MediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); + final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1); final serverManager = MultiServerManager(); @@ -1825,7 +1856,7 @@ void main() { testWidgets('without a gesture signal, suppression clears on the legacy safety timeout', (tester) async { var activations = 0; - final person = MediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); + final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1); final serverManager = MultiServerManager(); @@ -1872,7 +1903,7 @@ void main() { var activations = 0; final gesture = ValueNotifier(true); addTearDown(gesture.dispose); - final person = MediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); + final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1); final serverManager = MultiServerManager(); @@ -1927,7 +1958,7 @@ void main() { var activations = 0; final gesture = ValueNotifier(true); addTearDown(gesture.dispose); - final person = MediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); + final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person'); final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1); final serverManager = MultiServerManager(); @@ -1980,7 +2011,7 @@ void main() { Widget buildRail({required bool autofocus}) { final serverManager = MultiServerManager(); - final item = MediaItem(id: 'item_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final item = testMediaItem(id: 'item_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); return ChangeNotifierProvider( create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), @@ -2008,7 +2039,7 @@ void main() { testWidgets('lays out when bottom-positioned in a stack', (tester) async { final serverManager = MultiServerManager(); - final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); await tester.pumpWidget( @@ -2049,7 +2080,7 @@ void main() { }); final serverManager = MultiServerManager(); - final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); await tester.pumpWidget( @@ -2089,7 +2120,7 @@ void main() { final focusedItemIds = []; final activeHubIds = []; - final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); Widget buildRail(double backgroundBleedLeft) { diff --git a/test/widgets/tv_spotlight_scaffold_test.dart b/test/widgets/tv_spotlight_scaffold_test.dart index 68594fd0..4927bed6 100644 --- a/test/widgets/tv_spotlight_scaffold_test.dart +++ b/test/widgets/tv_spotlight_scaffold_test.dart @@ -4,8 +4,9 @@ import 'package:plezy/media/media_hub.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/widgets/tv_spotlight_scaffold.dart'; +import '../test_helpers/media_items.dart'; -MediaItem _item(String id) => MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, title: id); +MediaItem _item(String id) => testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, title: id); MediaHub _hub(String id, List items) => MediaHub(id: id, title: id, type: 'movie', size: items.length, items: items);