mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
477 lines
13 KiB
Swift
477 lines
13 KiB
Swift
//
|
|
// QualitySelectorRowViews.swift
|
|
// Yattee
|
|
//
|
|
// Row views for stream selection in quality selector.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@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)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@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)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@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: - 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()
|
|
}
|