Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions lib/screens/media_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import '../providers/download_provider.dart';
import '../providers/offline_watch_provider.dart';
import '../providers/watch_state_store.dart';
import '../services/catalog/catalog_source.dart';
import '../services/catalog/seerr_catalog_source.dart';
import '../utils/app_logger.dart';
import '../utils/formatters.dart';
import '../utils/scroll_utils.dart';
Expand Down Expand Up @@ -92,6 +93,7 @@ import '../widgets/hub_section.dart';
import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/rasterized_gradient.dart';
import '../widgets/seerr_request_sheet.dart';
import '../widgets/tv_browse_rail.dart';
import '../widgets/tv_spotlight_background.dart';

Expand Down Expand Up @@ -2635,6 +2637,46 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
);
}

/// Empty episode list, upgraded with a Seerr Request action when a
/// connected Seerr may request TV. [season] scopes the request to that
/// season; null requests the whole show. Falls back to the plain empty
/// state offline, without permission, or on Specials (which the request
/// sheet drops).
Widget _sectionNoEpisodes(BuildContext context, MediaItem? season) {
final seerr = widget.isOffline ? null : Provider.of<CatalogSourcesProvider?>(context, listen: false)?.seerrSource;
final requestable =
seerr != null && seerr.canRequest(MediaKind.show) && !(season != null && isSpecialSeasonNumber(season.index));
if (!requestable) return _sectionEmpty(context, t.messages.noEpisodesFoundGeneral);
return Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
children: [
Text(
t.messages.noEpisodesFoundGeneral,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey),
),
const SizedBox(height: 16),
FilledButton.tonalIcon(
onPressed: () => unawaited(_requestMissingEpisodes(seerr, season)),
icon: const AppIcon(Symbols.download_rounded, fill: 1),
label: Text(t.seerr.request),
),
],
),
),
);
}

Future<void> _requestMissingEpisodes(SeerrCatalogSource seerr, MediaItem? season) async {
final client = _getMediaClientForMetadata(context);
if (client == null) {
showErrorSnackBar(context, t.seerr.requestsLoadFailed);
return;
}
await showSeerrRequestSheetForLibraryItem(context, source: seerr, client: client, item: season ?? _metadata);
}

/// Retryable error for a section whose fetch threw (vs. [_sectionEmpty], which
/// means a successful-but-empty result). Reuses the app-wide [ErrorStateWidget]
/// so the Retry button is dpad-focusable.
Expand Down Expand Up @@ -3304,7 +3346,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
else if (_episodes.isNotEmpty)
_buildEpisodesList()
else
_sectionEmpty(context, t.messages.noEpisodesFoundGeneral),
_sectionNoEpisodes(context, _seasons[_selectedSeasonIndex]),
],
const SizedBox(height: 24),
] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[
Expand All @@ -3318,7 +3360,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
else if (_episodes.isNotEmpty)
_buildEpisodesList()
else
_sectionEmpty(context, t.messages.noEpisodesFoundGeneral),
_sectionNoEpisodes(context, metadata.isSeason ? metadata : null),
const SizedBox(height: 24),
],

Expand Down
76 changes: 76 additions & 0 deletions lib/widgets/media_context_menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';

import '../providers/watch_state_store.dart';
import '../media/episode_collection.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
Expand All @@ -16,6 +17,7 @@ import '../media/media_playlist.dart';
import '../media/media_server_client.dart';
import '../metadata_edit/metadata_edit_adapters.dart';
import '../media/media_version.dart';
import '../services/catalog/seerr_catalog_source.dart';
import '../services/plex_client.dart';
import '../services/media_list_playback_launcher.dart';
import '../services/music/music_playback_service.dart';
Expand All @@ -28,6 +30,7 @@ import '../utils/download_utils.dart';
import '../utils/quality_preset_labels.dart';
import '../utils/media_version_resolver.dart';
import '../utils/global_key_utils.dart';
import '../providers/catalog_sources_provider.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/offline_mode_provider.dart';
Expand Down Expand Up @@ -60,6 +63,7 @@ import 'pill_input_decoration.dart';
import '../widgets/focusable_list_tile.dart';
import '../widgets/overlay_sheet.dart';
import '../widgets/rating_bottom_sheet.dart';
import '../widgets/seerr_request_sheet.dart';
import '../i18n/strings.g.dart';

class _MenuAction {
Expand All @@ -81,6 +85,34 @@ bool isAdminActionAllowedForMediaItem({
return isOwnerOrAdmin && !blockedByPlexHomeRole;
}

/// Whether the menu should offer a Seerr "Request" action: Seerr must be
/// connected with request permission for [kind], and the item must be a
/// movie, show, or season.
///
/// Movies are gated on having no known local file. Plex inlines `Media[]`
/// in browse listings and both backends' detail fetches carry versions, but
/// Jellyfin's slim browse field set omits `MediaSources` (see
/// `_browseFields`), so its movie cards fail open — like shows, whose
/// season completeness no listing exposes; [SeerrRequestSheet] itself
/// reports when everything is already available. Seasons gate on
/// [leafCount] instead: only a regular season with no episodes on the
/// server is offered (the request sheet drops Specials).
bool isSeerrRequestVisible({
required SeerrCatalogSource? seerrSource,
required MediaKind? kind,
required List<MediaVersion>? mediaVersions,
required int? seasonNumber,
required int? leafCount,
}) {
if (seerrSource == null || kind == null) return false;
if (kind != MediaKind.movie && kind != MediaKind.show && kind != MediaKind.season) return false;
if (kind == MediaKind.movie && (mediaVersions?.isNotEmpty ?? false)) return false;
if (kind == MediaKind.season && (isSpecialSeasonNumber(seasonNumber) || (leafCount ?? 0) > 0)) {
return false;
}
return seerrSource.canRequest(kind);
}

/// A reusable wrapper widget that adds a context menu (long press / right click)
/// to any media item with appropriate actions based on the item type.
/// Caller-supplied entry appended to a [MediaContextMenu] (e.g. the
Expand Down Expand Up @@ -539,6 +571,22 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
}

// Request via Seerr — offered for movies, shows, and episode-less
// regular seasons when a connected Seerr instance grants request
// permission for this kind; see isSeerrRequestVisible for the
// placeholder-detection caveats. Hidden while the item's server is
// unreachable: resolving the TMDB id needs a live metadata fetch.
if (itemServerOnline &&
isSeerrRequestVisible(
seerrSource: Provider.of<CatalogSourcesProvider?>(context, listen: false)?.seerrSource,
kind: mediaKind,
mediaVersions: mediaItem.mediaVersions,
seasonNumber: mediaItem.index,
leafCount: mediaItem.leafCount,
)) {
menuActions.add(_MenuAction(value: 'request_seerr', icon: Symbols.download_rounded, label: t.seerr.request));
}

// Add to... (for episodes, movies, shows, and seasons). Plex-only —
// uses `buildMetadataUri` + `addToPlaylist` / `addToCollection`. The
// Jellyfin item-add API is different and not wired here yet.
Expand Down Expand Up @@ -757,6 +805,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
await _showFileInfo(context);
break;

case 'request_seerr':
await _handleRequestSeerr(context);
break;

case 'add_to':
await _showAddToSubmenu(context);
break;
Expand Down Expand Up @@ -994,6 +1046,30 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
}

/// Resolve the item's TMDB id and open the Seerr request sheet. Presented
/// from the menu's own context so a screen-level OverlaySheetHost is found
/// (see _showContextMenu).
Future<void> _handleRequestSeerr(BuildContext context) async {
// Re-read: Seerr can disconnect (session invalidated, or disconnected in
// Settings) between menu build and tap.
final seerrSource = Provider.of<CatalogSourcesProvider?>(context, listen: false)?.seerrSource;
if (seerrSource == null) {
showErrorSnackBar(context, t.seerr.requestsLoadFailed);
return;
}

final MediaServerClient client;
try {
client = _getMediaClientForItem();
} catch (_) {
showErrorSnackBar(context, t.seerr.requestsLoadFailed);
return;
}

if (!mounted) return;
await showSeerrRequestSheetForLibraryItem(this.context, source: seerrSource, client: client, item: _mediaItem!);
}

Future<bool> _handlePlayVersion(BuildContext context) async {
final item = _mediaItem!;
final itemServerId = serverIdOrNull(_itemServerId);
Expand Down
78 changes: 77 additions & 1 deletion lib/widgets/seerr_request_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';

import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../models/seerr/seerr_details.dart';
import '../models/seerr/seerr_media.dart';
import '../models/seerr/seerr_public_settings.dart';
Expand All @@ -15,6 +17,7 @@ import '../services/catalog/seerr_catalog_source.dart';
import '../services/seerr/seerr_constants.dart';
import '../services/seerr/seerr_exceptions.dart';
import '../utils/app_logger.dart';
import '../utils/dialogs.dart';
import '../utils/snackbar_helper.dart';
import 'app_icon.dart';
import 'app_menu.dart';
Expand All @@ -30,15 +33,78 @@ Future<void> showSeerrRequestSheet(
required MediaKind kind,
required int tmdbId,
required String title,
List<int> initialSeasons = const [],
}) {
return OverlaySheetController.showAdaptive<void>(
context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => SeerrRequestSheet(source: source, kind: kind, tmdbId: tmdbId, title: title),
builder: (_) =>
SeerrRequestSheet(source: source, kind: kind, tmdbId: tmdbId, title: title, initialSeasons: initialSeasons),
);
}

/// Resolve a library [item]'s TMDB id through its media server and open the
/// request sheet. Seasons request through their show, preselected in the
/// sheet. Shows a loading dialog while resolving and an error snackbar when
/// the item can't be resolved to a TMDB id.
Future<void> showSeerrRequestSheetForLibraryItem(
BuildContext context, {
required SeerrCatalogSource source,
required MediaServerClient client,
required MediaItem item,
}) async {
// A season's show lands in a backend-dependent field: Plex children carry
// the show in `parentRatingKey` (parentId), while Jellyfin's seasons
// endpoint carries `SeriesId` (grandparentId) and may omit `ParentId`.
final isSeason = item.kind == MediaKind.season;
final lookupId = isSeason ? (item.parentId ?? item.grandparentId) : item.id;
if (lookupId == null) {
showErrorSnackBar(context, t.seerr.requestsLoadFailed);
return;
}

var loadingShown = false;
try {
showLoadingDialog(context);
loadingShown = true;

final externalIds = await client.fetchExternalIds(lookupId);

if (loadingShown && context.mounted) {
Navigator.pop(context);
loadingShown = false;
}

final tmdbId = externalIds.tmdb;
if (tmdbId == null) {
if (context.mounted) {
showErrorSnackBar(context, t.seerr.requestsLoadFailed);
}
return;
}

if (context.mounted) {
await showSeerrRequestSheet(
context,
source: source,
kind: isSeason ? MediaKind.show : item.kind,
tmdbId: tmdbId,
title: isSeason ? (item.parentTitle ?? item.grandparentTitle ?? item.displayTitle) : item.displayTitle,
initialSeasons: isSeason ? [?item.index] : const [],
);
}
} catch (e) {
appLogger.w('Seerr: library request resolution failed for ${item.id}', error: e);
if (loadingShown && context.mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
if (context.mounted) {
showErrorSnackBar(context, t.seerr.requestsLoadFailed);
}
}
}

/// The full Seerr request flow, mirroring the web UI: per-season selection
/// with availability states, a 4K toggle, and the advanced destination
/// pickers — every section gated by the same instance settings and user
Expand All @@ -49,12 +115,17 @@ class SeerrRequestSheet extends StatefulWidget {
final int tmdbId;
final String title;

/// TV only: seasons to preselect once loaded, where requestable (e.g. a
/// season card's Request action preselects that season).
final List<int> initialSeasons;

const SeerrRequestSheet({
super.key,
required this.source,
required this.kind,
required this.tmdbId,
required this.title,
this.initialSeasons = const [],
});

@override
Expand Down Expand Up @@ -147,6 +218,11 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
_seasons = seasons;
_allServers = servers;
_loading = false;
_selectedSeasons.addAll([
for (final season in seasons)
if (widget.initialSeasons.contains(season.seasonNumber) && !_seasonBlocked(season.seasonNumber))
season.seasonNumber,
]);
});
_selectDefaultServer();
} catch (e) {
Expand Down
Loading