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" : {