refactor: share the paginated grid tab, cached remote store, and tile focus

- PaginatedCardGridTabState: the collections and playlists tabs were 95%
  identical; they now supply only pageSize/fetchPage/idOf instead of each
  duplicating the grid, memo, inflation budget and focus wiring.
- EtagCachedRemoteStore: the anime-lists and fribb mapping stores now share
  one download/cache/isolate-parse/conditional-GET lifecycle.
- FocusableTileStateMixin manages its own initState/didUpdateWidget/dispose
  instead of requiring every caller to forward three lifecycle hooks.

Also drops unused ServerCapabilities entries and dead code in
focusable_list_tile and music/track_row.
This commit is contained in:
edde746
2026-07-26 06:09:48 +02:00
parent 352b88109b
commit 316a69a1de
56 changed files with 615 additions and 1455 deletions
+18 -6
View File
@@ -7,23 +7,35 @@ import 'owned_focus_node_binding.dart';
/// auto-scrolls the tile into view when it gains focus.
mixin FocusableTileStateMixin<T extends StatefulWidget> on State<T> {
final _focusNodeBinding = OwnedFocusNodeBinding();
FocusNode? _boundExternalNode;
FocusNode? get widgetFocusNode;
FocusNode get effectiveFocusNode => _focusNodeBinding.node;
void initFocusNode() {
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange);
@override
void initState() {
super.initState();
_bindFocusNode();
}
void updateFocusNode(FocusNode? oldFocusNode) {
if (oldFocusNode != widgetFocusNode) {
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange);
@override
void didUpdateWidget(T oldWidget) {
super.didUpdateWidget(oldWidget);
if (_boundExternalNode != widgetFocusNode) {
_bindFocusNode();
}
}
void disposeFocusNode() {
@override
void dispose() {
_focusNodeBinding.dispose();
super.dispose();
}
void _bindFocusNode() {
_boundExternalNode = widgetFocusNode;
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange);
}
void _onFocusChange() {
+1 -1
View File
@@ -260,7 +260,7 @@ abstract class MediaServerClient {
/// `/Audio/{id}/Lyrics` (per-line tick offsets when synced); Plex: a
/// sidecar-lyrics track stream (`streamType 4`) fetched from
/// `/library/streams/{id}` and parsed from LRC. Synced-ness is per
/// [Lyrics.synced]; gated by [ServerCapabilities.lyrics].
/// [Lyrics.synced]; per-track absence is the runtime gate.
Future<Lyrics?> fetchLyrics(MediaItem track);
/// Free-text search across the user's libraries.
-108
View File
@@ -1,17 +1,3 @@
/// How the alpha-jump bar behaves for libraries on this backend.
enum AlphaBarMode {
/// No alpha bar — hide entirely.
none,
/// Plex: server reports per-letter cumulative offsets via `/firstCharacter`,
/// taps scroll the grid to the offset.
scrollSnap,
/// Jellyfin: bar acts as a filter button — taps set `NameStartsWith` query
/// param, results re-fetch.
nameStartsWithFilter,
}
/// Static capability flags advertised by a [MediaServerClient]. UI consults
/// these to gate feature affordances per server (e.g. hide Live TV when no
/// connected server supports it).
@@ -21,14 +7,6 @@ enum AlphaBarMode {
/// Jellyfin features are wired in over time, the corresponding flags flip
/// without changing call sites.
class ServerCapabilities {
/// Server-side `PlayQueue` resource (Plex `/playQueues`) — enables shared
/// queue state across devices and Watch Together coordination.
final bool serverSidePlayQueue;
/// Server-side editable playlists (Plex `/playlists`, Jellyfin
/// `/Playlists`).
final bool serverSidePlaylists;
/// This backend kind has a Live TV / DVR API the app can talk to. Whether
/// a *specific* server has Live TV configured is a runtime concern —
/// [MultiServerProvider.checkLiveTvAvailability] probes each server and
@@ -41,17 +19,9 @@ class ServerCapabilities {
/// when [liveTv] is true.
final bool liveTvDvr;
/// Server proxies subtitle search (e.g. OpenSubtitles).
final bool subtitleSearch;
/// Server can transcode video.
final bool videoTranscoding;
/// Server supports server-side downloads / "sync" (the queued-from-server
/// model). Both Plex and Jellyfin support client-driven downloads, which
/// is a separate concept.
final bool serverSideSync;
/// Server provides curated recommendation hubs (Plex Discover). Jellyfin
/// returns synthesized hubs but with sparser categorisation.
final bool richHubs;
@@ -72,32 +42,9 @@ class ServerCapabilities {
/// Hides the "Search subtitles" affordance when false.
final bool externalSubtitleSearch;
/// Persisting per-track audio/subtitle preferences server-side. Plex uses
/// `/library/metadata/{id}/prefs` + `selectStream`; Jellyfin saves selected
/// stream indexes from `/Sessions/Playing/Progress` when the user's Jellyfin
/// remember-selection settings are enabled. When false, in-player switching
/// still works but choices don't follow the user across devices.
final bool trackPreferencePersistence;
/// Multi-endpoint connection model with endpoint racing/failover. Plex gets
/// local/remote/relay candidates from plex.tv; Jellyfin uses user-entered
/// URLs for the same server.
final bool endpointFailover;
/// Watch progress can be queued offline and replayed when reconnected
/// ([OfflineWatchSyncService]). Jellyfin reports inline only today.
final bool offlineWatchQueue;
/// Discord rich-presence integration. Plex-only because the RPC payload
/// uses Plex-shaped session/metadata.
final bool discordRpc;
/// Server exposes metadata edit endpoints. Hides edit affordances when false.
final bool richMetadataEdit;
/// How the alpha-jump bar should behave for this backend's libraries.
final AlphaBarMode alphaBar;
/// Server can supply thumbnails for the player's seek-bar scrub preview.
/// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite
/// sheets. Both backends are wired through [ScrubPreviewSource]; the flag
@@ -109,73 +56,40 @@ class ServerCapabilities {
/// `/Items?ParentId=...&Recursive=false` queries.
final bool folderGrouping;
/// Server can supply track lyrics. Jellyfin exposes `/Audio/{id}/Lyrics`;
/// Plex surfaces sidecar `.lrc`/`.txt` files as track streams
/// (`streamType 4`) fetched via `/library/streams/{id}`. Gates the lyrics
/// affordance in the music player; per-track absence is the runtime gate.
final bool lyrics;
/// Server can build an "instant mix" / radio track list from a seed item.
/// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues
/// (`POST /playQueues?type=audio&uri=...station...`).
final bool instantMix;
/// Server can transcode audio to a capped bitrate. Plex:
/// `/music/:/transcode/universal`; Jellyfin: `PlaybackInfo` with an audio
/// `TranscodingProfile`. Gates the music quality picker (vs original-only).
final bool audioTranscoding;
const ServerCapabilities({
this.serverSidePlayQueue = false,
this.serverSidePlaylists = false,
this.liveTv = false,
this.liveTvDvr = false,
this.subtitleSearch = false,
this.videoTranscoding = true,
this.serverSideSync = false,
this.richHubs = false,
this.numericUserRating = false,
this.userFavorites = false,
this.continueWatchingRemoval = false,
this.externalSubtitleSearch = false,
this.trackPreferencePersistence = false,
this.endpointFailover = false,
this.offlineWatchQueue = false,
this.discordRpc = false,
this.richMetadataEdit = false,
this.alphaBar = AlphaBarMode.none,
this.scrubThumbnails = false,
this.folderGrouping = false,
this.lyrics = false,
this.instantMix = false,
this.audioTranscoding = false,
});
/// Defaults for a fully-featured Plex server.
static const ServerCapabilities plex = ServerCapabilities(
serverSidePlayQueue: true,
serverSidePlaylists: true,
liveTv: true,
liveTvDvr: true,
subtitleSearch: true,
videoTranscoding: true,
serverSideSync: true,
richHubs: true,
numericUserRating: true,
userFavorites: false,
continueWatchingRemoval: true,
externalSubtitleSearch: true,
trackPreferencePersistence: true,
endpointFailover: true,
offlineWatchQueue: true,
discordRpc: true,
richMetadataEdit: true,
alphaBar: AlphaBarMode.scrollSnap,
scrubThumbnails: true,
folderGrouping: true,
lyrics: true,
instantMix: true,
audioTranscoding: true,
);
/// Defaults for a Jellyfin server.
@@ -188,28 +102,17 @@ class ServerCapabilities {
/// `/LiveTv/Programs`. Detection + channel listing are wired today;
/// EPG and tuning are follow-ups.
static const ServerCapabilities jellyfin = ServerCapabilities(
serverSidePlayQueue: false,
serverSidePlaylists: true,
liveTv: true,
liveTvDvr: false,
subtitleSearch: false,
videoTranscoding: true,
serverSideSync: false,
richHubs: false,
numericUserRating: false,
userFavorites: true,
externalSubtitleSearch: false,
trackPreferencePersistence: true,
endpointFailover: true,
offlineWatchQueue: false,
discordRpc: false,
richMetadataEdit: true,
alphaBar: AlphaBarMode.nameStartsWithFilter,
scrubThumbnails: true,
folderGrouping: true,
lyrics: true,
instantMix: true,
audioTranscoding: true,
);
/// Every flag here is fixed per backend *kind* except [videoTranscoding],
@@ -218,29 +121,18 @@ class ServerCapabilities {
/// ever becomes a runtime probe.
ServerCapabilities copyWith({bool? videoTranscoding}) {
return ServerCapabilities(
serverSidePlayQueue: serverSidePlayQueue,
serverSidePlaylists: serverSidePlaylists,
liveTv: liveTv,
liveTvDvr: liveTvDvr,
subtitleSearch: subtitleSearch,
videoTranscoding: videoTranscoding ?? this.videoTranscoding,
serverSideSync: serverSideSync,
richHubs: richHubs,
numericUserRating: numericUserRating,
userFavorites: userFavorites,
continueWatchingRemoval: continueWatchingRemoval,
externalSubtitleSearch: externalSubtitleSearch,
trackPreferencePersistence: trackPreferencePersistence,
endpointFailover: endpointFailover,
offlineWatchQueue: offlineWatchQueue,
discordRpc: discordRpc,
richMetadataEdit: richMetadataEdit,
alphaBar: alphaBar,
scrubThumbnails: scrubThumbnails,
folderGrouping: folderGrouping,
lyrics: lyrics,
instantMix: instantMix,
audioTranscoding: audioTranscoding,
);
}
}
-92
View File
@@ -1,92 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'livetv_channel.dart';
part 'livetv_lineup.g.dart';
List<LiveTvChannel> _parseChannels(Object? raw) => parseFlexibleJsonList(raw, LiveTvChannel.fromJson);
@JsonSerializable(createToJson: false)
class LiveTvCountry {
final String? key;
final String? type;
@JsonKey(defaultValue: '')
final String title;
@JsonKey(defaultValue: '')
final String code;
final String? language;
final String? languageTitle;
final String? example;
@JsonKey(fromJson: flexibleInt)
final int? flavor;
const LiveTvCountry({
this.key,
this.type,
required this.title,
required this.code,
this.language,
this.languageTitle,
this.example,
this.flavor,
});
factory LiveTvCountry.fromJson(Map<String, dynamic> json) => _$LiveTvCountryFromJson(json);
}
@JsonSerializable(createToJson: false)
class LiveTvLanguage {
@JsonKey(defaultValue: '')
final String code;
@JsonKey(defaultValue: '')
final String title;
const LiveTvLanguage({required this.code, required this.title});
factory LiveTvLanguage.fromJson(Map<String, dynamic> json) => _$LiveTvLanguageFromJson(json);
}
@JsonSerializable(createToJson: false)
class LiveTvRegion {
@JsonKey(defaultValue: '')
final String key;
final String? type;
@JsonKey(defaultValue: '')
final String title;
const LiveTvRegion({required this.key, this.type, required this.title});
factory LiveTvRegion.fromJson(Map<String, dynamic> json) => _$LiveTvRegionFromJson(json);
}
@JsonSerializable(createToJson: false)
class LiveTvLineup {
@JsonKey(defaultValue: '')
final String uuid;
final String? type;
final String? title;
@JsonKey(fromJson: flexibleInt)
final int? lineupType;
final String? location;
@JsonKey(name: 'Channel', fromJson: _parseChannels)
final List<LiveTvChannel> channels;
const LiveTvLineup({
required this.uuid,
this.type,
this.title,
this.lineupType,
this.location,
this.channels = const [],
});
factory LiveTvLineup.fromJson(Map<String, dynamic> json) => _$LiveTvLineupFromJson(json);
}
class LiveTvLineupResult {
final String? lineupGroupUuid;
final List<LiveTvLineup> lineups;
const LiveTvLineupResult({this.lineupGroupUuid, required this.lineups});
}
-42
View File
@@ -1,42 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'livetv_lineup.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LiveTvCountry _$LiveTvCountryFromJson(Map<String, dynamic> json) =>
LiveTvCountry(
key: json['key'] as String?,
type: json['type'] as String?,
title: json['title'] as String? ?? '',
code: json['code'] as String? ?? '',
language: json['language'] as String?,
languageTitle: json['languageTitle'] as String?,
example: json['example'] as String?,
flavor: flexibleInt(json['flavor']),
);
LiveTvLanguage _$LiveTvLanguageFromJson(Map<String, dynamic> json) =>
LiveTvLanguage(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
);
LiveTvRegion _$LiveTvRegionFromJson(Map<String, dynamic> json) => LiveTvRegion(
key: json['key'] as String? ?? '',
type: json['type'] as String?,
title: json['title'] as String? ?? '',
);
LiveTvLineup _$LiveTvLineupFromJson(Map<String, dynamic> json) => LiveTvLineup(
uuid: json['uuid'] as String? ?? '',
type: json['type'] as String?,
title: json['title'] as String?,
lineupType: flexibleInt(json['lineupType']),
location: json['location'] as String?,
channels: json['Channel'] == null
? const []
: _parseChannels(json['Channel']),
);
-26
View File
@@ -1,26 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
part 'livetv_server_status.g.dart';
@JsonSerializable(createToJson: false)
class LiveTvServerStatus {
@JsonKey(name: 'livetv', fromJson: flexibleInt)
final int? liveTvCount;
@JsonKey(fromJson: flexibleBoolNullable)
final bool? allowTuners;
final String? ownerFeatures;
const LiveTvServerStatus({this.liveTvCount, this.allowTuners, this.ownerFeatures});
factory LiveTvServerStatus.fromJson(Map<String, dynamic> json) => _$LiveTvServerStatusFromJson(json);
Set<String> get ownerFeatureSet =>
(ownerFeatures ?? '').split(',').map((feature) => feature.trim()).where((feature) => feature.isNotEmpty).toSet();
bool get hasConfiguredDvr => (liveTvCount ?? 0) > 0;
bool get supportsTuners => allowTuners != false;
bool get hasDvrFeature => ownerFeatureSet.contains('dvr');
bool get hasLiveTvFeature => ownerFeatureSet.contains('livetv');
}
-14
View File
@@ -1,14 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'livetv_server_status.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LiveTvServerStatus _$LiveTvServerStatusFromJson(Map<String, dynamic> json) =>
LiveTvServerStatus(
liveTvCount: flexibleInt(json['livetv']),
allowTuners: flexibleBoolNullable(json['allowTuners']),
ownerFeatures: json['ownerFeatures'] as String?,
);
-66
View File
@@ -1,66 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'livetv_capture_buffer.dart';
import 'livetv_program.dart';
import 'media_grab_operation.dart';
part 'livetv_session.g.dart';
LiveTvProgram? _programFromRaw(Object? raw) => parseFlexibleJsonObject(raw, LiveTvProgram.fromJson);
MediaGrabOperation? _grabOperationFromRaw(Object? raw) => parseFlexibleJsonObject(raw, MediaGrabOperation.fromJson);
CaptureBuffer? _captureBufferFromRaw(Object? raw) {
final map = firstFlexibleMap(raw);
if (map == null) return null;
final session = firstFlexibleMap(map['TranscodeSession']) ?? map;
return CaptureBuffer.fromTranscodeSession(session);
}
@JsonSerializable(createToJson: false)
class LiveTvSession {
@JsonKey(readValue: readStringField, defaultValue: '')
final String sessionID;
@JsonKey(readValue: readStringField)
final String? dvrID;
final String? channelIdentifier;
final String? channelCallSign;
final String? channelTitle;
final String? activityUUID;
@JsonKey(fromJson: flexibleInt)
final int? currentPosition;
@JsonKey(fromJson: flexibleInt)
final int? nextPosition;
@JsonKey(fromJson: flexibleInt)
final int? startedAt;
@JsonKey(name: 'CaptureBuffer', fromJson: _captureBufferFromRaw)
final CaptureBuffer? captureBuffer;
@JsonKey(name: 'MediaGrabOperation', fromJson: _grabOperationFromRaw)
final MediaGrabOperation? grabOperation;
@JsonKey(name: 'Timeline', fromJson: firstFlexibleMap)
final Map<String, dynamic>? timeline;
@JsonKey(name: 'AiringMetadataItem', fromJson: _programFromRaw)
final LiveTvProgram? airingMetadataItem;
@JsonKey(name: 'UpNextMetadataItem', fromJson: _programFromRaw)
final LiveTvProgram? upNextMetadataItem;
const LiveTvSession({
required this.sessionID,
this.dvrID,
this.channelIdentifier,
this.channelCallSign,
this.channelTitle,
this.activityUUID,
this.currentPosition,
this.nextPosition,
this.startedAt,
this.captureBuffer,
this.grabOperation,
this.timeline,
this.airingMetadataItem,
this.upNextMetadataItem,
});
factory LiveTvSession.fromJson(Map<String, dynamic> json) => _$LiveTvSessionFromJson(json);
}
-25
View File
@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'livetv_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LiveTvSession _$LiveTvSessionFromJson(Map<String, dynamic> json) =>
LiveTvSession(
sessionID: readStringField(json, 'sessionID') as String? ?? '',
dvrID: readStringField(json, 'dvrID') as String?,
channelIdentifier: json['channelIdentifier'] as String?,
channelCallSign: json['channelCallSign'] as String?,
channelTitle: json['channelTitle'] as String?,
activityUUID: json['activityUUID'] as String?,
currentPosition: flexibleInt(json['currentPosition']),
nextPosition: flexibleInt(json['nextPosition']),
startedAt: flexibleInt(json['startedAt']),
captureBuffer: _captureBufferFromRaw(json['CaptureBuffer']),
grabOperation: _grabOperationFromRaw(json['MediaGrabOperation']),
timeline: firstFlexibleMap(json['Timeline']),
airingMetadataItem: _programFromRaw(json['AiringMetadataItem']),
upNextMetadataItem: _programFromRaw(json['UpNextMetadataItem']),
);
-103
View File
@@ -1,103 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
import 'livetv_dvr.dart';
import 'media_subscription.dart';
part 'media_grabber_device.g.dart';
List<ChannelMapping> _parseChannelMappings(Object? raw) => parseFlexibleJsonList(raw, ChannelMapping.fromJson);
List<SubscriptionSetting> _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson);
@JsonSerializable(createToJson: false)
class MediaGrabber {
@JsonKey(defaultValue: '')
final String identifier;
final String? protocol;
final String? title;
const MediaGrabber({required this.identifier, this.protocol, this.title});
factory MediaGrabber.fromJson(Map<String, dynamic> json) => _$MediaGrabberFromJson(json);
}
/// Tuner/grabber device known to Plex Media Server.
@JsonSerializable(createToJson: false)
class MediaGrabberDevice {
@JsonKey(defaultValue: '')
final String key;
@JsonKey(defaultValue: '')
final String uuid;
final String? uri;
final String? protocol;
final String? title;
final String? make;
final String? model;
final String? modelNumber;
final String? firmware;
@JsonKey(fromJson: flexibleInt)
final int? tuners;
final String? sources;
@JsonKey(fromJson: flexibleInt)
final int? status;
@JsonKey(fromJson: flexibleInt)
final int? state;
@JsonKey(fromJson: flexibleInt)
final int? lastSeenAt;
@JsonKey(name: 'ChannelMapping', fromJson: _parseChannelMappings)
final List<ChannelMapping> channelMappings;
@JsonKey(name: 'Setting', fromJson: _parseSettings)
final List<SubscriptionSetting> settings;
const MediaGrabberDevice({
required this.key,
required this.uuid,
this.uri,
this.protocol,
this.title,
this.make,
this.model,
this.modelNumber,
this.firmware,
this.tuners,
this.sources,
this.status,
this.state,
this.lastSeenAt,
this.channelMappings = const [],
this.settings = const [],
});
factory MediaGrabberDevice.fromJson(Map<String, dynamic> json) => _$MediaGrabberDeviceFromJson(json);
}
@JsonSerializable(createToJson: false)
class MediaGrabberDeviceChannel {
@JsonKey(readValue: readStringField, defaultValue: '')
final String identifier;
@JsonKey(readValue: readStringField)
final String? key;
@JsonKey(readValue: readStringField)
final String? name;
@JsonKey(fromJson: flexibleBool)
final bool drm;
@JsonKey(fromJson: flexibleBool)
final bool hd;
const MediaGrabberDeviceChannel({required this.identifier, this.key, this.name, this.drm = false, this.hd = false});
factory MediaGrabberDeviceChannel.fromJson(Map<String, dynamic> json) => _$MediaGrabberDeviceChannelFromJson(json);
}
class MediaGrabberChannelMapRequest {
final List<String> channelsEnabled;
final Map<String, String> channelMapping;
final Map<String, String> channelMappingByKey;
const MediaGrabberChannelMapRequest({
this.channelsEnabled = const [],
this.channelMapping = const {},
this.channelMappingByKey = const {},
});
}
-47
View File
@@ -1,47 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_grabber_device.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaGrabber _$MediaGrabberFromJson(Map<String, dynamic> json) => MediaGrabber(
identifier: json['identifier'] as String? ?? '',
protocol: json['protocol'] as String?,
title: json['title'] as String?,
);
MediaGrabberDevice _$MediaGrabberDeviceFromJson(Map<String, dynamic> json) =>
MediaGrabberDevice(
key: json['key'] as String? ?? '',
uuid: json['uuid'] as String? ?? '',
uri: json['uri'] as String?,
protocol: json['protocol'] as String?,
title: json['title'] as String?,
make: json['make'] as String?,
model: json['model'] as String?,
modelNumber: json['modelNumber'] as String?,
firmware: json['firmware'] as String?,
tuners: flexibleInt(json['tuners']),
sources: json['sources'] as String?,
status: flexibleInt(json['status']),
state: flexibleInt(json['state']),
lastSeenAt: flexibleInt(json['lastSeenAt']),
channelMappings: json['ChannelMapping'] == null
? const []
: _parseChannelMappings(json['ChannelMapping']),
settings: json['Setting'] == null
? const []
: _parseSettings(json['Setting']),
);
MediaGrabberDeviceChannel _$MediaGrabberDeviceChannelFromJson(
Map<String, dynamic> json,
) => MediaGrabberDeviceChannel(
identifier: readStringField(json, 'identifier') as String? ?? '',
key: readStringField(json, 'key') as String?,
name: readStringField(json, 'name') as String?,
drm: json['drm'] == null ? false : flexibleBool(json['drm']),
hd: json['hd'] == null ? false : flexibleBool(json['hd']),
);
@@ -1,26 +1,12 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/library_query.dart';
import '../../../media/media_item.dart';
import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../mixins/standard_paginated_view.dart';
import '../../../services/settings_service.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/card_inflation_budget.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_card_sliver_layout.dart';
import '../../../widgets/settings_builder.dart';
import '../../../widgets/skeleton_media_card.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../../../i18n/strings.g.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart';
import 'paginated_card_grid_tab.dart';
/// Collections tab for library screen.
/// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root.
@@ -40,24 +26,13 @@ class LibraryCollectionsTab extends BaseLibraryTab<MediaItem> {
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
}
class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, LibraryCollectionsTab>
with
LibraryTabFocusMixin<LibraryCollectionsTab>,
PaginatedItemLoader<MediaItem, LibraryCollectionsTab>,
StandardPaginatedView<MediaItem, LibraryCollectionsTab>,
SkeletonUpgradeScheduler {
static const int _pageSize = 36;
/// Reuses card widgets across delegate swaps so tab-level setStates
/// (pagination, refreshes) don't rebuild every realized card inside layout.
final SliverChildMemo<MediaItem> _cardMemo = SliverChildMemo<MediaItem>();
class _LibraryCollectionsTabState extends PaginatedCardGridTabState<MediaItem, LibraryCollectionsTab> {
@override
int get pageSize => 36;
@override
String get focusNodeDebugLabel => 'collections_first_item';
@override
int get itemCount => totalSize;
@override
IconData get emptyIcon => Symbols.collections_rounded;
@@ -71,7 +46,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().collectionsStream;
@override
Future<List<MediaItem>> loadData() async => const [];
String idOf(MediaItem item) => item.id;
@override
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) {
@@ -80,42 +55,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
}
@override
Future<void> loadItems() {
return loadStandardPaginatedItems(
pageSize: _pageSize,
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
onLoaded: (_, _) => markItemsLoaded(),
);
}
@override
Widget buildContent(List<MediaItem> items) {
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final settings = SettingsService.instance;
final viewMode = settings.read(SettingsService.viewMode);
final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
return CustomScrollView(
clipBehavior: Clip.none,
slivers: [
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
_buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout),
],
);
},
);
}
static const double _focusDecorationPadding = 3.0;
EdgeInsets get _effectivePadding {
final base = GridLayoutConstants.gridPadding;
return base.copyWith(top: base.top + _focusDecorationPadding);
}
bool get _usesSquareCards {
bool get usesSquareCards {
final loaded = loadedItems.values;
return loaded.isNotEmpty && loaded.every(_isMusicCollection);
}
@@ -124,94 +64,4 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
// safe fallback. Jellyfin BoxSets are server-wide and must opt in per item.
bool _isMusicCollection(MediaItem item) =>
item.kind.isMusic || (item is PlexMediaItem && widget.library.kind.isMusic);
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
final shape = _usesSquareCards ? CardShape.square : null;
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: totalSize,
density: density,
padding: _effectivePadding,
fullBleedImage: useFullCardLayout,
shape: shape,
listEpoch: (ViewMode.list, totalSize, density, shape),
gridEpochBuilder: (geometry) =>
(ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape),
itemBuilder: (context, position) {
final index = position.index;
final item = loadedItems[index];
if (item == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
if (!position.isGrid) {
return _cardMemo.widgetFor(
index,
item,
epoch: position.layoutEpoch!,
build: () =>
_buildMediaCardItem(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true),
);
}
final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!);
if (cached != null) return cached;
if (CardInflationBudget.isScrollingContext(context) &&
!InputModeTracker.isKeyboardMode(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return const SkeletonMediaCard();
}
return _cardMemo.widgetFor(
index,
item,
epoch: position.layoutEpoch!,
build: () => _buildMediaCardItem(
index,
isFirstRow: position.isFirstRow,
isFirstColumn: position.isFirstColumn,
fullBleedImage: useFullCardLayout,
),
);
},
);
}
Widget _buildMediaCardItem(
int index, {
required bool isFirstRow,
required bool isFirstColumn,
bool disableScale = false,
bool fullBleedImage = false,
}) {
final item = loadedItems[index];
if (item == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
return FocusableMediaCard(
key: Key(item.id),
item: item,
focusNode: index == 0 ? firstItemFocusNode : null,
disableScale: disableScale,
fullBleedImage: fullBleedImage,
cardShapeOverride: _usesSquareCards ? CardShape.square : null,
onListRefresh: loadItems,
onNavigateUp: isFirstRow ? widget.onBack : null,
onBack: widget.onBack,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
);
}
void _navigateToSidebar() {
MainScreenFocusScope.focusSidebarOf(context);
}
@override
void dispose() {
disposePagination();
super.dispose();
}
}
@@ -1,28 +1,13 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/library_query.dart';
import '../../../media/media_item.dart';
import '../../../media/media_kind.dart';
import '../../../media/media_playlist.dart';
import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../mixins/standard_paginated_view.dart';
import '../../../services/settings_service.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/card_inflation_budget.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_card_sliver_layout.dart';
import '../../../widgets/settings_builder.dart';
import '../../../widgets/skeleton_media_card.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../../../i18n/strings.g.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart';
import 'paginated_card_grid_tab.dart';
/// Playlists tab for library screen
/// Shows playlists that contain items from the current library
@@ -42,24 +27,13 @@ class LibraryPlaylistsTab extends BaseLibraryTab<MediaPlaylist> {
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
}
class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, LibraryPlaylistsTab>
with
LibraryTabFocusMixin<LibraryPlaylistsTab>,
PaginatedItemLoader<MediaPlaylist, LibraryPlaylistsTab>,
StandardPaginatedView<MediaPlaylist, LibraryPlaylistsTab>,
SkeletonUpgradeScheduler {
static const int _pageSize = 200;
/// Reuses card widgets across delegate swaps so tab-level setStates
/// (pagination, refreshes) don't rebuild every realized card inside layout.
final SliverChildMemo<MediaPlaylist> _cardMemo = SliverChildMemo<MediaPlaylist>();
class _LibraryPlaylistsTabState extends PaginatedCardGridTabState<MediaPlaylist, LibraryPlaylistsTab> {
@override
int get pageSize => 200;
@override
String get focusNodeDebugLabel => 'playlists_first_item';
@override
int get itemCount => totalSize;
@override
IconData get emptyIcon => Symbols.playlist_play_rounded;
@@ -73,7 +47,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream;
@override
Future<List<MediaPlaylist>> loadData() async => const [];
String idOf(MediaPlaylist playlist) => playlist.id;
@override
Future<LibraryPage<MediaPlaylist>> fetchPage(int start, int size, AbortController? abort) {
@@ -86,130 +60,5 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
}
@override
Future<void> loadItems() {
return loadStandardPaginatedItems(
pageSize: _pageSize,
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
onLoaded: (_, _) => markItemsLoaded(),
);
}
@override
Widget buildContent(List<MediaPlaylist> items) {
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final settings = SettingsService.instance;
final viewMode = settings.read(SettingsService.viewMode);
final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
return CustomScrollView(
clipBehavior: Clip.none,
slivers: [
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
_buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout),
],
);
},
);
}
static const double _focusDecorationPadding = 3.0;
EdgeInsets get _effectivePadding {
final base = GridLayoutConstants.gridPadding;
return base.copyWith(top: base.top + _focusDecorationPadding);
}
bool get _usesSquareCards => widget.library.kind.isMusic;
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
final shape = _usesSquareCards ? CardShape.square : null;
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: totalSize,
density: density,
padding: _effectivePadding,
fullBleedImage: useFullCardLayout,
shape: shape,
listEpoch: (ViewMode.list, totalSize, density, shape),
gridEpochBuilder: (geometry) =>
(ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape),
itemBuilder: (context, position) {
final index = position.index;
final playlist = loadedItems[index];
if (playlist == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
if (!position.isGrid) {
return _cardMemo.widgetFor(
index,
playlist,
epoch: position.layoutEpoch!,
build: () =>
_buildPlaylistCard(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true),
);
}
final cached = _cardMemo.tryGet(index, playlist, epoch: position.layoutEpoch!);
if (cached != null) return cached;
if (CardInflationBudget.isScrollingContext(context) &&
!InputModeTracker.isKeyboardMode(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return const SkeletonMediaCard();
}
return _cardMemo.widgetFor(
index,
playlist,
epoch: position.layoutEpoch!,
build: () => _buildPlaylistCard(
index,
isFirstRow: position.isFirstRow,
isFirstColumn: position.isFirstColumn,
fullBleedImage: useFullCardLayout,
),
);
},
);
}
Widget _buildPlaylistCard(
int index, {
required bool isFirstRow,
required bool isFirstColumn,
bool disableScale = false,
bool fullBleedImage = false,
}) {
final playlist = loadedItems[index];
if (playlist == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
return FocusableMediaCard(
key: Key(playlist.id),
item: playlist,
focusNode: index == 0 ? firstItemFocusNode : null,
disableScale: disableScale,
fullBleedImage: fullBleedImage,
cardShapeOverride: _usesSquareCards ? CardShape.square : null,
onListRefresh: loadItems,
onNavigateUp: isFirstRow ? widget.onBack : null,
onBack: widget.onBack,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
);
}
void _navigateToSidebar() {
MainScreenFocusScope.focusSidebarOf(context);
}
@override
void dispose() {
disposePagination();
super.dispose();
}
bool get usesSquareCards => widget.library.kind.isMusic;
}
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/media_item.dart';
import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../mixins/standard_paginated_view.dart';
import '../../../services/settings_service.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/card_inflation_budget.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_card_sliver_layout.dart';
import '../../../widgets/settings_builder.dart';
import '../../../widgets/skeleton_media_card.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart';
/// Library tabs whose whole body is one paginated grid of media cards.
///
/// Owns the grid: sparse page loading, the card widget memo, the inflation
/// budget and skeleton-upgrade handshake, and first-item/sidebar focus wiring.
/// Subclasses supply only what differs per tab — [pageSize], [fetchPage],
/// [usesSquareCards], [idOf], and the empty/error chrome from
/// [BaseLibraryTabState].
abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibraryTab<T>>
extends BaseLibraryTabState<T, W>
with
LibraryTabFocusMixin<W>,
PaginatedItemLoader<T, W>,
StandardPaginatedView<T, W>,
SkeletonUpgradeScheduler<W> {
static const double _focusDecorationPadding = 3.0;
/// Reuses card widgets across delegate swaps so tab-level setStates
/// (pagination, refreshes) don't rebuild every realized card inside layout.
final SliverChildMemo<T> _cardMemo = SliverChildMemo<T>();
/// Items fetched per page.
int get pageSize;
/// Whether cards render with the square container silhouette.
bool get usesSquareCards;
/// Card key for [item]. The tabs' item types share no common supertype.
String idOf(T item);
@override
int get itemCount => totalSize;
@override
Future<List<T>> loadData() async => <T>[];
@override
Future<void> loadItems() {
return loadStandardPaginatedItems(
pageSize: pageSize,
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
onLoaded: (_, _) => markItemsLoaded(),
);
}
@override
Widget buildContent(List<T> items) {
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final settings = SettingsService.instance;
final viewMode = settings.read(SettingsService.viewMode);
final density = settings.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout);
return CustomScrollView(
clipBehavior: Clip.none,
slivers: [
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
_buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout),
],
);
},
);
}
EdgeInsets get _effectivePadding {
final base = GridLayoutConstants.gridPadding;
return base.copyWith(top: base.top + _focusDecorationPadding);
}
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
final shape = usesSquareCards ? CardShape.square : null;
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: totalSize,
density: density,
padding: _effectivePadding,
fullBleedImage: useFullCardLayout,
shape: shape,
listEpoch: (ViewMode.list, totalSize, density, shape),
gridEpochBuilder: (geometry) =>
(ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape),
itemBuilder: (context, position) {
final index = position.index;
final item = loadedItems[index];
if (item == null) {
ensureIndexLoaded(index, pageSize: pageSize);
return const SkeletonMediaCard();
}
if (!position.isGrid) {
return _cardMemo.widgetFor(
index,
item,
epoch: position.layoutEpoch!,
build: () => _buildCard(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true),
);
}
final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!);
if (cached != null) return cached;
if (CardInflationBudget.isScrollingContext(context) &&
!InputModeTracker.isKeyboardMode(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return const SkeletonMediaCard();
}
return _cardMemo.widgetFor(
index,
item,
epoch: position.layoutEpoch!,
build: () => _buildCard(
index,
isFirstRow: position.isFirstRow,
isFirstColumn: position.isFirstColumn,
fullBleedImage: useFullCardLayout,
),
);
},
);
}
Widget _buildCard(
int index, {
required bool isFirstRow,
required bool isFirstColumn,
bool disableScale = false,
bool fullBleedImage = false,
}) {
final item = loadedItems[index];
if (item == null) {
ensureIndexLoaded(index, pageSize: pageSize);
return const SkeletonMediaCard();
}
return FocusableMediaCard(
key: Key(idOf(item)),
item: item,
focusNode: index == 0 ? firstItemFocusNode : null,
disableScale: disableScale,
fullBleedImage: fullBleedImage,
cardShapeOverride: usesSquareCards ? CardShape.square : null,
onListRefresh: loadItems,
onNavigateUp: isFirstRow ? widget.onBack : null,
onBack: widget.onBack,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
);
}
void _navigateToSidebar() {
MainScreenFocusScope.focusSidebarOf(context);
}
@override
void dispose() {
disposePagination();
super.dispose();
}
}
@@ -1,27 +1,23 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:xml/xml.dart';
import '../../models/trackers/anime_lists_mapping.dart';
import '../../utils/abortable_http_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/json_utils.dart';
import '../../utils/platform_http_client_stub.dart'
if (dart.library.io) '../../utils/platform_http_client_io.dart'
as platform;
import '../base_shared_preferences_service.dart';
import 'etag_cached_remote_store.dart';
class AnimeListsIndex {
class AnimeListsIndex implements RemoteIndex {
final Map<int, List<AnimeListEntry>> byTvdb;
final Map<int, List<AnimeListEntry>> byTmdbTv;
const AnimeListsIndex({required this.byTvdb, required this.byTmdbTv});
@override
bool get isEmpty => byTvdb.isEmpty && byTmdbTv.isEmpty;
@override
String get logSummary => '${byTvdb.length} tvdb entries';
}
abstract interface class AnimeListsMappingLookup {
@@ -32,88 +28,25 @@ abstract interface class AnimeListsMappingLookup {
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId});
}
class AnimeListsMappingStore implements AnimeListsMappingLookup {
static const String _diskFileName = 'anime-list.xml';
static const String _prefsEtagKey = 'anime_lists_etag';
static const String _prefsLastCheckKey = 'anime_lists_last_check';
static const String _sourceUrl = 'https://cdn.jsdelivr.net/gh/Anime-Lists/anime-lists@master/anime-list.xml';
static const Duration _refreshInterval = Duration(days: 7);
static const Duration _requestTimeout = Duration(seconds: 60);
AnimeListsMappingStore._();
static final AnimeListsMappingStore instance = AnimeListsMappingStore._();
AnimeListsIndex? _index;
Future<AnimeListsIndex>? _loading;
bool _refreshRunning = false;
Future<AnimeListsIndex> _ensureLoaded() async {
final existing = _index;
if (existing != null) return existing;
final loading = _loading;
if (loading != null) return loading;
final fresh = _loadOrFetch();
_loading = fresh;
try {
final idx = await fresh;
if (!idx.isEmpty) {
_index = idx;
unawaited(maybeRefresh());
}
return idx;
} finally {
_loading = null;
}
}
Future<AnimeListsIndex> _loadOrFetch() async {
final path = await _diskPath();
try {
return await compute(_readAndParseAnimeLists, path);
} on FileSystemException {
appLogger.d('Anime-Lists: no disk cache, downloading from jsDelivr');
final raw = await _download();
if (raw == null) return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {});
return await compute(parseAnimeListsIndex, raw);
} catch (e) {
appLogger.w('Anime-Lists: parse failed - deleting disk copy so next lookup re-downloads', error: e);
await _deleteDiskCopy();
return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {});
}
}
Future<String?> _download() async {
final client = platform.createPlatformClient();
try {
final res = await sendAbortableHttpRequest(
client,
'GET',
Uri.parse(_sourceUrl),
headers: const {'Accept': 'application/xml,text/xml'},
timeout: _requestTimeout,
operation: 'Anime-Lists mapping download',
class AnimeListsMappingStore extends EtagCachedRemoteStore<AnimeListsIndex> implements AnimeListsMappingLookup {
AnimeListsMappingStore._()
: super(
diskFileName: 'anime-list.xml',
prefsEtagKey: 'anime_lists_etag',
prefsLastCheckKey: 'anime_lists_last_check',
sourceUrl: 'https://cdn.jsdelivr.net/gh/Anime-Lists/anime-lists@master/anime-list.xml',
acceptHeader: 'application/xml,text/xml',
logLabel: 'Anime-Lists',
emptyIndex: const AnimeListsIndex(byTvdb: {}, byTmdbTv: {}),
parse: parseAnimeListsIndex,
readAndParse: _readAndParseAnimeLists,
);
if (res.statusCode != 200) {
appLogger.d('Anime-Lists: download returned HTTP ${res.statusCode}');
return null;
}
await _writeDiskCopy(res.body, etag: res.headers['etag']);
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setInt(_prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch);
return res.body;
} catch (e) {
appLogger.w('Anime-Lists: download failed', error: e);
return null;
} finally {
client.close();
}
}
static final AnimeListsMappingStore instance = AnimeListsMappingStore._();
@override
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async {
final idx = await _ensureLoaded();
final idx = await ensureLoaded();
return lookupAnimeListEpisodeInIndex(
idx,
tvdbId: tvdbId,
@@ -125,7 +58,7 @@ class AnimeListsMappingStore implements AnimeListsMappingLookup {
@override
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async {
final idx = await _ensureLoaded();
final idx = await ensureLoaded();
if (tvdbId != null) {
final ids = _seasonAnimeIds(idx.byTvdb[tvdbId], AnimeListProvider.tvdb, season);
if (ids.isNotEmpty) return ids;
@@ -138,7 +71,7 @@ class AnimeListsMappingStore implements AnimeListsMappingLookup {
@override
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async {
final idx = await _ensureLoaded();
final idx = await ensureLoaded();
if (tvdbId != null) {
final entries = idx.byTvdb[tvdbId];
if (entries != null && entries.isNotEmpty) return {for (final entry in entries) entry.anidbId};
@@ -149,79 +82,6 @@ class AnimeListsMappingStore implements AnimeListsMappingLookup {
}
return const <int>{};
}
Future<void> maybeRefresh() async {
if (_refreshRunning) return;
if (_index == null) return;
_refreshRunning = true;
try {
final prefs = await BaseSharedPreferencesService.sharedCache();
final lastCheck = prefs.getInt(_prefsLastCheckKey) ?? 0;
final now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < _refreshInterval.inMilliseconds) return;
final etag = prefs.getString(_prefsEtagKey);
final client = platform.createPlatformClient();
try {
final res = await sendAbortableHttpRequest(
client,
'GET',
Uri.parse(_sourceUrl),
headers: {'If-None-Match': ?etag, 'Accept': 'application/xml,text/xml'},
timeout: _requestTimeout,
operation: 'Anime-Lists mapping refresh',
);
await prefs.setInt(_prefsLastCheckKey, now);
if (res.statusCode == 304) {
appLogger.d('Anime-Lists: mapping unchanged (304)');
return;
}
if (res.statusCode != 200) {
appLogger.d('Anime-Lists: refresh returned HTTP ${res.statusCode}');
return;
}
await _writeDiskCopy(res.body, etag: res.headers['etag']);
final fresh = await compute(parseAnimeListsIndex, res.body);
_index = fresh;
appLogger.d('Anime-Lists: mapping refreshed (${fresh.byTvdb.length} tvdb entries)');
} finally {
client.close();
}
} catch (e) {
appLogger.d('Anime-Lists: refresh failed (non-fatal)', error: e);
} finally {
_refreshRunning = false;
}
}
Future<void> _writeDiskCopy(String body, {String? etag}) async {
await File(await _diskPath()).writeAsString(body, flush: true);
if (etag != null) {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(_prefsEtagKey, etag);
}
}
Future<void> _deleteDiskCopy() async {
try {
await File(await _diskPath()).delete();
} on FileSystemException {
// Already gone.
}
}
Future<String> _diskPath() async {
final dir = await getApplicationSupportDirectory();
return p.join(dir.path, _diskFileName);
}
@visibleForTesting
void resetForTesting() {
_index = null;
_loading = null;
}
}
@visibleForTesting
@@ -0,0 +1,216 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../utils/abortable_http_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/platform_http_client_stub.dart'
if (dart.library.io) '../../utils/platform_http_client_io.dart'
as platform;
import '../base_shared_preferences_service.dart';
/// An index parsed out of a cached remote file.
abstract interface class RemoteIndex {
bool get isEmpty;
/// Reported after a successful refresh, e.g. `'1234 tvdb entries'`.
String get logSummary;
}
/// Base for the mapping stores backed by a static file on jsDelivr.
///
/// Owns the whole lifecycle: lazy download on first use, a disk copy in the
/// app-support directory, parsing in a background isolate, and a weekly
/// conditional-GET ([maybeRefresh], If-None-Match) to pick up upstream changes.
/// Subclasses supply the source, the cache keys and the two isolate entry
/// points, and build their lookups on top of [ensureLoaded].
abstract class EtagCachedRemoteStore<T extends RemoteIndex> {
static const Duration _refreshInterval = Duration(days: 7);
static const Duration _requestTimeout = Duration(seconds: 60);
final String diskFileName;
final String prefsEtagKey;
final String prefsLastCheckKey;
final String sourceUrl;
final String acceptHeader;
/// Prefixes log lines and the abortable-request operation names.
final String logLabel;
/// Returned when nothing could be loaded; never cached.
final T emptyIndex;
/// Parses a raw body. Top-level so it can run in a `compute` isolate.
final T Function(String raw) parse;
/// Reads the disk copy and parses it inside the isolate. Halves peak memory
/// vs. reading the string on the main isolate and shipping it across.
final T Function(String path) readAndParse;
EtagCachedRemoteStore({
required this.diskFileName,
required this.prefsEtagKey,
required this.prefsLastCheckKey,
required this.sourceUrl,
required this.acceptHeader,
required this.logLabel,
required this.emptyIndex,
required this.parse,
required this.readAndParse,
});
T? _index;
Future<T>? _loading;
bool _refreshRunning = false;
/// Lazily load, downloading on first use. Subsequent calls return the
/// cached index in O(1). Concurrent callers share the same Future.
/// Schedules a background refresh after the first successful load.
@protected
Future<T> ensureLoaded() async {
final existing = _index;
if (existing != null) return existing;
final loading = _loading;
if (loading != null) return loading;
final fresh = _loadOrFetch();
_loading = fresh;
try {
final idx = await fresh;
// Don't cache an empty index (network failure, no disk copy) — let the
// next lookup retry so transient offline periods self-heal.
if (!idx.isEmpty) {
_index = idx;
unawaited(maybeRefresh());
}
return idx;
} finally {
_loading = null;
}
}
Future<T> _loadOrFetch() async {
final path = await _diskPath();
try {
return await compute(readAndParse, path);
} on FileSystemException {
appLogger.d('$logLabel: no disk cache, downloading from jsDelivr');
final raw = await _download();
if (raw == null) return emptyIndex;
return await compute(parse, raw);
} catch (e) {
appLogger.w('$logLabel: parse failed — deleting disk copy so next lookup re-downloads', error: e);
await _deleteDiskCopy();
return emptyIndex;
}
}
/// GET the mapping, save it to disk, and return the body. Returns `null`
/// on any failure (offline, 4xx/5xx, timeout).
Future<String?> _download() async {
final client = platform.createPlatformClient();
try {
final res = await sendAbortableHttpRequest(
client,
'GET',
Uri.parse(sourceUrl),
headers: {'Accept': acceptHeader},
timeout: _requestTimeout,
operation: '$logLabel mapping download',
);
if (res.statusCode != 200) {
appLogger.d('$logLabel: download returned HTTP ${res.statusCode}');
return null;
}
await _writeDiskCopy(res.body, etag: res.headers['etag']);
// Seed the weekly throttle so a same-week relaunch skips the refresh.
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setInt(prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch);
return res.body;
} catch (e) {
appLogger.w('$logLabel: download failed', error: e);
return null;
} finally {
client.close();
}
}
/// Conditional-GET the mapping if the last check was >[_refreshInterval] ago
/// and we already have an index loaded. No-op when nothing is loaded — the
/// first lookup handles the initial download.
Future<void> maybeRefresh() async {
if (_refreshRunning) return;
if (_index == null) return;
_refreshRunning = true;
try {
final prefs = await BaseSharedPreferencesService.sharedCache();
final lastCheck = prefs.getInt(prefsLastCheckKey) ?? 0;
final now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < _refreshInterval.inMilliseconds) return;
final etag = prefs.getString(prefsEtagKey);
final client = platform.createPlatformClient();
try {
final res = await sendAbortableHttpRequest(
client,
'GET',
Uri.parse(sourceUrl),
headers: {'If-None-Match': ?etag, 'Accept': acceptHeader},
timeout: _requestTimeout,
operation: '$logLabel mapping refresh',
);
await prefs.setInt(prefsLastCheckKey, now);
if (res.statusCode == 304) {
appLogger.d('$logLabel: mapping unchanged (304)');
return;
}
if (res.statusCode != 200) {
appLogger.d('$logLabel: refresh returned HTTP ${res.statusCode}');
return;
}
await _writeDiskCopy(res.body, etag: res.headers['etag']);
final fresh = await compute(parse, res.body);
_index = fresh;
appLogger.d('$logLabel: mapping refreshed (${fresh.logSummary})');
} finally {
client.close();
}
} catch (e) {
appLogger.d('$logLabel: refresh failed (non-fatal)', error: e);
} finally {
_refreshRunning = false;
}
}
Future<void> _writeDiskCopy(String body, {String? etag}) async {
await File(await _diskPath()).writeAsString(body, flush: true);
if (etag != null) {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(prefsEtagKey, etag);
}
}
Future<void> _deleteDiskCopy() async {
try {
await File(await _diskPath()).delete();
} on FileSystemException {
// Already gone.
}
}
Future<String> _diskPath() async {
final dir = await getApplicationSupportDirectory();
return p.join(dir.path, diskFileName);
}
@visibleForTesting
void resetForTesting() {
_index = null;
_loading = null;
}
}
+26 -183
View File
@@ -1,17 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../models/trackers/fribb_mapping_row.dart';
import '../base_shared_preferences_service.dart';
import '../../utils/abortable_http_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/platform_http_client_stub.dart'
if (dart.library.io) '../../utils/platform_http_client_io.dart'
as platform;
import 'etag_cached_remote_store.dart';
/// Indexed view of the Fribb mapping database, queried by external ID.
///
@@ -19,7 +12,7 @@ import '../../utils/platform_http_client_stub.dart'
/// A single tvdb_id may map to multiple rows (split-cour anime → one per
/// season); callers that have a Plex season number should filter by
/// [FribbMappingRow.tvdbSeason] or [FribbMappingRow.tmdbSeason].
class FribbIndex {
class FribbIndex implements RemoteIndex {
final Map<int, List<FribbMappingRow>> byTvdb;
final Map<int, List<FribbMappingRow>> byTmdb;
final Map<String, List<FribbMappingRow>> byImdb;
@@ -30,7 +23,11 @@ class FribbIndex {
const FribbIndex({required this.byTvdb, required this.byTmdb, required this.byImdb, this.byMal = const {}});
@override
bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty && byMal.isEmpty;
@override
String get logSummary => '${byTvdb.length} tvdb entries';
}
abstract interface class FribbMappingLookup {
@@ -39,107 +36,31 @@ abstract interface class FribbMappingLookup {
Future<FribbMappingRow?> lookupByMal(int malId);
}
/// Loads and refreshes the Fribb anime-lists mapping on demand.
///
/// On first lookup the ~5 MB JSON is downloaded from jsDelivr and cached to
/// the app-support directory. Subsequent lookups read from the cache. Parsing
/// runs in a background isolate. [maybeRefresh] does a weekly conditional-GET
/// (If-None-Match) to pick up upstream changes.
class FribbMappingStore implements FribbMappingLookup {
static const String _diskFileName = 'anime-list-mini.json';
static const String _prefsEtagKey = 'fribb_anime_list_etag';
static const String _prefsLastCheckKey = 'fribb_anime_list_last_check';
/// jsDelivr (CDN-backed). `raw.githubusercontent.com` rate-limits
/// aggressively on shared IPs and returns 429 mid-refresh.
static const String _sourceUrl = 'https://cdn.jsdelivr.net/gh/Fribb/anime-lists@master/anime-list-mini.json';
static const Duration _refreshInterval = Duration(days: 7);
static const Duration _requestTimeout = Duration(seconds: 60);
FribbMappingStore._();
static final FribbMappingStore instance = FribbMappingStore._();
FribbIndex? _index;
Future<FribbIndex>? _loading;
bool _refreshRunning = false;
/// Lazily load, downloading on first use. Subsequent calls return the
/// cached index in O(1). Concurrent callers share the same Future.
/// Schedules a background refresh after the first successful load.
Future<FribbIndex> _ensureLoaded() async {
final existing = _index;
if (existing != null) return existing;
final loading = _loading;
if (loading != null) return loading;
final fresh = _loadOrFetch();
_loading = fresh;
try {
final idx = await fresh;
// Don't cache an empty index (network failure, no disk copy) — let the
// next lookup retry so transient offline periods self-heal.
if (!idx.isEmpty) {
_index = idx;
unawaited(maybeRefresh());
}
return idx;
} finally {
_loading = null;
}
}
Future<FribbIndex> _loadOrFetch() async {
final path = await _diskPath();
try {
return await compute(_readAndParse, path);
} on FileSystemException {
appLogger.d('Fribb: no disk cache, downloading from jsDelivr');
final raw = await _download();
if (raw == null) return const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {});
return await compute(parseFribbIndex, raw);
} catch (e) {
appLogger.w('Fribb: parse failed — deleting disk copy so next lookup re-downloads', error: e);
await _deleteDiskCopy();
return const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {});
}
}
/// GET the mapping, save it to disk, and return the body. Returns `null`
/// on any failure (offline, 4xx/5xx, timeout).
Future<String?> _download() async {
final client = platform.createPlatformClient();
try {
final res = await sendAbortableHttpRequest(
client,
'GET',
Uri.parse(_sourceUrl),
headers: const {'Accept': 'application/json'},
timeout: _requestTimeout,
operation: 'Fribb mapping download',
/// Loads and refreshes the Fribb anime-lists mapping on demand — the ~5 MB
/// JSON, indexed by external ID.
class FribbMappingStore extends EtagCachedRemoteStore<FribbIndex> implements FribbMappingLookup {
FribbMappingStore._()
: super(
diskFileName: 'anime-list-mini.json',
prefsEtagKey: 'fribb_anime_list_etag',
prefsLastCheckKey: 'fribb_anime_list_last_check',
// jsDelivr (CDN-backed). `raw.githubusercontent.com` rate-limits
// aggressively on shared IPs and returns 429 mid-refresh.
sourceUrl: 'https://cdn.jsdelivr.net/gh/Fribb/anime-lists@master/anime-list-mini.json',
acceptHeader: 'application/json',
logLabel: 'Fribb',
emptyIndex: const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}),
parse: parseFribbIndex,
readAndParse: _readAndParse,
);
if (res.statusCode != 200) {
appLogger.d('Fribb: download returned HTTP ${res.statusCode}');
return null;
}
await _writeDiskCopy(res.body, etag: res.headers['etag']);
// Seed the weekly throttle so a same-week relaunch skips the refresh.
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setInt(_prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch);
return res.body;
} catch (e) {
appLogger.w('Fribb: download failed', error: e);
return null;
} finally {
client.close();
}
}
static final FribbMappingStore instance = FribbMappingStore._();
/// Look up rows by Plex external IDs. Returns the first non-empty candidate
/// list in preference order: tvdb → tmdb → imdb.
@override
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async {
final idx = await _ensureLoaded();
final idx = await ensureLoaded();
if (tvdbId != null) {
final hits = idx.byTvdb[tvdbId];
if (hits != null && hits.isNotEmpty) return hits;
@@ -156,87 +77,9 @@ class FribbMappingStore implements FribbMappingLookup {
}
@override
Future<FribbMappingRow?> lookupByMal(int malId) async => (await _ensureLoaded()).byMal[malId];
/// Conditional-GET the mapping if the last check was >[_refreshInterval] ago
/// and we already have an index loaded. No-op when nothing is loaded — the
/// first lookup handles the initial download.
Future<void> maybeRefresh() async {
if (_refreshRunning) return;
if (_index == null) return;
_refreshRunning = true;
try {
final prefs = await BaseSharedPreferencesService.sharedCache();
final lastCheck = prefs.getInt(_prefsLastCheckKey) ?? 0;
final now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < _refreshInterval.inMilliseconds) return;
final etag = prefs.getString(_prefsEtagKey);
final client = platform.createPlatformClient();
try {
final res = await sendAbortableHttpRequest(
client,
'GET',
Uri.parse(_sourceUrl),
headers: {'If-None-Match': ?etag, 'Accept': 'application/json'},
timeout: _requestTimeout,
operation: 'Fribb mapping refresh',
);
await prefs.setInt(_prefsLastCheckKey, now);
if (res.statusCode == 304) {
appLogger.d('Fribb: mapping unchanged (304)');
return;
}
if (res.statusCode != 200) {
appLogger.d('Fribb: refresh returned HTTP ${res.statusCode}');
return;
}
await _writeDiskCopy(res.body, etag: res.headers['etag']);
final fresh = await compute(parseFribbIndex, res.body);
_index = fresh;
appLogger.d('Fribb: mapping refreshed (${fresh.byTvdb.length} tvdb entries)');
} finally {
client.close();
}
} catch (e) {
appLogger.d('Fribb: refresh failed (non-fatal)', error: e);
} finally {
_refreshRunning = false;
}
}
Future<void> _writeDiskCopy(String body, {String? etag}) async {
await File(await _diskPath()).writeAsString(body, flush: true);
if (etag != null) {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(_prefsEtagKey, etag);
}
}
Future<void> _deleteDiskCopy() async {
try {
await File(await _diskPath()).delete();
} on FileSystemException {
// Already gone.
}
}
Future<String> _diskPath() async {
final dir = await getApplicationSupportDirectory();
return p.join(dir.path, _diskFileName);
}
@visibleForTesting
void resetForTesting() {
_index = null;
_loading = null;
}
Future<FribbMappingRow?> lookupByMal(int malId) async => (await ensureLoaded()).byMal[malId];
}
/// Read the JSON from disk and parse it inside the isolate. Halves peak
/// memory vs. reading the string on the main isolate and shipping it across.
FribbIndex _readAndParse(String path) {
final raw = File(path).readAsStringSync();
return parseFribbIndex(raw);
+3 -5
View File
@@ -390,16 +390,15 @@ class _AppMenuItemTileState<T> extends State<AppMenuItemTile<T>> with FocusableT
@override
void initState() {
super.initState();
initFocusNode();
effectiveFocusNode.addListener(_updateFocusedState);
}
@override
void didUpdateWidget(AppMenuItemTile<T> oldWidget) {
final rebinds = oldWidget.focusNode != widget.focusNode;
if (rebinds) effectiveFocusNode.removeListener(_updateFocusedState);
super.didUpdateWidget(oldWidget);
if (oldWidget.focusNode != widget.focusNode) {
effectiveFocusNode.removeListener(_updateFocusedState);
updateFocusNode(oldWidget.focusNode);
if (rebinds) {
effectiveFocusNode.addListener(_updateFocusedState);
_isFocused = effectiveFocusNode.hasFocus;
}
@@ -408,7 +407,6 @@ class _AppMenuItemTileState<T> extends State<AppMenuItemTile<T>> with FocusableT
@override
void dispose() {
effectiveFocusNode.removeListener(_updateFocusedState);
disposeFocusNode();
super.dispose();
}
-72
View File
@@ -82,24 +82,6 @@ class _FocusableListTileState extends State<FocusableListTile> with FocusableTil
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@override
void initState() {
super.initState();
initFocusNode();
}
@override
void didUpdateWidget(FocusableListTile oldWidget) {
super.didUpdateWidget(oldWidget);
updateFocusNode(oldWidget.focusNode);
}
@override
void dispose() {
disposeFocusNode();
super.dispose();
}
@override
Widget build(BuildContext context) {
// When hovered/focused with a custom hoverColor, use onError-style foreground
@@ -212,24 +194,6 @@ class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@override
void initState() {
super.initState();
initFocusNode();
}
@override
void didUpdateWidget(FocusableRadioListTile<T> oldWidget) {
super.didUpdateWidget(oldWidget);
updateFocusNode(oldWidget.focusNode);
}
@override
void dispose() {
disposeFocusNode();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ClickableCursor(
@@ -316,24 +280,6 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@override
void initState() {
super.initState();
initFocusNode();
}
@override
void didUpdateWidget(FocusableSwitchListTile oldWidget) {
super.didUpdateWidget(oldWidget);
updateFocusNode(oldWidget.focusNode);
}
@override
void dispose() {
disposeFocusNode();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ClickableCursor(
@@ -398,24 +344,6 @@ class _FocusableCheckboxListTileState extends State<FocusableCheckboxListTile>
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@override
void initState() {
super.initState();
initFocusNode();
}
@override
void didUpdateWidget(FocusableCheckboxListTile oldWidget) {
super.didUpdateWidget(oldWidget);
updateFocusNode(oldWidget.focusNode);
}
@override
void dispose() {
disposeFocusNode();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ClickableCursor(
-18
View File
@@ -123,24 +123,6 @@ class _TrackRowState extends State<TrackRow> with ContextMenuTapMixin<TrackRow>,
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@override
void initState() {
super.initState();
initFocusNode();
}
@override
void didUpdateWidget(TrackRow oldWidget) {
super.didUpdateWidget(oldWidget);
updateFocusNode(oldWidget.focusNode);
}
@override
void dispose() {
disposeFocusNode();
super.dispose();
}
void _handleFocusChange(bool hasFocus) {
setState(() {
_hasFocus = hasFocus;
+3 -3
View File
@@ -7,9 +7,10 @@ import 'package:plezy/media/media_library.dart';
import 'package:plezy/mixins/library_tab_state.dart';
import 'package:provider/provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import '../test_helpers/multi_server_fixtures.dart';
class _Probe extends StatefulWidget {
const _Probe({required this.library, required this.onState});
@@ -45,8 +46,7 @@ void main() {
late _ProbeState state;
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final provider = MultiServerProvider(manager, aggregation);
final provider = testMultiServerProvider(manager);
// provider.dispose() cascades to manager.dispose() — only register
// the outer teardown to avoid a double-close on the manager's stream.
addTearDown(provider.dispose);
@@ -16,13 +16,13 @@ import 'package:plezy/providers/discover_provider.dart';
import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/trackers_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_watch_sync_service.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/services/system_shelf_service.dart';
import 'package:provider/provider.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
void main() {
@@ -51,7 +51,7 @@ void main() {
storage: storage,
);
final serverManager = MultiServerManager();
final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager));
final multiServer = testMultiServerProvider(serverManager);
// The session tree instantiates MusicPlaybackServiceImpl (the mini-player
// overlay watches it), which needs the database + offline watch service.
final offlineWatch = OfflineWatchSyncService(database: db, serverManager: serverManager);
+2 -2
View File
@@ -8,11 +8,11 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/storage_service.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
MediaLibrary _lib(String key, {String type = 'movie', ServerId? serverId, String title = 'L'}) => MediaLibrary(
@@ -563,7 +563,7 @@ void main() {
test('online-servers listener is removed on dispose', () {
final manager = MultiServerManager();
final multiServer = MultiServerProvider(manager, DataAggregationService(manager));
final multiServer = testMultiServerProvider(manager);
final before = multiServer.onlineServersListenerCount;
final scoped = LibrariesProvider(multiServer: multiServer);
@@ -4,12 +4,11 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/providers/offline_mode_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_auth_service.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
void main() {
@@ -105,7 +104,7 @@ void main() {
httpClient: MockClient((_) async => http.Response('', 401)),
);
manager.debugRegisterJellyfinClientForTesting(client, online: false);
final multi = MultiServerProvider(manager, DataAggregationService(manager));
final multi = testMultiServerProvider(manager);
final p = OfflineModeProvider(manager, multiServerProvider: multi);
await p.initialize();
@@ -122,7 +121,7 @@ void main() {
test('expected but unreachable visible servers enter offline without live clients', () async {
final manager = MultiServerManager();
final multi = MultiServerProvider(manager, DataAggregationService(manager));
final multi = testMultiServerProvider(manager);
final p = OfflineModeProvider(manager, multiServerProvider: multi);
await p.initialize();
manager.updateServerStatus(ServerId('plex-server'), false);
@@ -146,7 +145,7 @@ void main() {
test('expected but unreachable profile servers enter offline once visibility settles', () async {
final manager = MultiServerManager();
final multi = MultiServerProvider(manager, DataAggregationService(manager));
final multi = testMultiServerProvider(manager);
final p = OfflineModeProvider(manager, multiServerProvider: multi);
expect(p.isOffline, isFalse);
@@ -173,7 +172,7 @@ void main() {
test('Plex auth errors without live clients stay out of generic offline', () async {
final manager = MultiServerManager();
final multi = MultiServerProvider(manager, DataAggregationService(manager));
final multi = testMultiServerProvider(manager);
final p = OfflineModeProvider(manager, multiServerProvider: multi);
await p.initialize();
@@ -10,11 +10,9 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/models/catalog/catalog_cast_member.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/providers/catalog_sources_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/catalog_item_detail_screen.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/catalog_library_matcher.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -24,6 +22,7 @@ import 'package:plezy/widgets/media_card.dart';
import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
class _FakeCatalogSource implements CatalogSource {
@@ -127,7 +126,7 @@ Future<void> _pumpDetail(
}) async {
final sources = _FakeCatalogSourcesProvider(source);
final serverManager = MultiServerManager();
final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager));
final multiServer = testMultiServerProvider(serverManager);
final matcher = _FakeCatalogLibraryMatcher(multiServer, matches);
addTearDown(sources.dispose);
addTearDown(source.dispose);
+2 -3
View File
@@ -6,12 +6,10 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_cast_member.dart';
import 'package:plezy/providers/catalog_sources_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/catalog_item_detail_screen.dart';
import 'package:plezy/screens/catalog_search_screen.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/catalog_library_matcher.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -19,6 +17,7 @@ import 'package:plezy/widgets/app_menu.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:provider/provider.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
/// Only the members the search screen touches; everything else throws.
@@ -197,7 +196,7 @@ Future<void> _pumpMenuSearch(WidgetTester tester, _FakeSearchSource source, {req
final sources = _FakeCatalogSourcesProvider(source);
final manager = MultiServerManager();
final multiServer = MultiServerProvider(manager, DataAggregationService(manager));
final multiServer = testMultiServerProvider(manager);
final matcher = _FakeCatalogLibraryMatcher(multiServer);
addTearDown(manager.dispose);
addTearDown(multiServer.dispose);
@@ -13,7 +13,6 @@ import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/collection_detail_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
@@ -29,6 +28,7 @@ import 'package:plezy/widgets/media_card_sliver_layout.dart';
import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/paged_fakes.dart';
import '../test_helpers/prefs.dart';
@@ -133,7 +133,7 @@ Future<_CollectionHarness> _createHarness(List<MediaItem> items) async {
final client = _CollectionClient(items);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
addTearDown(() async {
downloadProvider.dispose();
+3 -3
View File
@@ -31,7 +31,6 @@ import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/discover_screen.dart';
import 'package:plezy/screens/main_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/storage_service.dart';
@@ -46,6 +45,7 @@ import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -82,7 +82,7 @@ void main() {
final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: [item], size: 1);
final client = _FakeMediaServerClient(hubs: [hub]);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final hiddenLibrariesProvider = HiddenLibrariesProvider();
final librariesProvider = LibrariesProvider();
final watchTogetherProvider = WatchTogetherProvider();
@@ -262,7 +262,7 @@ void main() {
];
final client = _FakeMediaServerClient(hubs: const [], continueWatching: onDeck);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final hiddenLibrariesProvider = HiddenLibrariesProvider();
final librariesProvider = LibrariesProvider();
final watchTogetherProvider = WatchTogetherProvider();
@@ -18,7 +18,6 @@ import 'package:plezy/navigation/main_screen_scope.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/downloads/downloads_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
@@ -33,6 +32,7 @@ import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/io_fakes.dart';
import '../../test_helpers/multi_server_fixtures.dart';
class _FakeConnectionRegistry extends ConnectionRegistry {
_FakeConnectionRegistry(super.db);
@@ -77,7 +77,7 @@ void main() {
await downloadProvider.ensureInitialized();
serverManager = MultiServerManager();
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
multiServerProvider = testMultiServerProvider(serverManager);
});
tearDown(() async {
@@ -15,7 +15,6 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/downloads/sync_rules_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
@@ -27,6 +26,7 @@ import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/multi_server_fixtures.dart';
PlexConnection _plexConnection() {
return PlexConnection(
@@ -213,7 +213,7 @@ void main() {
addTearDown(authClient.close);
serverManager.debugRegisterJellyfinClientForTesting(authClient, online: false);
serverManager.debugMarkAuthErrorForTesting(ServerId('auth-jf'));
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
multiServerProvider = testMultiServerProvider(serverManager);
await insertRule(ServerId('plex-srv'), 'show-1');
await insertRule(ServerId('jf-machine'), 'show-2');
@@ -233,7 +233,7 @@ void main() {
});
testWidgets('removes orphaned sync rules from the sync rules screen', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
multiServerProvider = testMultiServerProvider(serverManager);
await insertRule(ServerId('orphan-srv'), '76672');
await pumpScreen(tester);
@@ -256,7 +256,7 @@ void main() {
});
testWidgets('provider rebuilds reuse the connection stream subscription', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
multiServerProvider = testMultiServerProvider(serverManager);
await insertRule(ServerId('orphan-srv'), '76672');
await pumpScreen(tester);
@@ -268,7 +268,7 @@ void main() {
});
testWidgets('does not autofocus the first sync rule in pointer mode', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
multiServerProvider = testMultiServerProvider(serverManager);
await insertRule(ServerId('orphan-srv'), '76672');
FocusManager.instance.primaryFocus?.unfocus();
@@ -279,7 +279,7 @@ void main() {
});
testWidgets('keyboard navigation reaches and toggles the sync rule switch', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
multiServerProvider = testMultiServerProvider(serverManager);
await insertRule(ServerId('orphan-srv'), '76672');
await pumpScreen(tester, keyboardMode: true);
@@ -297,7 +297,7 @@ void main() {
});
testWidgets('setting sync rule count to zero removes the rule', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
multiServerProvider = testMultiServerProvider(serverManager);
await insertRule(ServerId('orphan-srv'), '76672');
await pumpScreen(tester, keyboardMode: true);
+2 -2
View File
@@ -11,7 +11,6 @@ import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/hub_detail_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -21,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';
import '../test_helpers/multi_server_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -144,7 +144,7 @@ Future<_HubHarness> _createHarness(List<MediaItem> items, {required MediaBackend
await SettingsService.getInstance();
final client = _PagedHubClient(items, backend: backend);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final provider = testMultiServerProvider(manager);
addTearDown(provider.dispose);
return _HubHarness(client: client, provider: provider);
}
@@ -8,14 +8,13 @@ import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/folder_tree_view.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/backend_client_fixtures.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
void main() {
@@ -62,12 +61,7 @@ void main() {
);
}),
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(() {
provider.dispose();
manager.dispose();
});
final provider = testMultiServer(clients: [client]).provider;
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>.value(
@@ -11,7 +11,6 @@ import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/libraries_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/storage_service.dart';
@@ -22,6 +21,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
const _libraryA = MediaLibrary(
@@ -135,7 +135,7 @@ final class _Harness {
final hiddenLibraries = HiddenLibrariesProvider();
await hiddenLibraries.ensureInitialized();
final manager = MultiServerManager();
final multiServer = MultiServerProvider(manager, DataAggregationService(manager));
final multiServer = testMultiServerProvider(manager);
return _Harness(libraries: libraries, hiddenLibraries: hiddenLibraries, multiServer: multiServer);
}
@@ -13,7 +13,6 @@ import 'package:plezy/media/media_library.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/state_messages.dart';
import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
@@ -24,6 +23,7 @@ import 'package:plezy/widgets/media_card.dart';
import '../../test_helpers/backend_client_fixtures.dart';
import '../../test_helpers/library_tab_scaffold.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
final _musicLibrary = MediaLibrary(
@@ -165,7 +165,7 @@ class _MusicBrowseHarness {
}),
);
manager = MultiServerManager()..debugRegisterClientForTesting(client);
provider = MultiServerProvider(manager, DataAggregationService(manager));
provider = testMultiServerProvider(manager);
}
void dispose() {
@@ -17,7 +17,6 @@ import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/state_messages.dart';
import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/storage_service.dart';
@@ -27,6 +26,7 @@ import 'package:plezy/widgets/focusable_filter_chip.dart';
import '../../test_helpers/library_tab_scaffold.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
void main() {
@@ -191,7 +191,7 @@ class _BrowseHarness {
manager = MultiServerManager()
..debugRegisterClientForTesting(clientA)
..debugRegisterClientForTesting(clientB);
provider = MultiServerProvider(manager, DataAggregationService(manager));
provider = testMultiServerProvider(manager);
}
MediaLibrary _libraryFor(_BrowseClient client) {
@@ -16,7 +16,6 @@ import 'package:plezy/media/media_playlist.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/tabs/library_playlists_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/services/plex_api_cache.dart';
@@ -28,6 +27,7 @@ import 'package:plezy/widgets/media_card_sliver_layout.dart';
import '../../test_helpers/backend_client_fixtures.dart';
import '../../test_helpers/library_tab_scaffold.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
final _serverId = ServerId('playlist-server');
@@ -246,7 +246,7 @@ class _PlaylistHarness {
}),
);
manager = MultiServerManager()..debugRegisterClientForTesting(client);
provider = MultiServerProvider(manager, DataAggregationService(manager));
provider = testMultiServerProvider(manager);
}
Future<void> dispose() async {
+3 -2
View File
@@ -19,13 +19,14 @@ import 'package:plezy/models/livetv_channel.dart';
import 'package:plezy/models/livetv_program.dart';
import 'package:plezy/screens/livetv/tabs/guide_tab.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/multi_server_fixtures.dart';
const _selectDown = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.enter,
logicalKey: LogicalKeyboardKey.enter,
@@ -202,7 +203,7 @@ final class _GuideHarness {
final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null;
final manager = MultiServerManager()..debugRegisterClientForTesting(serverA);
if (serverB != null) manager.debugRegisterClientForTesting(serverB);
final provider = MultiServerProvider(manager, DataAggregationService(manager))
final provider = testMultiServerProvider(manager)
..debugSetLiveTvServersForTesting([
LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a'),
if (serverB != null) LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b'),
+3 -3
View File
@@ -16,12 +16,12 @@ import 'package:plezy/models/livetv_program.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/livetv/live_tv_screen.dart';
import 'package:plezy/screens/livetv/tabs/guide_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
void main() {
@@ -75,7 +75,7 @@ void main() {
final manager = MultiServerManager()
..debugRegisterClientForTesting(failedClient)
..debugRegisterClientForTesting(healthyClient);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final provider = testMultiServerProvider(manager);
provider.debugSetLiveTvServersForTesting([
LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a', lineup: 'provider-a'),
LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b', lineup: 'provider-b'),
@@ -148,7 +148,7 @@ Future<_LiveTvHarness> _pumpLiveTvScreen(WidgetTester tester) async {
final liveTv = _FakeLiveTvSupport();
final client = _FakeMediaServerClient(liveTv);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final provider = testMultiServerProvider(manager);
provider.debugSetLiveTvServersForTesting([
LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr-a', lineup: 'provider-a'),
]);
@@ -14,12 +14,13 @@ import 'package:plezy/models/livetv_program.dart';
import 'package:plezy/models/media_subscription.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/livetv/live_tv_show_schedule_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/multi_server_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() => initializeDateFormatting('en'));
@@ -113,7 +114,7 @@ LiveTvProgram _program({String? guid}) => LiveTvProgram(
MultiServerProvider _providerFor(MediaServerClient client) {
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
return MultiServerProvider(manager, DataAggregationService(manager));
return testMultiServerProvider(manager);
}
Future<void> _pumpScreen(WidgetTester tester, MultiServerProvider provider) async {
+3 -2
View File
@@ -13,11 +13,12 @@ import 'package:plezy/models/media_grab_operation.dart';
import 'package:plezy/models/media_subscription.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/livetv/tabs/recordings_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/multi_server_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -27,7 +28,7 @@ void main() {
final dvr = _ControllableDvr();
final client = _FakeMediaServerClient(dvr);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager))
final provider = testMultiServerProvider(manager)
..debugSetLiveTvServersForTesting([LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr')]);
addTearDown(provider.dispose);
final tabKey = GlobalKey<RecordingsTabState>();
+7 -7
View File
@@ -22,7 +22,6 @@ import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/watch_state_store.dart';
import 'package:plezy/screens/media_detail_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import '../test_helpers/paged_fakes.dart';
import 'package:plezy/services/download_manager_service.dart';
@@ -46,6 +45,7 @@ import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/profile_navigation.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -308,7 +308,7 @@ void main() {
pendingPlayableDescendants: descendantsCompleter.future,
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final provider = testMultiServerProvider(manager);
addTearDown(provider.dispose);
await tester.pumpWidget(
@@ -434,7 +434,7 @@ void main() {
},
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final provider = testMultiServerProvider(manager);
addTearDown(provider.dispose);
await tester.pumpWidget(
@@ -531,7 +531,7 @@ void main() {
childrenPageErrors: {season1.id: Exception('season cache failed')},
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final provider = testMultiServerProvider(manager);
addTearDown(provider.dispose);
await tester.pumpWidget(
@@ -622,7 +622,7 @@ void main() {
childrenPageFutures: {season2.id: season2Completer.future},
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final provider = testMultiServerProvider(manager);
addTearDown(provider.dispose);
await tester.pumpWidget(
@@ -708,7 +708,7 @@ void main() {
PlexApiCache.initialize(database);
JellyfinApiCache.initialize(database);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final offlineWatch = OfflineWatchSyncService(database: database, serverManager: manager);
final downloadManager = DownloadManagerService(
database: database,
@@ -832,7 +832,7 @@ void main() {
await downloadProvider.ensureInitialized();
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final watchStateOverlay = WatchStateStore();
addTearDown(() async {
+2 -2
View File
@@ -19,7 +19,6 @@ import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/metadata_edit/metadata_edit_models.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/metadata_edit_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/file_picker_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
@@ -32,6 +31,7 @@ import 'package:provider/provider.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/http_fixtures.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
void main() {
setUp(() {
@@ -327,7 +327,7 @@ Future<_EditorHarness> _pumpEditor(WidgetTester tester, _PlexMetadataRequests re
PlexApiCache.initialize(database);
final client = testPlexClient(serverId: ServerId('server-1'), handler: requests.handle);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final provider = testMultiServerProvider(manager);
final metadata = ValueNotifier<MediaItem>(_show());
await tester.pumpWidget(
@@ -9,7 +9,6 @@ import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/music/album_detail_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/settings_service.dart';
@@ -19,6 +18,7 @@ import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/multi_server_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -109,7 +109,7 @@ Future<_AlbumHarness> _createHarness(List<MediaItem> tracks) async {
final client = _FakeMusicClient(tracks);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
addTearDown(multiServerProvider.dispose);
@@ -10,14 +10,13 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/music/now_playing_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/multi_server_fixtures.dart';
MediaItem _track({required String id, required String title, required String album, required int year}) {
return testMediaItem(
@@ -118,13 +117,8 @@ void main() {
TvDetectionService.debugSetAppleTVOverride(isTv);
PlatformDetector.debugSetIsDesktopOSOverride(!isTv);
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(service.dispose);
addTearDown(() {
multiServerProvider.dispose();
manager.dispose();
});
final multiServerProvider = testMultiServer().provider;
await tester.pumpWidget(
InputModeTracker(
+2 -8
View File
@@ -7,8 +7,6 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/music/queue_sheet.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -17,6 +15,7 @@ import 'package:plezy/widgets/music/track_row.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
MediaItem _track(String id, String title) => testMediaItem(
@@ -73,13 +72,8 @@ void main() {
});
Widget wrap(MusicPlaybackService service) {
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(service.dispose);
addTearDown(() {
multiServerProvider.dispose();
manager.dispose();
});
final multiServerProvider = testMultiServer().provider;
return TranslationProvider(
child: MultiProvider(
@@ -20,7 +20,6 @@ import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/playlist/playlist_detail_screen.dart';
import 'package:plezy/screens/playlist/playlist_item_card.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
@@ -41,6 +40,7 @@ import 'package:plezy/utils/media_image_helper.dart';
import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/paged_fakes.dart';
import '../test_helpers/prefs.dart';
@@ -671,7 +671,7 @@ Future<_PlaylistHarness> _createHarness(
final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt, deleteResult: deleteResult, backend: backend);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final playbackState = PlaybackStateProvider();
addTearDown(() async {
@@ -20,12 +20,12 @@ import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/providers/user_profile_provider.dart';
import 'package:plezy/screens/profile/profile_teardown.dart';
import 'package:plezy/services/plex_auth_service.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/services/system_shelf_service.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
class _PlexHome extends PlexHomeService {
@@ -275,7 +275,7 @@ Future<_Harness> _pumpHarness(
await storage.setActiveProfileId(profile.id);
await active.initialize();
final manager = MultiServerManager();
final multiServer = MultiServerProvider(manager, DataAggregationService(manager));
final multiServer = testMultiServerProvider(manager);
final shelf = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
shelf.beginProfileSession(profile.id);
SystemShelfService.debugOverrideInstance(shelf);
@@ -22,7 +22,6 @@ import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/settings/add_jellyfin_screen.dart';
import 'package:plezy/services/jellyfin_auth_service.dart';
import 'package:plezy/services/credential_vault.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_lan_discovery_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/storage_service.dart';
@@ -30,6 +29,7 @@ import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
Profile _profile(String id) =>
@@ -244,7 +244,7 @@ class _RouteHarness {
storage: storage,
);
final manager = _CountingJellyfinManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final binder = _CountingActiveProfileBinder(
activeProfile: activeProfiles,
connections: connections,
@@ -8,11 +8,11 @@ import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/play_queue.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/playback_state_provider.dart';
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';
import '../test_helpers/multi_server_fixtures.dart';
MediaItem _meta(String id, {String? title}) =>
testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id');
@@ -106,7 +106,7 @@ void main() {
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
final manager = _StubManager(null);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
final serverProvider = testMultiServerProvider(manager);
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
@@ -176,8 +176,7 @@ void main() {
],
);
final manager = _StubManager(client);
final aggregation = DataAggregationService(manager);
final serverProvider = MultiServerProvider(manager, aggregation);
final serverProvider = testMultiServerProvider(manager);
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
@@ -211,7 +210,7 @@ void main() {
addTearDown(playback.dispose);
final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3], clientBackend: MediaBackend.plex);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
final serverProvider = testMultiServerProvider(manager);
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
@@ -244,7 +243,7 @@ void main() {
fetchError: StateError('network unavailable'),
);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
final serverProvider = testMultiServerProvider(manager);
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
@@ -272,7 +271,7 @@ void main() {
addTearDown(playback.dispose);
final client = _RecordingClient(seriesEpisodes: [ep1, ep2], clientBackend: MediaBackend.plex);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
final serverProvider = testMultiServerProvider(manager);
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
@@ -330,7 +329,7 @@ void main() {
);
final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
final serverProvider = testMultiServerProvider(manager);
addTearDown(serverProvider.dispose);
return (playback, client, serverProvider);
}
@@ -403,7 +402,7 @@ void main() {
addTearDown(playback.dispose);
final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
final serverProvider = testMultiServerProvider(manager);
addTearDown(serverProvider.dispose);
final result = await probe(tester, playback, serverProvider, ep3);
@@ -8,14 +8,13 @@ import 'package:http/testing.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
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/multi_server_fixtures.dart';
void main() {
late AppDatabase db;
@@ -107,7 +106,7 @@ void main() {
final manager = MultiServerManager();
manager.debugRegisterClientForTesting(plex);
manager.debugRegisterJellyfinClientForTesting(jellyfin);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final provider = testMultiServerProvider(manager);
addTearDown(() {
provider.dispose();
manager.dispose();
@@ -8,10 +8,11 @@ import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/video_player_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/utils/live_tv_player_navigation.dart';
import '../test_helpers/multi_server_fixtures.dart';
void main() {
late MultiServerManager manager;
late MultiServerProvider multiServer;
@@ -19,7 +20,7 @@ void main() {
setUp(() async {
await LocaleSettings.setLocale(AppLocale.bg);
manager = MultiServerManager();
multiServer = MultiServerProvider(manager, DataAggregationService(manager));
multiServer = testMultiServerProvider(manager);
});
tearDown(() {
@@ -15,7 +15,6 @@ import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -24,6 +23,7 @@ import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:provider/provider.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
@@ -49,8 +49,7 @@ Future<({int Function() selects, int Function() backs})> _pumpLibraryManagementL
addTearDown(hiddenLibrariesProvider.dispose);
final fallbackManager = multiServerProvider == null ? MultiServerManager() : null;
final effectiveMultiServerProvider =
multiServerProvider ?? MultiServerProvider(fallbackManager!, DataAggregationService(fallbackManager));
final effectiveMultiServerProvider = multiServerProvider ?? testMultiServerProvider(fallbackManager!);
if (fallbackManager != null) {
addTearDown(() {
effectiveMultiServerProvider.dispose();
@@ -268,7 +267,7 @@ class _LibraryActionHarness {
);
manager.debugRegisterClientForTesting(owner);
}
provider = MultiServerProvider(manager, DataAggregationService(manager));
provider = testMultiServerProvider(manager);
}
void dispose() {
+6 -6
View File
@@ -30,7 +30,6 @@ import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/music/album_detail_screen.dart';
import 'package:plezy/screens/music/artist_detail_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
@@ -46,6 +45,7 @@ import 'package:plezy/widgets/media_context_menu.dart';
import 'package:provider/provider.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/profile_stack.dart';
@@ -124,7 +124,7 @@ void main() {
final client = _AudioPlaylistClient(tracks);
final music = _RecordingMusicPlaybackService();
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(withStorage: false);
addTearDown(() async {
await stack.dispose();
@@ -228,7 +228,7 @@ void main() {
])..blockWithAbort = true;
final playback = PlaybackStateProvider();
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(withStorage: false);
addTearDown(() async {
playback.dispose();
@@ -293,7 +293,7 @@ void main() {
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(withStorage: false);
addTearDown(() async {
await stack.dispose();
@@ -569,7 +569,7 @@ Future<GlobalKey<MediaContextMenuState>> _pumpPlexMovieMenu(
}),
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(db: db, withStorage: false);
addTearDown(() async {
await stack.dispose();
@@ -771,7 +771,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu(
await downloadProvider.ensureInitialized();
final client = _RelatedMusicClient(relatedItems);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(db: db, withStorage: false);
final music = _RecordingMusicPlaybackService();
final rootNavigatorKey = GlobalKey<NavigatorState>();
+2 -8
View File
@@ -14,8 +14,6 @@ import 'package:plezy/models/download_models.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -26,6 +24,7 @@ import 'package:plezy/widgets/music/mini_player.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
import '../../test_helpers/profile_stack.dart';
@@ -159,14 +158,9 @@ void main() {
ActiveProfileProvider? activeProfileProvider,
DownloadProvider? downloadProvider,
}) {
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(service.dispose);
addTearDown(observer.suppress.dispose);
addTearDown(() {
multiServerProvider.dispose();
manager.dispose();
});
final multiServerProvider = testMultiServer().provider;
return TranslationProvider(
child: MultiProvider(
+7 -13
View File
@@ -14,7 +14,6 @@ import 'package:plezy/navigation/navigation_tabs.dart';
import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -22,6 +21,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
import 'package:provider/provider.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/theme.dart';
@@ -82,8 +82,7 @@ Future<void> _pumpBasicRail(
addTearDown(hiddenLibrariesProvider.dispose);
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final multiServerProvider = MultiServerProvider(manager, aggregation);
final multiServerProvider = testMultiServerProvider(manager);
addTearDown(multiServerProvider.dispose);
final rail = SideNavigationRail(
@@ -140,8 +139,7 @@ void main() {
addTearDown(hiddenLibrariesProvider.dispose);
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final multiServerProvider = MultiServerProvider(manager, aggregation);
final multiServerProvider = testMultiServerProvider(manager);
addTearDown(multiServerProvider.dispose);
await tester.pumpWidget(
@@ -205,8 +203,7 @@ void main() {
addTearDown(hiddenLibrariesProvider.dispose);
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final multiServerProvider = MultiServerProvider(manager, aggregation);
final multiServerProvider = testMultiServerProvider(manager);
addTearDown(multiServerProvider.dispose);
await tester.pumpWidget(
@@ -309,8 +306,7 @@ void main() {
addTearDown(hiddenLibrariesProvider.dispose);
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final multiServerProvider = MultiServerProvider(manager, aggregation);
final multiServerProvider = testMultiServerProvider(manager);
addTearDown(multiServerProvider.dispose);
final reports = <bool>[];
@@ -373,8 +369,7 @@ void main() {
addTearDown(hiddenLibrariesProvider.dispose);
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final multiServerProvider = MultiServerProvider(manager, aggregation);
final multiServerProvider = testMultiServerProvider(manager);
addTearDown(multiServerProvider.dispose);
final sideNavKey = GlobalKey<SideNavigationRailState>();
@@ -450,8 +445,7 @@ void main() {
addTearDown(hiddenLibrariesProvider.dispose);
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final multiServerProvider = MultiServerProvider(manager, aggregation);
final multiServerProvider = testMultiServerProvider(manager);
addTearDown(multiServerProvider.dispose);
final sideNavKey = GlobalKey<SideNavigationRailState>();
+33 -33
View File
@@ -10,7 +10,6 @@ import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/device_performance.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
@@ -25,6 +24,7 @@ import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -346,7 +346,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
@@ -418,7 +418,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
@@ -473,7 +473,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: theme,
home: Scaffold(
@@ -513,7 +513,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -557,7 +557,7 @@ void main() {
Widget rail(MediaHub hub) {
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -599,7 +599,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
@@ -689,7 +689,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
@@ -762,7 +762,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
@@ -822,7 +822,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
@@ -884,7 +884,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -939,7 +939,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1001,7 +1001,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1039,7 +1039,7 @@ void main() {
Widget buildRail(List<MediaHub> hubs, {String? initialHubId, String? initialItemId, bool autofocus = false}) {
final serverManager = MultiServerManager();
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1081,7 +1081,7 @@ void main() {
Widget buildRail(List<MediaHub> hubs, {String? initialHubId}) {
final serverManager = MultiServerManager();
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1124,7 +1124,7 @@ void main() {
Widget buildRail(List<MediaHub> hubs, {String? initialItemId}) {
final serverManager = MultiServerManager();
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1204,7 +1204,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1318,7 +1318,7 @@ void main() {
Widget buildRail(List<MediaHub> hubs) {
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1401,7 +1401,7 @@ void main() {
Widget buildRail({required bool backgroundLoaded}) {
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1477,7 +1477,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1579,7 +1579,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1655,7 +1655,7 @@ void main() {
final serverManager = MultiServerManager();
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1744,7 +1744,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1835,7 +1835,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1907,7 +1907,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -1972,7 +1972,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -2023,7 +2023,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -2071,7 +2071,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -2127,7 +2127,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -2178,7 +2178,7 @@ void main() {
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<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -2213,7 +2213,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -2258,7 +2258,7 @@ void main() {
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
@@ -2289,7 +2289,7 @@ void main() {
testWidgets('background bleed updates do not renotify rail focus', (tester) async {
final serverManager = MultiServerManager();
final multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
final multiServerProvider = testMultiServerProvider(serverManager);
addTearDown(multiServerProvider.dispose);
final focusedItemIds = <String>[];