ffmpeg's reconnect loop deliberately retries 503 without bound (#1520), so a server that keeps refusing the stream at open time left a silent black screen: ExoPlayer fell back to MPV, MPV reconnected forever, and no error ever reached the screen. A new open-phase watchdog arms on the first 503 seen before any frame renders and, after 20s without one, synthesizes a server-http-503 error that shows an actionable dialog. Mid-stream 503s and live TV keep their existing ride-out paths. close #1830
166 lines
5.3 KiB
Dart
166 lines
5.3 KiB
Dart
// ignore_for_file: invalid_annotation_target
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
|
|
part 'models.freezed.dart';
|
|
|
|
@freezed
|
|
sealed class BufferRange with _$BufferRange {
|
|
const factory BufferRange({required Duration start, required Duration end}) = _BufferRange;
|
|
}
|
|
|
|
final RegExp _httpStatusPattern = RegExp(r'\b(?:HTTP error |Response code: )(\d{3})\b');
|
|
|
|
/// Server statuses that end playback outright: nothing client-side recovers a
|
|
/// transcoding-limit rejection or a file the server cannot read. Everything
|
|
/// else is transient — notably 503, which the reconnect path retries
|
|
/// mid-stream (#1520) and the player screen's open-phase watchdog bounds at
|
|
/// open time instead of latching here (#1830).
|
|
const Set<int> fatalPlaybackHttpStatuses = {404, 500};
|
|
|
|
/// [cause] is an optional machine-readable tag (e.g. `server-http-500`),
|
|
/// letting the UI branch without parsing [message].
|
|
@Freezed(toStringOverride: false)
|
|
sealed class PlayerError with _$PlayerError {
|
|
const PlayerError._();
|
|
|
|
const factory PlayerError(String message, {String? cause}) = _PlayerError;
|
|
|
|
/// Cause tag for a server-side HTTP 500 — shared-user bandwidth or
|
|
/// transcoding limit rejection set by the server owner.
|
|
static const String serverHttp500 = 'server-http-500';
|
|
|
|
/// Cause tag for a server-side HTTP 404 on the media stream. The server
|
|
/// resolved the item but cannot read the file behind it (moved, deleted, or
|
|
/// on unavailable storage), so no retry or backend switch can recover it.
|
|
static const String serverHttp404 = 'server-http-404';
|
|
|
|
/// Cause tag for a persistent HTTP 503 on the media stream during the open
|
|
/// phase. Mid-stream 503 is transient — ffmpeg reconnects through server
|
|
/// restarts (#1520) — so this tag is never derived from a raw status: only
|
|
/// the player screen's open-phase watchdog synthesizes it, after a refused
|
|
/// open has produced no frame for the whole patience window (#1830).
|
|
static const String serverHttp503 = 'server-http-503';
|
|
|
|
/// HTTP status [logText] reports, or null when it names none.
|
|
///
|
|
/// A [PlayerError] carries no status field: mpv only ever tells us the
|
|
/// end-file reason. ffmpeg does log the status, one warn-level line ahead of
|
|
/// the error-level failure (`http: HTTP error 404 Not Found`), and media3's
|
|
/// exception chain stringifies it as `Response code: 404`, so scanning the
|
|
/// player's own log stream is the only way to recover it.
|
|
static int? httpStatusFromLog(String logText) {
|
|
final match = _httpStatusPattern.firstMatch(logText);
|
|
return match == null ? null : int.tryParse(match.group(1)!);
|
|
}
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
enum PlayerLogLevel { none, fatal, error, warn, info, verbose, debug, trace }
|
|
|
|
@freezed
|
|
sealed class AudioTrack with _$AudioTrack {
|
|
const AudioTrack._();
|
|
|
|
const factory AudioTrack({
|
|
required String id,
|
|
String? title,
|
|
String? language,
|
|
String? codec,
|
|
int? channels,
|
|
int? sampleRate,
|
|
int? bitrate,
|
|
@Default(false) bool isDefault,
|
|
@Default(false) bool isForced,
|
|
}) = _AudioTrack;
|
|
|
|
static const auto = AudioTrack(id: 'auto', title: 'Auto');
|
|
|
|
static const off = AudioTrack(id: 'no', title: 'Off');
|
|
|
|
int? get channelsCount => channels;
|
|
}
|
|
|
|
@freezed
|
|
sealed class SubtitleTrack with _$SubtitleTrack {
|
|
const SubtitleTrack._();
|
|
|
|
const factory SubtitleTrack({
|
|
required String id,
|
|
String? title,
|
|
String? language,
|
|
String? codec,
|
|
@Default(false) bool isDefault,
|
|
@Default(false) bool isForced,
|
|
@Default(false) bool isExternal,
|
|
@Default(false) bool isContainer,
|
|
String? uri,
|
|
}) = _SubtitleTrack;
|
|
|
|
factory SubtitleTrack.uri(
|
|
String uri, {
|
|
String? title,
|
|
String? language,
|
|
String? codec,
|
|
bool isDefault = false,
|
|
bool isForced = false,
|
|
bool isContainer = false,
|
|
}) => SubtitleTrack(
|
|
id: 'external:$uri',
|
|
title: title,
|
|
language: language,
|
|
codec: codec,
|
|
isDefault: isDefault,
|
|
isForced: isForced,
|
|
isExternal: true,
|
|
isContainer: isContainer,
|
|
uri: uri,
|
|
);
|
|
|
|
static const auto = SubtitleTrack(id: 'auto', title: 'Auto');
|
|
|
|
static const off = SubtitleTrack(id: 'no', title: 'Off');
|
|
}
|
|
|
|
@Freezed(toStringOverride: false)
|
|
sealed class Tracks with _$Tracks {
|
|
const Tracks._();
|
|
|
|
const factory Tracks({
|
|
@Default(<AudioTrack>[]) List<AudioTrack> audio,
|
|
@Default(<SubtitleTrack>[]) List<SubtitleTrack> subtitle,
|
|
}) = _Tracks;
|
|
|
|
@override
|
|
String toString() => 'Tracks(audio: ${audio.length}, subtitle: ${subtitle.length})';
|
|
}
|
|
|
|
@freezed
|
|
sealed class TrackSelection with _$TrackSelection {
|
|
const factory TrackSelection({AudioTrack? audio, SubtitleTrack? subtitle, SubtitleTrack? secondarySubtitle}) =
|
|
_TrackSelection;
|
|
}
|
|
|
|
@freezed
|
|
sealed class AudioDevice with _$AudioDevice {
|
|
const factory AudioDevice({required String name, @Default('') String description}) = _AudioDevice;
|
|
|
|
static const auto = AudioDevice(name: 'auto', description: 'Auto');
|
|
}
|
|
|
|
@Freezed(toStringOverride: false)
|
|
sealed class PlayerLog with _$PlayerLog {
|
|
const PlayerLog._();
|
|
|
|
const factory PlayerLog({required PlayerLogLevel level, required String prefix, required String text}) = _PlayerLog;
|
|
|
|
@override
|
|
String toString() => '[$prefix] ${level.name}: $text';
|
|
}
|
|
|
|
@freezed
|
|
sealed class Media with _$Media {
|
|
const factory Media(String uri, {Map<String, String>? headers, Duration? start}) = _Media;
|
|
}
|