Wiedergabe: der alte Motor lief weiter, während der neue startete
Der Ton, der nach dem Schließen der App weiterlief, und die neue Wiedergabe, die stumm blieb, sind derselbe Fehler — und er ist meiner. `stop()` machte drei Netzwerkaufrufe, bevor es den Motor abschaltete: Fortschritt melden, Transcoder anhalten, Tuner freigeben. Beim Wechsel habe ich das in den Hintergrund gelegt, damit man nach dem Antippen nicht wartet. Damit lief der alte Motor die ganze Zeit weiter, und wenn er endlich abräumte, nahm er der bereits laufenden neuen Wiedergabe die Audiositzung weg. Bei Live-TV kam dazu, dass der Motor weiterspielte, während die App längst im Hintergrund war — Hintergrundton ist erlaubt, und niemand hatte ihn abgeschaltet. Jetzt ist beides getrennt. `silence()` legt den Motor sofort still, ohne Netzwerk und ohne Warten; `releaseOnServer()` meldet danach in Ruhe, dass Schluss ist. Beim Wechsel wird zuerst stillgelegt, dann gebaut — nie laufen zwei Motoren gleichzeitig. Dazu eine Grenze für ewiges Laden: ein Live-Strom kann sterben, ohne dass der Motor es merkt, und der Kreisel drehte sich dann bis zum nächsten Antippen. Nach vierzig Sekunden ohne Daten wird abgebrochen, der Motor stillgelegt und gesagt, was los ist — sonst bliebe bei Live-TV obendrein der Tuner belegt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c348ab0755
commit
c2ddc10871
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage": "en",
|
"sourceLanguage": "en",
|
||||||
"strings": {
|
"strings": {
|
||||||
|
"%@ GB": {
|
||||||
|
"comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
},
|
||||||
"%@ → %@": {
|
"%@ → %@": {
|
||||||
"comment": "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
|
"comment": "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
|
||||||
"isCommentAutoGenerated": true,
|
"isCommentAutoGenerated": true,
|
||||||
@@ -13,8 +17,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"%@ GB" : {
|
"1080p": {
|
||||||
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
"comment": "A label for a 1080p video.",
|
||||||
"isCommentAutoGenerated": true
|
"isCommentAutoGenerated": true
|
||||||
},
|
},
|
||||||
"4K": {
|
"4K": {
|
||||||
@@ -25,8 +29,8 @@
|
|||||||
"comment": "A label for a 720p video.",
|
"comment": "A label for a 720p video.",
|
||||||
"isCommentAutoGenerated": true
|
"isCommentAutoGenerated": true
|
||||||
},
|
},
|
||||||
"1080p" : {
|
"SD": {
|
||||||
"comment" : "A label for a 1080p video.",
|
"comment": "A label for a 720p movie.",
|
||||||
"isCommentAutoGenerated": true
|
"isCommentAutoGenerated": true
|
||||||
},
|
},
|
||||||
"action.apply": {
|
"action.apply": {
|
||||||
@@ -2561,6 +2565,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.stalled": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Es kommen keine Daten mehr. Der Strom ist abgerissen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No more data is arriving. The stream stopped."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.stop": {
|
"player.stop": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2577,6 +2597,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.subtitleTracks": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Untertitel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Subtitles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.subtitlesOff": {
|
"player.subtitlesOff": {
|
||||||
"comment": "Eintrag, der die Untertitel abschaltet",
|
"comment": "Eintrag, der die Untertitel abschaltet",
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
@@ -2595,23 +2632,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.subtitleTracks" : {
|
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Untertitel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Subtitles"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"player.tracks": {
|
"player.tracks": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2834,10 +2854,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SD" : {
|
|
||||||
"comment" : "A label for a 720p movie.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"search.hint": {
|
"search.hint": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
@@ -107,13 +107,17 @@ struct RootView: View {
|
|||||||
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
|
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
|
||||||
/// abgestimmt, bevor es hier ankommt.
|
/// abgestimmt, bevor es hier ankommt.
|
||||||
private func handOver(to request: PlaybackRequest?) async {
|
private func handOver(to request: PlaybackRequest?) async {
|
||||||
|
// Zuerst und ohne Warten: den alten Motor stilllegen. Das Aufräumen am
|
||||||
|
// Server darf dauern, das Abschalten nicht — läuft der alte Motor
|
||||||
|
// weiter, während der neue startet, streiten sich zwei um dieselbe
|
||||||
|
// Audiositzung. Zu hören war das als alter Ton, der weiterlief, und
|
||||||
|
// als neue Wiedergabe, die stumm blieb.
|
||||||
let previous = player
|
let previous = player
|
||||||
|
previous?.silence()
|
||||||
guard let request else {
|
|
||||||
player = nil
|
player = nil
|
||||||
if let previous { await previous.stop() }
|
if let previous { Task { await previous.releaseOnServer() } }
|
||||||
return
|
|
||||||
}
|
guard let request else { return }
|
||||||
|
|
||||||
let fresh = PlayerModel(
|
let fresh = PlayerModel(
|
||||||
request: request,
|
request: request,
|
||||||
@@ -129,7 +133,6 @@ struct RootView: View {
|
|||||||
fresh.onPictureInPictureRestore { playback.expand() }
|
fresh.onPictureInPictureRestore { playback.expand() }
|
||||||
|
|
||||||
player = fresh
|
player = fresh
|
||||||
if let previous { Task { await previous.stop() } }
|
|
||||||
await fresh.start()
|
await fresh.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ final class PlayerModel {
|
|||||||
/// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht
|
/// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht
|
||||||
/// mehr.
|
/// mehr.
|
||||||
private var programmeEndsAt: Date?
|
private var programmeEndsAt: Date?
|
||||||
|
/// Wacht darüber, dass aus „lädt" nicht „lädt für immer" wird.
|
||||||
|
@ObservationIgnored private var bufferWatchdog: Task<Void, Never>?
|
||||||
/// Marken, die bereits automatisch übersprungen wurden. Ohne dieses
|
/// Marken, die bereits automatisch übersprungen wurden. Ohne dieses
|
||||||
/// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen
|
/// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen
|
||||||
/// lassen — und man käme nie zurück.
|
/// lassen — und man käme nie zurück.
|
||||||
@@ -362,6 +364,29 @@ final class PlayerModel {
|
|||||||
|
|
||||||
// MARK: - Steuerung
|
// MARK: - Steuerung
|
||||||
|
|
||||||
|
/// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel.
|
||||||
|
///
|
||||||
|
/// Ein Live-Strom kann sterben, ohne dass der Motor es merkt — der Tuner
|
||||||
|
/// gibt auf, der Transcoder bricht ab, und der Player wartet auf Daten,
|
||||||
|
/// die nie kommen. Ohne diese Grenze dreht sich der Kreisel bis zum
|
||||||
|
/// nächsten Antippen, und niemand weiß, ob noch etwas kommt.
|
||||||
|
private func watchBuffering(_ isBuffering: Bool) {
|
||||||
|
bufferWatchdog?.cancel()
|
||||||
|
guard isBuffering else { return }
|
||||||
|
|
||||||
|
bufferWatchdog = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(40))
|
||||||
|
guard !Task.isCancelled, let self, self.isBuffering else { return }
|
||||||
|
|
||||||
|
diagnostics.note("Seit 40 s keine Daten — abgebrochen")
|
||||||
|
// Den Motor gleich stilllegen: er würde sonst weiter an einem
|
||||||
|
// toten Strom ziehen, und bei Live-TV bliebe der Tuner belegt.
|
||||||
|
silence()
|
||||||
|
phase = .failed(message: String(localized: "player.stalled"))
|
||||||
|
Task { await self.releaseOnServer() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Holt die laufende Sendung nach, falls die angezeigte vorbei ist.
|
/// Holt die laufende Sendung nach, falls die angezeigte vorbei ist.
|
||||||
///
|
///
|
||||||
/// Beim Einschalten reist der Name mit; ein Sender läuft aber weiter,
|
/// Beim Einschalten reist der Name mit; ein Sender läuft aber weiter,
|
||||||
@@ -426,10 +451,30 @@ final class PlayerModel {
|
|||||||
selectedSubtitleTrack = track
|
selectedSubtitleTrack = track
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() async {
|
/// Legt den Motor **sofort** still. Kein Netzwerk, kein Warten.
|
||||||
|
///
|
||||||
|
/// Getrennt vom Aufräumen am Server, und das ist der ganze Punkt: früher
|
||||||
|
/// stand der Motor erst nach drei Netzwerkaufrufen still. Wurde in der
|
||||||
|
/// Zwischenzeit etwas Neues gestartet, liefen zwei Motoren gleichzeitig,
|
||||||
|
/// und der alte hat der neuen Wiedergabe beim Abräumen die Audiositzung
|
||||||
|
/// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem
|
||||||
|
/// die App im Hintergrund war —, und das Neue blieb stumm.
|
||||||
|
func silence() {
|
||||||
eventTask?.cancel()
|
eventTask?.cancel()
|
||||||
reportTask?.cancel()
|
reportTask?.cancel()
|
||||||
|
|
||||||
|
engine?.teardown()
|
||||||
|
engine = nil
|
||||||
|
pictureInPicture = nil
|
||||||
|
pictureInPictureDelegate = nil
|
||||||
|
surface.playerLayer.player = nil
|
||||||
|
isPlaying = false
|
||||||
|
isBuffering = false
|
||||||
|
bufferWatchdog?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet dem Server, dass Schluss ist. Darf dauern.
|
||||||
|
func releaseOnServer() async {
|
||||||
// Letzte Meldung vor dem Verlassen: erst sie nimmt den Titel auf dem
|
// Letzte Meldung vor dem Verlassen: erst sie nimmt den Titel auf dem
|
||||||
// Server aus „wird gerade abgespielt" heraus.
|
// Server aus „wird gerade abgespielt" heraus.
|
||||||
await report(state: .stopped)
|
await report(state: .stopped)
|
||||||
@@ -446,12 +491,11 @@ final class PlayerModel {
|
|||||||
if let subscription = request.liveSubscriptionKey, let server {
|
if let subscription = request.liveSubscriptionKey, let server {
|
||||||
await server.endLiveSession(subscriptionKey: subscription)
|
await server.endLiveSession(subscriptionKey: subscription)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
engine?.teardown()
|
func stop() async {
|
||||||
engine = nil
|
silence()
|
||||||
pictureInPicture = nil
|
await releaseOnServer()
|
||||||
pictureInPictureDelegate = nil
|
|
||||||
surface.playerLayer.player = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fortschritt melden
|
// MARK: - Fortschritt melden
|
||||||
@@ -582,6 +626,7 @@ final class PlayerModel {
|
|||||||
case .stateChanged(let state):
|
case .stateChanged(let state):
|
||||||
isPlaying = state == .playing
|
isPlaying = state == .playing
|
||||||
isBuffering = state == .buffering || state == .loading
|
isBuffering = state == .buffering || state == .loading
|
||||||
|
watchBuffering(isBuffering)
|
||||||
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
|
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
|
||||||
// abzulesen, ob der Motor hängt oder die Leitung.
|
// abzulesen, ob der Motor hängt oder die Leitung.
|
||||||
diagnostics.note("Zustand: \(state)")
|
diagnostics.note("Zustand: \(state)")
|
||||||
|
|||||||
Reference in New Issue
Block a user