Absturz beim Abspielen, und die Annahme dahinter

Der Crashlog vom Gerät:

    _dispatch_assert_queue_fail
    _swift_task_checkIsolatedSwift
    MainActor.assumeIsolated
    VLCPlayerEngine.mediaPlayerStateChanged(_:)
    VLCKit input_thread_Events

Im Code stand als Begründung für assumeIsolated, VLCKit stelle seine
Rückrufe über die Hauptwarteschlange zu. Das ist falsch — es ruft den
Delegaten von seinem eigenen Input-Thread. Die App brach deshalb hart
ab, sobald VLC den ersten Zustandswechsel meldete, also unmittelbar
beim Drücken auf Play.

Alle drei Delegat-Methoden springen jetzt mit Task { @MainActor in }
sauber hinüber. Die Reihenfolge bleibt erhalten: die Aufgaben werden aus
demselben Thread nacheinander eingereiht.

Dieselbe Annahme steckte im AVPlayer-Motor in den KVO-Beobachtern —
KVO meldet auf dem Thread, der die Änderung ausgelöst hat, und AVPlayer
ändert seinen Zustand auch aus internen Threads. Ebenfalls umgestellt.
Die beiden Rückrufe mit ausdrücklichem queue: .main behalten
assumeIsolated: dort ist die Annahme belegt statt geraten, und der
Zeitbeobachter feuert viermal je Sekunde.

Dazu die Anmeldung: Plex führt den Rücksprung über forwardUrl nicht
zuverlässig aus. Auf dem iPad landet der Browser nach dem Anmelden auf
watch.plex.tv und bleibt stehen — der Token liegt bereit, aber die App
wartete auf einen Rückruf, der nie kam. Das Fenster wird jetzt geöffnet,
ohne darauf zu warten; parallel wird im Sekundentakt beim Server
nachgefragt. Der Rücksprung ist damit eine Annehmlichkeit, keine
Voraussetzung.

132 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 21:25:17 +02:00
co-authored by Claude Opus 5
parent d9ae4b8a1f
commit a2b3f901ed
6 changed files with 410 additions and 290 deletions
+238 -212
View File
@@ -471,6 +471,222 @@
} }
} }
}, },
"diagnostics.connection": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Verbindung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Connection"
}
}
}
},
"diagnostics.connectionUsed": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Verbindung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Connection"
}
}
}
},
"diagnostics.copy": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bericht kopieren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy report"
}
}
}
},
"diagnostics.copyHint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Enthält keinen Token. Kannst du unbesorgt weitergeben."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Contains no token. Safe to share."
}
}
}
},
"diagnostics.lastAttempt": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Letzter Abspielversuch"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Last playback attempt"
}
}
}
},
"diagnostics.method": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Weg"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Method"
}
}
}
},
"diagnostics.noServer": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Keine Serververbindung."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No server connection."
}
}
}
},
"diagnostics.nothingYet": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Noch nichts abgespielt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing played yet."
}
}
}
},
"diagnostics.relayWarning": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Via the Plex relay. Heavily throttled — large files barely start over it."
}
}
}
},
"diagnostics.source": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Quelle (ohne Token)"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Source (token removed)"
}
}
}
},
"diagnostics.steps": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ablauf"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Timeline"
}
}
}
},
"diagnostics.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Diagnose"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Diagnostics"
}
}
}
},
"diagnostics.title.label": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Titel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Title"
}
}
}
},
"downloads.active": { "downloads.active": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -2117,6 +2333,24 @@
} }
} }
}, },
"settings.build": {
"comment": "Zeitpunkt, zu dem der laufende Build entstanden ist",
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Build"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Build"
}
}
}
},
"settings.connection": { "settings.connection": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -3180,227 +3414,19 @@
} }
} }
}, },
"settings.build": { "signIn.timedOut": {
"comment": "Zeitpunkt, zu dem der laufende Build entstanden ist", "comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen",
"localizations": { "localizations": {
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Build" "value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal."
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Build" "value": "Sign-in wasn't completed. Please try again."
}
}
}
},
"diagnostics.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Diagnose"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Diagnostics"
}
}
}
},
"diagnostics.connection": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Verbindung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Connection"
}
}
}
},
"diagnostics.noServer": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Keine Serververbindung."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No server connection."
}
}
}
},
"diagnostics.relayWarning": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Via the Plex relay. Heavily throttled — large files barely start over it."
}
}
}
},
"diagnostics.lastAttempt": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Letzter Abspielversuch"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Last playback attempt"
}
}
}
},
"diagnostics.nothingYet": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Noch nichts abgespielt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing played yet."
}
}
}
},
"diagnostics.title.label": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Titel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Title"
}
}
}
},
"diagnostics.method": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Weg"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Method"
}
}
}
},
"diagnostics.connectionUsed": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Verbindung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Connection"
}
}
}
},
"diagnostics.source": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Quelle (ohne Token)"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Source (token removed)"
}
}
}
},
"diagnostics.steps": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ablauf"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Timeline"
}
}
}
},
"diagnostics.copy": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bericht kopieren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy report"
}
}
}
},
"diagnostics.copyHint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Enthält keinen Token. Kannst du unbesorgt weitergeben."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Contains no token. Safe to share."
} }
} }
} }
+54 -30
View File
@@ -150,14 +150,24 @@ final class FlixySession {
/// Öffnet die Anmeldeseite von Plex. /// Öffnet die Anmeldeseite von Plex.
/// ///
/// Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem /// Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem
/// Plex einen Token herausgibt. Sichtbar ist er nicht mehr: Plex liefert /// Plex einen Token herausgibt. Sichtbar ist er nicht mehr.
/// seine eigene Anmeldemaske aus und schickt die App danach über ///
/// `forwardUrl` zurück. /// Das Fenster wird geöffnet, aber **nicht abgewartet**: Plex führt den
/// Rücksprung über `forwardUrl` nicht zuverlässig aus. Auf dem iPad landet
/// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen
/// — der Token liegt längst bereit, aber die App wartete auf einen
/// Rückruf, der nie kam. Stattdessen wird hier im Sekundentakt beim Server
/// nachgefragt und nebenbei geprüft, ob das Fenster geschlossen wurde.
func beginSignIn() async { func beginSignIn() async {
phase = .authenticating phase = .authenticating
let pin: PlexPin
do { do {
let pin = try await authenticator.requestPin() pin = try await authenticator.requestPin()
} catch {
phase = .failed(message: message(for: error))
return
}
guard let url = pin.authorizationURL( guard let url = pin.authorizationURL(
clientIdentifier: identity.clientIdentifier, clientIdentifier: identity.clientIdentifier,
@@ -168,29 +178,49 @@ final class FlixySession {
return return
} }
try await webAuthenticator.authenticate( webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme)
url: url,
callbackScheme: Branding.urlScheme
)
// Das Fenster ist zu, jetzt liegt der Token bereit. Kurz // Die Frist entspricht der Gültigkeit des Codes bei Plex.
// nachgefragt statt sofort erwartet: zwischen dem Rücksprung und for _ in 0..<900 {
// dem Eintrag bei Plex liegen manchmal ein, zwei Sekunden. try? await Task.sleep(for: .seconds(1))
let token = try await authenticator.waitForAuthorization(
pin: pin, if let token = try? await authenticator.token(for: pin) {
pollInterval: .milliseconds(500), webAuthenticator.dismiss()
timeout: .seconds(20)
)
await connect(accountToken: token) await connect(accountToken: token)
return
} catch PlexWebAuthenticator.Failure.cancelled {
// Fenster geschlossen. Kein Fehler — kommentarlos zurück.
phase = .signedOut
} catch let error as PlexWebAuthenticator.Failure {
phase = .failed(message: webAuthenticationMessage(for: error))
} catch {
phase = .failed(message: message(for: error))
} }
// Fenster geschlossen, ohne dass ein Token da ist.
if webAuthenticator.didReturn {
switch webAuthenticator.outcome {
case .cancelled, nil:
// Abgebrochen oder zurückgesprungen, ohne dass der Token
// schon bereitlag. Noch ein paar Sekunden nachfragen,
// bevor aufgegeben wird — zwischen Rücksprung und Eintrag
// bei Plex liegt manchmal eine Sekunde.
if let token = try? await graceTokenLookup(pin: pin) {
await connect(accountToken: token)
return
}
phase = .signedOut
case .presentationFailed(let description):
phase = .failed(message: description)
}
return
}
}
webAuthenticator.dismiss()
phase = .failed(message: String(localized: "signIn.timedOut"))
}
/// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach.
private func graceTokenLookup(pin: PlexPin) async throws -> String? {
for _ in 0..<5 {
if let token = try? await authenticator.token(for: pin) { return token }
try? await Task.sleep(for: .milliseconds(700))
}
return nil
} }
func cancelSignIn() { func cancelSignIn() {
@@ -199,12 +229,6 @@ final class FlixySession {
phase = .signedOut phase = .signedOut
} }
private func webAuthenticationMessage(for failure: PlexWebAuthenticator.Failure) -> String {
switch failure {
case .cancelled: String(localized: "signIn.cancelled")
case .presentationFailed(let description): description
}
}
func signOut() { func signOut() {
authorizationTask?.cancel() authorizationTask?.cancel()
+55 -23
View File
@@ -21,42 +21,74 @@ final class PlexWebAuthenticator: NSObject {
case presentationFailed(String) case presentationFailed(String)
} }
/// Führt die Anmeldung durch und kehrt zurück, sobald Plex die App /// Das laufende Fenster — damit es sich auch von außen schließen lässt.
/// zurückgeschickt hat. ///
func authenticate(url: URL, callbackScheme: String) async throws(Failure) { /// Nötig, weil Plex den Rücksprung nicht immer ausführt: manchmal landet
do { /// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen.
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in /// Der Token liegt dann längst bereit, nur das Fenster weiß es nicht.
private var session: ASWebAuthenticationSession?
/// Wie das Fenster ausgegangen ist.
///
/// `nil`, solange es noch offen ist.
private(set) var outcome: Failure?
private(set) var didReturn = false
/// Öffnet das Anmeldefenster und kehrt **sofort** zurück.
///
/// Bewusst nicht abwartend: Plex führt den Rücksprung über `forwardUrl`
/// nicht zuverlässig aus. Auf dem iPad landet der Browser nach dem
/// Anmelden auf watch.plex.tv und bleibt stehen — der Token liegt längst
/// bereit, aber ein abwartender Aufruf hinge auf einen Rückruf, der nie
/// kommt. Der Aufrufer fragt deshalb parallel beim Server nach und sieht
/// hier nur nach, ob das Fenster zwischendurch geschlossen wurde.
func present(url: URL, callbackScheme: String) {
outcome = nil
didReturn = false
let session = ASWebAuthenticationSession( let session = ASWebAuthenticationSession(
url: url, url: url,
callbackURLScheme: callbackScheme callbackURLScheme: callbackScheme
) { _, error in ) { [weak self] _, error in
if let error { // ASWebAuthenticationSession ruft den Abschlussblock auf dem
continuation.resume(throwing: error) // Hauptthread auf — anders als VLCKit, wo dieselbe Annahme die App
} else { // zum Absturz gebracht hat. Hier ist sie dokumentiert.
continuation.resume() MainActor.assumeIsolated {
guard let self else { return }
if let error = error as? ASWebAuthenticationSessionError,
error.code == .canceledLogin {
self.outcome = .cancelled
} else if let error {
self.outcome = .presentationFailed(error.localizedDescription)
}
self.didReturn = true
self.session = nil
} }
} }
session.presentationContextProvider = self session.presentationContextProvider = self
// `false`, damit eine bestehende Plex-Anmeldung in Safari // `false`, damit eine bestehende Plex-Anmeldung in Safari mitgenutzt
// mitgenutzt wird: wer dort schon angemeldet ist, braucht nur // wird: wer dort schon angemeldet ist, braucht nur zu bestätigen.
// zu bestätigen statt das Passwort erneut einzugeben.
session.prefersEphemeralWebBrowserSession = false session.prefersEphemeralWebBrowserSession = false
guard session.start() else { guard session.start() else {
continuation.resume(throwing: Failure.presentationFailed( outcome = .presentationFailed(String(localized: "signIn.windowFailed"))
String(localized: "signIn.windowFailed") didReturn = true
))
return return
} }
self.session = session
} }
} catch let error as ASWebAuthenticationSessionError where error.code == .canceledLogin { }
throw .cancelled
} catch let error as Failure { extension PlexWebAuthenticator {
throw error /// Schließt das Anmeldefenster.
} catch { ///
throw .presentationFailed(error.localizedDescription) /// Wird gerufen, sobald der Token auf anderem Weg eingetroffen ist — sonst
} /// bliebe der Browser mit der Plex-Seite offen stehen, obwohl die App
/// schon angemeldet ist.
func dismiss() {
session?.cancel()
session = nil
} }
} }
@@ -189,7 +189,11 @@ public final class AVPlayerEngine: VideoPlayerEngine {
private func observePlayer() { private func observePlayer() {
observations.append( observations.append(
player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
MainActor.assumeIsolated { // Kein `assumeIsolated`: KVO meldet auf dem Thread, der die
// Änderung ausgelöst hat, und AVPlayer ändert seinen Zustand
// auch aus internen Threads. Genau dieselbe Annahme hat die App
// im VLC-Motor zum Absturz gebracht.
Task { @MainActor in
guard let self else { return } guard let self else { return }
switch player.timeControlStatus { switch player.timeControlStatus {
case .playing: self.state = .playing case .playing: self.state = .playing
@@ -207,6 +211,9 @@ public final class AVPlayerEngine: VideoPlayerEngine {
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main queue: .main
) { [weak self] time in ) { [weak self] time in
// Hier ist `assumeIsolated` belegt statt geraten: die Warteschlange
// steht eine Zeile darüber. Viermal je Sekunde eine Aufgabe
// einzureihen wäre unnötiger Aufwand.
MainActor.assumeIsolated { MainActor.assumeIsolated {
guard let self, time.isNumeric else { return } guard let self, time.isNumeric else { return }
self.continuation?.yield(.timeChanged(.seconds(time.seconds))) self.continuation?.yield(.timeChanged(.seconds(time.seconds)))
@@ -217,7 +224,9 @@ public final class AVPlayerEngine: VideoPlayerEngine {
private func observe(item: AVPlayerItem) { private func observe(item: AVPlayerItem) {
observations.append( observations.append(
item.observe(\.status, options: [.new]) { [weak self] item, _ in item.observe(\.status, options: [.new]) { [weak self] item, _ in
MainActor.assumeIsolated { // Siehe oben: auch dieser Rückruf kommt nicht verlässlich vom
// Hauptthread.
Task { @MainActor in
guard let self else { return } guard let self else { return }
switch item.status { switch item.status {
case .failed: case .failed:
@@ -241,6 +250,7 @@ public final class AVPlayerEngine: VideoPlayerEngine {
object: item, object: item,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
// Ebenfalls ausdrücklich auf der Hauptwarteschlange.
MainActor.assumeIsolated { MainActor.assumeIsolated {
guard let self else { return } guard let self else { return }
self.state = .ended self.state = .ended
@@ -45,6 +45,23 @@ public actor PlexAuthenticator {
) )
} }
/// Fragt einmal nach, ob der Code inzwischen bestätigt wurde.
///
/// Gibt `nil` zurück, solange er es nicht ist. Für Aufrufer, die ihre
/// eigene Schleife führen — etwa weil sie nebenher noch auf das Schließen
/// eines Anmeldefensters achten müssen.
public func token(for pin: PlexPin) async throws(PlexError) -> String? {
let current = try await client.send(
PlexRequest(path: "/api/v2/pins/\(pin.id)"),
as: PlexPin.self
)
guard let token = current.authToken, !token.isEmpty else { return nil }
try? tokenStore.store(token, for: Self.accountTokenAccount)
await client.setToken(token)
return token
}
/// Wartet darauf, dass der Nutzer den Code im Browser bestätigt. /// Wartet darauf, dass der Nutzer den Code im Browser bestätigt.
/// ///
/// Plex bietet keinen Rückkanal — es bleibt nur regelmäßiges Nachfragen. /// Plex bietet keinen Rückkanal — es bleibt nur regelmäßiges Nachfragen.
@@ -143,13 +143,24 @@ public final class VLCPlayerEngine: NSObject, VideoPlayerEngine {
// MARK: - VLCMediaPlayerDelegate // MARK: - VLCMediaPlayerDelegate
/// VLCKit stellt seine Rückrufe standardmäßig über die Hauptwarteschlange zu /// VLCKit ruft den Delegaten von seinem **eigenen Input-Thread**, nicht über
/// (siehe `VLCEventsConfiguration`). Deshalb `assumeIsolated` statt eines /// die Hauptwarteschlange.
/// `Task`: ein nachgelagerter Task würde die Reihenfolge der Zustandswechsel ///
/// durcheinanderbringen — „spielt" könnte nach „gestoppt" ankommen. /// Hier stand vorher das Gegenteil, und darauf aufbauend `assumeIsolated` —
/// mit dem Ergebnis, dass die App beim Drücken auf Play hart abbrach, sobald
/// VLC den ersten Zustandswechsel meldete:
///
/// _dispatch_assert_queue_fail
/// MainActor.assumeIsolated
/// VLCPlayerEngine.mediaPlayerStateChanged(_:)
/// VLCKit input_thread_Events
///
/// Deshalb jetzt ein echter Sprung auf den Hauptakteur. Die Reihenfolge bleibt
/// dabei erhalten: die Aufgaben werden aus demselben Thread nacheinander
/// eingereiht und auch so abgearbeitet.
extension VLCPlayerEngine: VLCMediaPlayerDelegate { extension VLCPlayerEngine: VLCMediaPlayerDelegate {
public nonisolated func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { public nonisolated func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) {
MainActor.assumeIsolated { Task { @MainActor in
switch newState { switch newState {
case .opening: case .opening:
state = .loading state = .loading
@@ -176,13 +187,13 @@ extension VLCPlayerEngine: VLCMediaPlayerDelegate {
} }
public nonisolated func mediaPlayerTimeChanged(_ aNotification: Notification) { public nonisolated func mediaPlayerTimeChanged(_ aNotification: Notification) {
MainActor.assumeIsolated { Task { @MainActor in
continuation?.yield(.timeChanged(currentTime)) continuation?.yield(.timeChanged(currentTime))
} }
} }
public nonisolated func mediaPlayerLengthChanged(_ length: Int64) { public nonisolated func mediaPlayerLengthChanged(_ length: Int64) {
MainActor.assumeIsolated { Task { @MainActor in
guard length > 0 else { return } guard length > 0 else { return }
let value = Duration.milliseconds(length) let value = Duration.milliseconds(length)
duration = value duration = value