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.
This commit is contained in:
edde746
2026-07-31 21:45:33 +02:00
parent 6f9edd3e93
commit 86c8011b72
35 changed files with 290 additions and 45 deletions
+39
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/utils/dialogs.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -111,6 +112,44 @@ void main() {
await expectLater(result, completion(isNull));
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('media-unreadable dialog names the server-side cause and cannot be dismissed by the barrier', (
tester,
) async {
final hostContext = await _pumpHost(tester);
final result = showMediaUnreadableDialog(hostContext);
await tester.pumpAndSettle();
expect(find.text(t.messages.mediaUnreadableTitle), findsOneWidget);
// The body has to say what a 404 on the stream actually means, because the
// only recovery is on the server (#1750).
expect(find.textContaining('HTTP 404'), findsOneWidget);
expect(find.textContaining('could not read'), findsOneWidget);
// Barrier taps must not strand the caller's future.
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
await tester.tap(find.text(t.common.close));
await tester.pumpAndSettle();
await expectLater(result, completes);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('server-limit dialog stays distinct from the media-unreadable one', (tester) async {
final hostContext = await _pumpHost(tester);
final result = showServerLimitDialog(hostContext);
await tester.pumpAndSettle();
expect(find.text(t.messages.serverLimitTitle), findsOneWidget);
expect(find.textContaining('HTTP 500'), findsOneWidget);
expect(find.text(t.messages.mediaUnreadableTitle), findsNothing);
await tester.tap(find.text(t.common.close));
await tester.pumpAndSettle();
await expectLater(result, completes);
});
}
Future<BuildContext> _pumpHost(WidgetTester tester) async {