Files
yattee/Yattee/Views/Player/QualitySelector/QualitySelectorRowViews.swift
Arkadiusz Fal 67ed8c322b Add embedded audio/subtitle track selection for multi-track files
Files with multiple embedded tracks (e.g. MKV from media sources or
downloads) previously exposed none of them: mpv's track-list was never
read, aid was never set, and sid was only ever no/auto. The quality
selector showed no Audio or Subtitles tab for such files.

MPVClient now observes track-list/aid/sid and delivers a coalesced,
leniently decoded [MPVTrack] snapshot; selection state is derived from
the reported "selected" flags. Embedded tracks are switched live via
aid/sid (no reload), surfaced in the existing Audio/Subtitles sections
alongside external captions on iOS, tvOS and macOS. External sub-add/
audio-add tracks are filtered out to avoid double-listing. User picks
are sticky across same-video reloads (quality switch, audio mode,
retries) and preferred audio/subtitle languages auto-select a matching
embedded track once per load, with external captions taking precedence.
2026-08-03 23:34:06 +02:00

597 lines
17 KiB
Swift

//
// QualitySelectorRowViews.swift
// Yattee
//
// Row views for stream selection in quality selector.
//
import SwiftUI
#if os(tvOS)
/// Padding used inside selection row buttons so the tvOS focus background
/// fills the whole row.
private let tvRowVerticalPadding: CGFloat = 14
private let tvRowHorizontalPadding: CGFloat = 20
#endif
// MARK: - Adaptive Stream Row
/// Row view for HLS/DASH adaptive streams.
struct AdaptiveStreamRowView: View {
let stream: Stream
let isSelected: Bool
let onTap: () -> Void
private var format: StreamFormat {
StreamFormat.detect(from: stream)
}
/// Quality label from resolution/fps if available
private var qualityLabel: String? {
if let resolution = stream.resolution {
var label = resolution.description
if let fps = stream.fps, fps > 30 {
label += " \(fps)fps"
}
return label
}
return nil
}
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(String(localized: format == .hls ? "stream.format.hls" : "stream.format.dash"))
.font(.headline)
// Show quality badge when available
if let quality = qualityLabel {
Text(quality)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.15))
.clipShape(Capsule())
}
}
Text(String(localized: format == .hls ? "stream.format.appleHLS" : "stream.format.mpegDash"))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle())
}
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain)
#endif
}
}
// MARK: - Video Stream Row
/// Row view for video streams (muxed or video-only).
struct VideoStreamRowView: View {
let stream: Stream
let isSelected: Bool
let isPreferredQuality: Bool
let isDownloaded: Bool
let showAdvancedDetails: Bool
let requiresSoftwareDecode: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 2) {
titleRow
if showAdvancedDetails {
detailsRow
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.frame(minHeight: showAdvancedDetails ? nil : 36)
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle())
}
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain)
#endif
}
@ViewBuilder
private var titleRow: some View {
HStack(spacing: 6) {
if isDownloaded {
Image(systemName: "arrow.down.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else if isPreferredQuality {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
Text(stream.qualityLabel)
.font(.headline)
if !stream.isMuxed && requiresSoftwareDecode {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(.yellow)
.help(String(localized: "player.quality.softwareDecode.warning"))
}
if showAdvancedDetails {
codecBadge
}
}
}
@ViewBuilder
private var codecBadge: some View {
if stream.isMuxed {
Text(String(localized: "stream.badge.stream"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.green.opacity(0.2))
.foregroundStyle(.green)
.clipShape(Capsule())
} else if let codec = stream.videoCodec {
let isSoftware = requiresSoftwareDecode
Text(formatCodec(codec))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(isSoftware ? Color.yellow.opacity(0.2) : codecColor(codec).opacity(0.2))
.foregroundStyle(isSoftware ? .yellow : codecColor(codec))
.clipShape(Capsule())
}
}
@ViewBuilder
private var detailsRow: some View {
let details = formatStreamDetails(bitrate: stream.bitrate, fileSize: stream.formattedFileSize)
if stream.isMuxed || !details.isEmpty {
HStack(spacing: 4) {
if stream.isMuxed {
Image(systemName: "speaker.wave.2.fill")
.font(.caption2)
.foregroundStyle(.secondary)
}
if !details.isEmpty {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
// MARK: - Formatting Helpers
private func formatCodec(_ codec: String) -> String {
let lowercased = codec.lowercased()
if lowercased.contains("avc") || lowercased.contains("h264") {
return "H.264"
} else if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") {
return "HEVC"
} else if lowercased.contains("vp9") || lowercased.contains("vp09") {
return "VP9"
} else if lowercased.contains("av1") || lowercased.contains("av01") {
return "AV1"
}
return codec.uppercased()
}
private func codecColor(_ codec: String) -> Color {
let lowercased = codec.lowercased()
if lowercased.contains("av1") || lowercased.contains("av01") {
return .blue
} else if lowercased.contains("vp9") || lowercased.contains("vp09") {
return .orange
} else if lowercased.contains("avc") || lowercased.contains("h264") {
return .red
} else if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") {
return .green
}
return .gray
}
private func formatStreamDetails(bitrate: Int?, fileSize: String?) -> String {
var parts: [String] = []
if let bitrate {
parts.append(formatBitrate(bitrate))
}
if let fileSize {
parts.append(fileSize)
}
return parts.joined(separator: " · ")
}
private func formatBitrate(_ bitrate: Int) -> String {
if bitrate >= 1_000_000 {
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
} else {
return "\(bitrate / 1000) kbps"
}
}
}
// MARK: - Audio Stream Row
/// Row view for audio-only streams.
struct AudioStreamRowView: View {
let stream: Stream
let isSelected: Bool
let isPreferred: Bool
let showAdvancedDetails: Bool
let trackInfo: AudioTrackInfo
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 2) {
titleRow
if showAdvancedDetails {
detailsRow
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.frame(minHeight: showAdvancedDetails ? nil : 36)
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle())
}
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain)
#endif
}
@ViewBuilder
private var titleRow: some View {
HStack(spacing: 6) {
if isPreferred {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
Text(trackInfo.language)
.font(.headline)
if showAdvancedDetails, let codec = stream.audioCodec {
Text(codec.uppercased())
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.purple.opacity(0.2))
.foregroundStyle(.purple)
.clipShape(Capsule())
}
}
}
@ViewBuilder
private var detailsRow: some View {
let details = formatAudioDetails(
trackType: trackInfo.trackType,
bitrate: stream.bitrate,
fileSize: stream.formattedFileSize
)
if !details.isEmpty {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func formatAudioDetails(trackType: String?, bitrate: Int?, fileSize: String?) -> String {
var parts: [String] = []
if let trackType {
parts.append(trackType)
}
if let bitrate {
parts.append(formatBitrate(bitrate))
}
if let fileSize {
parts.append(fileSize)
}
return parts.joined(separator: " · ")
}
private func formatBitrate(_ bitrate: Int) -> String {
if bitrate >= 1_000_000 {
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
} else {
return "\(bitrate / 1000) kbps"
}
}
}
// MARK: - Caption Row
/// Row view for caption/subtitle selection.
struct CaptionRowView: View {
let caption: Caption?
let isSelected: Bool
let isPreferred: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 2) {
titleRow
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.frame(minHeight: 36)
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle())
}
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain)
#endif
}
@ViewBuilder
private var titleRow: some View {
HStack(spacing: 6) {
if isPreferred {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
Text(caption?.displayName ?? String(localized: "stream.subtitles.off"))
.font(.headline)
if let caption, caption.isAutoGenerated {
Text(String(localized: "stream.subtitle.auto"))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.gray.opacity(0.2))
.foregroundStyle(.secondary)
.clipShape(Capsule())
}
}
}
}
// MARK: - Embedded Track Row
/// Row view for embedded (in-container) audio/subtitle tracks reported by mpv.
struct EmbeddedTrackRowView: View {
let track: MPVTrack
let isSelected: Bool
let isPreferred: Bool
let showAdvancedDetails: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 2) {
titleRow
if showAdvancedDetails, let detail = track.detailText {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.frame(minHeight: showAdvancedDetails ? nil : 36)
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle())
}
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain)
#endif
}
@ViewBuilder
private var titleRow: some View {
HStack(spacing: 6) {
if isPreferred {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
Text(track.displayName)
.font(.headline)
if track.isDefault {
badge(String(localized: "player.track.default"))
}
if track.isForced {
badge(String(localized: "player.track.forced"))
}
}
}
private func badge(_ text: String) -> some View {
Text(text)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.gray.opacity(0.2))
.foregroundStyle(.secondary)
.clipShape(Capsule())
}
}
// MARK: - Previews
#Preview("Adaptive Stream Row") {
VStack(spacing: 0) {
AdaptiveStreamRowView(
stream: .hlsPreview,
isSelected: true,
onTap: {}
)
.padding()
Divider()
AdaptiveStreamRowView(
stream: .hlsNoQualityPreview,
isSelected: false,
onTap: {}
)
.padding()
}
.cardBackground()
.padding()
}
#Preview("Video Stream Row") {
VStack(spacing: 0) {
VideoStreamRowView(
stream: .preview,
isSelected: true,
isPreferredQuality: true,
isDownloaded: false,
showAdvancedDetails: true,
requiresSoftwareDecode: false,
onTap: {}
)
.padding()
Divider()
VideoStreamRowView(
stream: .videoOnlyPreview,
isSelected: false,
isPreferredQuality: false,
isDownloaded: false,
showAdvancedDetails: true,
requiresSoftwareDecode: true,
onTap: {}
)
.padding()
}
.cardBackground()
.padding()
}
#Preview("Audio Stream Row") {
VStack(spacing: 0) {
AudioStreamRowView(
stream: .audioPreview,
isSelected: true,
isPreferred: true,
showAdvancedDetails: true,
trackInfo: AudioTrackInfo(language: "English", trackType: "ORIGINAL"),
onTap: {}
)
.padding()
}
.cardBackground()
.padding()
}
#Preview("Caption Row") {
VStack(spacing: 0) {
CaptionRowView(
caption: nil,
isSelected: true,
isPreferred: false,
onTap: {}
)
.padding()
Divider()
CaptionRowView(
caption: .preview,
isSelected: false,
isPreferred: true,
onTap: {}
)
.padding()
Divider()
CaptionRowView(
caption: .autoGeneratedPreview,
isSelected: false,
isPreferred: false,
onTap: {}
)
.padding()
}
.cardBackground()
.padding()
}