mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
342 lines
9.8 KiB
Swift
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()
|
|
}
|