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:
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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 {},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user