Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Front Row Tests/Main Menu/SpatialAudioSampleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
88 changes: 88 additions & 0 deletions Front Row Tests/Playback/MediaTrackChoiceTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
9 changes: 1 addition & 8 deletions Front Row/Inspector/Models/MediaInspectionLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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? {
Expand Down
29 changes: 29 additions & 0 deletions Front Row/Models/MediaLanguageName.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 4 additions & 4 deletions Front Row/Playback/Models/MediaSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
145 changes: 145 additions & 0 deletions Front Row/Playback/Models/MediaTrackChoice.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading