Non-English users saw English text in a dozen places and blank labels in sixteen more. The English came from sites that produce their copy away from the widget that renders it, which is what the structural hardcoded-string check cannot see: picture-in-picture refused with a raw literal instead of the pipErrors.notSupported key that already existed; the two Jellyfin/Emby auth throws missing display: rendered their developer message on the add-server form; ServerParsingException.toString() fed its English into the localized "Failed to load servers" wrapper; Watch Together interpolated the whole PeerError, so a failed create read "Failed to create session: PeerError(PeerErrorType.timeout): Timed out creating session" and join printed its prefix twice; the hub and playlist continuation footers rendered exception.toString(); shader rows showed an English title over an already translated subtitle; the player queue fell back to the raw Dart enum name; a Plex home user with no title showed "Unknown"; a failed player start showed "Exception: Failed to initialize player"; and the tvOS top-shelf header was hardcoded in an extension that has no Flutter engine. The blanks came from three recent features that added English keys without translations. clean_translations.py filled all 21 siblings with empty strings, so the Android TV resolution switch, every Jellyfin/Emby recording-rule field, the demuxer row, the Companion Remote address caption and the Seerr blocklist pill rendered nothing at all. Two fixes are structural rather than key swaps. ContinuationStatusSliver now takes an errorContext and calls a new non-logging localizedLoadErrorText, so no future throw can leak through it. lib/mpv stays free of user-facing copy: it raises a PlayerInitializationException sentinel and a PlayerError.playerInitFailed cause tag that the player screen resolves to localized text. The tvOS section title travels in the shelf payload, additively, so an older cache still renders.
180 lines
5.9 KiB
Dart
180 lines
5.9 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};
|
|
|
|
/// The native player core failed to come up. Carries no message: the UI
|
|
/// owns the wording so `lib/mpv` stays free of user-facing copy.
|
|
class PlayerInitializationException implements Exception {
|
|
const PlayerInitializationException();
|
|
|
|
@override
|
|
String toString() => 'PlayerInitializationException';
|
|
}
|
|
|
|
/// [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';
|
|
|
|
/// Cause tag for a failed native core start. The accompanying [message] is
|
|
/// the raw thrown error, kept for diagnostics only; the UI picks localized
|
|
/// copy from this tag instead of parsing it.
|
|
static const String playerInitFailed = 'player-init-failed';
|
|
|
|
/// 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 { 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;
|
|
}
|