Settings rows carried their own platform-conditional typography and density, so on desktop and TV they rendered a 16px title, 14px subtitle and 80px row while every other row in the app — the Focusable*ListTile defaults plus ThemeData.listTileTheme's `dense: true` — renders 13/12 in 61px. Drop the overrides instead of re-tuning them: the tile defaults already encode the app's row style, and the explicit title styles were redundant under a dense ListTile (they also masked the disabled/selected title color). settingsOptionTitleStyle now only serves group children that are not ListTiles, and matches the dense title unconditionally. SettingsGroup hands its children that same compact density, so the plain ListTiles used as non-interactive info rows stop standing 11px taller than their interactive siblings.
627 lines
25 KiB
Dart
627 lines
25 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../focus/focusable_action_bar.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 '../models/catalog/catalog_cast_member.dart';
|
|
import '../models/catalog/catalog_item.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/desktop_window_padding.dart';
|
|
import '../utils/formatters.dart';
|
|
import '../utils/media_navigation_helper.dart';
|
|
import '../utils/platform_detector.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();
|
|
List<FocusNode> _libraryMatchFocusNodes = const [];
|
|
CatalogSource? _watchlistSource;
|
|
SeerrCatalogSource? _requestSource;
|
|
bool _mutatingWatchlist = false;
|
|
|
|
/// 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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_resolveMatches());
|
|
unawaited(_loadCast());
|
|
unawaited(_loadRelated());
|
|
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();
|
|
_watchlistSource?.watchlistChanges.removeListener(_onWatchlistChanged);
|
|
for (final node in _libraryMatchFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onWatchlistChanged() {
|
|
// ignore: no-empty-block - membership state lives in the source
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _resolveMatches() async {
|
|
try {
|
|
_setMatches(await context.read<CatalogLibraryMatcher>().match(widget.item));
|
|
} catch (e) {
|
|
appLogger.w('Catalog library match failed for ${widget.item.identityKey}', error: e);
|
|
_setMatches(const []);
|
|
}
|
|
}
|
|
|
|
void _setMatches(List<MediaItem> matches) {
|
|
if (!mounted) return;
|
|
for (final node in _libraryMatchFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
_libraryMatchFocusNodes = [
|
|
for (var index = 0; index < matches.length; index++)
|
|
FocusNode(
|
|
debugLabel: 'catalog_library_match_$index',
|
|
onKeyEvent: (node, event) => _handleLibraryMatchKey(index, event),
|
|
),
|
|
];
|
|
setState(() => _matches = matches);
|
|
}
|
|
|
|
CatalogSource? get _ownSource =>
|
|
context.read<CatalogSourcesProvider>().connectedSources.firstWhereOrNull((s) => s.id == widget.item.source);
|
|
|
|
/// One lazy request against the item's own source; failures just leave the
|
|
/// section hidden.
|
|
Future<void> _loadCast() async {
|
|
final source = _ownSource;
|
|
if (source == null) return;
|
|
try {
|
|
final cast = await source.fetchCast(widget.item);
|
|
if (mounted) setState(() => _cast = cast);
|
|
} catch (e) {
|
|
appLogger.d('Catalog cast load failed for ${widget.item.identityKey}', error: e);
|
|
}
|
|
}
|
|
|
|
/// One lazy request against the item's own source; failures just leave the
|
|
/// row hidden.
|
|
Future<void> _loadRelated() async {
|
|
final source = _ownSource;
|
|
if (source == null) return;
|
|
try {
|
|
final related = await source.fetchRelated(widget.item);
|
|
if (mounted) setState(() => _related = related);
|
|
} catch (e) {
|
|
appLogger.d('Catalog related load failed for ${widget.item.identityKey}', error: e);
|
|
}
|
|
}
|
|
|
|
bool get _hasActions => _watchlistSource != null || _requestSource != null;
|
|
|
|
bool get _hasLibraryMatches => _libraryMatchFocusNodes.isNotEmpty;
|
|
|
|
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);
|
|
}
|
|
|
|
bool _focusSectionBelowLibraryMatches() {
|
|
if (_cast?.isNotEmpty ?? false) {
|
|
_requestCastFocus();
|
|
return true;
|
|
}
|
|
if (_related?.isNotEmpty ?? false) {
|
|
_requestRelatedFocus();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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 (_hasActions) {
|
|
_requestActionBarFocus();
|
|
} 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 _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 _requestRelatedFocus() {
|
|
_relatedSectionKey.currentState?.requestFocusFromMemory();
|
|
}
|
|
|
|
void _focusSectionBelowActions() {
|
|
if (_hasLibraryMatches) {
|
|
_requestLibraryMatchFocus(0);
|
|
} else if (_cast?.isNotEmpty ?? false) {
|
|
_requestCastFocus();
|
|
} else {
|
|
_requestRelatedFocus();
|
|
}
|
|
}
|
|
|
|
void _focusSectionAboveCast() {
|
|
if (_hasLibraryMatches) {
|
|
_requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1);
|
|
} else {
|
|
_requestActionBarFocus();
|
|
}
|
|
}
|
|
|
|
void _focusSectionAboveRelated() {
|
|
if (_cast?.isNotEmpty ?? false) {
|
|
_requestCastFocus();
|
|
} else if (_hasLibraryMatches) {
|
|
_requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1);
|
|
} else {
|
|
_requestActionBarFocus();
|
|
}
|
|
}
|
|
|
|
bool? get _isOnWatchlist => _watchlistSource?.isOnWatchlist(widget.item.kind, widget.item.ids);
|
|
|
|
Future<void> _toggleWatchlist() async {
|
|
final source = _watchlistSource;
|
|
final current = _isOnWatchlist;
|
|
if (source == null || current == null || _mutatingWatchlist) return;
|
|
_mutatingWatchlist = true;
|
|
try {
|
|
if (current) {
|
|
await source.removeFromWatchlist(widget.item.kind, widget.item.ids);
|
|
} else {
|
|
await source.addToWatchlist(widget.item.kind, widget.item.ids);
|
|
}
|
|
} catch (_) {
|
|
if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
|
} finally {
|
|
_mutatingWatchlist = false;
|
|
}
|
|
}
|
|
|
|
Widget _buildLibraryMatchTile(MediaItem match, int index) {
|
|
return FocusableListTile(
|
|
focusNode: _libraryMatchFocusNodes[index],
|
|
leading: BackendBadge(backend: match.backend, size: 24),
|
|
// Plex matches carry their library title; Jellyfin's search-based
|
|
// lookup doesn't, so fall back to the server name alone.
|
|
title: Text(match.libraryTitle ?? match.serverName ?? match.backend.name),
|
|
subtitle: match.libraryTitle != null && match.serverName != null ? Text(match.serverName!) : null,
|
|
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 = widget.item;
|
|
final parts = <String>[
|
|
if (item.year != null) '${item.year}',
|
|
if (item.runtimeMinutes != null) formatDurationTextual(Duration(minutes: item.runtimeMinutes!).inMilliseconds),
|
|
if (item.certification != null && item.certification!.isNotEmpty) item.certification!,
|
|
];
|
|
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,
|
|
};
|
|
|
|
/// Score (with a compact vote count), airing status, episode count, and
|
|
/// network/studio — all data that rode along on the row fetch.
|
|
Widget? _buildStatsChips(ThemeData theme) {
|
|
final item = widget.item;
|
|
String? score;
|
|
if (item.rating != null) {
|
|
score = item.rating!.toStringAsFixed(1);
|
|
if (item.votes != null && item.votes! > 0) {
|
|
final compactVotes = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName);
|
|
score = '$score (${compactVotes.format(item.votes)})';
|
|
}
|
|
}
|
|
final chips = <Widget>[
|
|
if (score != null) StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: score),
|
|
if (item.airStatus != null) StatChip(label: _statusLabel(item.airStatus!)),
|
|
if (item.episodeCount != null) StatChip(label: t.explore.episodeCount(n: item.episodeCount!)),
|
|
if (item.network != null) StatChip(label: item.network!),
|
|
];
|
|
if (chips.isEmpty) return null;
|
|
return Wrap(spacing: 8, runSpacing: 8, children: chips);
|
|
}
|
|
|
|
/// 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(widget.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 || _hasActions ? _focusSectionAboveCast : null,
|
|
onNavigateDown: (_related?.isNotEmpty ?? false) ? _requestRelatedFocus : null,
|
|
debugLabel: 'catalog_cast_row',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// "More like this" from the item's own source, rendered through the
|
|
/// standard shelf so cards, long-press menus, and taps behave exactly like
|
|
/// the Explore rows (tap opens another catalog detail screen).
|
|
Widget _buildRelatedSection(List<CatalogItem> related) {
|
|
return HubSection(
|
|
key: _relatedSectionKey,
|
|
hub: MediaHub(
|
|
id: 'catalog-related:${widget.item.source.name}:${widget.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 = widget.item;
|
|
final theme = Theme.of(context);
|
|
final onWatchlist = _isOnWatchlist;
|
|
|
|
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: [
|
|
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 (_watchlistSource != null || _requestSource != null)
|
|
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: onWatchlist == null
|
|
? null
|
|
: () => unawaited(_toggleWatchlist()),
|
|
),
|
|
if (_requestSource case final SeerrCatalogSource seerr)
|
|
FocusableAction(
|
|
icon: Symbols.download_rounded,
|
|
tooltip: t.seerr.request,
|
|
onPressed: () => unawaited(
|
|
showSeerrRequestSheet(
|
|
hostContext,
|
|
source: seerr,
|
|
kind: item.kind,
|
|
tmdbId: item.ids.tmdb!,
|
|
title: item.title,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips],
|
|
const SizedBox(height: 24),
|
|
if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge),
|
|
const SizedBox(height: 24),
|
|
_buildLibrarySection(theme),
|
|
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
|
|
const SizedBox(height: 28),
|
|
_buildCastSection(theme, cast),
|
|
],
|
|
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,
|
|
)!,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|