Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,31 @@
//
// CardBackgroundModifier.swift
// Yattee
//
// Reusable card background modifier for grouped list items.
//
import SwiftUI
/// Applies platform-appropriate card background with rounded corners.
///
/// Uses `ListBackgroundStyle.card` for consistent appearance across platforms.
struct CardBackgroundModifier: ViewModifier {
var cornerRadius: CGFloat = 10
func body(content: Content) -> some View {
content
.background(ListBackgroundStyle.card.color)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
}
extension View {
/// Applies card background styling used in grouped lists.
///
/// - Parameter cornerRadius: The corner radius for the rounded rectangle. Defaults to 10.
/// - Returns: A view with card background applied.
func cardBackground(cornerRadius: CGFloat = 10) -> some View {
modifier(CardBackgroundModifier(cornerRadius: cornerRadius))
}
}

View File

@@ -0,0 +1,341 @@
//
// 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("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()
}

View File

@@ -0,0 +1,476 @@
//
// 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(format == .hls ? "HLS" : "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(format == .hls ? "Apple HLS" : "MPEG-DASH (Best for MPV)")
.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("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("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()
}

View File

@@ -0,0 +1,43 @@
//
// QualitySelectorTypes.swift
// Yattee
//
// Types used by QualitySelectorView and its components.
//
import Foundation
/// Tab selection for the quality selector view.
enum QualitySelectorTab: String, CaseIterable, Sendable {
case video
case audio
case subtitles
/// Localized display label for the tab.
var label: String {
switch self {
case .video:
String(localized: "player.quality.video")
case .audio:
String(localized: "stream.audio")
case .subtitles:
String(localized: "stream.subtitles")
}
}
}
/// Navigation destination for quality selector detail pages.
enum QualitySelectorDestination: Hashable {
case video
case audio
case subtitles
}
/// Parsed audio track information for display.
struct AudioTrackInfo: Sendable {
/// The formatted language name (e.g., "English", "Japanese").
let language: String
/// Track type badge text ("AD" for auto-dubbed, "ORIGINAL" for original audio, nil otherwise).
let trackType: String?
}