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;