Removing a tag containing a space or symbol silently did nothing: the removed values were pre-encoded and the transport encoded them again, so Plex matched nothing while the UI showed the tag gone. Folder listings and the shared list wrapper also converted every transport failure (401, 500, timeout) into a successful empty list, so an unreachable server rendered 'no folders'/'no results' with no retry while Jellyfin surfaced an error. Failures now propagate to the existing error paths, and the match screen gained one.
276 lines
9.3 KiB
Dart
276 lines
9.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../media/ids.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../focus/focusable_button.dart';
|
|
import '../focus/focusable_text_field.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../media/media_item.dart';
|
|
import '../mixins/controller_disposer_mixin.dart';
|
|
import '../models/plex/plex_match_result.dart';
|
|
import '../services/plex_client.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/provider_extensions.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import '../widgets/app_icon.dart';
|
|
import '../widgets/focusable_list_tile.dart';
|
|
import '../widgets/focused_scroll_scaffold.dart';
|
|
import '../widgets/pill_input_decoration.dart';
|
|
import '../widgets/optimized_media_image.dart';
|
|
import '../widgets/loading_indicator_box.dart';
|
|
|
|
/// Fix / apply a metadata match on a movie or show. Plex-only feature; the
|
|
/// underlying [PlexClient.findMatches]/[PlexClient.applyMatch] endpoints
|
|
/// are not part of the neutral [MediaServerClient] surface.
|
|
///
|
|
/// On success, pops `true` so the caller can refresh its view.
|
|
class PlexMatchScreen extends StatefulWidget {
|
|
final MediaItem metadata;
|
|
|
|
const PlexMatchScreen({super.key, required this.metadata});
|
|
|
|
@override
|
|
State<PlexMatchScreen> createState() => _PlexMatchScreenState();
|
|
}
|
|
|
|
class _PlexMatchScreenState extends State<PlexMatchScreen> with ControllerDisposerMixin {
|
|
late final PlexClient _client;
|
|
late final TextEditingController _nameController = createTextEditingController(text: widget.metadata.title);
|
|
late final TextEditingController _yearController = createTextEditingController(
|
|
text: widget.metadata.year?.toString() ?? '',
|
|
);
|
|
final _nameFocus = FocusNode();
|
|
final _yearFocus = FocusNode();
|
|
final _searchFocus = FocusNode();
|
|
|
|
List<PlexMatchResult>? _results;
|
|
bool _isSearching = false;
|
|
String? _applyingGuid;
|
|
|
|
bool get _isApplying => _applyingGuid != null;
|
|
|
|
/// Treat the item as unmatched if its [MediaItem.guid] is missing or
|
|
/// references the Plex no-agent marker.
|
|
bool get _isUnmatched {
|
|
final guid = widget.metadata.guid;
|
|
return guid == null || guid.isEmpty || guid.contains('agents.none://');
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_client = context.getPlexClientWithFallback(serverIdOrNull(widget.metadata.serverId));
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
if (InputModeTracker.isKeyboardMode(context)) {
|
|
_nameFocus.requestFocus();
|
|
}
|
|
_search();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameFocus.dispose();
|
|
_yearFocus.dispose();
|
|
_searchFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _search() async {
|
|
if (_isSearching) return;
|
|
setState(() => _isSearching = true);
|
|
List<PlexMatchResult>? results;
|
|
try {
|
|
results = await _client.findMatches(
|
|
widget.metadata.id,
|
|
title: _nameController.text.trim(),
|
|
year: _yearController.text.trim(),
|
|
);
|
|
} catch (e, st) {
|
|
// [PlexClient._wrapListApiCall] rethrows — catch here so the screen
|
|
// doesn't get stuck on the loading spinner.
|
|
appLogger.e('Failed to search for matches', error: e, stackTrace: st);
|
|
if (mounted) showErrorSnackBar(context, t.errors.searchFailed(error: e));
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
// On failure keep previous results; fall back to the empty state so the
|
|
// spinner never sticks on a first-search failure.
|
|
_results = results ?? _results ?? const [];
|
|
_isSearching = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _applyMatch(PlexMatchResult result) async {
|
|
if (_isApplying) return;
|
|
setState(() => _applyingGuid = result.guid);
|
|
bool success = false;
|
|
try {
|
|
success = await _client.applyMatch(
|
|
widget.metadata.id,
|
|
guid: result.guid,
|
|
name: result.name,
|
|
year: result.year?.toString(),
|
|
);
|
|
} catch (e, st) {
|
|
// [PlexClient._wrapBoolApiCall] rethrows — catch here so
|
|
// `_applyingGuid` doesn't get stuck non-null.
|
|
appLogger.e('Failed to apply match', error: e, stackTrace: st);
|
|
}
|
|
if (!mounted) return;
|
|
setState(() => _applyingGuid = null);
|
|
if (success) {
|
|
showSuccessSnackBar(context, t.matchScreen.matchApplied);
|
|
Navigator.pop(context, true);
|
|
} else {
|
|
showErrorSnackBar(context, t.matchScreen.matchFailed);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FocusedScrollScaffold(
|
|
title: Text(_isUnmatched ? t.matchScreen.match : t.matchScreen.fixMatch),
|
|
slivers: [
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
sliver: SliverToBoxAdapter(child: _buildSearchForm(context)),
|
|
),
|
|
if (_isSearching || _results == null)
|
|
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
|
|
else if (_results!.isEmpty)
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: Center(
|
|
child: Text(
|
|
t.matchScreen.noMatchesFound,
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
SliverPadding(
|
|
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 24),
|
|
sliver: SliverList.builder(
|
|
itemCount: _results!.length,
|
|
itemBuilder: (context, index) => _buildResultTile(_results![index]),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchForm(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: .stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: FocusableTextField(
|
|
controller: _nameController,
|
|
focusNode: _nameFocus,
|
|
decoration: pillInputDecoration(
|
|
context,
|
|
hintText: t.matchScreen.titleHint,
|
|
prefixIcon: const AppIcon(Symbols.search_rounded),
|
|
),
|
|
textInputAction: TextInputAction.next,
|
|
onSubmitted: (_) => _yearFocus.requestFocus(),
|
|
onNavigateRight: _yearFocus.requestFocus,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
SizedBox(
|
|
width: 140,
|
|
child: FocusableTextField(
|
|
controller: _yearController,
|
|
focusNode: _yearFocus,
|
|
decoration: pillInputDecoration(context, hintText: t.matchScreen.yearHint),
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(4)],
|
|
textInputAction: TextInputAction.search,
|
|
onSubmitted: (_) => _search(),
|
|
onNavigateLeft: _nameFocus.requestFocus,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
FocusableButton(
|
|
focusNode: _searchFocus,
|
|
useBackgroundFocus: true,
|
|
onPressed: _isSearching ? null : _search,
|
|
child: FilledButton.icon(
|
|
onPressed: _isSearching ? null : _search,
|
|
icon: const AppIcon(Symbols.search_rounded),
|
|
label: Text(t.matchScreen.search),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
|
shape: const StadiumBorder(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildResultTile(PlexMatchResult result) {
|
|
final isApplyingThis = _isApplying && _applyingGuid == result.guid;
|
|
final yearLabel = result.year?.toString() ?? '';
|
|
final titleText = yearLabel.isEmpty ? result.name : '${result.name} (${result.year})';
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4),
|
|
child: FocusableListTile(
|
|
enabled: !_isApplying,
|
|
onTap: () => _applyMatch(result),
|
|
leading: SizedBox(
|
|
width: 48,
|
|
height: 72,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: OptimizedMediaImage(
|
|
client: _client,
|
|
imagePath: result.thumb,
|
|
fit: BoxFit.cover,
|
|
fallbackIcon: Symbols.movie_rounded,
|
|
),
|
|
),
|
|
),
|
|
title: Text(titleText, style: const TextStyle(fontWeight: .w600)),
|
|
subtitle: result.summary != null && result.summary!.isNotEmpty
|
|
? Text(result.summary!, maxLines: 2, overflow: .ellipsis)
|
|
: null,
|
|
trailing: isApplyingThis
|
|
? const LoadingIndicatorBox(size: 24)
|
|
: result.score != null
|
|
? _ScoreChip(score: result.score!)
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ScoreChip extends StatelessWidget {
|
|
final int score;
|
|
const _ScoreChip({required this.score});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(100)),
|
|
child: Text(
|
|
'$score',
|
|
style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: .w600),
|
|
),
|
|
);
|
|
}
|
|
}
|