diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 30204c1..168be9d 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -53,6 +53,13 @@ final class PlayerModel { private(set) var audioStreams: [MediaStream] = [] /// Welche davon gerade klingt. private(set) var selectedAudioStream: MediaStream? + + /// Die Untertitel, wie sie **in der Datei** stehen — aus demselben Grund + /// wie beim Ton: ein umgerechneter Stream bringt genau einen mit, und der + /// heisst dann „CC". + private(set) var subtitleStreams: [MediaStream] = [] + /// Welcher davon zu sehen ist. `nil` heisst: keiner. + private(set) var selectedSubtitleStream: MediaStream? private(set) var selectedSubtitleTrack: MediaTrack? /// Die Marke, in der wir uns gerade befinden — Grundlage für den @@ -668,7 +675,11 @@ final class PlayerModel { isLocalConnection: isLocalConnection, // Ohne diese Angabe wählt der Server, und bei einem // umgerechneten Stream kommt danach nur noch **eine** Spur an. - audioStreamIndex: selectedAudioStream?.index + audioStreamIndex: selectedAudioStream?.index, + // `-1` statt nichts: ohne Angabe sucht der Server selbst einen + // aus, und dann steht plötzlich ein Untertitel im Bild, den + // niemand bestellt hat. + subtitleStreamIndex: selectedSubtitleStream?.index ?? -1 )) playbackSession = target.sessionID playbackVersionID = target.versionID @@ -1429,6 +1440,14 @@ final class PlayerModel { backend: any MediaBackend ) async { audioStreams = part.audioStreams + subtitleStreams = part.subtitleStreams + // Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt, + // wenn es einen passenden gibt, und sonst bleibt es ohne. + selectedSubtitleStream = languages.subtitleLanguage.isEmpty ? nil + : subtitleStreams.first { + LanguageCode.matches(languages.subtitleLanguage, $0.languageCode) + } + guard !audioStreams.isEmpty else { selectedAudioStream = nil return @@ -1484,6 +1503,32 @@ final class PlayerModel { await restart() } + /// Stellt die Untertitel um. + /// + /// Dieselbe Bauart wie beim Ton, und aus demselben Grund: rechnet der + /// Server um, steckt die Wahl in der Adresse. Bei unveränderter Wiedergabe + /// liegen dagegen alle im Stream, und der Player schaltet ohne + /// Unterbrechung um. + func selectSubtitleStream(_ stream: MediaStream?) async { + guard stream != selectedSubtitleStream else { return } + selectedSubtitleStream = stream + + if case .directPlay = decision?.method { + let treffer = stream.flatMap { gewählt in + subtitleTracks.first { LanguageCode.matches(gewählt.languageCode, $0.languageCode) } + } + // Nur wenn sich die Wahl im Player wirklich abbilden lässt. Sonst + // stünde der falsche Untertitel im Bild, und das wäre schlimmer + // als eine kurze Unterbrechung. + if stream == nil || treffer != nil { + selectSubtitleTrack(treffer) + return + } + } + + await restart() + } + /// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt. var rememberedTrackName: String? { guard let seriesID = item?.seriesID, diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 0438aa2..3855b2b 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -293,7 +293,14 @@ struct PlayerView: View { // Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen // Szenen lesbar bleibt, wenig genug, dass das Bild nicht // verfälscht wirkt. - Color.black.opacity(0.35).ignoresSafeArea() + // + // **Und unantastbar.** Er deckt das ganze Fenster ab und lag damit + // über dem Bild: solange die Bedienung zu sehen war, landete jeder + // Klick auf ihm statt auf dem Film. Zu merken war das an genau + // einer Stelle — nach dem Pausieren bleibt die Bedienung stehen, + // und der Klick, der wieder starten sollte, blendete nur aus. + // Anhalten ging, fortsetzen nicht. + Color.black.opacity(0.35).ignoresSafeArea().allowsHitTesting(false) } } diff --git a/Flixy/Views/Player/TrackPickerSheet.swift b/Flixy/Views/Player/TrackPickerSheet.swift index 9570397..b4efcdc 100644 --- a/Flixy/Views/Player/TrackPickerSheet.swift +++ b/Flixy/Views/Player/TrackPickerSheet.swift @@ -72,18 +72,20 @@ struct TrackPickerSheet: View { // mehreren, keine getrennte Eigenschaft. row( title: String(localized: "player.subtitlesOff"), - isSelected: model.selectedSubtitleTrack == nil + isSelected: model.selectedSubtitleStream == nil ) { - model.selectSubtitleTrack(nil) + Task { await model.selectSubtitleStream(nil) } dismiss() } - ForEach(model.subtitleTracks) { track in + // Wie beim Ton die Spuren der Datei: ein umgerechneter + // Stream bringt genau einen mit, und der heisst „CC". + ForEach(model.subtitleStreams) { stream in row( - title: track.displayName, - isSelected: model.selectedSubtitleTrack == track + title: stream.name, + isSelected: model.selectedSubtitleStream == stream ) { - model.selectSubtitleTrack(track) + Task { await model.selectSubtitleStream(stream) } dismiss() } } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift index b996b60..b2e3386 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift @@ -40,7 +40,8 @@ extension JellyfinBackend { itemID: intent.itemID, sourceID: sourceID, transcode: transcode, - audioStreamIndex: intent.audioStreamIndex + audioStreamIndex: intent.audioStreamIndex, + subtitleStreamIndex: intent.subtitleStreamIndex ) let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first @@ -121,7 +122,8 @@ extension JellyfinBackend { itemID: String, sourceID: String, transcode: TranscodeSettings, - audioStreamIndex: Int? = nil + audioStreamIndex: Int? = nil, + subtitleStreamIndex: Int? = nil ) async throws(MediaBackendError) -> JellyfinPlaybackInfo { // Jellyfin rechnet in Bit, die App in Kilobit. let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000 @@ -181,6 +183,8 @@ extension JellyfinBackend { URLQueryItem(name: "enableTranscoding", value: "true"), ] + (audioStreamIndex.map { [URLQueryItem(name: "audioStreamIndex", value: String($0))] + } ?? []) + (subtitleStreamIndex.map { + [URLQueryItem(name: "subtitleStreamIndex", value: String($0))] } ?? []), body: body ), diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift b/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift index a1a34ee..7b50261 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift @@ -27,6 +27,16 @@ public struct PlaybackIntent: Sendable, Equatable { /// zweite ankommt. Genau daran scheiterte die Sprachwahl bisher: sie lief /// auf einer Liste mit einem Eintrag. public let audioStreamIndex: Int? + /// Welcher Untertitel eingebrannt oder mitgeliefert werden soll. + /// + /// Dieselbe Lage wie beim Ton: rechnet der Server um, liefert er genau + /// den einen aus, für den er sich entschieden hat. Wer erst im Player + /// wählen will, hat dann nichts mehr zur Auswahl. + /// + /// **`-1` heisst ausdrücklich „keine".** Nicht dasselbe wie `nil`: das + /// hiesse „such dir was aus", und der Server sucht sich dann durchaus + /// etwas aus. + public let subtitleStreamIndex: Int? public init( itemID: String, @@ -35,7 +45,8 @@ public struct PlaybackIntent: Sendable, Equatable { transcode: TranscodeSettings? = nil, startTime: Duration = .zero, isLocalConnection: Bool = false, - audioStreamIndex: Int? = nil + audioStreamIndex: Int? = nil, + subtitleStreamIndex: Int? = nil ) { self.itemID = itemID self.versionIndex = versionIndex @@ -44,6 +55,7 @@ public struct PlaybackIntent: Sendable, Equatable { self.startTime = startTime self.isLocalConnection = isLocalConnection self.audioStreamIndex = audioStreamIndex + self.subtitleStreamIndex = subtitleStreamIndex } } diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift b/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift index 5fe2643..fae6342 100644 --- a/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift @@ -285,3 +285,62 @@ struct AudioStreamIndexTests { #expect(!werte.contains { $0.name == "audioStreamIndex" }) } } + +@Suite("Untertitel ausdrücklich bestellen") +struct SubtitleStreamIndexTests { + private func backend(_ transport: RecordingTransport) -> JellyfinBackend { + JellyfinBackend( + client: JellyfinClient( + baseURL: URL(string: "http://server:8096")!, + identity: JellyfinIdentity( + client: "Flixy", device: "Testgerät", deviceID: "gerät-1", version: "1" + ), + token: "tok", + transport: transport + ), + account: JellyfinAccount( + userID: "u1", userName: "Test", token: "tok", isAdministrator: false + ), + serverName: "Attrappe" + ) + } + + private func intent(subtitle: Int?) -> PlaybackIntent { + PlaybackIntent( + itemID: "abc", + versionIndex: 0, + file: MediaFile(id: "src", key: "src", itemID: "abc"), + transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil), + subtitleStreamIndex: subtitle + ) + } + + private func query(_ transport: RecordingTransport) throws -> [URLQueryItem] { + let asked = try #require(transport.lastRequest) + return URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + } + + @Test("Die Nummer steht in der Anfrage") + func indexIsSent() async throws { + let transport = RecordingTransport() + _ = try? await backend(transport).preparePlayback(intent(subtitle: 4)) + #expect(try query(transport).contains(URLQueryItem(name: "subtitleStreamIndex", value: "4"))) + } + + /// **Der Unterschied, der zählt.** Ohne Angabe sucht der Server selbst + /// einen aus; „keine" muss man ihm ausdrücklich sagen, sonst steht + /// plötzlich ein Untertitel im Bild, den niemand bestellt hat. + @Test("Minus eins heisst ausdrücklich keine") + func minusOneMeansNone() async throws { + let transport = RecordingTransport() + _ = try? await backend(transport).preparePlayback(intent(subtitle: -1)) + #expect(try query(transport).contains(URLQueryItem(name: "subtitleStreamIndex", value: "-1"))) + } + + @Test("Ohne Angabe steht sie nicht da") + func indexIsOmitted() async throws { + let transport = RecordingTransport() + _ = try? await backend(transport).preparePlayback(intent(subtitle: nil)) + #expect(!(try query(transport)).contains { $0.name == "subtitleStreamIndex" }) + } +}