feat(metadata): add Jellyfin edit support
This commit is contained in:
@@ -1,206 +0,0 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/file_picker_service.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../widgets/optimized_media_image.dart';
|
||||
import 'loading_indicator_box.dart';
|
||||
|
||||
class ArtworkPickerDialog extends StatefulWidget {
|
||||
final PlexClient client;
|
||||
final String ratingKey;
|
||||
final String element; // "posters" or "arts"
|
||||
|
||||
const ArtworkPickerDialog({super.key, required this.client, required this.ratingKey, required this.element});
|
||||
|
||||
@override
|
||||
State<ArtworkPickerDialog> createState() => _ArtworkPickerDialogState();
|
||||
}
|
||||
|
||||
class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
|
||||
List<Map<String, dynamic>>? _artworkList;
|
||||
bool _isLoading = true;
|
||||
bool _isApplying = false;
|
||||
|
||||
({int crossAxisCount, double aspectRatio, String title}) get _elementConfig {
|
||||
return switch (widget.element) {
|
||||
'posters' => (crossAxisCount: 3, aspectRatio: 2.0 / 3.0, title: t.metadataEdit.selectPoster),
|
||||
'arts' => (crossAxisCount: 2, aspectRatio: 16.0 / 9.0, title: t.metadataEdit.selectBackground),
|
||||
'clearLogos' => (crossAxisCount: 2, aspectRatio: 2.5 / 1.0, title: t.metadataEdit.selectLogo),
|
||||
'squareArts' => (crossAxisCount: 3, aspectRatio: 1.0, title: t.metadataEdit.selectSquareArt),
|
||||
_ => (crossAxisCount: 3, aspectRatio: 2.0 / 3.0, title: t.metadataEdit.selectPoster),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadArtwork();
|
||||
}
|
||||
|
||||
Future<void> _loadArtwork() async {
|
||||
final artwork = await widget.client.getAvailableArtwork(widget.ratingKey, widget.element);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_artworkList = artwork;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectArtwork(Map<String, dynamic> artwork) async {
|
||||
// Use ratingKey (the artwork provider identifier) rather than key (a
|
||||
// file-serving path that is already percent-encoded). Passing key through
|
||||
// Dio's query-parameter encoding double-encodes it, causing Plex to
|
||||
// silently ignore the selection despite returning 200.
|
||||
final url = artwork['ratingKey'] as String? ?? artwork['key'] as String?;
|
||||
if (url == null || _isApplying) return;
|
||||
await _runArtworkUpdate(() => widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url));
|
||||
}
|
||||
|
||||
Future<void> _addFromUrl() async {
|
||||
final url = await showTextInputDialog(
|
||||
context,
|
||||
title: t.metadataEdit.fromUrl,
|
||||
labelText: t.metadataEdit.imageUrl,
|
||||
hintText: t.metadataEdit.enterImageUrl,
|
||||
);
|
||||
|
||||
if (url == null || url.isEmpty || !mounted) return;
|
||||
await _runArtworkUpdate(() => widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url));
|
||||
}
|
||||
|
||||
Future<void> _uploadFile() async {
|
||||
final result = await FilePickerService.instance.pickFiles(type: FileType.image, withData: true);
|
||||
|
||||
if (result == null || result.files.isEmpty || !mounted) return;
|
||||
|
||||
final bytes = result.files.first.bytes;
|
||||
if (bytes == null) return;
|
||||
await _runArtworkUpdate(() => widget.client.uploadArtwork(widget.ratingKey, widget.element, bytes));
|
||||
}
|
||||
|
||||
/// Runs an artwork update API call with shared loading-state and
|
||||
/// error-handling. The underlying client throws on HTTP errors (see
|
||||
/// [PlexClient] `_wrapBoolApiCall`), so we must catch here or `_isApplying`
|
||||
/// gets stuck `true` and the user sees an infinite spinner.
|
||||
Future<void> _runArtworkUpdate(Future<bool> Function() action) async {
|
||||
if (_isApplying) return;
|
||||
setState(() => _isApplying = true);
|
||||
bool success = false;
|
||||
try {
|
||||
success = await action();
|
||||
} catch (e, st) {
|
||||
appLogger.e('Artwork update failed', error: e, stackTrace: st);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _isApplying = false);
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.metadataEdit.artworkUpdated);
|
||||
Navigator.pop(context, true);
|
||||
} else {
|
||||
showErrorSnackBar(context, t.metadataEdit.artworkUpdateFailed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(_elementConfig.title),
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
height: 400,
|
||||
child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(),
|
||||
),
|
||||
actions: [
|
||||
if (_isApplying) const Padding(padding: EdgeInsets.all(8), child: LoadingIndicatorBox(size: 24)),
|
||||
FocusableButton(
|
||||
onPressed: _addFromUrl,
|
||||
child: TextButton.icon(
|
||||
onPressed: _addFromUrl,
|
||||
icon: const AppIcon(Symbols.link_rounded, size: 18),
|
||||
label: Text(t.metadataEdit.fromUrl),
|
||||
),
|
||||
),
|
||||
FocusableButton(
|
||||
onPressed: _uploadFile,
|
||||
child: TextButton.icon(
|
||||
onPressed: _uploadFile,
|
||||
icon: const AppIcon(Symbols.upload_rounded, size: 18),
|
||||
label: Text(t.metadataEdit.uploadFile),
|
||||
),
|
||||
),
|
||||
FocusableButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtworkContent() {
|
||||
if (_artworkList == null || _artworkList!.isEmpty) {
|
||||
return Center(child: Text(t.metadataEdit.noArtworkAvailable));
|
||||
}
|
||||
return _buildGrid();
|
||||
}
|
||||
|
||||
Widget _buildGrid() {
|
||||
final config = _elementConfig;
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: config.crossAxisCount,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: config.aspectRatio,
|
||||
),
|
||||
itemCount: _artworkList!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final artwork = _artworkList![index];
|
||||
final thumbUrl = artwork['thumb'] as String?;
|
||||
final isSelected = artwork['selected'] == true;
|
||||
|
||||
return FocusableWrapper(
|
||||
borderRadius: 8,
|
||||
onSelect: () => _selectArtwork(artwork),
|
||||
child: GestureDetector(
|
||||
onTap: () => _selectArtwork(artwork),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: OptimizedMediaImage(client: widget.client, imagePath: thumbUrl, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle),
|
||||
child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../metadata_edit/metadata_edit_adapters.dart';
|
||||
import '../media/media_version.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../services/plex_client.dart';
|
||||
@@ -43,7 +44,7 @@ import '../focus/focusable_text_field.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../screens/plex_match_screen.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/plex_metadata_edit_screen.dart';
|
||||
import '../screens/metadata_edit_screen.dart';
|
||||
import '../utils/smart_deletion_handler.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
@@ -180,8 +181,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
};
|
||||
|
||||
/// Get the correct PlexClient for this item's server. Throws on
|
||||
/// non-Plex backends — Plex-only flows (Add to Collection, metadata
|
||||
/// edit, etc.) call this directly. Backend-neutral flows must use
|
||||
/// non-Plex backends — Plex-only flows (Add to Collection, match,
|
||||
/// unmatch, etc.) call this directly. Backend-neutral flows must use
|
||||
/// [_getMediaClientForItem] instead.
|
||||
PlexClient _getClientForItem() => context.getPlexClientWithFallback(_itemServerId);
|
||||
|
||||
@@ -203,7 +204,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final isCollection = mediaKind == MediaKind.collection;
|
||||
|
||||
// Backend-aware gate: a few menu items remain Plex-only because the
|
||||
// server-side feature has no Jellyfin equivalent (metadata edit, match).
|
||||
// server-side feature has no Jellyfin equivalent (match/unmatch).
|
||||
// No fallback: items without a backend marker show only neutral actions —
|
||||
// dispatching a Plex-only action against an unknown-backend item could
|
||||
// crash or hit the wrong server.
|
||||
@@ -237,6 +238,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(_itemServerId!) : null;
|
||||
final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false;
|
||||
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
|
||||
final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind);
|
||||
|
||||
final menuActions = <_MenuAction>[];
|
||||
|
||||
@@ -317,15 +319,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate));
|
||||
}
|
||||
|
||||
// Edit Metadata (for movies, shows, seasons, and episodes) — admin only
|
||||
// Plex-only: opens PlexMetadataEditScreen which talks to Plex's
|
||||
// `/library/metadata/{id}` PUT API; Jellyfin has no equivalent in v1.
|
||||
if (isPlex &&
|
||||
isAdmin &&
|
||||
(mediaKind == MediaKind.movie ||
|
||||
mediaKind == MediaKind.show ||
|
||||
mediaKind == MediaKind.season ||
|
||||
mediaKind == MediaKind.episode)) {
|
||||
// Edit Metadata — admin-only and backend-capability gated.
|
||||
if (canEditMetadata) {
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'edit_metadata', icon: Symbols.edit_rounded, label: t.metadataEdit.editMetadata),
|
||||
);
|
||||
@@ -655,10 +650,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
didNavigate = true;
|
||||
if (context.mounted) {
|
||||
final item = mediaItem!;
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => PlexMetadataEditScreen(metadata: item)),
|
||||
);
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: item)));
|
||||
_notifyRefresh(item.id);
|
||||
}
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user