Files
plezy/lib/utils/dialogs.dart
T
edde746 86c8011b72 fix(player): tell the user when the server cannot read the media file
A 404 on the media stream means the server resolved the item but could not
open the file behind it — moved, deleted, or on storage that went away.
Jellyfin maps the resulting FileNotFoundException to 404, and PlaybackInfo
never stats the file, so negotiation succeeds and only the stream request
fails. Playback then died with a snackbar reading "Failed to open
[REDACTED_URL]" before popping the route, which tells the user nothing and
leaves nothing useful in a bug report.

Generalize the HTTP-500 log probe into PlayerError.httpStatusFromLog and
latch every status in fatalPlaybackHttpStatuses. Each latches on its own so
the 503 that stream-lavf-o deliberately retries cannot mask the fatal status
behind it. A 404 now raises a dedicated modal naming the cause and the fix.

On Android a 404 previously failed the "Response code: 500" string test and
fell through to the ExoPlayer→MPV fallback, showing "switching to compatible
player" before failing again on the same request. Read the real status off
HttpDataSource.InvalidResponseCodeException instead and skip the fallback:
an HTTP status is not a codec problem.
2026-07-31 21:45:33 +02:00

435 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../focus/focusable_text_field.dart';
import '../focus/input_mode_tracker.dart';
import '../i18n/strings.g.dart';
import '../mixins/controller_disposer_mixin.dart';
import '../widgets/app_icon.dart';
import '../widgets/dialog_action_button.dart';
import '../widgets/focusable_list_tile.dart';
import 'focus_utils.dart';
const _buttonPadding = EdgeInsets.symmetric(horizontal: 18, vertical: 14);
const _buttonShape = StadiumBorder();
/// Shows a dialog on the nearest navigator instead of Flutter's default root
/// navigator. Use this for profile/session-owned modal routes so they are
/// disposed when the active profile session is replaced.
Future<T?> showScopedDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
bool barrierDismissible = true,
}) {
return showDialog<T>(
context: context,
builder: builder,
barrierDismissible: barrierDismissible,
useRootNavigator: false,
);
}
/// Shows a confirmation dialog with consistent button sizing and autofocus.
/// Returns true if user confirmed, false if cancelled.
Future<bool> showConfirmDialog(
BuildContext context, {
required String title,
required String message,
required String confirmText,
String? cancelText,
bool isDestructive = false,
}) async {
final confirmed = await showScopedDialog<bool>(
context: context,
builder: (dialogContext) {
final colorScheme = Theme.of(dialogContext).colorScheme;
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
DialogActionButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext, false),
label: cancelText ?? t.common.cancel,
style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
),
DialogActionButton(
onPressed: () => Navigator.pop(dialogContext, true),
label: confirmText,
isPrimary: true,
style: isDestructive
? FilledButton.styleFrom(
padding: _buttonPadding,
shape: _buttonShape,
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
)
: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
),
],
);
},
);
return confirmed ?? false;
}
/// Shows a non-dismissible loading-spinner dialog. Caller is responsible for
/// closing it via `Navigator.pop(context)` when the work completes.
void showLoadingDialog(BuildContext context) {
showScopedDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
}
/// Shows the server-side 500 modal (bandwidth/transcoding limit rejection).
Future<void> showServerLimitDialog(BuildContext context) async {
await showScopedDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(t.messages.serverLimitTitle),
content: Text(t.messages.serverLimitBody),
actions: [
DialogActionButton(
autofocus: true,
onPressed: () => Navigator.of(ctx).pop(),
label: t.common.close,
isPrimary: true,
style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
),
],
),
);
}
/// Shows the server-side 404 modal: the item exists but the server cannot read
/// the file behind it, so nothing client-side can recover the playback.
Future<void> showMediaUnreadableDialog(BuildContext context) async {
await showScopedDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(t.messages.mediaUnreadableTitle),
content: Text(t.messages.mediaUnreadableBody),
actions: [
DialogActionButton(
autofocus: true,
onPressed: () => Navigator.of(ctx).pop(),
label: t.common.close,
isPrimary: true,
style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
),
],
),
);
}
/// Shows a delete confirmation dialog.
/// Convenience wrapper around [showConfirmDialog] with destructive styling.
Future<bool> showDeleteConfirmation(
BuildContext context, {
required String title,
required String message,
String? confirmText,
}) {
return showConfirmDialog(
context,
title: title,
message: message,
confirmText: confirmText ?? t.common.delete,
isDestructive: true,
);
}
/// Shows a text input dialog and returns validated submitted text.
///
/// Returns `null` when the dialog is cancelled or dismissed. Validation errors
/// are shown in the field and keep the dialog open, so a non-null result always
/// represents an explicit, valid submission.
Future<String?> showTextInputDialog(
BuildContext context, {
required String title,
required String labelText,
String? hintText,
String? initialValue,
String? confirmText,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
String? Function(String)? validator,
bool allowEmpty = false,
bool multiline = false,
bool obscureText = false,
}) {
return showScopedDialog<String>(
context: context,
builder: (context) => _TextInputDialog(
title: title,
labelText: labelText,
hintText: hintText,
initialValue: initialValue,
confirmText: confirmText,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
validator: validator,
allowEmpty: allowEmpty,
multiline: multiline,
obscureText: obscureText,
),
);
}
class _TextInputDialog extends StatefulWidget {
final String title;
final String labelText;
final String? hintText;
final String? initialValue;
final String? confirmText;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final String? Function(String)? validator;
final bool allowEmpty;
final bool multiline;
final bool obscureText;
const _TextInputDialog({
required this.title,
required this.labelText,
required this.hintText,
this.initialValue,
this.confirmText,
this.keyboardType,
this.inputFormatters,
this.validator,
this.allowEmpty = false,
this.multiline = false,
this.obscureText = false,
}) : assert(!multiline || !obscureText, 'A text input dialog cannot be both multiline and obscure.');
@override
State<_TextInputDialog> createState() => _TextInputDialogState();
}
class _TextInputDialogState extends State<_TextInputDialog> with ControllerDisposerMixin {
late final TextEditingController _controller;
final _fieldFocusNode = FocusNode(debugLabel: 'TextInputField');
final _cancelFocusNode = FocusNode(debugLabel: 'TextInputCancel');
final _saveFocusNode = FocusNode(debugLabel: 'TextInputSave');
String? _errorText;
@override
void initState() {
super.initState();
_controller = createTextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_fieldFocusNode.dispose();
_cancelFocusNode.dispose();
_saveFocusNode.dispose();
super.dispose();
}
String? _validate(String text) {
if (text.isEmpty && !widget.allowEmpty) return t.addServer.required;
return widget.validator?.call(text);
}
void _submit() {
final text = _controller.text;
final errorText = _validate(text);
if (errorText != null) {
setState(() => _errorText = errorText);
return;
}
Navigator.pop(context, text);
}
void _handleChanged(String text) {
if (_errorText == null) return;
setState(() => _errorText = _validate(text));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: widget.multiline ? SizedBox(width: 400, child: textField) : textField,
actions: [
DialogActionButton(
focusNode: _cancelFocusNode,
onPressed: () => Navigator.pop(context),
onNavigateRight: _saveFocusNode.requestFocus,
label: t.common.cancel,
),
DialogActionButton(
onPressed: _submit,
label: widget.confirmText ?? t.common.save,
focusNode: _saveFocusNode,
onNavigateLeft: _cancelFocusNode.requestFocus,
),
],
);
}
Widget get textField {
return FocusableTextField(
controller: _controller,
focusNode: _fieldFocusNode,
autofocus: true,
tvTextInputPresentation: widget.multiline
? TvTextInputPresentation.flutterOverlay
: TvTextInputPresentation.automatic,
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText),
keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null),
inputFormatters: widget.inputFormatters,
textInputAction: widget.multiline ? TextInputAction.newline : TextInputAction.done,
obscureText: widget.obscureText,
maxLines: widget.multiline ? 8 : 1,
minLines: widget.multiline ? 3 : 1,
onChanged: _handleChanged,
onNavigateDown: _saveFocusNode.requestFocus,
onSubmitted: widget.multiline ? null : (_) => _saveFocusNode.requestFocus(),
);
}
}
/// Shows a simple option picker dialog with focusable items for TV/keyboard navigation.
/// Returns the selected value, or null if cancelled. Each option's [icon] may
/// be `null` to render a label-only row (useful when the choices are variants
/// of the same thing and a repeated icon would just be noise).
/// Optional persistent toggle rendered above the option rows. Its state is held
/// by the dialog (toggling does not pop), and [onChanged] mirrors the new value
/// out so the caller can read it once an option row is picked.
typedef OptionPickerToggle = ({String label, IconData? icon, bool value, ValueChanged<bool> onChanged});
Future<T?> showOptionPickerDialog<T>(
BuildContext context, {
required String title,
required List<({IconData? icon, String label, T value})> options,
Future<T?> Function(T value)? onBeforeClose,
OptionPickerToggle? toggle,
}) {
final focusFirstItem = InputModeTracker.isKeyboardMode(context, listen: false);
return showScopedDialog<T>(
context: context,
builder: (context) => _OptionPickerDialog<T>(
title: title,
options: options,
focusFirstItem: focusFirstItem,
onBeforeClose: onBeforeClose,
toggle: toggle,
),
);
}
class _OptionPickerDialog<T> extends StatefulWidget {
final String title;
final List<({IconData? icon, String label, T value})> options;
final bool focusFirstItem;
final Future<T?> Function(T value)? onBeforeClose;
final OptionPickerToggle? toggle;
const _OptionPickerDialog({
required this.title,
required this.options,
this.focusFirstItem = false,
this.onBeforeClose,
this.toggle,
});
@override
State<_OptionPickerDialog<T>> createState() => _OptionPickerDialogState<T>();
}
class _OptionPickerDialogState<T> extends State<_OptionPickerDialog<T>> {
late final FocusNode _initialFocusNode;
late bool _toggleValue;
@override
void initState() {
super.initState();
_initialFocusNode = FocusNode(debugLabel: 'OptionPickerInitialFocus');
_toggleValue = widget.toggle?.value ?? false;
if (widget.focusFirstItem) {
FocusUtils.requestFocusAfterBuild(this, _initialFocusNode);
}
}
@override
void dispose() {
_initialFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const rowPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 4);
const rowHorizontalTitleGap = 8.0;
const rowMinLeadingWidth = 24.0;
final toggle = widget.toggle;
void updateToggle(bool value) {
setState(() => _toggleValue = value);
toggle?.onChanged(value);
}
return SimpleDialog(
title: Text(widget.title),
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
constraints: const BoxConstraints(minWidth: 304),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
children: [
if (toggle != null)
MergeSemantics(
child: FocusableListTile(
title: Row(
children: [
if (toggle.icon != null) ...[
AppIcon(toggle.icon!, fill: 1, size: 24),
const SizedBox(width: rowHorizontalTitleGap),
],
Expanded(
child: Text(
toggle.label,
style: Theme.of(context).textTheme.bodyLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: rowHorizontalTitleGap),
ExcludeFocus(
child: Switch(value: _toggleValue, onChanged: updateToggle),
),
],
),
contentPadding: rowPadding,
onTap: () => updateToggle(!_toggleValue),
),
),
...List.generate(widget.options.length, (index) {
final option = widget.options[index];
final icon = option.icon;
return FocusableListTile(
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
leading: icon != null ? AppIcon(icon, fill: 1, size: 24) : null,
title: Text(option.label, style: Theme.of(context).textTheme.bodyLarge),
contentPadding: rowPadding,
horizontalTitleGap: rowHorizontalTitleGap,
minLeadingWidth: rowMinLeadingWidth,
onTap: () async {
if (widget.onBeforeClose != null) {
final result = await widget.onBeforeClose!(option.value);
if (context.mounted) Navigator.pop(context, result);
} else {
Navigator.pop(context, option.value);
}
},
);
}),
],
);
}
}