diff --git a/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift b/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift new file mode 100644 index 000000000..b583c9da6 --- /dev/null +++ b/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift @@ -0,0 +1,210 @@ +import XCTest +@testable import Silo + +/// Offline playback of a server-prepared MP4: every source audio track, timed +/// text inside the file, and ASS/PGS sidecars from the manifest. +final class OfflinePreparedTrackInventoryTests: XCTestCase { + + // MARK: - Factories + + private func track( + id: Int64, + kind: PlayerTrack.Kind, + title: String?, + lang: String?, + codec: String?, + isDefault: Bool = false, + isForced: Bool = false, + isExternal: Bool = false + ) -> PlayerTrack { + PlayerTrack( + trackId: id, + kind: kind, + title: title, + lang: lang, + codec: codec, + audioChannelCount: kind == .audio ? 2 : nil, + bitrate: nil, + isDefault: isDefault, + isForced: isForced, + isHearingImpaired: false, + isExternal: isExternal, + isSelected: false, + ffIndex: isExternal ? nil : Int(id), + srcId: nil + ) + } + + private func manifestAudio(title: String?, language: String?) -> AudioTrack { + AudioTrack( + index: nil, + codec: "aac", + channels: 2, + channelLayout: "stereo", + bitrate: nil, + sampleRate: nil, + language: language, + title: title, + embeddedTitle: nil, + isDefault: nil + ) + } + + private func manifest(deliveryFormat: String?) throws -> OfflineManifest { + var fields = [ + "\"download_id\": \"d1\"", + "\"content_id\": \"c1\"", + "\"type\": \"movie\"", + "\"title\": \"Movie\"", + "\"quality\": \"medium\"", + "\"media_file_id\": \"42\"" + ] + if let deliveryFormat { fields.append("\"delivery_format\": \"\(deliveryFormat)\"") } + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode( + OfflineManifest.self, + from: Data("{\(fields.joined(separator: ","))}".utf8) + ) + } + + // MARK: - Prepared-file detection + + func testOnlyRemuxAndTranscodeDeliveriesArePreparedFiles() throws { + XCTAssertTrue(try manifest(deliveryFormat: "transcode").isServerPreparedFile) + XCTAssertTrue(try manifest(deliveryFormat: "remux").isServerPreparedFile) + XCTAssertFalse(try manifest(deliveryFormat: "original").isServerPreparedFile) + XCTAssertFalse(try manifest(deliveryFormat: nil).isServerPreparedFile) + } + + // MARK: - Audio + + func testAudioTitlesComeFromTheManifestByPosition() { + let probed = [ + track(id: 1, kind: .audio, title: "ENG (aac)", lang: "eng", codec: "aac", isDefault: true), + track(id: 2, kind: .audio, title: "JPN (aac)", lang: "jpn", codec: "aac"), + track(id: 3, kind: .audio, title: "Track 3 (aac)", lang: nil, codec: "aac"), + ] + let described = [ + manifestAudio(title: "English 5.1", language: "en"), + manifestAudio(title: nil, language: "ja"), + manifestAudio(title: "Commentary", language: "en"), + ] + + let tracks = OfflinePreparedTrackInventory.audioTracks(probed, manifestTracks: described) + + XCTAssertEqual(tracks.map(\.title), ["English 5.1", nil, "Commentary"]) + XCTAssertEqual(tracks.map(\.lang), ["eng", "jpn", "en"]) + XCTAssertEqual(tracks.map(\.primaryLabel), ["English 5.1", "Japanese", "Commentary"]) + XCTAssertEqual(tracks.map(\.trackId), [1, 2, 3]) + XCTAssertEqual(tracks.map(\.isDefault), [true, false, false]) + } + + func testUndeterminedProbedLanguageTakesTheManifestLanguage() { + let probed = [track(id: 1, kind: .audio, title: "UND (aac)", lang: "und", codec: "aac")] + let tracks = OfflinePreparedTrackInventory.audioTracks( + probed, + manifestTracks: [manifestAudio(title: nil, language: "de")] + ) + XCTAssertEqual(tracks.first?.lang, "de") + } + + func testRealAudioTitleIsKept() { + let probed = [track(id: 1, kind: .audio, title: "Director's Cut Mix", lang: "eng", codec: "aac")] + let tracks = OfflinePreparedTrackInventory.audioTracks( + probed, + manifestTracks: [manifestAudio(title: "English", language: "en")] + ) + XCTAssertEqual(tracks.first?.title, "Director's Cut Mix") + } + + func testMismatchedManifestDoesNotRelabelByPosition() { + let probed = [ + track(id: 1, kind: .audio, title: "ENG (aac)", lang: "eng", codec: "aac"), + track(id: 2, kind: .audio, title: "FRE (aac)", lang: "fre", codec: "aac"), + ] + let tracks = OfflinePreparedTrackInventory.audioTracks( + probed, + manifestTracks: [manifestAudio(title: "Commentary", language: "en")] + ) + XCTAssertEqual(tracks.map(\.title), [nil, nil]) + XCTAssertEqual(tracks.map(\.primaryLabel), ["English", "French"]) + } + + // MARK: - Default audio ordinal + + func testManifestAudioOrdinalResolvesToTheProbedStream() { + XCTAssertEqual(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: 1, probedTrackIDs: [1, 2, 3]), 2) + XCTAssertEqual(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: 0, probedTrackIDs: [1]), 1) + } + + func testOutOfRangeAudioOrdinalFallsBackToTheFileDefault() { + XCTAssertNil(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: 3, probedTrackIDs: [1, 2, 3])) + XCTAssertNil(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: -1, probedTrackIDs: [1])) + XCTAssertNil(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: 0, probedTrackIDs: [])) + } + + // MARK: - Subtitles + + private var preparedSubtitles: [PlayerTrack] { + [ + // The MP4 muxer flags the first timed-text track default. + track(id: 4, kind: .sub, title: "ENG (mov_text)", lang: "eng", codec: "mov_text", isDefault: true), + track(id: 5, kind: .sub, title: "SDH", lang: "eng", codec: "mov_text"), + track(id: 6, kind: .sub, title: "FRE (mov_text)", lang: "fre", codec: "mov_text", isForced: true), + track(id: 0x4000_0000, kind: .sub, title: "Japanese", lang: "ja", codec: "ass", isExternal: true), + ] + } + + func testTimedTextDropsTheMuxerDefaultAndSynthesizedNames() { + let tracks = OfflinePreparedTrackInventory.subtitleTracks(preparedSubtitles) + + XCTAssertEqual(tracks.map(\.isDefault), [false, false, false, false]) + XCTAssertEqual(tracks.map(\.title), [nil, "SDH", nil, "Japanese"]) + XCTAssertEqual(tracks.map(\.isForced), [false, false, true, false]) + XCTAssertEqual(tracks.map(\.languageFirstPrimaryLabel), ["English", "English", "French", "Japanese"]) + XCTAssertNil(tracks[0].attributesLabel?.range(of: "Default")) + } + + func testMuxerDefaultDoesNotEnableSubtitlesOffline() { + // Offline manifests carry no server-resolved subtitle policy, so the + // snapshot is empty; nothing may turn a track on from the file flag. + let pick = SubtitleAutoResolver.resolve(.init( + preferredLanguage: nil, + mode: nil, + showForced: false, + trackSignature: nil, + availableSubtitles: OfflinePreparedTrackInventory.subtitleTracks(preparedSubtitles), + currentAudioLanguage: "eng" + )) + XCTAssertEqual(pick, .noChange) + } + + func testForcedPreferenceSelectsTheForcedTimedTextTrack() { + let pick = SubtitleAutoResolver.resolve(.init( + preferredLanguage: "fr", + mode: .auto, + showForced: true, + trackSignature: nil, + availableSubtitles: OfflinePreparedTrackInventory.subtitleTracks(preparedSubtitles), + currentAudioLanguage: "fre" + )) + XCTAssertEqual(pick, .select(OfflinePreparedTrackInventory.subtitleTracks(preparedSubtitles)[2])) + } + + // MARK: - Sidecar codecs + + func testSupSidecarClassifiesAsPGS() { + let codec = SubtitleCodecClassifier.externalTrackCodec(engineCodec: "subrip", declaredFormat: "sup") + XCTAssertEqual(codec, "sup") + XCTAssertTrue(SubtitleCodecClassifier.isBitmap(codec)) + } + + func testRecognizedSidecarCodecsKeepTheEngineName() { + XCTAssertEqual(SubtitleCodecClassifier.externalTrackCodec(engineCodec: "ass", declaredFormat: "ass"), "ass") + XCTAssertEqual(SubtitleCodecClassifier.externalTrackCodec(engineCodec: "subrip", declaredFormat: "srt"), "subrip") + XCTAssertEqual(SubtitleCodecClassifier.externalTrackCodec(engineCodec: "webvtt", declaredFormat: "vtt"), "webvtt") + XCTAssertEqual(SubtitleCodecClassifier.externalTrackCodec(engineCodec: "subrip", declaredFormat: nil), "subrip") + XCTAssertFalse(SubtitleCodecClassifier.isBitmap("subrip")) + } +} diff --git a/iosApp/Tests/SubtitleDisplayOrderTests.swift b/iosApp/Tests/SubtitleDisplayOrderTests.swift index bc3b372e4..c0d7d1da0 100644 --- a/iosApp/Tests/SubtitleDisplayOrderTests.swift +++ b/iosApp/Tests/SubtitleDisplayOrderTests.swift @@ -133,6 +133,8 @@ final class SubtitleDisplayOrderTests: XCTestCase { XCTAssertLessThan(SubtitleDisplayOrder.formatRank("hdmv_pgs_subtitle"), SubtitleDisplayOrder.formatRank("dvd_subtitle")) XCTAssertLessThan(SubtitleDisplayOrder.formatRank("dvd_subtitle"), SubtitleDisplayOrder.formatRank(nil)) XCTAssertEqual(SubtitleDisplayOrder.formatRank("srt"), SubtitleDisplayOrder.formatRank("subrip")) + // Offline manifests name PGS sidecars by their `sup` file format. + XCTAssertEqual(SubtitleDisplayOrder.formatRank("sup"), SubtitleDisplayOrder.formatRank("pgssub")) } func testCanonicalLanguageKey() { diff --git a/iosApp/iosApp/Downloads/DownloadModels.swift b/iosApp/iosApp/Downloads/DownloadModels.swift index 743f5b684..a26af0ba2 100644 --- a/iosApp/iosApp/Downloads/DownloadModels.swift +++ b/iosApp/iosApp/Downloads/DownloadModels.swift @@ -199,6 +199,15 @@ struct OfflineManifest: Codable, Hashable, Sendable { /// the former public `format` name. var format: String { quality } + /// Whether the downloaded file is the server's remux/transcode output + /// rather than the original source file. + var isServerPreparedFile: Bool { + guard let delivery = deliveryFormat? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), !delivery.isEmpty else { return false } + return delivery != "original" + } + private enum CodingKeys: String, CodingKey { case downloadId case contentId @@ -386,6 +395,7 @@ struct OfflineAudioTrack: Codable, Hashable, Sendable { /// origin (`external:{index}` or `downloaded:{id}`). struct OfflineSubtitle: Codable, Hashable, Sendable { let language: String? + let title: String? let format: String? let forced: Bool? let hearingImpaired: Bool? diff --git a/iosApp/iosApp/Downloads/OfflinePlayback.swift b/iosApp/iosApp/Downloads/OfflinePlayback.swift index 1b796d30e..151227c6d 100644 --- a/iosApp/iosApp/Downloads/OfflinePlayback.swift +++ b/iosApp/iosApp/Downloads/OfflinePlayback.swift @@ -32,6 +32,9 @@ struct OfflinePreparedPlayback { /// Cached poster on disk, so the Now Playing widget gets artwork /// without a catalog fetch. let posterFileURL: URL? + /// The file is an MP4 the server remuxed or transcoded, not the source. + /// Its container metadata needs `OfflinePreparedTrackInventory`. + let isServerPreparedFile: Bool } /// Synthesizes the same `PreparedPlayback` the online path produces, but @@ -86,7 +89,8 @@ enum OfflinePlaybackBuilder { prepared: prepared, downloadId: record.id, mediaItemId: leafId, - posterFileURL: posterFileURL + posterFileURL: posterFileURL, + isServerPreparedFile: manifest.isServerPreparedFile ) } @@ -110,12 +114,15 @@ enum OfflinePlaybackBuilder { index: index, language: subtitle.language, codec: subtitle.format, - label: nil, + label: subtitle.title, // Manifest subtitles are always sidecar files - // (`external:{i}` / `downloaded:{id}`), never embedded, so - // they must survive Aether's embedded-track inventory merge. + // (`external:{i}`, `downloaded:{id}`, or an `embedded:{i}` + // ASS/PGS track extracted for a prepared MP4), never streams + // of the downloaded file, so they must survive Aether's + // embedded-track inventory merge. source: "external", forced: subtitle.forced, + hearingImpaired: subtitle.hearingImpaired, url: fileURL.absoluteString )) } diff --git a/iosApp/iosApp/Screens/Detail/DetailPlaybackFormatting.swift b/iosApp/iosApp/Screens/Detail/DetailPlaybackFormatting.swift index 8ae67bda9..a84747081 100644 --- a/iosApp/iosApp/Screens/Detail/DetailPlaybackFormatting.swift +++ b/iosApp/iosApp/Screens/Detail/DetailPlaybackFormatting.swift @@ -601,7 +601,7 @@ enum DetailPlaybackFormatting { if codec == "ass" || codec.contains("ass") { return "ASS" } if codec == "ssa" || codec.contains("ssa") { return "SSA" } if codec == "vtt" || codec.contains("webvtt") { return "WebVTT" } - if codec.contains("pgs") || codec.contains("hdmv") { return "PGS" } + if codec == "sup" || codec.contains("pgs") || codec.contains("hdmv") { return "PGS" } if codec.contains("dvd") || codec.contains("vobsub") { return "VobSub" } if codec.contains("mov_text") || codec.contains("tx3g") { return "TX3G" } return codec.uppercased() diff --git a/iosApp/iosApp/Screens/Player/AetherLoadSpec.swift b/iosApp/iosApp/Screens/Player/AetherLoadSpec.swift index 315c498a2..244f3f714 100644 --- a/iosApp/iosApp/Screens/Player/AetherLoadSpec.swift +++ b/iosApp/iosApp/Screens/Player/AetherLoadSpec.swift @@ -162,6 +162,15 @@ struct AetherLoadSpec { /// Also Aether's own default; see `PlayerSettings.deinterlaceFieldRate`. static let defaultDeinterlaceFieldRate: DeinterlaceFieldRate = .field + /// Resolve a download manifest's audio ordinal (`selected_audio_track_index`, + /// a position among the delivered file's audio streams) to the stream id + /// Aether selects by. Nil when the ordinal is outside the probed file, so + /// the load falls back to the file's default track. + static func offlineAudioStreamIndex(manifestOrdinal: Int, probedTrackIDs: [Int]) -> Int32? { + guard probedTrackIDs.indices.contains(manifestOrdinal) else { return nil } + return Int32(exactly: probedTrackIDs[manifestOrdinal]) + } + @MainActor init( offlineURL: URL, diff --git a/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift b/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift new file mode 100644 index 000000000..b8b4de0d6 --- /dev/null +++ b/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Local-playback corrections to the track inventory AetherEngine probes. +/// +/// A server-prepared download (remux or transcode) is an MP4 the server muxed +/// itself, and its container metadata is not the source's: +/// - MP4 stores a track title only as its handler name, which FFmpeg does not +/// read back as `title`, so Aether falls back to a synthesized +/// "ENG (aac)" / "Track 2 (mov_text)" name; +/// - the MP4 muxer marks the first timed-text track default whatever the +/// source said, so that flag carries no authoring intent. +/// The manifest's `audio_tracks` list the delivered file's audio streams in +/// order, so it restores the audio titles. Original-quality downloads play the +/// source file itself and are left exactly as probed. +enum OfflinePreparedTrackInventory { + + /// Replace Aether's synthesized audio names with the manifest's titles. + /// When the counts disagree the file is not the one the manifest + /// describes, so ordinals cannot be trusted and the tracks stay as probed. + static func audioTracks(_ tracks: [PlayerTrack], manifestTracks: [AudioTrack]?) -> [PlayerTrack] { + let described = manifestTracks ?? [] + guard described.count == tracks.count else { + return tracks.map { track in + track.replacing(title: hasSynthesizedName(track) ? nil : track.title) + } + } + return zip(tracks, described).map { track, manifestTrack in + guard hasSynthesizedName(track) else { return track } + return track.replacing( + title: PlayerTrack.normalizedText(manifestTrack.title), + lang: track.normalizedLanguageCode ?? PlayerTrack.normalizedText(manifestTrack.language) + ) + } + } + + /// Drop the muxer's default flag and synthesized names from the timed-text + /// tracks inside the file. Sidecars keep the manifest's own metadata. + static func subtitleTracks(_ tracks: [PlayerTrack]) -> [PlayerTrack] { + tracks.map { track in + guard !track.isExternal else { return track } + return track.replacing( + title: hasSynthesizedName(track) ? nil : track.title, + isDefault: false + ) + } + } + + /// Aether names an untitled stream " ()", or + /// "Track ()" without a language (`Demuxer.trackInfo`). + static func hasSynthesizedName(_ track: PlayerTrack) -> Bool { + guard let title = track.title, let codec = track.codec else { return false } + if let lang = track.lang, title == "\(lang.uppercased()) (\(codec))" { + return true + } + if let ffIndex = track.ffIndex, title == "Track \(ffIndex) (\(codec))" { + return true + } + return false + } +} + +private extension PlayerTrack { + func replacing(title: String?, lang: String? = nil, isDefault: Bool? = nil) -> PlayerTrack { + PlayerTrack( + trackId: trackId, + kind: kind, + title: title, + lang: lang ?? self.lang, + codec: codec, + audioChannelCount: audioChannelCount, + bitrate: bitrate, + isDefault: isDefault ?? self.isDefault, + isForced: isForced, + isHearingImpaired: isHearingImpaired, + isExternal: isExternal, + isSelected: isSelected, + ffIndex: ffIndex, + srcId: srcId + ) + } +} diff --git a/iosApp/iosApp/Screens/Player/PlayerTrack.swift b/iosApp/iosApp/Screens/Player/PlayerTrack.swift index 6dcc5cd85..888ec82f1 100644 --- a/iosApp/iosApp/Screens/Player/PlayerTrack.swift +++ b/iosApp/iosApp/Screens/Player/PlayerTrack.swift @@ -194,7 +194,7 @@ struct PlayerTrack: Identifiable, Equatable, Hashable { return locale.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased() } - private static func normalizedText(_ value: String?) -> String? { + static func normalizedText(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil diff --git a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift index c2762d656..57a0a6a45 100644 --- a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift +++ b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift @@ -702,6 +702,7 @@ class PlayerViewModel { private struct OfflinePlaybackContext { let downloadId: String let mediaItemId: String + let isServerPreparedFile: Bool } private var offlinePlaybackContext: OfflinePlaybackContext? /// Mirrors the server's default watched threshold (90%) so an offline @@ -3015,10 +3016,17 @@ class PlayerViewModel { try AetherEngine.probe(url: localURL) }.value try requireCurrentStreamLoad(expectedStreamLoadGeneration) - guard probe.audioTracks.indices.contains(ordinal) else { - throw AetherLoadSpec.ValidationError.invalidAudioTrackIndex(ordinal) + audioStreamIndex = AetherLoadSpec.offlineAudioStreamIndex( + manifestOrdinal: ordinal, + probedTrackIDs: probe.audioTracks.map(\.id) + ) + if audioStreamIndex == nil { + // A download has no server to replan against, so a stale + // manifest ordinal must not fail playback outright. + Self.logger.warning( + "Offline audio ordinal \(ordinal, privacy: .public) is outside the file's \(probe.audioTracks.count, privacy: .public) audio tracks; using the file default" + ) } - audioStreamIndex = Int32(probe.audioTracks[ordinal].id) } else { audioStreamIndex = nil } @@ -3196,19 +3204,37 @@ class PlayerViewModel { srcId: ordinal ) } - audioTracks = ApplePlaybackV3PlanAdapter.audioPickerTracks( + let isServerPreparedFile = offlinePlaybackContext?.isServerPreparedFile == true + let pickerAudioTracks = ApplePlaybackV3PlanAdapter.audioPickerTracks( aetherTracks: aetherAudioTracks, plan: activePreparedProtocolV3?.plan, version: currentSelectedVersion ) - let aetherSubtitleTracks = engine.subtitleTracks.map { track in + audioTracks = isServerPreparedFile + ? OfflinePreparedTrackInventory.audioTracks( + pickerAudioTracks, + manifestTracks: currentSelectedVersion?.audioTracks + ) + : pickerAudioTracks + let probedSubtitleTracks = engine.subtitleTracks.map { track in let appTrackID = aetherPlaybackController.appSubtitleID(forAetherID: track.id) + let sidecarIndex = track.isExternal + ? SubtitleTrackIdSpace.sidecarIndex(from: appTrackID) + : nil + let codec = track.isExternal + ? SubtitleCodecClassifier.externalTrackCodec( + engineCodec: track.codec, + declaredFormat: sidecarIndex.flatMap { index in + knownExternalSubtitles.first { $0.index == index }?.codec + } + ) + : track.codec return PlayerTrack( trackId: appTrackID, kind: .sub, title: track.name, lang: track.language, - codec: track.codec, + codec: codec, audioChannelCount: nil, bitrate: nil, isDefault: track.isDefault, @@ -3217,11 +3243,12 @@ class PlayerViewModel { isExternal: track.isExternal, isSelected: engine.activeSubtitleTrackIndex == track.id, ffIndex: track.isExternal ? nil : track.id, - srcId: track.isExternal - ? SubtitleTrackIdSpace.sidecarIndex(from: appTrackID) - : nil + srcId: sidecarIndex ) } + let aetherSubtitleTracks = isServerPreparedFile + ? OfflinePreparedTrackInventory.subtitleTracks(probedSubtitleTracks) + : probedSubtitleTracks // V3 inventory is an authoritative menu, not a preload list. Aether // receives only the current plan's artifact; presenting its probed // embedded tracks alongside every server sidecar would create two @@ -4245,7 +4272,8 @@ class PlayerViewModel { ) preparedOfflineContext = OfflinePlaybackContext( downloadId: offline.downloadId, - mediaItemId: offline.mediaItemId + mediaItemId: offline.mediaItemId, + isServerPreparedFile: offline.isServerPreparedFile ) preparedOfflineArtworkURL = offline.posterFileURL prepared = offline.prepared diff --git a/iosApp/iosApp/Screens/Player/Subtitles/SubtitleCodecClassifier.swift b/iosApp/iosApp/Screens/Player/Subtitles/SubtitleCodecClassifier.swift index cf42b9e07..9934d8103 100644 --- a/iosApp/iosApp/Screens/Player/Subtitles/SubtitleCodecClassifier.swift +++ b/iosApp/iosApp/Screens/Player/Subtitles/SubtitleCodecClassifier.swift @@ -1,8 +1,10 @@ import Foundation enum SubtitleCodecClassifier { + /// `sup` is the PGS elementary-stream file format: offline manifests + /// name PGS sidecars by it, so it classifies as PGS. private static let bitmapCodecs: Set = [ - "hdmv_pgs_subtitle", "pgssub", "pgs", + "hdmv_pgs_subtitle", "pgssub", "pgs", "sup", "dvd_subtitle", "dvdsub", "vobsub", "dvb_subtitle", "dvbsub", "xsub", ] @@ -19,4 +21,20 @@ enum SubtitleCodecClassifier { || codec.contains("dvb_sub") || codec.contains("vobsub") } + + /// The codec to publish for a host-registered sidecar. AetherEngine names + /// an external track from its format hint and reports "subrip" for any + /// hint it does not know, including `sup`; the declared format is the + /// truth there, so a PGS sidecar still classifies and ranks as bitmap. + static func externalTrackCodec(engineCodec: String?, declaredFormat: String?) -> String? { + guard engineCodec?.lowercased() == "subrip", + let declared = declaredFormat? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !declared.isEmpty, + !["srt", "subrip"].contains(declared) else { + return engineCodec + } + return declared + } } diff --git a/iosApp/iosApp/Screens/Player/Subtitles/SubtitleDisplayOrder.swift b/iosApp/iosApp/Screens/Player/Subtitles/SubtitleDisplayOrder.swift index d2fb3c75a..26f4e4c12 100644 --- a/iosApp/iosApp/Screens/Player/Subtitles/SubtitleDisplayOrder.swift +++ b/iosApp/iosApp/Screens/Player/Subtitles/SubtitleDisplayOrder.swift @@ -104,7 +104,7 @@ enum SubtitleDisplayOrder { if codec.contains("ssa") { return 2 } if codec == "vtt" || codec.contains("webvtt") { return 3 } if codec.contains("mov_text") || codec.contains("movtext") || codec.contains("tx3g") { return 4 } - if codec.contains("pgs") || codec.contains("hdmv") { return 5 } + if codec == "sup" || codec.contains("pgs") || codec.contains("hdmv") { return 5 } if codec.contains("dvd") || codec.contains("vobsub") || codec.contains("dvb") { return 6 } return 7 }