From dca1568f7985a04c49f72e5c6c38c272d80d44fd Mon Sep 17 00:00:00 2001 From: itsjoshpark Date: Mon, 24 Aug 2026 15:51:36 -0400 Subject: [PATCH] fix: name the tracks the audio and subtitle menus offer The menus were built only from AVFoundation's media selection groups, which left them saying two wrong things. A file whose tracks sit in no alternate group gets no media selection group at all, so both menus read "None" even though the file plainly has tracks - which is what Front Row > Play Spatial Audio Sample did, and what the Inspector's Tracks tab contradicted. Where no group exists, the menus now fall back to the asset's own tracks and switch between them by enabling the matching player item track. Media selection is still preferred wherever it exists, since it is what HTTP Live Streaming and well-tagged files describe themselves with. An untagged track was named "Unknown language", which is the `und` marker rendered as text rather than anything about the track. One rule now names every entry in both menus: the track's own title qualified by its language, else the language, else its number. An HLS rendition's NAME arrives as a title and a caption option carries no tag at all, so both keep the names they had. --- .../Main Menu/SpatialAudioSampleTests.swift | 20 +++ .../Playback/MediaTrackChoiceTests.swift | 88 +++++++++++ .../Models/MediaInspectionLoader.swift | 9 +- Front Row/Models/MediaLanguageName.swift | 29 ++++ .../Playback/Models/MediaSelection.swift | 8 +- .../Playback/Models/MediaTrackChoice.swift | 145 ++++++++++++++++++ Front Row/Playback/Models/PlayEngine.swift | 74 +++++++-- .../Playback/Views/AudioTrackPicker.swift | 18 +-- Front Row/Playback/Views/PlayerControls.swift | 2 +- Front Row/Playback/Views/SubtitlePicker.swift | 20 +-- Front Row/Resources/Localizable.xcstrings | 6 + 11 files changed, 374 insertions(+), 45 deletions(-) create mode 100644 Front Row Tests/Playback/MediaTrackChoiceTests.swift create mode 100644 Front Row/Models/MediaLanguageName.swift create mode 100644 Front Row/Playback/Models/MediaTrackChoice.swift diff --git a/Front Row Tests/Main Menu/SpatialAudioSampleTests.swift b/Front Row Tests/Main Menu/SpatialAudioSampleTests.swift index 1d16c2f..175779d 100644 --- a/Front Row Tests/Main Menu/SpatialAudioSampleTests.swift +++ b/Front Row Tests/Main Menu/SpatialAudioSampleTests.swift @@ -123,5 +123,25 @@ private final class Answer: @unchecked Sendable { """ ) } + + /// That the Audio Track menu still finds the clip's audio. + /// + /// The sample's tracks sit in no alternate group, so AVFoundation offers no media + /// selection group for them and the menu has to fall back to the tracks themselves. Both + /// halves are asserted: a group appearing here would mean the fallback is no longer what + /// this clip exercises. + @Test(.timeLimit(.minutes(1))) + @MainActor + func audioIsOfferedWithoutAMediaSelectionGroup() async throws { + let asset = AVURLAsset(url: AppCommands.spatialAudioSampleURL) + + let group = try await asset.loadMediaSelectionGroup(for: .audible) + #expect(group == nil, "The spatial audio sample now groups its tracks after all") + + let choices = await MediaTrackChoice.choices( + in: group, of: asset, mediaTypes: [.audio]) + #expect(choices.count == 1) + #expect(choices.first?.name == "Track 1") + } } } diff --git a/Front Row Tests/Playback/MediaTrackChoiceTests.swift b/Front Row Tests/Playback/MediaTrackChoiceTests.swift new file mode 100644 index 0000000..8f56683 --- /dev/null +++ b/Front Row Tests/Playback/MediaTrackChoiceTests.swift @@ -0,0 +1,88 @@ +// +// MediaTrackChoiceTests.swift +// Front Row Tests +// +// Created by Joshua Park on 8/24/26. +// + +import Testing + +@testable import Front_Row + +/// How an entry in the Audio Track and Subtitle menus gets its name. +@Suite struct MediaTrackChoiceTests { + + @Test func titleAndLanguageReadTogether() { + #expect( + MediaTrackChoice.name( + title: "Director’s Commentary", + languageTag: "en", + describedName: "English", + index: 2 + ) == "Director’s Commentary (English)") + } + + /// An untagged track gets no parenthetical, rather than one saying nothing. + @Test func aTitleWithNoLanguageStandsAlone() { + #expect( + MediaTrackChoice.name( + title: "Commentary", languageTag: "und", describedName: nil, index: 1 + ) == "Commentary") + } + + /// An HLS rendition's `NAME` arrives as the track's title, with no language beside it. + @Test func aStreamRenditionIsNamedByItsTitle() { + #expect( + MediaTrackChoice.name( + title: "Commentary", languageTag: nil, describedName: "Unknown", index: 1 + ) == "Commentary") + } + + /// What AVFoundation adds to a language is worth keeping. + @Test func aQualifiedNameIsKeptIntact() { + #expect( + MediaTrackChoice.name( + title: nil, languageTag: "en", describedName: "English (SDH)", index: 1 + ) == "English (SDH)") + } + + /// No language tag at all is not the same as a tag saying the language is unknown. + @Test func anUntaggedCaptionKeepsItsName() { + #expect( + MediaTrackChoice.name( + title: nil, languageTag: nil, describedName: "CC", index: 1 + ) == "CC") + } + + /// The name AVFoundation builds out of `und` is the placeholder this rule exists to replace. + @Test func undeterminedLanguageIsNumberedInstead() { + #expect( + MediaTrackChoice.name( + title: nil, languageTag: "und", describedName: "Unknown language", index: 1 + ) == "Track 1") + } + + @Test func aLanguageAloneNamesTheTrack() { + #expect( + MediaTrackChoice.name( + title: nil, languageTag: "fr", describedName: nil, index: 3 + ) == "French") + } + + /// A track with nothing to go on is numbered within its own kind, so the only audio track in a + /// file is the first one whatever else the file holds. + @Test(arguments: [1, 2, 3]) + func anAnonymousTrackIsNumbered(index: Int) { + #expect( + MediaTrackChoice.name( + title: nil, languageTag: nil, describedName: nil, index: index + ) == "Track \(index)") + } + + @Test func anEmptyTitleCountsAsNoTitle() { + #expect( + MediaTrackChoice.name( + title: "", languageTag: "en", describedName: nil, index: 1 + ) == "English") + } +} diff --git a/Front Row/Inspector/Models/MediaInspectionLoader.swift b/Front Row/Inspector/Models/MediaInspectionLoader.swift index ed67eda..8c3882c 100644 --- a/Front Row/Inspector/Models/MediaInspectionLoader.swift +++ b/Front Row/Inspector/Models/MediaInspectionLoader.swift @@ -188,7 +188,7 @@ private struct TrackDetails { let tag = try? await track.load(.extendedLanguageTag) let code = try? await track.load(.languageCode) - self.language = Self.languageName(tag: tag ?? code) + self.language = MediaLanguageName.name(forTag: tag ?? code) let metadata = (try? await track.load(.commonMetadata)) ?? [] let titleItem = AVMetadataItem.metadataItems( @@ -197,13 +197,6 @@ private struct TrackDetails { self.title = try? await titleItem?.load(.stringValue) } - /// Resolves a language tag to its name in the viewer's language. `und` is what an MP4 writes - /// when the track was never tagged, so it counts as no language at all. - private static func languageName(tag: String?) -> String? { - guard let tag, !tag.isEmpty, tag != "und" else { return nil } - return Locale.current.localizedString(forIdentifier: tag) ?? tag - } - private var subType: FourCharCode? { format?.mediaSubType.rawValue } private var dimensions: CGSize? { diff --git a/Front Row/Models/MediaLanguageName.swift b/Front Row/Models/MediaLanguageName.swift new file mode 100644 index 0000000..9d21f67 --- /dev/null +++ b/Front Row/Models/MediaLanguageName.swift @@ -0,0 +1,29 @@ +// +// MediaLanguageName.swift +// Front Row +// +// Created by Joshua Park on 8/24/26. +// + +import Foundation + +/// Resolves a track's language tag to the name of that language. +enum MediaLanguageName { + + /// The tag a container writes when a track was never given a language. + private static let undetermined = "und" + + /// The language's name in the viewer's language, or `nil` when the tag names no language. + /// + /// `und` is what an MP4 writes for a track nobody tagged, so it counts as no language at all + /// rather than as a language called "Unknown language". + static func name(forTag tag: String?) -> String? { + guard let tag, !tag.isEmpty, tag != undetermined else { return nil } + return Locale.current.localizedString(forIdentifier: tag) ?? tag + } + + /// Whether the tag is the marker for a track that was never given a language. + static func isUndetermined(_ tag: String?) -> Bool { + tag == undetermined + } +} diff --git a/Front Row/Playback/Models/MediaSelection.swift b/Front Row/Playback/Models/MediaSelection.swift index 0d35823..4a4f6bb 100644 --- a/Front Row/Playback/Models/MediaSelection.swift +++ b/Front Row/Playback/Models/MediaSelection.swift @@ -26,12 +26,12 @@ extension AVMediaSelectionOption { } extension AVMediaSelectionGroup { - /// The subtitle options worth offering as a choice. + /// The options worth offering as a choice. /// - /// Forced-only tracks are left out. They exist to be switched on automatically for + /// Forced-only subtitles are left out. They exist to be switched on automatically for /// foreign-language passages inside a track the viewer already chose, not to be picked by - /// hand. - var selectableSubtitleOptions: [AVMediaSelectionOption] { + /// hand. No audio track carries the characteristic, so the same filter serves both menus. + var selectableOptions: [AVMediaSelectionOption] { options.filter { !$0.hasMediaCharacteristic(.containsOnlyForcedSubtitles) } } } diff --git a/Front Row/Playback/Models/MediaTrackChoice.swift b/Front Row/Playback/Models/MediaTrackChoice.swift new file mode 100644 index 0000000..e26215f --- /dev/null +++ b/Front Row/Playback/Models/MediaTrackChoice.swift @@ -0,0 +1,145 @@ +// +// MediaTrackChoice.swift +// Front Row +// +// Created by Joshua Park on 8/24/26. +// + +import AVFoundation + +/// One entry in the Audio Track or Subtitle menu. +/// +/// Containers describe their alternate tracks two ways, so the menus are built from two. A file +/// that groups its tracks gets a media selection group, which AVFoundation switches between. A +/// file that groups nothing has tracks and no more, and the choice is applied by enabling one of +/// the player item's tracks. +struct MediaTrackChoice: Identifiable, Hashable { + + /// How picking this entry is applied to the player item. + enum Selection: Hashable { + case option(AVMediaSelectionOption) + case track(CMPersistentTrackID) + } + + let id: String + let name: String + let selection: Selection +} + +extension MediaTrackChoice { + + /// The name to show for one track. + /// + /// - Parameters: + /// - title: The track's own title. An MP4 track title and an HLS rendition's `NAME` both + /// arrive here, and either is the best name there is. + /// - languageTag: The track's language. + /// - describedName: What AVFoundation calls the option, which qualifies a language with + /// "Forced" or "SDH". Nil for a plain asset track, which carries no such name. + /// - index: The track's place among its own kind, counting from one. + static func name( + title: String?, + languageTag: String?, + describedName: String?, + index: Int + ) -> String { + let language = MediaLanguageName.name(forTag: languageTag) + + if let title, !title.isEmpty { + guard let language else { return title } + return String( + localized: "\(title) (\(language))", + comment: "A media track named by its title, qualified by its language" + ) + } + + // An untagged option is named after `und` itself - "Unknown language" - which says no more + // than the number does, and reads like a language the file claims to be in. + if !MediaLanguageName.isUndetermined(languageTag), let describedName, !describedName.isEmpty + { + return describedName + } + + if let language { return language } + + return String( + localized: "Track \(index)", + comment: "A media track that carries no name or language of its own" + ) + } + + /// The choices to offer for one kind of track. + /// + /// A media selection group is used wherever the asset has one - it is what HTTP Live Streaming + /// and well-tagged files describe themselves with. Only when there is none do the asset's own + /// tracks stand in. + @MainActor + static func choices( + in group: AVMediaSelectionGroup?, + of asset: AVAsset, + mediaTypes: [AVMediaType] + ) async -> [MediaTrackChoice] { + if let group { + return await choices(in: group) + } + + var tracks: [AVAssetTrack] = [] + for mediaType in mediaTypes { + tracks += (try? await asset.loadTracks(withMediaType: mediaType)) ?? [] + } + return await choices(from: tracks) + } + + @MainActor + private static func choices(in group: AVMediaSelectionGroup) async -> [MediaTrackChoice] { + var choices: [MediaTrackChoice] = [] + for option in group.selectableOptions { + let title = try? await AVMetadataItem.metadataItems( + from: option.commonMetadata, filteredByIdentifier: .commonIdentifierTitle + ).first?.load(.stringValue) + + choices.append( + MediaTrackChoice( + id: option.stableID, + name: name( + title: title, + languageTag: option.extendedLanguageTag, + describedName: option.displayName, + index: choices.count + 1 + ), + selection: .option(option) + )) + } + return choices + } + + @MainActor + private static func choices(from tracks: [AVAssetTrack]) async -> [MediaTrackChoice] { + var choices: [MediaTrackChoice] = [] + for track in tracks { + let characteristics = (try? await track.load(.mediaCharacteristics)) ?? [] + guard !characteristics.contains(.containsOnlyForcedSubtitles) else { continue } + + let metadata = (try? await track.load(.commonMetadata)) ?? [] + let title = try? await AVMetadataItem.metadataItems( + from: metadata, filteredByIdentifier: .commonIdentifierTitle + ).first?.load(.stringValue) + + let tag = try? await track.load(.extendedLanguageTag) + let code = try? await track.load(.languageCode) + + choices.append( + MediaTrackChoice( + id: "track-\(track.trackID)", + name: name( + title: title, + languageTag: tag ?? code, + describedName: nil, + index: choices.count + 1 + ), + selection: .track(track.trackID) + )) + } + return choices + } +} diff --git a/Front Row/Playback/Models/PlayEngine.swift b/Front Row/Playback/Models/PlayEngine.swift index 95f88e5..887fa87 100644 --- a/Front Row/Playback/Models/PlayEngine.swift +++ b/Front Row/Playback/Models/PlayEngine.swift @@ -44,6 +44,10 @@ import SwiftUI url.isFileURL && convertibleFileExtensions.contains(url.pathExtension.lowercased()) } + /// What the Subtitle menu covers, kept in one place so the choices it lists and the tracks it + /// switches stay the same set. + private static let legibleTypes: [AVMediaType] = [.subtitle, .closedCaption] + /// How often (in seconds of playback) the current position is saved while playing. private static let periodicPositionSaveInterval: TimeInterval = 5 @@ -126,21 +130,23 @@ import SwiftUI } } - private(set) var subtitleGroup: AVMediaSelectionGroup? + private var subtitleGroup: AVMediaSelectionGroup? + + private(set) var subtitleChoices: [MediaTrackChoice] = [] - var subtitle: AVMediaSelectionOption? { + var subtitle: MediaTrackChoice? { didSet { - guard let subtitleGroup else { return } - selectTrack(subtitle, in: subtitleGroup) + selectTrack(subtitle, ofTypes: Self.legibleTypes, in: subtitleGroup) } } - private(set) var audioGroup: AVMediaSelectionGroup? + private var audioGroup: AVMediaSelectionGroup? - var audioTrack: AVMediaSelectionOption? { + private(set) var audioChoices: [MediaTrackChoice] = [] + + var audioTrack: MediaTrackChoice? { didSet { - guard let audioGroup else { return } - selectTrack(audioTrack, in: audioGroup) + selectTrack(audioTrack, ofTypes: [.audio], in: audioGroup) } } @@ -220,6 +226,10 @@ import SwiftUI self.subtitleGroup = try? await newAsset.loadMediaSelectionGroup(for: .legible) self.audioGroup = try? await newAsset.loadMediaSelectionGroup(for: .audible) + self.subtitleChoices = await MediaTrackChoice.choices( + in: subtitleGroup, of: newAsset, mediaTypes: Self.legibleTypes) + self.audioChoices = await MediaTrackChoice.choices( + in: audioGroup, of: newAsset, mediaTypes: [.audio]) if let loadedDuration = try? await newAsset.load(.duration) { mediaDuration = loadedDuration.seconds } @@ -244,8 +254,8 @@ import SwiftUI player.play() - self.subtitle = subtitleGroup?.options.first - self.audioTrack = audioGroup?.options.first + self.subtitle = subtitleChoices.first + self.audioTrack = audioChoices.first return .opened } @@ -300,6 +310,19 @@ import SwiftUI } .store(in: ¤tItemSubs) + // A file with no media selection group is switched by enabling one of the item's tracks, + // and the item has none to enable until it has read them. Re-applying costs nothing when + // the choice is already in force. + playerItem.publisher(for: \.tracks) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + self.selectTrack(self.audioTrack, ofTypes: [.audio], in: self.audioGroup) + self.selectTrack( + self.subtitle, ofTypes: Self.legibleTypes, in: self.subtitleGroup) + } + .store(in: ¤tItemSubs) + playerItem.publisher(for: \.presentationSize) .removeDuplicates() .receive(on: DispatchQueue.main) @@ -366,9 +389,11 @@ import SwiftUI asset?.cancelLoading() asset = nil - // Cleared before the selections, whose observers go no further once the group is gone. + // Cleared before the selections, whose observers find nothing left to apply to. subtitleGroup = nil audioGroup = nil + subtitleChoices = [] + audioChoices = [] subtitle = nil audioTrack = nil @@ -479,9 +504,32 @@ import SwiftUI wasPausedBeforeSeeking = false } - private func selectTrack(_ option: AVMediaSelectionOption?, in group: AVMediaSelectionGroup) { + /// Applies a menu choice to the current item, by whichever route the file describes its + /// tracks. + private func selectTrack( + _ choice: MediaTrackChoice?, + ofTypes mediaTypes: [AVMediaType], + in group: AVMediaSelectionGroup? + ) { guard let item = player.currentItem else { return } - item.select(option, in: group) + + if let group { + guard case .option(let option) = choice?.selection else { + item.select(nil, in: group) + return + } + item.select(option, in: group) + return + } + + var chosenID: CMPersistentTrackID? + if case .track(let id) = choice?.selection { chosenID = id } + + for track in item.tracks { + guard let assetTrack = track.assetTrack, mediaTypes.contains(assetTrack.mediaType) + else { continue } + track.isEnabled = assetTrack.trackID == chosenID + } } private func addPeriodicTimeObserver() { diff --git a/Front Row/Playback/Views/AudioTrackPicker.swift b/Front Row/Playback/Views/AudioTrackPicker.swift index e88ad54..aaabe4a 100644 --- a/Front Row/Playback/Views/AudioTrackPicker.swift +++ b/Front Row/Playback/Views/AudioTrackPicker.swift @@ -13,19 +13,19 @@ struct AudioTrackPicker: View { @Bindable var playEngine: PlayEngine var body: some View { - if let group = playEngine.audioGroup { - Picker("Audio Track", selection: $playEngine.audioTrack) { - Text("Off").tag(nil as AVMediaSelectionOption?) - - ForEach(group.options, id: \.stableID) { option in - Text(verbatim: option.displayName).tag(Optional(option)) - } - } - } else { + if playEngine.audioChoices.isEmpty { Picker("Audio Track", selection: .constant(0)) { Text("None").tag(0) } .disabled(true) + } else { + Picker("Audio Track", selection: $playEngine.audioTrack) { + Text("Off").tag(nil as MediaTrackChoice?) + + ForEach(playEngine.audioChoices) { choice in + Text(verbatim: choice.name).tag(Optional(choice)) + } + } } } } diff --git a/Front Row/Playback/Views/PlayerControls.swift b/Front Row/Playback/Views/PlayerControls.swift index 7bccd79..071cd63 100644 --- a/Front Row/Playback/Views/PlayerControls.swift +++ b/Front Row/Playback/Views/PlayerControls.swift @@ -25,7 +25,7 @@ struct PlayerControls: View { .disabled(!playEngine.isLoaded) DurationLabel() PlaybackSpeedIndicator(playEngine: playEngine) - if playEngine.subtitleGroup != nil { + if !playEngine.subtitleChoices.isEmpty { Menu { SubtitlePicker(playEngine: playEngine) } label: { diff --git a/Front Row/Playback/Views/SubtitlePicker.swift b/Front Row/Playback/Views/SubtitlePicker.swift index 827e2b1..73be7a3 100644 --- a/Front Row/Playback/Views/SubtitlePicker.swift +++ b/Front Row/Playback/Views/SubtitlePicker.swift @@ -16,21 +16,21 @@ struct SubtitlePicker: View { @Bindable var playEngine: PlayEngine var body: some View { - if let group = playEngine.subtitleGroup { - Picker("Subtitle", selection: $playEngine.subtitle) { - Text("Off").tag(nil as AVMediaSelectionOption?) - - ForEach(group.selectableSubtitleOptions, id: \.stableID) { option in - Text(verbatim: option.displayName).tag(Optional(option)) - } - } - .pickerStyle(.inline) - } else { + if playEngine.subtitleChoices.isEmpty { Picker("Subtitle", selection: .constant(0)) { Text("None").tag(0) } .pickerStyle(.inline) .disabled(true) + } else { + Picker("Subtitle", selection: $playEngine.subtitle) { + Text("Off").tag(nil as MediaTrackChoice?) + + ForEach(playEngine.subtitleChoices) { choice in + Text(verbatim: choice.name).tag(Optional(choice)) + } + } + .pickerStyle(.inline) } } } diff --git a/Front Row/Resources/Localizable.xcstrings b/Front Row/Resources/Localizable.xcstrings index e5c80d0..c096d8e 100644 --- a/Front Row/Resources/Localizable.xcstrings +++ b/Front Row/Resources/Localizable.xcstrings @@ -1097,6 +1097,9 @@ } } }, + "%@ (%@)" : { + "comment" : "A media track named by its title, qualified by its language" + }, "%@ bps" : { "comment" : "Bits per second", "localizations" : { @@ -17769,6 +17772,9 @@ } } }, + "Track %lld" : { + "comment" : "A media track that carries no name or language of its own" + }, "Tracks" : { "comment" : "Inspector tab", "localizations" : {