Files
plezy/lib/screens/plex_match_screen.dart

266 lines
8.8 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);
final results = await _client.findMatches(
widget.metadata.id,
title: _nameController.text.trim(),
year: _yearController.text.trim(),
);
if (!mounted) return;
setState(() {
_results = results;
_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),
),
);
}
}