mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
474 lines
13 KiB
Markdown
474 lines
13 KiB
Markdown
# 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
|
|
|
|
```swift
|
|
// 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
|
|
|
|
```swift
|
|
if let player = playerLayer.player {
|
|
let audioTracks = player.tracks(mediaType: .audio)
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### From VideoPlayerView
|
|
|
|
```swift
|
|
if let player = playerView.playerLayer?.player {
|
|
let tracks = player.tracks(mediaType: .audio)
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### From SwiftUI Coordinator
|
|
|
|
```swift
|
|
let audioTracks = coordinator.playerLayer?.player.tracks(mediaType: .audio) ?? []
|
|
```
|
|
|
|
## MediaPlayerTrack Protocol
|
|
|
|
All tracks conform to `MediaPlayerTrack`:
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
// Find English audio track
|
|
if let englishTrack = audioTracks.first(where: { $0.languageCode == "en" }) {
|
|
player.select(track: englishTrack)
|
|
}
|
|
```
|
|
|
|
### Select Track by Index
|
|
|
|
```swift
|
|
let audioTracks = player.tracks(mediaType: .audio)
|
|
if audioTracks.count > 1 {
|
|
player.select(track: audioTracks[1])
|
|
}
|
|
```
|
|
|
|
### Check Currently Selected Track
|
|
|
|
```swift
|
|
let currentAudio = audioTracks.first(where: { $0.isEnabled })
|
|
print("Current audio: \(currentAudio?.name ?? "none")")
|
|
```
|
|
|
|
## Track Selection Examples
|
|
|
|
### Audio Track Selection
|
|
|
|
```swift
|
|
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)
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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:
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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) {}
|
|
}
|
|
```
|
|
|