Files
streamyfin/docs/ks-player/TrackManagement.md

13 KiB

Track Management

KSPlayer provides APIs for managing audio, video, and subtitle tracks within media files.

Overview

Tracks represent individual streams within a media container (video tracks, audio tracks, subtitle tracks). You can:

  • Query available tracks
  • Get track metadata
  • Select/enable specific tracks

Getting Tracks

From MediaPlayerProtocol

// Get audio tracks
let audioTracks = player.tracks(mediaType: .audio)

// Get video tracks  
let videoTracks = player.tracks(mediaType: .video)

// Get subtitle tracks
let subtitleTracks = player.tracks(mediaType: .subtitle)

From KSPlayerLayer

if let player = playerLayer.player {
    let audioTracks = player.tracks(mediaType: .audio)
    // ...
}

From VideoPlayerView

if let player = playerView.playerLayer?.player {
    let tracks = player.tracks(mediaType: .audio)
    // ...
}

From SwiftUI Coordinator

let audioTracks = coordinator.playerLayer?.player.tracks(mediaType: .audio) ?? []

MediaPlayerTrack Protocol

All tracks conform to MediaPlayerTrack:

public protocol MediaPlayerTrack: AnyObject, CustomStringConvertible {
    var trackID: Int32 { get }
    var name: String { get }
    var languageCode: String? { get }
    var mediaType: AVFoundation.AVMediaType { get }
    var nominalFrameRate: Float { get set }
    var bitRate: Int64 { get }
    var bitDepth: Int32 { get }
    var isEnabled: Bool { get set }
    var isImageSubtitle: Bool { get }
    var rotation: Int16 { get }
    var dovi: DOVIDecoderConfigurationRecord? { get }
    var fieldOrder: FFmpegFieldOrder { get }
    var formatDescription: CMFormatDescription? { get }
}

Track Properties

Basic Properties

Property Type Description
trackID Int32 Unique track identifier
name String Track name (often empty)
languageCode String? ISO 639-1/639-2 language code
mediaType AVMediaType .audio, .video, or .subtitle
isEnabled Bool Whether track is currently active

Audio Properties

Property Type Description
bitRate Int64 Audio bitrate in bps
audioStreamBasicDescription AudioStreamBasicDescription? Core Audio format info

Video Properties

Property Type Description
naturalSize CGSize Video dimensions
nominalFrameRate Float Frame rate
bitRate Int64 Video bitrate in bps
bitDepth Int32 Color depth (8, 10, 12)
rotation Int16 Rotation in degrees
fieldOrder FFmpegFieldOrder Interlacing type
dynamicRange DynamicRange? SDR/HDR/Dolby Vision
dovi DOVIDecoderConfigurationRecord? Dolby Vision config

Color Properties

Property Type Description
colorPrimaries String? Color primaries (e.g., "ITU_R_709_2")
transferFunction String? Transfer function
yCbCrMatrix String? YCbCr matrix
colorSpace CGColorSpace? Computed color space

Subtitle Properties

Property Type Description
isImageSubtitle Bool True for bitmap subtitles (SUP, VobSub)

Computed Properties

extension MediaPlayerTrack {
    // Localized language name
    var language: String? {
        languageCode.flatMap { Locale.current.localizedString(forLanguageCode: $0) }
    }
    
    // FourCC codec type
    var codecType: FourCharCode
    
    // Video format subtype
    var mediaSubType: CMFormatDescription.MediaSubType
}

Selecting Tracks

Select a Track

// Find English audio track
if let englishTrack = audioTracks.first(where: { $0.languageCode == "en" }) {
    player.select(track: englishTrack)
}

Select Track by Index

let audioTracks = player.tracks(mediaType: .audio)
if audioTracks.count > 1 {
    player.select(track: audioTracks[1])
}

Check Currently Selected Track

let currentAudio = audioTracks.first(where: { $0.isEnabled })
print("Current audio: \(currentAudio?.name ?? "none")")

Track Selection Examples

Audio Track Selection

func selectAudioTrack(languageCode: String) {
    let audioTracks = player.tracks(mediaType: .audio)
    
    if let track = audioTracks.first(where: { $0.languageCode == languageCode }) {
        player.select(track: track)
        print("Selected: \(track.language ?? track.name)")
    }
}

// Usage
selectAudioTrack(languageCode: "en")  // English
selectAudioTrack(languageCode: "es")  // Spanish
selectAudioTrack(languageCode: "ja")  // Japanese

Video Track Selection (Multi-angle/quality)

func selectVideoTrack(preferredBitrate: Int64) {
    let videoTracks = player.tracks(mediaType: .video)
    
    // Find closest bitrate
    let sorted = videoTracks.sorted { 
        abs($0.bitRate - preferredBitrate) < abs($1.bitRate - preferredBitrate) 
    }
    
    if let track = sorted.first {
        player.select(track: track)
        print("Selected video: \(track.naturalSize.width)x\(track.naturalSize.height)")
    }
}

HDR Track Selection

func selectHDRTrack() {
    let videoTracks = player.tracks(mediaType: .video)
    
    // Prefer Dolby Vision, then HDR10, then SDR
    let preferredOrder: [DynamicRange] = [.dolbyVision, .hdr10, .hlg, .sdr]
    
    for range in preferredOrder {
        if let track = videoTracks.first(where: { $0.dynamicRange == range }) {
            player.select(track: track)
            print("Selected: \(range)")
            return
        }
    }
}

UIKit Track Selection UI

Using UIAlertController

func showAudioTrackPicker() {
    guard let player = playerLayer?.player else { return }
    
    let audioTracks = player.tracks(mediaType: .audio)
    guard !audioTracks.isEmpty else { return }
    
    let alert = UIAlertController(
        title: "Select Audio Track",
        message: nil,
        preferredStyle: .actionSheet
    )
    
    for track in audioTracks {
        let title = track.language ?? track.name
        let action = UIAlertAction(title: title, style: .default) { _ in
            player.select(track: track)
        }
        
        if track.isEnabled {
            action.setValue(true, forKey: "checked")
        }
        
        alert.addAction(action)
    }
    
    alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    present(alert, animated: true)
}

Track Info Display

func displayTrackInfo() {
    guard let player = playerLayer?.player else { return }
    
    // Video info
    if let videoTrack = player.tracks(mediaType: .video).first(where: { $0.isEnabled }) {
        print("Video: \(videoTrack.naturalSize.width)x\(videoTrack.naturalSize.height)")
        print("FPS: \(videoTrack.nominalFrameRate)")
        print("Bitrate: \(videoTrack.bitRate / 1000) kbps")
        print("HDR: \(videoTrack.dynamicRange?.description ?? "SDR")")
    }
    
    // Audio info
    if let audioTrack = player.tracks(mediaType: .audio).first(where: { $0.isEnabled }) {
        print("Audio: \(audioTrack.language ?? "Unknown")")
        print("Bitrate: \(audioTrack.bitRate / 1000) kbps")
    }
}

SwiftUI Track Selection

Audio Track Picker

struct AudioTrackPicker: View {
    let player: MediaPlayerProtocol?
    
    var audioTracks: [MediaPlayerTrack] {
        player?.tracks(mediaType: .audio) ?? []
    }
    
    var body: some View {
        Menu {
            ForEach(audioTracks, id: \.trackID) { track in
                Button {
                    player?.select(track: track)
                } label: {
                    HStack {
                        Text(track.language ?? track.name)
                        if track.isEnabled {
                            Image(systemName: "checkmark")
                        }
                    }
                }
            }
        } label: {
            Image(systemName: "waveform.circle.fill")
        }
    }
}

Video Track Picker

struct VideoTrackPicker: View {
    let player: MediaPlayerProtocol?
    
    var videoTracks: [MediaPlayerTrack] {
        player?.tracks(mediaType: .video) ?? []
    }
    
    var body: some View {
        Picker("Video", selection: Binding(
            get: { videoTracks.first(where: { $0.isEnabled })?.trackID },
            set: { newValue in
                if let track = videoTracks.first(where: { $0.trackID == newValue }) {
                    player?.select(track: track)
                }
            }
        )) {
            ForEach(videoTracks, id: \.trackID) { track in
                Text("\(Int(track.naturalSize.width))x\(Int(track.naturalSize.height))")
                    .tag(track.trackID as Int32?)
            }
        }
    }
}

Automatic Track Selection

Configure KSOptions for automatic track selection:

class CustomOptions: KSOptions {
    // Prefer English audio
    override func wantedAudio(tracks: [MediaPlayerTrack]) -> Int? {
        if let index = tracks.firstIndex(where: { $0.languageCode == "en" }) {
            return index
        }
        return nil  // Use default selection
    }
    
    // Prefer highest quality video
    override func wantedVideo(tracks: [MediaPlayerTrack]) -> Int? {
        if let index = tracks.enumerated().max(by: { $0.element.bitRate < $1.element.bitRate })?.offset {
            return index
        }
        return nil
    }
}

Track Events

Detecting Track Changes

func player(layer: KSPlayerLayer, state: KSPlayerState) {
    if state == .readyToPlay {
        let player = layer.player
        
        // Log available tracks
        print("Audio tracks: \(player.tracks(mediaType: .audio).count)")
        print("Video tracks: \(player.tracks(mediaType: .video).count)")
        print("Subtitle tracks: \(player.tracks(mediaType: .subtitle).count)")
        
        // Get current selections
        let currentAudio = player.tracks(mediaType: .audio).first(where: { $0.isEnabled })
        let currentVideo = player.tracks(mediaType: .video).first(where: { $0.isEnabled })
        
        print("Current audio: \(currentAudio?.language ?? "unknown")")
        print("Current video: \(currentVideo?.naturalSize ?? .zero)")
    }
}

Complete Example

class TrackSelectionController: UIViewController, KSPlayerLayerDelegate {
    private var playerLayer: KSPlayerLayer!
    private var audioButton: UIButton!
    private var videoInfoLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        
        let url = URL(string: "https://example.com/multi-track-video.mkv")!
        playerLayer = KSPlayerLayer(url: url, options: KSOptions(), delegate: self)
    }
    
    func player(layer: KSPlayerLayer, state: KSPlayerState) {
        if state == .readyToPlay {
            updateTrackUI()
        }
    }
    
    private func updateTrackUI() {
        guard let player = playerLayer?.player else { return }
        
        // Update video info
        if let video = player.tracks(mediaType: .video).first(where: { $0.isEnabled }) {
            videoInfoLabel.text = """
                \(Int(video.naturalSize.width))x\(Int(video.naturalSize.height)) @ \(Int(video.nominalFrameRate))fps
                \(video.dynamicRange?.description ?? "SDR")
                """
        }
        
        // Update audio button
        let audioCount = player.tracks(mediaType: .audio).count
        audioButton.isHidden = audioCount < 2
        
        if let audio = player.tracks(mediaType: .audio).first(where: { $0.isEnabled }) {
            audioButton.setTitle(audio.language ?? "Audio", for: .normal)
        }
    }
    
    @objc private func audioButtonTapped() {
        guard let player = playerLayer?.player else { return }
        
        let tracks = player.tracks(mediaType: .audio)
        let alert = UIAlertController(title: "Audio Track", message: nil, preferredStyle: .actionSheet)
        
        for track in tracks {
            let title = [track.language, track.name]
                .compactMap { $0 }
                .joined(separator: " - ")
            
            let action = UIAlertAction(title: title.isEmpty ? "Track \(track.trackID)" : title, style: .default) { [weak self] _ in
                player.select(track: track)
                self?.updateTrackUI()
            }
            
            if track.isEnabled {
                action.setValue(true, forKey: "checked")
                alert.preferredAction = action
            }
            
            alert.addAction(action)
        }
        
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        present(alert, animated: true)
    }
    
    private func setupUI() {
        audioButton = UIButton(type: .system)
        audioButton.addTarget(self, action: #selector(audioButtonTapped), for: .touchUpInside)
        view.addSubview(audioButton)
        
        videoInfoLabel = UILabel()
        videoInfoLabel.numberOfLines = 0
        view.addSubview(videoInfoLabel)
    }
    
    // Delegate methods...
    func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {}
    func player(layer: KSPlayerLayer, finish error: Error?) {}
    func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) {}
}