Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
1491 lines
61 KiB
Dart
1491 lines
61 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../focus/focusable_action_bar.dart';
|
|
import '../focus/focusable_button.dart';
|
|
import '../focus/focusable_wrapper.dart';
|
|
import '../focus/dpad_navigator.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../focus/key_event_utils.dart';
|
|
import '../focus/locked_hub_controller.dart';
|
|
import '../i18n/app_locale_utils.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../media/media_hub.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_item_merge.dart';
|
|
import '../media/media_rating.dart';
|
|
import '../media/media_version.dart';
|
|
import '../models/catalog/catalog_cast_member.dart';
|
|
import '../models/catalog/catalog_item.dart';
|
|
import '../models/catalog/catalog_metadata.dart';
|
|
import '../providers/catalog_sources_provider.dart';
|
|
import '../services/catalog/catalog_library_matcher.dart';
|
|
import '../services/catalog/catalog_source.dart';
|
|
import '../services/catalog/seerr_catalog_source.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/catalog_navigation_helper.dart';
|
|
import '../utils/desktop_window_padding.dart';
|
|
import '../utils/formatters.dart';
|
|
import '../utils/country_codes.dart';
|
|
import '../utils/language_codes.dart';
|
|
import '../utils/media_navigation_helper.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/rating_utils.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import '../widgets/app_bar_back_button.dart';
|
|
import '../widgets/app_icon.dart';
|
|
import '../widgets/backend_badge.dart';
|
|
import '../widgets/cast_member_strip.dart';
|
|
import '../widgets/focusable_list_tile.dart';
|
|
import '../widgets/hub_section.dart';
|
|
import '../widgets/optimized_media_image.dart';
|
|
import '../widgets/overlay_sheet.dart';
|
|
import '../widgets/seerr_request_sheet.dart';
|
|
import '../widgets/settings_section.dart';
|
|
import '../widgets/stat_chip.dart';
|
|
|
|
/// Detail screen for a catalog item (Explore tab). Renders from provider
|
|
/// data — no media server required — and resolves library availability in
|
|
/// place: an "In these libraries" list when the item is owned, tappable
|
|
/// through to the normal media detail screen.
|
|
class CatalogItemDetailScreen extends StatefulWidget {
|
|
final CatalogItem item;
|
|
|
|
const CatalogItemDetailScreen({super.key, required this.item});
|
|
|
|
@override
|
|
State<CatalogItemDetailScreen> createState() => _CatalogItemDetailScreenState();
|
|
}
|
|
|
|
class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
|
final _backButtonFocusNode = FocusNode(debugLabel: 'catalog_detail_back');
|
|
final _castSectionKey = GlobalKey();
|
|
final _castStripKey = GlobalKey<CastMemberStripState>();
|
|
final _relatedSectionKey = GlobalKey<HubSectionState>();
|
|
final _hubFocusMemory = HubFocusMemory();
|
|
final ScrollController _scrollController = ScrollController();
|
|
final _spoilerTagFocusNode = FocusNode(debugLabel: 'catalog_spoiler_tags');
|
|
List<FocusNode> _linkFocusNodes = const [];
|
|
List<FocusNode> _relationFocusNodes = const [];
|
|
List<CatalogTag> _orderedTags = const [];
|
|
List<CatalogLink> _streamingLinks = const [];
|
|
List<CatalogLink> _otherLinks = const [];
|
|
bool _showSpoilerTags = false;
|
|
|
|
/// Focus node per library copy, keyed by [MediaItem.globalKey] so a later
|
|
/// resolution pass that adds a copy keeps the nodes — and therefore the
|
|
/// focus — of the rows already on screen. [_libraryMatchFocusNodes] is the
|
|
/// same nodes in display order, for index-based dpad traversal.
|
|
final Map<String, FocusNode> _libraryMatchNodesByKey = {};
|
|
List<FocusNode> _libraryMatchFocusNodes = const [];
|
|
CatalogSource? _watchlistSource;
|
|
SeerrCatalogSource? _requestSource;
|
|
bool _mutatingWatchlist = false;
|
|
|
|
CatalogItem? _detailItem;
|
|
|
|
/// Library items matching this catalog item; null while resolving.
|
|
List<MediaItem>? _matches;
|
|
|
|
/// Cast/characters from the item's own source; null while loading (the
|
|
/// section only renders once loaded non-empty).
|
|
List<CatalogCastMember>? _cast;
|
|
|
|
/// "More like this" from the item's own source; null while loading (the
|
|
/// row only renders once loaded non-empty).
|
|
List<CatalogItem>? _related;
|
|
|
|
/// Labelled franchise edges, flattened to one entry per title. Providers
|
|
/// routinely return a single sequel or spin-off per label, and a shelf per
|
|
/// label spent a whole hub row — header, scroll row, one card — on it.
|
|
List<({CatalogRelationType type, CatalogItem item})> _relationEntries = const [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_syncDetailCollections(widget.item);
|
|
unawaited(_resolveMatches(widget.item));
|
|
unawaited(_loadDetail());
|
|
final sources = context.read<CatalogSourcesProvider>();
|
|
_watchlistSource = sources.watchlistSourceFor(widget.item);
|
|
// Request needs a connected Seerr, the permission for this kind, and a
|
|
// tmdb id (Trakt items carry one natively; MAL items get theirs from the
|
|
// Fribb mapping at row time).
|
|
final seerr = sources.seerrSource;
|
|
if (seerr != null && widget.item.ids.tmdb != null && seerr.canRequest(widget.item.kind)) {
|
|
_requestSource = seerr;
|
|
}
|
|
final source = _watchlistSource;
|
|
if (source != null) {
|
|
source.watchlistChanges.addListener(_onWatchlistChanged);
|
|
if (source.isOnWatchlist(widget.item.kind, widget.item.ids) == null) {
|
|
unawaited(source.ensureWatchlistLoaded());
|
|
}
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _actionBarKey.currentState?.requestFocusOnFirst();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_backButtonFocusNode.dispose();
|
|
_spoilerTagFocusNode.dispose();
|
|
for (final node in _linkFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
_watchlistSource?.watchlistChanges.removeListener(_onWatchlistChanged);
|
|
for (final node in _libraryMatchNodesByKey.values) {
|
|
node.dispose();
|
|
}
|
|
for (final node in _relationFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onWatchlistChanged() {
|
|
// ignore: no-empty-block - membership state lives in the source
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _resolveMatches(CatalogItem item) async {
|
|
List<MediaItem> matches;
|
|
try {
|
|
matches = await context.read<CatalogLibraryMatcher>().match(item);
|
|
} catch (e) {
|
|
appLogger.w('Catalog library match failed for ${item.identityKey}', error: e);
|
|
// A failed pass is no evidence about copies an earlier pass already
|
|
// found; only claim "not in your library" when nothing has resolved.
|
|
if (_matches == null) _mergeMatches(const []);
|
|
return;
|
|
}
|
|
_mergeMatches(matches);
|
|
}
|
|
|
|
/// Fold a resolution pass into the visible copies.
|
|
///
|
|
/// Union, never replace. The bare row item and its detail-enriched form
|
|
/// resolve concurrently, and `findByExternalIdsAcrossServers` logs and skips
|
|
/// per-server failures — so a later pass can legitimately come back short a
|
|
/// server that answered the first one. Replacing would drop rows for copies
|
|
/// that are still there.
|
|
void _mergeMatches(List<MediaItem> matches) {
|
|
if (!mounted) return;
|
|
final focused = _libraryMatchNodesByKey.values.firstWhereOrNull((node) => node.hasPrimaryFocus);
|
|
final merged = mergeLibraryCopies(_matches ?? const [], matches);
|
|
final keys = {for (final match in merged) match.globalKey};
|
|
for (final key in _libraryMatchNodesByKey.keys.toList()) {
|
|
if (!keys.contains(key)) _libraryMatchNodesByKey.remove(key)!.dispose();
|
|
}
|
|
_libraryMatchFocusNodes = [
|
|
for (final match in merged)
|
|
_libraryMatchNodesByKey.putIfAbsent(
|
|
match.globalKey,
|
|
() => FocusNode(
|
|
debugLabel: 'catalog_library_match_${match.globalKey}',
|
|
// Resolved live: merging a pass can reorder the rows, so a
|
|
// captured index would steer the wrong copy.
|
|
onKeyEvent: (node, event) {
|
|
final index = _libraryMatchFocusNodes.indexOf(node);
|
|
return index < 0 ? KeyEventResult.ignored : _handleLibraryMatchKey(index, event);
|
|
},
|
|
),
|
|
),
|
|
];
|
|
setState(() => _matches = merged);
|
|
// A merge re-sorts, and SettingsGroup shapes its cards by list index, so
|
|
// the tile holding the focused node can be rebuilt from scratch and drop
|
|
// it. Reclaim it rather than dumping a dpad user on another copy.
|
|
if (focused != null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || focused.hasPrimaryFocus || focused.context == null) return;
|
|
focused.requestFocus();
|
|
});
|
|
}
|
|
}
|
|
|
|
CatalogSource? get _ownSource =>
|
|
context.read<CatalogSourcesProvider>().connectedSources.firstWhereOrNull((s) => s.id == widget.item.source);
|
|
|
|
CatalogItem get _item => _detailItem ?? widget.item;
|
|
|
|
void _syncDetailCollections(CatalogItem item) {
|
|
final tags = [
|
|
for (final tag in item.tags ?? const <CatalogTag>[])
|
|
if (tag.name.trim().isNotEmpty) tag,
|
|
]..sort((a, b) => (b.rank ?? -1).compareTo(a.rank ?? -1));
|
|
final streamingLinks = <CatalogLink>[];
|
|
final otherLinks = <CatalogLink>[];
|
|
for (final link in item.links ?? const <CatalogLink>[]) {
|
|
if (link.label.trim().isEmpty || link.url.trim().isEmpty) continue;
|
|
(link.isStreaming ? streamingLinks : otherLinks).add(link);
|
|
}
|
|
for (final node in _linkFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
_orderedTags = tags;
|
|
_streamingLinks = streamingLinks;
|
|
_otherLinks = otherLinks;
|
|
_linkFocusNodes = [
|
|
for (var index = 0; index < streamingLinks.length + otherLinks.length; index++)
|
|
FocusNode(debugLabel: 'catalog_external_link_$index'),
|
|
];
|
|
}
|
|
|
|
/// One lazy detail load against the item's own source; failures keep the
|
|
/// opening item visible and leave provider-only sections hidden.
|
|
Future<void> _loadDetail() async {
|
|
final source = _ownSource;
|
|
if (source == null) return;
|
|
try {
|
|
final detail = await source.fetchDetail(widget.item);
|
|
if (!mounted) return;
|
|
final relationEntries = [
|
|
for (final relation in detail.relations)
|
|
for (final item in relation.items) (type: relation.type, item: item),
|
|
];
|
|
_syncDetailCollections(detail.item);
|
|
_replaceRelationFocusNodes(relationEntries.length);
|
|
setState(() {
|
|
_detailItem = detail.item;
|
|
_cast = detail.cast;
|
|
_related = detail.related;
|
|
_relationEntries = relationEntries;
|
|
});
|
|
// The row form of a Plex Discover item carries only its rating key;
|
|
// the detail body brings the external ids (#1715). Ask again with the
|
|
// full set whenever enrichment added id forms — not just when the bare
|
|
// lookup came back empty: the exact `plex://` guid finds only copies in
|
|
// libraries on the modern agent, while a legacy-agent sibling is
|
|
// reachable solely through the imdb/tmdb forms (#1754). The result
|
|
// merges, so a re-ask can only add copies.
|
|
final gainedIds = !widget.item.ids.allKeys.toSet().containsAll(detail.item.ids.allKeys);
|
|
if (gainedIds) {
|
|
unawaited(_resolveMatches(detail.item));
|
|
}
|
|
} catch (e) {
|
|
appLogger.d('Catalog detail load failed for ${widget.item.identityKey}', error: e);
|
|
}
|
|
}
|
|
|
|
void _replaceRelationFocusNodes(int count) {
|
|
for (final node in _relationFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
_relationFocusNodes = [
|
|
for (var index = 0; index < count; index++) FocusNode(debugLabel: 'catalog_relation_$index'),
|
|
];
|
|
}
|
|
|
|
bool get _hasTrailer => _item.trailerUrl?.trim().isNotEmpty ?? false;
|
|
|
|
bool get _hasActions => _watchlistSource != null || _requestSource != null || _hasTrailer;
|
|
|
|
bool get _hasLibraryMatches => _libraryMatchFocusNodes.isNotEmpty;
|
|
|
|
bool get _hasSpoilerTags => _orderedTags.any((tag) => tag.isSpoiler);
|
|
|
|
bool get _hasSpoilerReveal => _hasSpoilerTags && !_showSpoilerTags;
|
|
|
|
bool get _hasDetailActions => _hasSpoilerReveal || _linkFocusNodes.isNotEmpty;
|
|
|
|
bool get _hasCast => _cast?.isNotEmpty ?? false;
|
|
|
|
bool get _hasRelations => _relationEntries.isNotEmpty;
|
|
|
|
bool get _hasRelated => _related?.isNotEmpty ?? false;
|
|
|
|
bool get _hasSectionsBelowCast => _hasRelations || _hasRelated;
|
|
|
|
void _revealFocusNode(FocusNode? node, {double alignment = 0.3}) {
|
|
final focusContext = node?.context;
|
|
if (focusContext == null) return;
|
|
unawaited(
|
|
Scrollable.ensureVisible(
|
|
focusContext,
|
|
alignment: alignment,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _requestLibraryMatchFocus(int index) {
|
|
if (index < 0 || index >= _libraryMatchFocusNodes.length) return;
|
|
final node = _libraryMatchFocusNodes[index];
|
|
node.requestFocus();
|
|
_revealFocusNode(node);
|
|
}
|
|
|
|
void _requestLinkFocus(int index) {
|
|
if (index < 0 || index >= _linkFocusNodes.length) return;
|
|
final node = _linkFocusNodes[index];
|
|
node.requestFocus();
|
|
_revealFocusNode(node);
|
|
}
|
|
|
|
void _requestFirstDetailActionFocus() {
|
|
if (_hasSpoilerReveal) {
|
|
_spoilerTagFocusNode.requestFocus();
|
|
_revealFocusNode(_spoilerTagFocusNode);
|
|
} else {
|
|
_requestLinkFocus(0);
|
|
}
|
|
}
|
|
|
|
void _requestLastDetailActionFocus() {
|
|
if (_linkFocusNodes.isNotEmpty) {
|
|
_requestLinkFocus(_linkFocusNodes.length - 1);
|
|
} else if (_hasSpoilerReveal) {
|
|
_spoilerTagFocusNode.requestFocus();
|
|
_revealFocusNode(_spoilerTagFocusNode);
|
|
}
|
|
}
|
|
|
|
void _requestCastFocus() {
|
|
if (!(_cast?.isNotEmpty ?? false)) return;
|
|
_castStripKey.currentState?.requestFocus();
|
|
final sectionContext = _castSectionKey.currentContext;
|
|
if (sectionContext == null) return;
|
|
unawaited(
|
|
Scrollable.ensureVisible(
|
|
sectionContext,
|
|
alignment: 0.3,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _requestActionBarFocus() {
|
|
_actionBarKey.currentState?.requestFocusOnFirst();
|
|
if (!_scrollController.hasClients) return;
|
|
unawaited(_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut));
|
|
}
|
|
|
|
void _requestRelationFocus(int index) {
|
|
if (index < 0 || index >= _relationFocusNodes.length) return;
|
|
final node = _relationFocusNodes[index];
|
|
node.requestFocus();
|
|
_revealFocusNode(node);
|
|
}
|
|
|
|
void _requestRelatedFocus() {
|
|
_relatedSectionKey.currentState?.requestFocusFromMemory();
|
|
}
|
|
|
|
void _focusSectionBelowCast() {
|
|
if (_hasRelations) {
|
|
_requestRelationFocus(0);
|
|
} else {
|
|
_requestRelatedFocus();
|
|
}
|
|
}
|
|
|
|
bool _focusSectionBelowLibraryMatches() {
|
|
if (_hasCast) {
|
|
_requestCastFocus();
|
|
return true;
|
|
}
|
|
if (_hasSectionsBelowCast) {
|
|
_focusSectionBelowCast();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void _focusSectionBelowDetailActions() {
|
|
if (_hasLibraryMatches) {
|
|
_requestLibraryMatchFocus(0);
|
|
} else if (_hasCast) {
|
|
_requestCastFocus();
|
|
} else if (_hasSectionsBelowCast) {
|
|
_focusSectionBelowCast();
|
|
}
|
|
}
|
|
|
|
void _focusSectionBelowActions() {
|
|
if (_hasDetailActions) {
|
|
_requestFirstDetailActionFocus();
|
|
} else {
|
|
_focusSectionBelowDetailActions();
|
|
}
|
|
}
|
|
|
|
void _focusSectionAboveLibraryMatches() {
|
|
if (_hasDetailActions) {
|
|
_requestLastDetailActionFocus();
|
|
} else {
|
|
_requestActionBarFocus();
|
|
}
|
|
}
|
|
|
|
void _focusSectionAboveCast() {
|
|
if (_hasLibraryMatches) {
|
|
_requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1);
|
|
} else if (_hasDetailActions) {
|
|
_requestLastDetailActionFocus();
|
|
} else {
|
|
_requestActionBarFocus();
|
|
}
|
|
}
|
|
|
|
void _focusSectionAboveRelations() {
|
|
if (_hasCast) {
|
|
_requestCastFocus();
|
|
} else {
|
|
_focusSectionAboveCast();
|
|
}
|
|
}
|
|
|
|
void _focusSectionAboveRelated() {
|
|
if (_hasRelations) {
|
|
_requestRelationFocus(_relationEntries.length - 1);
|
|
} else {
|
|
_focusSectionAboveRelations();
|
|
}
|
|
}
|
|
|
|
KeyEventResult _handleLibraryMatchKey(int index, KeyEvent event) {
|
|
if (!event.isActionable) return KeyEventResult.ignored;
|
|
final key = event.logicalKey;
|
|
if (key.isUpKey) {
|
|
if (index > 0) {
|
|
_requestLibraryMatchFocus(index - 1);
|
|
} else if (_hasDetailActions || _hasActions) {
|
|
_focusSectionAboveLibraryMatches();
|
|
} else {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isDownKey) {
|
|
if (index + 1 < _libraryMatchFocusNodes.length) {
|
|
_requestLibraryMatchFocus(index + 1);
|
|
} else if (!_focusSectionBelowLibraryMatches()) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
void _revealSpoilerTags() {
|
|
if (!_hasSpoilerReveal) return;
|
|
setState(() => _showSpoilerTags = true);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
if (_linkFocusNodes.isNotEmpty) {
|
|
_requestLinkFocus(0);
|
|
} else {
|
|
_focusSectionBelowDetailActions();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _openExternalUrl(String value) async {
|
|
final uri = Uri.tryParse(value);
|
|
if (uri == null || !uri.hasScheme) {
|
|
appLogger.d('Catalog external URL is invalid: $value');
|
|
return;
|
|
}
|
|
try {
|
|
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
if (!opened) appLogger.d('Catalog external URL could not be opened: $value');
|
|
} catch (e) {
|
|
appLogger.d('Catalog external URL failed to open: $value', error: e);
|
|
}
|
|
}
|
|
|
|
bool? get _isOnWatchlist => _watchlistSource?.isOnWatchlist(_item.kind, _item.ids);
|
|
|
|
Future<void> _toggleWatchlist() async {
|
|
final source = _watchlistSource;
|
|
if (source == null || _mutatingWatchlist) return;
|
|
final current = _isOnWatchlist;
|
|
// Parity with lib/screens/media_detail/action_buttons.dart: the action
|
|
// stays focusable while membership is unknown, and a press kicks the
|
|
// snapshot load rather than toggling a state we haven't read yet.
|
|
if (current == null) {
|
|
unawaited(source.ensureWatchlistLoaded());
|
|
return;
|
|
}
|
|
_mutatingWatchlist = true;
|
|
try {
|
|
if (current) {
|
|
await source.removeFromWatchlist(_item.kind, _item.ids);
|
|
} else {
|
|
await source.addToWatchlist(_item.kind, _item.ids);
|
|
}
|
|
} catch (_) {
|
|
if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
|
} finally {
|
|
_mutatingWatchlist = false;
|
|
}
|
|
}
|
|
|
|
/// The technical label of a copy's best version, e.g. "4K HEVC MKV
|
|
/// (45.0 Mbps)". Library names are user-chosen and need not mention
|
|
/// resolution, which is exactly the question two copies of one movie pose
|
|
/// (#1754), so the row states it outright. Omitted when the backend
|
|
/// reported no resolution at all, rather than showing "Unknown".
|
|
static String? _libraryMatchQuality(MediaItem match) {
|
|
MediaVersion? best;
|
|
for (final version in match.mediaVersions ?? const <MediaVersion>[]) {
|
|
if (version.resolutionHeight == null) continue;
|
|
if (best == null || version.resolutionHeight! > best.resolutionHeight!) best = version;
|
|
}
|
|
return best?.displayLabel;
|
|
}
|
|
|
|
Widget _buildLibraryMatchTile(MediaItem match, int index) {
|
|
// Plex matches carry their library title; MediaBrowser search-based lookup
|
|
// only does when the ancestors call succeeded, so fall back to the server
|
|
// name alone. The subtitle carries whatever else tells two copies apart.
|
|
final details = [?_libraryMatchQuality(match), ?(match.libraryTitle == null ? null : match.serverName)];
|
|
return FocusableListTile(
|
|
focusNode: _libraryMatchFocusNodes[index],
|
|
leading: BackendBadge(backend: match.backend, size: 24),
|
|
title: Text(match.libraryTitle ?? match.serverName ?? match.backend.dialect?.productName ?? 'Plex'),
|
|
subtitle: details.isEmpty ? null : Text(details.join(' • ')),
|
|
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
|
onTap: () => unawaited(navigateToMediaItemDetails(context, match)),
|
|
);
|
|
}
|
|
|
|
/// Library availability, resolved in place: a progress row while the
|
|
/// matcher runs, "Not in your library" when nothing matched, otherwise an
|
|
/// "In these libraries" list whose rows open the normal media detail
|
|
/// screen. Rows are focusable tiles (dpad-safe, background focus effect).
|
|
Widget _buildLibrarySection(ThemeData theme) {
|
|
final mutedStyle = theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.5));
|
|
final matches = _matches;
|
|
|
|
if (matches == null) {
|
|
return Row(
|
|
children: [
|
|
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
|
const SizedBox(width: 10),
|
|
Text(t.explore.checkingLibrary, style: mutedStyle),
|
|
],
|
|
);
|
|
}
|
|
|
|
if (matches.isEmpty) {
|
|
return Row(
|
|
children: [
|
|
AppIcon(Symbols.info_rounded, fill: 1, size: 18, color: theme.colorScheme.onSurface.withValues(alpha: 0.5)),
|
|
const SizedBox(width: 8),
|
|
Text(t.explore.notInLibrary, style: mutedStyle),
|
|
],
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(t.explore.inTheseLibraries, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 12),
|
|
// M3E grouped cards, same row anatomy as the settings/trackers hub:
|
|
// server-type logo leading, name, chevron trailing.
|
|
SettingsGroup(
|
|
margin: EdgeInsets.zero,
|
|
children: [
|
|
for (var index = 0; index < matches.length; index++) _buildLibraryMatchTile(matches[index], index),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String get _metaLine {
|
|
final item = _item;
|
|
final parts = <String>[
|
|
if (item.year != null) '${item.year}',
|
|
if (item.runtimeMinutes != null) formatDurationTextual(Duration(minutes: item.runtimeMinutes!).inMilliseconds),
|
|
if (item.certification?.trim().isNotEmpty ?? false) item.certification!.trim(),
|
|
];
|
|
return parts.join(' • ');
|
|
}
|
|
|
|
static String _statusLabel(CatalogAirStatus status) => switch (status) {
|
|
CatalogAirStatus.airing => t.explore.status.airing,
|
|
CatalogAirStatus.ended => t.explore.status.ended,
|
|
CatalogAirStatus.canceled => t.explore.status.canceled,
|
|
CatalogAirStatus.upcoming => t.explore.status.upcoming,
|
|
};
|
|
|
|
static String _seasonName(CatalogSeasonName season) => switch (season) {
|
|
CatalogSeasonName.winter => t.explore.season.winter,
|
|
CatalogSeasonName.spring => t.explore.season.spring,
|
|
CatalogSeasonName.summer => t.explore.season.summer,
|
|
CatalogSeasonName.fall => t.explore.season.fall,
|
|
};
|
|
|
|
static String _seasonLabel(CatalogSeasonInfo season) {
|
|
final name = _seasonName(season.name);
|
|
return season.year == null ? name : t.explore.season.withYear(season: name, year: season.year!);
|
|
}
|
|
|
|
static String _formatLabel(CatalogFormat format) => switch (format) {
|
|
CatalogFormat.tv => t.explore.format.tv,
|
|
CatalogFormat.tvShort => t.explore.format.tvShort,
|
|
CatalogFormat.movie => t.explore.format.movie,
|
|
CatalogFormat.special => t.explore.format.special,
|
|
CatalogFormat.ova => t.explore.format.ova,
|
|
CatalogFormat.ona => t.explore.format.ona,
|
|
CatalogFormat.music => t.explore.format.music,
|
|
CatalogFormat.other => t.explore.format.other,
|
|
};
|
|
|
|
static String _sourceMaterialLabel(CatalogSourceMaterial source) => switch (source) {
|
|
CatalogSourceMaterial.original => t.explore.sourceMaterial.original,
|
|
CatalogSourceMaterial.manga => t.explore.sourceMaterial.manga,
|
|
CatalogSourceMaterial.lightNovel => t.explore.sourceMaterial.lightNovel,
|
|
CatalogSourceMaterial.novel => t.explore.sourceMaterial.novel,
|
|
CatalogSourceMaterial.visualNovel => t.explore.sourceMaterial.visualNovel,
|
|
CatalogSourceMaterial.game => t.explore.sourceMaterial.game,
|
|
CatalogSourceMaterial.webComic => t.explore.sourceMaterial.webComic,
|
|
CatalogSourceMaterial.musicRelease => t.explore.sourceMaterial.musicRelease,
|
|
CatalogSourceMaterial.otherMedia => t.explore.sourceMaterial.otherMedia,
|
|
};
|
|
|
|
static String _creditRoleLabel(CatalogCreditRole role) => switch (role) {
|
|
CatalogCreditRole.director => t.explore.creditRole.director,
|
|
CatalogCreditRole.writer => t.explore.creditRole.writer,
|
|
CatalogCreditRole.producer => t.explore.creditRole.producer,
|
|
CatalogCreditRole.creator => t.explore.creditRole.creator,
|
|
CatalogCreditRole.composer => t.explore.creditRole.composer,
|
|
};
|
|
|
|
static String _relationLabel(CatalogRelationType type) => switch (type) {
|
|
CatalogRelationType.prequel => t.explore.relation.prequel,
|
|
CatalogRelationType.sequel => t.explore.relation.sequel,
|
|
CatalogRelationType.sideStory => t.explore.relation.sideStory,
|
|
CatalogRelationType.spinOff => t.explore.relation.spinOff,
|
|
CatalogRelationType.alternativeVersion => t.explore.relation.alternativeVersion,
|
|
CatalogRelationType.summary => t.explore.relation.summary,
|
|
CatalogRelationType.parentStory => t.explore.relation.parentStory,
|
|
CatalogRelationType.adaptation => t.explore.relation.adaptation,
|
|
CatalogRelationType.other => t.explore.relation.other,
|
|
};
|
|
|
|
static String? _joinValues(Iterable<String>? raw, {String Function(String value)? displayName}) {
|
|
if (raw == null) return null;
|
|
final values = <String>[];
|
|
final seen = <String>{};
|
|
for (final rawValue in raw) {
|
|
final value = rawValue.trim();
|
|
if (value.isEmpty) continue;
|
|
final displayed = displayName?.call(value) ?? value;
|
|
if (displayed.trim().isNotEmpty && seen.add(displayed)) values.add(displayed);
|
|
}
|
|
return values.isEmpty ? null : values.join(' • ');
|
|
}
|
|
|
|
static String? _rankLabel(CatalogRank rank) {
|
|
if (!rank.allTime) {
|
|
final season = rank.season;
|
|
final window = switch ((season, rank.year)) {
|
|
(final CatalogSeasonName season, final int year) => t.explore.season.withYear(
|
|
season: _seasonName(season),
|
|
year: year,
|
|
),
|
|
(final CatalogSeasonName season, null) => _seasonName(season),
|
|
(null, final int year) => '$year',
|
|
_ => null,
|
|
};
|
|
return window == null ? null : t.explore.badge.rankSeasonal(n: rank.rank, season: window);
|
|
}
|
|
return switch (rank.scope) {
|
|
CatalogRankScope.popular => t.explore.badge.rankPopular(n: rank.rank),
|
|
CatalogRankScope.airing => t.explore.badge.rankAiring(n: rank.rank),
|
|
CatalogRankScope.rated => t.explore.badge.rankRated(n: rank.rank),
|
|
CatalogRankScope.favorited => t.explore.badge.rankFavorited(n: rank.rank),
|
|
CatalogRankScope.trending => t.explore.badge.rankTrending(n: rank.rank),
|
|
CatalogRankScope.seasonal => null,
|
|
};
|
|
}
|
|
|
|
static String? _availabilityLabel(CatalogAvailability availability, {required bool is4k}) {
|
|
if (is4k) {
|
|
return availability == CatalogAvailability.available ? t.explore.badge.availableIn4k : null;
|
|
}
|
|
return switch (availability) {
|
|
CatalogAvailability.available => t.explore.badge.available,
|
|
CatalogAvailability.partiallyAvailable => t.explore.badge.partiallyAvailable,
|
|
CatalogAvailability.unavailable => null,
|
|
};
|
|
}
|
|
|
|
static String _requestStateLabel(CatalogRequestState request, {required bool is4k}) {
|
|
if (is4k &&
|
|
{CatalogRequestState.pending, CatalogRequestState.approved, CatalogRequestState.processing}.contains(request)) {
|
|
return t.explore.badge.requested4k;
|
|
}
|
|
return switch (request) {
|
|
CatalogRequestState.pending => t.explore.badge.pendingApproval,
|
|
CatalogRequestState.approved => t.explore.badge.requested,
|
|
CatalogRequestState.processing => t.explore.badge.processing,
|
|
CatalogRequestState.declined => t.explore.badge.declined,
|
|
CatalogRequestState.failed => t.explore.badge.requestFailed,
|
|
};
|
|
}
|
|
|
|
/// Headline score, leaderboard context, audience counts, availability and
|
|
/// release-shape facts that fit the established compact chip treatment.
|
|
Widget? _buildStatsChips() {
|
|
final item = _item;
|
|
final locale = LocaleSettings.currentLocale.intlLocaleName;
|
|
final compact = NumberFormat.compact(locale: locale);
|
|
final chips = <Widget>[];
|
|
void add(String? label, {IconData? icon, Color? iconColor}) {
|
|
if (label?.trim().isNotEmpty ?? false) {
|
|
chips.add(StatChip(icon: icon, iconColor: iconColor, label: label!));
|
|
}
|
|
}
|
|
|
|
if (item.rating case final rating?) {
|
|
var score = rating.toStringAsFixed(1);
|
|
if (item.votes case final votes?) {
|
|
score = '$score (${t.explore.stats.votes(n: compact.format(votes))})';
|
|
}
|
|
add(score, icon: Symbols.star_rounded, iconColor: Colors.amber);
|
|
}
|
|
if (item.airStatus case final status?) add(_statusLabel(status));
|
|
if (item.episodeCount case final count?) add(t.explore.episodeCount(n: count));
|
|
if (item.unairedEpisodeCount case final count?) add(t.explore.detail.unairedEpisodes(n: count));
|
|
add(item.network);
|
|
if (item.broadcastSeason case final season?) add(_seasonLabel(season));
|
|
if (item.format case final format?) add(_formatLabel(format));
|
|
if (item.sourceMaterial case final source?) add(_sourceMaterialLabel(source));
|
|
if (item.isAdult == true) add(t.explore.badge.adult);
|
|
if (item.addedAt case final addedAt?) {
|
|
add(t.explore.detail.addedOn(date: DateFormat.yMMMd(locale).format(addedAt.toLocal())));
|
|
}
|
|
for (final rank in item.ranks ?? const <CatalogRank>[]) {
|
|
add(_rankLabel(rank));
|
|
}
|
|
|
|
final audience = item.audience;
|
|
if (audience != null) {
|
|
if (audience.watchingNow case final count?) add(t.explore.badge.watchingNow(n: compact.format(count)));
|
|
if (audience.listed case final count?) add(t.explore.stats.listed(n: compact.format(count)));
|
|
if (audience.viewers case final count? when audience.viewersPeriod != null) {
|
|
final formatted = compact.format(count);
|
|
add(switch (audience.viewersPeriod!) {
|
|
CatalogAudiencePeriod.day => t.explore.stats.viewersDay(n: formatted),
|
|
CatalogAudiencePeriod.week => t.explore.stats.viewersWeek(n: formatted),
|
|
CatalogAudiencePeriod.month => t.explore.stats.viewersMonth(n: formatted),
|
|
CatalogAudiencePeriod.year => t.explore.stats.viewersYear(n: formatted),
|
|
CatalogAudiencePeriod.allTime => t.explore.stats.viewersAllTime(n: formatted),
|
|
});
|
|
}
|
|
if (audience.planning case final count?) add(t.explore.stats.planning(n: compact.format(count)));
|
|
if (audience.watching case final count?) add(t.explore.stats.watching(n: compact.format(count)));
|
|
if (audience.completed case final count?) add(t.explore.stats.completed(n: compact.format(count)));
|
|
if (audience.onHold case final count?) add(t.explore.stats.onHold(n: compact.format(count)));
|
|
if (audience.dropped case final count?) add(t.explore.stats.dropped(n: compact.format(count)));
|
|
if (audience.favorited case final count?) add(t.explore.stats.favorited(n: compact.format(count)));
|
|
if (audience.dropRate case final rate?) {
|
|
add(t.explore.stats.dropRate(percent: NumberFormat.percentPattern(locale).format(rate)));
|
|
}
|
|
if (audience.comments case final count?) add(t.explore.stats.comments(n: count));
|
|
}
|
|
|
|
final server = item.serverState;
|
|
if (server != null) {
|
|
if (server.availability case final availability?) {
|
|
add(_availabilityLabel(availability, is4k: false));
|
|
}
|
|
if (server.availability4k case final availability?) {
|
|
add(_availabilityLabel(availability, is4k: true));
|
|
}
|
|
if (server.request case final request?) add(_requestStateLabel(request, is4k: false));
|
|
if (server.request4k case final request?) add(_requestStateLabel(request, is4k: true));
|
|
if (server.availableSeasons case final available? when server.totalSeasons != null) {
|
|
add(t.explore.badge.seasonsAvailable(available: available, total: server.totalSeasons!));
|
|
}
|
|
}
|
|
|
|
if (chips.isEmpty) return null;
|
|
return Wrap(spacing: 8, runSpacing: 8, children: chips);
|
|
}
|
|
|
|
/// Attributed scores, each behind its own brand badge where the source has
|
|
/// one — the same Rotten Tomatoes/IMDb/TMDB marks the media detail screen
|
|
/// draws. Sources without a mark (`critic`, `audience`, tracker scores)
|
|
/// keep their written label.
|
|
Widget? _buildRatingsSection(ThemeData theme) {
|
|
final compact = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName);
|
|
final chips = <Widget>[];
|
|
for (final rating in _item.ratings ?? const <MediaRatingSource>[]) {
|
|
final source = ratingSourceLabel(rating.source);
|
|
final badge = ratingInfoForSource(rating.source, rating.value);
|
|
if (source == null && badge == null) continue;
|
|
var label = badge == null ? '$source ${rating.value.toStringAsFixed(1)}' : badge.formattedValue;
|
|
if (rating.votes case final votes?) {
|
|
label = '$label (${t.explore.stats.votes(n: compact.format(votes))})';
|
|
}
|
|
chips.add(
|
|
badge == null
|
|
? StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: label)
|
|
: StatChip(
|
|
leading: SvgPicture.asset(badge.assetPath, width: 14, height: 14, semanticsLabel: source),
|
|
label: label,
|
|
),
|
|
);
|
|
}
|
|
if (chips.isEmpty) return null;
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(t.explore.detail.ratings, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Wrap(spacing: 8, runSpacing: 8, children: chips),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget? _buildScheduleSection(ThemeData theme) {
|
|
final item = _item;
|
|
final chips = <Widget>[];
|
|
final locale = LocaleSettings.currentLocale.intlLocaleName;
|
|
final broadcast = item.broadcast;
|
|
if (broadcast?.weekday case final weekday? when weekday >= DateTime.monday && weekday <= DateTime.sunday) {
|
|
final time = broadcast!.time?.trim();
|
|
if (time?.isNotEmpty ?? false) {
|
|
final day = DateFormat.EEEE(locale).format(DateTime.utc(2024, 1, weekday));
|
|
final timezone = broadcast.timezone?.trim();
|
|
chips.add(
|
|
StatChip(
|
|
label: timezone?.isNotEmpty ?? false
|
|
? t.explore.broadcastWithZone(day: day, time: time!, timezone: timezone!)
|
|
: t.explore.broadcast(day: day, time: time!),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
if (item.nextEpisode case final next?) {
|
|
final duration = formatDurationTextual(next.timeUntil(DateTime.now()).inMilliseconds);
|
|
chips.add(
|
|
StatChip(
|
|
label: next.episode == null
|
|
? t.explore.badge.nextAiringIn(duration: duration)
|
|
: t.explore.badge.nextEpisodeIn(episode: next.episode!, duration: duration),
|
|
),
|
|
);
|
|
}
|
|
if (chips.isEmpty) return null;
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(t.explore.detail.schedule, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Wrap(spacing: 8, runSpacing: 8, children: chips),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildFactRow(ThemeData theme, String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
SizedBox(
|
|
width: 140,
|
|
child: Text(
|
|
label,
|
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.65)),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Gap between definition-grid columns, and between relation tiles.
|
|
static const double _factColumnSpacing = 24;
|
|
static const double _relationTileSpacing = 12;
|
|
|
|
/// Definition rows flow into columns once there is room for them: budget,
|
|
/// box office and the rest are short values, and one pair per line leaves
|
|
/// most of a desktop window empty.
|
|
Widget _buildFactGrid(ThemeData theme, List<({String label, String value})> facts) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final columns = _gridColumns(constraints.maxWidth, facts.length);
|
|
if (columns < 2) {
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [for (final fact in facts) _buildFactRow(theme, fact.label, fact.value)],
|
|
);
|
|
}
|
|
final columnWidth = (constraints.maxWidth - _factColumnSpacing * (columns - 1)) / columns;
|
|
return Wrap(
|
|
spacing: _factColumnSpacing,
|
|
children: [
|
|
for (final fact in facts) SizedBox(width: columnWidth, child: _buildFactRow(theme, fact.label, fact.value)),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Columns that fit [width], never more than there are entries: two facts
|
|
/// on a wide window stay two columns instead of stretching to three.
|
|
static int _gridColumns(double width, int count) {
|
|
final fits = width >= 1100
|
|
? 3
|
|
: width >= 700
|
|
? 2
|
|
: 1;
|
|
return fits < count ? fits : count;
|
|
}
|
|
|
|
Widget? _buildFactsSection(ThemeData theme) {
|
|
final item = _item;
|
|
final locale = LocaleSettings.currentLocale.intlLocaleName;
|
|
final dateFormat = DateFormat.yMMMd(locale);
|
|
final currency = NumberFormat.simpleCurrency(locale: locale, name: 'USD', decimalDigits: 0);
|
|
final facts = <({String label, String value})>[];
|
|
void add(String label, String? value) {
|
|
if (value?.trim().isNotEmpty ?? false) facts.add((label: label, value: value!));
|
|
}
|
|
|
|
add(t.explore.detail.originalTitle, item.originalTitle);
|
|
add(t.explore.detail.alsoKnownAs, _joinValues(item.altTitles));
|
|
add(t.explore.detail.studios, _joinValues(item.studios));
|
|
add(t.explore.detail.country, _joinValues(item.countries, displayName: CountryCodes.getDisplayName));
|
|
add(t.explore.detail.language, _joinValues(item.languages, displayName: LanguageCodes.getDisplayName));
|
|
if (item.releaseDate case final date?) add(t.explore.detail.released, dateFormat.format(date.toLocal()));
|
|
if (item.physicalReleaseDate case final date?) {
|
|
add(t.explore.detail.physicalRelease, dateFormat.format(date.toLocal()));
|
|
}
|
|
if (item.endDate case final date?) add(t.explore.detail.ended, dateFormat.format(date.toLocal()));
|
|
if (item.userRating case final rating?) add(t.explore.detail.yourRating, rating.toStringAsFixed(1));
|
|
if (item.budget case final budget?) add(t.explore.detail.budget, currency.format(budget));
|
|
if (item.revenue case final revenue?) add(t.explore.detail.revenue, currency.format(revenue));
|
|
add(t.explore.detail.contentAdvisory, item.contentAdvisory);
|
|
if (facts.isEmpty) return null;
|
|
return _buildFactGrid(theme, facts);
|
|
}
|
|
|
|
Widget? _buildRecommendersSection(ThemeData theme) {
|
|
final recommenders = _item.recommenders;
|
|
if (recommenders == null || recommenders.isEmpty) return null;
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
for (var index = 0; index < recommenders.length; index++) ...[
|
|
if (index > 0) const SizedBox(height: 10),
|
|
Text(switch (recommenders[index].reason) {
|
|
CatalogRecommendationReason.favorited => t.explore.detail.favoritedBy(who: recommenders[index].displayName),
|
|
CatalogRecommendationReason.recommended => t.explore.detail.recommendedBy(
|
|
who: recommenders[index].displayName,
|
|
),
|
|
}, style: theme.textTheme.titleSmall),
|
|
if (recommenders[index].note?.trim().isNotEmpty ?? false) ...[
|
|
const SizedBox(height: 4),
|
|
Text(recommenders[index].note!.trim(), style: theme.textTheme.bodyMedium),
|
|
],
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget? _buildCrewSection(ThemeData theme) {
|
|
final credits = _item.credits;
|
|
if (credits == null || credits.isEmpty) return null;
|
|
final rows = <({String label, String value})>[];
|
|
for (final role in CatalogCreditRole.values) {
|
|
final names = _joinValues(credits.where((credit) => credit.role == role).map((credit) => credit.name));
|
|
if (names != null) rows.add((label: _creditRoleLabel(role), value: names));
|
|
}
|
|
if (rows.isEmpty) return null;
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(t.explore.detail.crew, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
_buildFactGrid(theme, rows),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget? _buildTagsSection(ThemeData theme) {
|
|
if (_orderedTags.isEmpty) return null;
|
|
final visibleTags = [
|
|
for (final tag in _orderedTags)
|
|
if (!tag.isSpoiler || _showSpoilerTags) tag,
|
|
];
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(t.explore.detail.tags, style: theme.textTheme.titleMedium),
|
|
if (visibleTags.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Wrap(spacing: 8, runSpacing: 8, children: [for (final tag in visibleTags) StatChip(label: tag.name)]),
|
|
],
|
|
if (_hasSpoilerReveal) ...[
|
|
const SizedBox(height: 8),
|
|
FocusableButton(
|
|
focusNode: _spoilerTagFocusNode,
|
|
onPressed: _revealSpoilerTags,
|
|
onNavigateUp: _hasActions ? _requestActionBarFocus : null,
|
|
onNavigateDown: _linkFocusNodes.isNotEmpty ? () => _requestLinkFocus(0) : _focusSectionBelowDetailActions,
|
|
child: OutlinedButton.icon(
|
|
onPressed: _revealSpoilerTags,
|
|
icon: const AppIcon(Symbols.visibility_rounded, fill: 1),
|
|
label: Text(t.explore.detail.revealSpoilerTags),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
void _focusAboveLinkGroup(int startIndex) {
|
|
if (startIndex > 0) {
|
|
_requestLinkFocus(startIndex - 1);
|
|
} else if (_hasSpoilerReveal) {
|
|
_spoilerTagFocusNode.requestFocus();
|
|
_revealFocusNode(_spoilerTagFocusNode);
|
|
} else {
|
|
_requestActionBarFocus();
|
|
}
|
|
}
|
|
|
|
void _focusBelowLinkGroup(int endIndex) {
|
|
if (endIndex < _linkFocusNodes.length) {
|
|
_requestLinkFocus(endIndex);
|
|
} else {
|
|
_focusSectionBelowDetailActions();
|
|
}
|
|
}
|
|
|
|
Widget _buildLinksSection(ThemeData theme, String title, List<CatalogLink> links, int startIndex) {
|
|
final endIndex = startIndex + links.length;
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(title, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (var localIndex = 0; localIndex < links.length; localIndex++)
|
|
FocusableButton(
|
|
focusNode: _linkFocusNodes[startIndex + localIndex],
|
|
onPressed: () => unawaited(_openExternalUrl(links[localIndex].url)),
|
|
onNavigateLeft: localIndex > 0 ? () => _requestLinkFocus(startIndex + localIndex - 1) : null,
|
|
onNavigateRight: localIndex + 1 < links.length
|
|
? () => _requestLinkFocus(startIndex + localIndex + 1)
|
|
: null,
|
|
onNavigateUp: () => _focusAboveLinkGroup(startIndex),
|
|
onNavigateDown: () => _focusBelowLinkGroup(endIndex),
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => unawaited(_openExternalUrl(links[localIndex].url)),
|
|
icon: const AppIcon(Symbols.open_in_new_rounded, fill: 1),
|
|
label: Text(t.explore.detail.openOn(site: links[localIndex].label)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildBackgroundSection(ThemeData theme, String background) {
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(t.explore.detail.background, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Text(background, style: theme.textTheme.bodyLarge),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Horizontal cast strip — the same [CastMemberStrip] cards as the media
|
|
/// detail screen. Trakt serves actors with their character; MAL serves
|
|
/// characters with their role, so the section is titled accordingly.
|
|
Widget _buildCastSection(ThemeData theme, List<CatalogCastMember> cast) {
|
|
return Column(
|
|
key: _castSectionKey,
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(
|
|
const {CatalogSourceId.mal, CatalogSourceId.anilist}.contains(_item.source)
|
|
? t.explore.characters
|
|
: t.explore.cast,
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 4),
|
|
CastMemberStrip(
|
|
key: _castStripKey,
|
|
members: [
|
|
for (final member in cast) (name: member.name, secondary: member.secondary, imagePath: member.imageUrl),
|
|
],
|
|
onNavigateUp: _hasLibraryMatches || _hasDetailActions || _hasActions ? _focusSectionAboveCast : null,
|
|
onNavigateDown: _hasSectionsBelowCast ? _focusSectionBelowCast : null,
|
|
debugLabel: 'catalog_cast_row',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Labelled franchise edges as compact rows rather than one hub per label:
|
|
/// a provider that returns a single sequel used to spend an entire shelf on
|
|
/// it. Rows flow into columns on wide viewports, like the facts above.
|
|
Widget _buildRelationsSection(ThemeData theme) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final count = _relationEntries.length;
|
|
final columns = _gridColumns(constraints.maxWidth, count);
|
|
final tileWidth = (constraints.maxWidth - _relationTileSpacing * (columns - 1)) / columns;
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(t.explore.detail.relatedTitles, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: _relationTileSpacing,
|
|
runSpacing: _relationTileSpacing,
|
|
children: [
|
|
for (var index = 0; index < count; index++)
|
|
SizedBox(
|
|
width: tileWidth,
|
|
child: _buildRelationTile(theme, index, columns: columns, count: count),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildRelationTile(ThemeData theme, int index, {required int columns, required int count}) {
|
|
final entry = _relationEntries[index];
|
|
final item = entry.item;
|
|
final label = _relationLabel(entry.type);
|
|
final year = item.year;
|
|
void open() => unawaited(navigateToCatalogItem(context, item));
|
|
return FocusableWrapper(
|
|
focusNode: _relationFocusNodes[index],
|
|
borderRadius: 12,
|
|
semanticLabel: '$label: ${item.title}',
|
|
onSelect: open,
|
|
onNavigateUp: index >= columns ? () => _requestRelationFocus(index - columns) : _focusSectionAboveRelations,
|
|
onNavigateDown: index + columns < count
|
|
? () => _requestRelationFocus(index + columns)
|
|
: _hasRelated
|
|
? _requestRelatedFocus
|
|
: null,
|
|
onNavigateLeft: index % columns == 0 ? null : () => _requestRelationFocus(index - 1),
|
|
onNavigateRight: (index + 1) % columns == 0 || index + 1 >= count ? null : () => _requestRelationFocus(index + 1),
|
|
child: GestureDetector(
|
|
onTap: open,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 40, height: 60),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: .start,
|
|
mainAxisSize: .min,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
year == null ? item.title : '${item.title} • $year',
|
|
style: theme.textTheme.bodyMedium,
|
|
maxLines: 2,
|
|
overflow: .ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
AppIcon(
|
|
Symbols.chevron_right_rounded,
|
|
fill: 1,
|
|
size: 18,
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Taste-based recommendations stay separate from labelled franchise facts.
|
|
Widget _buildRelatedSection(List<CatalogItem> related) {
|
|
return HubSection(
|
|
key: _relatedSectionKey,
|
|
hub: MediaHub(
|
|
id: 'catalog-related:${_item.source.name}:${_item.identityKey}',
|
|
identifier: 'explore.related',
|
|
title: t.discover.moreLikeThis,
|
|
type: 'mixed',
|
|
items: [for (final item in related) item.toMediaItem()],
|
|
size: related.length,
|
|
),
|
|
focusMemory: _hubFocusMemory,
|
|
icon: Symbols.recommend_rounded,
|
|
inset: true,
|
|
onNavigateUp: _focusSectionAboveRelated,
|
|
cardSizing: HubCardSizing.grid,
|
|
);
|
|
}
|
|
|
|
void _handleSystemBack() {
|
|
if (BackKeyCoordinator.consumeIfHandled()) return;
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final item = _item;
|
|
final theme = Theme.of(context);
|
|
final onWatchlist = _isOnWatchlist;
|
|
final tmdbId = item.ids.tmdb;
|
|
|
|
final viewInsets = MediaQuery.paddingOf(context);
|
|
final blockSystemBack = PlatformDetector.isTV() || InputModeTracker.shouldBlockSystemBack(context);
|
|
// Match the established detail-screen back policy: TV/keyboard back is
|
|
// owned by the focus tree, while native mobile back and iOS swipe-back
|
|
// remain route-driven. The overlay host always gets first refusal.
|
|
return OverlaySheetHost(
|
|
canPop: !blockSystemBack,
|
|
onSystemBack: _handleSystemBack,
|
|
child: Builder(
|
|
builder: (hostContext) => Focus(
|
|
onKeyEvent: (_, event) => handleBackKeyNavigation(hostContext, event),
|
|
child: Scaffold(
|
|
body: Stack(
|
|
children: [
|
|
SingleChildScrollView(
|
|
key: const Key('catalog_detail_scroll'),
|
|
controller: _scrollController,
|
|
// The backdrop lives inside the scrollable so it moves with
|
|
// the content (it extends under the status bar, so the safe
|
|
// areas are baked into the content padding instead of a
|
|
// SafeArea around the scroll view).
|
|
child: Stack(
|
|
children: [
|
|
if (item.backdropUrl != null)
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 320,
|
|
child: ShaderMask(
|
|
shaderCallback: (rect) => LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.black, Colors.black.withValues(alpha: 0.0)],
|
|
stops: const [0.3, 1.0],
|
|
).createShader(rect),
|
|
blendMode: BlendMode.dstIn,
|
|
child: OptimizedMediaImage.thumb(
|
|
imagePath: item.backdropUrl,
|
|
width: double.infinity,
|
|
height: 320,
|
|
fit: BoxFit.cover,
|
|
fallbackIcon: null,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
|
|
child: Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 140, height: 210),
|
|
),
|
|
const SizedBox(width: 20),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
if (item.tagline?.trim() case final tagline? when tagline.isNotEmpty) ...[
|
|
Text(
|
|
tagline,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.75),
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
],
|
|
Text(
|
|
item.title,
|
|
style: theme.textTheme.headlineMedium,
|
|
maxLines: 3,
|
|
overflow: .ellipsis,
|
|
),
|
|
if (_metaLine.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_metaLine,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
if (item.genres?.isNotEmpty ?? false) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
item.genres!.join(' • '),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
if (_hasActions)
|
|
FocusableActionBar(
|
|
key: _actionBarKey,
|
|
onNavigateDown: _focusSectionBelowActions,
|
|
actions: [
|
|
if (_watchlistSource != null)
|
|
FocusableAction(
|
|
icon: onWatchlist ?? false
|
|
? Symbols.bookmark_added_rounded
|
|
: Symbols.bookmark_add_rounded,
|
|
tooltip: onWatchlist ?? false
|
|
? t.explore.removeFromWatchlist
|
|
: t.explore.addToWatchlist,
|
|
onPressed: () => unawaited(_toggleWatchlist()),
|
|
),
|
|
if (_requestSource case final SeerrCatalogSource seerr when tmdbId != null)
|
|
FocusableAction(
|
|
icon: Symbols.download_rounded,
|
|
tooltip: t.seerr.request,
|
|
onPressed: () => unawaited(
|
|
showSeerrRequestSheet(
|
|
hostContext,
|
|
source: seerr,
|
|
kind: item.kind,
|
|
tmdbId: tmdbId,
|
|
title: item.title,
|
|
),
|
|
),
|
|
),
|
|
if (item.trailerUrl?.trim() case final trailer? when trailer.isNotEmpty)
|
|
FocusableAction(
|
|
icon: Symbols.play_circle_rounded,
|
|
tooltip: t.explore.detail.watchTrailer,
|
|
onPressed: () => unawaited(_openExternalUrl(trailer)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_buildStatsChips() case final Widget chips) ...[const SizedBox(height: 20), chips],
|
|
if (item.overview?.trim() case final overview? when overview.isNotEmpty) ...[
|
|
const SizedBox(height: 24),
|
|
Text(overview, style: theme.textTheme.bodyLarge),
|
|
],
|
|
if (item.background?.trim() case final background? when background.isNotEmpty) ...[
|
|
const SizedBox(height: 24),
|
|
_buildBackgroundSection(theme, background),
|
|
],
|
|
if (_buildFactsSection(theme) case final Widget facts) ...[
|
|
const SizedBox(height: 24),
|
|
facts,
|
|
],
|
|
if (_buildRecommendersSection(theme) case final Widget recommenders) ...[
|
|
const SizedBox(height: 24),
|
|
recommenders,
|
|
],
|
|
if (_buildRatingsSection(theme) case final Widget ratings) ...[
|
|
const SizedBox(height: 24),
|
|
ratings,
|
|
],
|
|
if (_buildScheduleSection(theme) case final Widget schedule) ...[
|
|
const SizedBox(height: 24),
|
|
schedule,
|
|
],
|
|
if (_buildCrewSection(theme) case final Widget crew) ...[const SizedBox(height: 24), crew],
|
|
if (_buildTagsSection(theme) case final Widget tags) ...[const SizedBox(height: 24), tags],
|
|
if (_streamingLinks.isNotEmpty) ...[
|
|
const SizedBox(height: 24),
|
|
_buildLinksSection(theme, t.explore.detail.watchOn, _streamingLinks, 0),
|
|
],
|
|
if (_otherLinks.isNotEmpty) ...[
|
|
const SizedBox(height: 24),
|
|
_buildLinksSection(theme, t.explore.detail.links, _otherLinks, _streamingLinks.length),
|
|
],
|
|
const SizedBox(height: 24),
|
|
_buildLibrarySection(theme),
|
|
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
|
|
const SizedBox(height: 28),
|
|
_buildCastSection(theme, cast),
|
|
],
|
|
if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)],
|
|
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
|
|
const SizedBox(height: 20),
|
|
_buildRelatedSection(related),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
child: DesktopAppBarHelper.buildAdjustedLeading(
|
|
AppBarBackButton(style: BackButtonStyle.circular, focusNode: _backButtonFocusNode),
|
|
context: hostContext,
|
|
)!,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|