mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
Yattee v2 rewrite
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
Reference in New Issue
Block a user