Untertitel kommen aus der Datei, und Fortsetzen geht wieder
Im Spurenblatt stand „CC" und sonst nichts — derselbe Fehler wie beim Ton: das ist AVFoundations Name für den einen Untertitel, den ein umgerechneter Stream mitbringt. Zur Auswahl steht jetzt, was in der Datei liegt, und die Nummer geht als `subtitleStreamIndex` mit in die Anfrage. `-1` heisst dabei ausdrücklich „keine". Das ist nicht dasselbe wie nichts zu schicken: dann sucht der Server sich einen aus, und es steht plötzlich ein Untertitel im Bild, den niemand bestellt hat. Und der Klick ins Bild: Anhalten ging, Fortsetzen nicht. Der Abdunkler hinter der Bedienung deckt das ganze Fenster ab und lag über dem Film — solange die Bedienung zu sehen war, landete jeder Klick auf ihm. Auffallen konnte das nur an einer Stelle: nach dem Pausieren bleibt die Bedienung stehen, also traf der Klick, der wieder starten sollte, nie das Bild. Der Abdunkler ist jetzt unantastbar; er dunkelt ab, mehr nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
60194c2d2a
commit
67c679006f
@@ -53,6 +53,13 @@ final class PlayerModel {
|
|||||||
private(set) var audioStreams: [MediaStream] = []
|
private(set) var audioStreams: [MediaStream] = []
|
||||||
/// Welche davon gerade klingt.
|
/// Welche davon gerade klingt.
|
||||||
private(set) var selectedAudioStream: MediaStream?
|
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?
|
private(set) var selectedSubtitleTrack: MediaTrack?
|
||||||
|
|
||||||
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
||||||
@@ -668,7 +675,11 @@ final class PlayerModel {
|
|||||||
isLocalConnection: isLocalConnection,
|
isLocalConnection: isLocalConnection,
|
||||||
// Ohne diese Angabe wählt der Server, und bei einem
|
// Ohne diese Angabe wählt der Server, und bei einem
|
||||||
// umgerechneten Stream kommt danach nur noch **eine** Spur an.
|
// 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
|
playbackSession = target.sessionID
|
||||||
playbackVersionID = target.versionID
|
playbackVersionID = target.versionID
|
||||||
@@ -1429,6 +1440,14 @@ final class PlayerModel {
|
|||||||
backend: any MediaBackend
|
backend: any MediaBackend
|
||||||
) async {
|
) async {
|
||||||
audioStreams = part.audioStreams
|
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 {
|
guard !audioStreams.isEmpty else {
|
||||||
selectedAudioStream = nil
|
selectedAudioStream = nil
|
||||||
return
|
return
|
||||||
@@ -1484,6 +1503,32 @@ final class PlayerModel {
|
|||||||
await restart()
|
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.
|
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
|
||||||
var rememberedTrackName: String? {
|
var rememberedTrackName: String? {
|
||||||
guard let seriesID = item?.seriesID,
|
guard let seriesID = item?.seriesID,
|
||||||
|
|||||||
@@ -293,7 +293,14 @@ struct PlayerView: View {
|
|||||||
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
||||||
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
|
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
|
||||||
// verfälscht wirkt.
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,18 +72,20 @@ struct TrackPickerSheet: View {
|
|||||||
// mehreren, keine getrennte Eigenschaft.
|
// mehreren, keine getrennte Eigenschaft.
|
||||||
row(
|
row(
|
||||||
title: String(localized: "player.subtitlesOff"),
|
title: String(localized: "player.subtitlesOff"),
|
||||||
isSelected: model.selectedSubtitleTrack == nil
|
isSelected: model.selectedSubtitleStream == nil
|
||||||
) {
|
) {
|
||||||
model.selectSubtitleTrack(nil)
|
Task { await model.selectSubtitleStream(nil) }
|
||||||
dismiss()
|
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(
|
row(
|
||||||
title: track.displayName,
|
title: stream.name,
|
||||||
isSelected: model.selectedSubtitleTrack == track
|
isSelected: model.selectedSubtitleStream == stream
|
||||||
) {
|
) {
|
||||||
model.selectSubtitleTrack(track)
|
Task { await model.selectSubtitleStream(stream) }
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ extension JellyfinBackend {
|
|||||||
itemID: intent.itemID,
|
itemID: intent.itemID,
|
||||||
sourceID: sourceID,
|
sourceID: sourceID,
|
||||||
transcode: transcode,
|
transcode: transcode,
|
||||||
audioStreamIndex: intent.audioStreamIndex
|
audioStreamIndex: intent.audioStreamIndex,
|
||||||
|
subtitleStreamIndex: intent.subtitleStreamIndex
|
||||||
)
|
)
|
||||||
|
|
||||||
let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first
|
let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first
|
||||||
@@ -121,7 +122,8 @@ extension JellyfinBackend {
|
|||||||
itemID: String,
|
itemID: String,
|
||||||
sourceID: String,
|
sourceID: String,
|
||||||
transcode: TranscodeSettings,
|
transcode: TranscodeSettings,
|
||||||
audioStreamIndex: Int? = nil
|
audioStreamIndex: Int? = nil,
|
||||||
|
subtitleStreamIndex: Int? = nil
|
||||||
) async throws(MediaBackendError) -> JellyfinPlaybackInfo {
|
) async throws(MediaBackendError) -> JellyfinPlaybackInfo {
|
||||||
// Jellyfin rechnet in Bit, die App in Kilobit.
|
// Jellyfin rechnet in Bit, die App in Kilobit.
|
||||||
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
|
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
|
||||||
@@ -181,6 +183,8 @@ extension JellyfinBackend {
|
|||||||
URLQueryItem(name: "enableTranscoding", value: "true"),
|
URLQueryItem(name: "enableTranscoding", value: "true"),
|
||||||
] + (audioStreamIndex.map {
|
] + (audioStreamIndex.map {
|
||||||
[URLQueryItem(name: "audioStreamIndex", value: String($0))]
|
[URLQueryItem(name: "audioStreamIndex", value: String($0))]
|
||||||
|
} ?? []) + (subtitleStreamIndex.map {
|
||||||
|
[URLQueryItem(name: "subtitleStreamIndex", value: String($0))]
|
||||||
} ?? []),
|
} ?? []),
|
||||||
body: body
|
body: body
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ public struct PlaybackIntent: Sendable, Equatable {
|
|||||||
/// zweite ankommt. Genau daran scheiterte die Sprachwahl bisher: sie lief
|
/// zweite ankommt. Genau daran scheiterte die Sprachwahl bisher: sie lief
|
||||||
/// auf einer Liste mit einem Eintrag.
|
/// auf einer Liste mit einem Eintrag.
|
||||||
public let audioStreamIndex: Int?
|
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(
|
public init(
|
||||||
itemID: String,
|
itemID: String,
|
||||||
@@ -35,7 +45,8 @@ public struct PlaybackIntent: Sendable, Equatable {
|
|||||||
transcode: TranscodeSettings? = nil,
|
transcode: TranscodeSettings? = nil,
|
||||||
startTime: Duration = .zero,
|
startTime: Duration = .zero,
|
||||||
isLocalConnection: Bool = false,
|
isLocalConnection: Bool = false,
|
||||||
audioStreamIndex: Int? = nil
|
audioStreamIndex: Int? = nil,
|
||||||
|
subtitleStreamIndex: Int? = nil
|
||||||
) {
|
) {
|
||||||
self.itemID = itemID
|
self.itemID = itemID
|
||||||
self.versionIndex = versionIndex
|
self.versionIndex = versionIndex
|
||||||
@@ -44,6 +55,7 @@ public struct PlaybackIntent: Sendable, Equatable {
|
|||||||
self.startTime = startTime
|
self.startTime = startTime
|
||||||
self.isLocalConnection = isLocalConnection
|
self.isLocalConnection = isLocalConnection
|
||||||
self.audioStreamIndex = audioStreamIndex
|
self.audioStreamIndex = audioStreamIndex
|
||||||
|
self.subtitleStreamIndex = subtitleStreamIndex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,3 +285,62 @@ struct AudioStreamIndexTests {
|
|||||||
#expect(!werte.contains { $0.name == "audioStreamIndex" })
|
#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" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user