Files
yattee/Yattee/Views/Player/QualitySelector/QualitySelectorDownloadRows.swift
Arkadiusz Fal 8464464199 Fix locales
2026-02-09 00:13:46 +01:00

342 lines
9.8 KiB
Swift

//
// QualitySelectorDownloadRows.swift
// Yattee
//
// Row views for downloaded content in quality selector.
//
import SwiftUI
// MARK: - Downloaded Video Row
/// Row view for downloaded video - displays download info (not tappable).
struct DownloadedVideoRowView: View {
let download: Download
let showAdvancedDetails: Bool
/// Whether this is a muxed download (has embedded audio).
private var isMuxed: Bool {
download.localAudioPath == nil
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
titleRow
if showAdvancedDetails {
detailsRow
}
}
Spacer()
}
.frame(minHeight: showAdvancedDetails ? nil : 36)
}
@ViewBuilder
private var titleRow: some View {
HStack(spacing: 6) {
Image(systemName: "arrow.down.circle.fill")
.font(.caption)
.foregroundStyle(.green)
Text(download.quality)
.font(.headline)
if showAdvancedDetails {
codecBadge
}
}
}
@ViewBuilder
private var codecBadge: some View {
if 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 = download.videoCodec {
Text(formatCodec(codec))
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(codecColor(codec).opacity(0.2))
.foregroundStyle(codecColor(codec))
.clipShape(Capsule())
}
}
@ViewBuilder
private var detailsRow: some View {
let fileSize: String? = download.videoTotalBytes > 0 ? formatFileSize(download.videoTotalBytes) : nil
let details = formatStreamDetails(bitrate: download.videoBitrate, fileSize: fileSize)
if isMuxed || !details.isEmpty {
HStack(spacing: 4) {
if 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 formatFileSize(_ bytes: Int64) -> String {
ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
}
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: - Downloaded Audio Row
/// Row view for downloaded audio - displays download info (not tappable).
struct DownloadedAudioRowView: View {
let download: Download
let showAdvancedDetails: Bool
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
titleRow
if showAdvancedDetails {
detailsRow
}
}
Spacer()
}
.frame(minHeight: showAdvancedDetails ? nil : 36)
}
@ViewBuilder
private var titleRow: some View {
HStack(spacing: 6) {
Image(systemName: "arrow.down.circle.fill")
.font(.caption)
.foregroundStyle(.green)
if let lang = download.audioLanguage {
Text(Locale.current.localizedString(forLanguageCode: lang) ?? lang)
.font(.headline)
} else {
Text(String(localized: "stream.audio"))
.font(.headline)
}
if showAdvancedDetails, let codec = download.audioCodec {
Text(codec.uppercased())
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.2))
.foregroundStyle(.blue)
.clipShape(Capsule())
}
}
}
@ViewBuilder
private var detailsRow: some View {
let fileSize: String? = download.audioTotalBytes > 0 ? formatFileSize(download.audioTotalBytes) : nil
let details = formatStreamDetails(bitrate: download.audioBitrate, fileSize: fileSize)
if !details.isEmpty {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func formatFileSize(_ bytes: Int64) -> String {
ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
}
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: - Downloaded Caption Row
/// Row view for downloaded caption - tappable to select/toggle subtitles.
struct DownloadedCaptionRowView: View {
let download: Download
let localCaptionURL: URL?
let currentCaption: Caption?
let onCaptionSelected: (Caption?) -> Void
let onDismiss: () -> Void
private var languageCode: String {
download.captionLanguage ?? "unknown"
}
private var languageName: String {
Locale.current.localizedString(forLanguageCode: languageCode) ?? languageCode
}
private var caption: Caption? {
localCaptionURL.map { url in
Caption(label: languageName, languageCode: languageCode, url: url)
}
}
private var isSelected: Bool {
caption?.url == currentCaption?.url
}
var body: some View {
Button {
if isSelected {
onCaptionSelected(nil)
} else if let caption {
onCaptionSelected(caption)
}
onDismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Image(systemName: "arrow.down.circle.fill")
.font(.caption)
.foregroundStyle(.green)
Text(languageName)
.font(.headline)
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.frame(minHeight: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Previews
#Preview("Downloaded Video Row") {
VStack(spacing: 0) {
DownloadedVideoRowView(
download: .preview,
showAdvancedDetails: true
)
.padding()
Divider()
DownloadedVideoRowView(
download: .muxedPreview,
showAdvancedDetails: true
)
.padding()
}
.cardBackground()
.padding()
}
#Preview("Downloaded Audio Row") {
VStack(spacing: 0) {
DownloadedAudioRowView(
download: .preview,
showAdvancedDetails: true
)
.padding()
}
.cardBackground()
.padding()
}
#Preview("Downloaded Caption Row") {
VStack(spacing: 0) {
DownloadedCaptionRowView(
download: .preview,
localCaptionURL: URL(string: "file:///Downloads/captions.vtt"),
currentCaption: nil,
onCaptionSelected: { _ in },
onDismiss: {}
)
.padding()
}
.cardBackground()
.padding()
}