mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
988
Yattee/Views/Video/DownloadQualitySheet.swift
Normal file
988
Yattee/Views/Video/DownloadQualitySheet.swift
Normal file
@@ -0,0 +1,988 @@
|
||||
//
|
||||
// DownloadQualitySheet.swift
|
||||
// Yattee
|
||||
//
|
||||
// Sheet for selecting download quality, audio track, and subtitles before downloading a video.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if !os(tvOS)
|
||||
struct DownloadQualitySheet: View {
|
||||
let video: Video
|
||||
var streams: [Stream] = []
|
||||
var captions: [Caption] = []
|
||||
var dislikeCount: Int?
|
||||
|
||||
enum DownloadTab: String, CaseIterable {
|
||||
case video
|
||||
case audio
|
||||
case subtitles
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .video: String(localized: "player.quality.video")
|
||||
case .audio: String(localized: "stream.audio")
|
||||
case .subtitles: String(localized: "stream.subtitles")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@State private var selectedTab: DownloadTab = .video
|
||||
@State private var selectedVideoStream: Stream?
|
||||
@State private var selectedAudioStream: Stream?
|
||||
@State private var selectedCaption: Caption?
|
||||
@State private var isDownloading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var fetchedStreams: [Stream]?
|
||||
@State private var fetchedCaptions: [Caption]?
|
||||
@State private var fetchedStoryboards: [Storyboard]?
|
||||
@State private var fetchedVideo: Video?
|
||||
@State private var isLoadingStreams = false
|
||||
|
||||
private var availableStreams: [Stream] {
|
||||
fetchedStreams ?? streams
|
||||
}
|
||||
|
||||
private var availableCaptions: [Caption] {
|
||||
fetchedCaptions ?? captions
|
||||
}
|
||||
|
||||
/// Video to use for download - prefer fetched video with full author details
|
||||
private var videoForDownload: Video {
|
||||
fetchedVideo ?? video
|
||||
}
|
||||
|
||||
/// Whether to show advanced stream details (codec, bitrate, size)
|
||||
private var showAdvancedStreamDetails: Bool {
|
||||
appEnvironment?.settingsManager.showAdvancedStreamDetails ?? false
|
||||
}
|
||||
|
||||
/// The preferred audio language from settings
|
||||
private var preferredAudioLanguage: String? {
|
||||
appEnvironment?.settingsManager.preferredAudioLanguage
|
||||
}
|
||||
|
||||
/// The preferred subtitles language from settings
|
||||
private var preferredSubtitlesLanguage: String? {
|
||||
appEnvironment?.settingsManager.preferredSubtitlesLanguage
|
||||
}
|
||||
|
||||
/// The preferred video quality from settings
|
||||
private var preferredQuality: VideoQuality {
|
||||
appEnvironment?.settingsManager.preferredQuality ?? .auto
|
||||
}
|
||||
|
||||
// MARK: - Stream Categories
|
||||
|
||||
/// Video streams (both muxed and video-only), sorted by quality
|
||||
private var videoStreams: [Stream] {
|
||||
let maxRes = preferredQuality.maxResolution
|
||||
let allVideoStreams = availableStreams
|
||||
.filter { !$0.isAudioOnly && $0.resolution != nil }
|
||||
.filter {
|
||||
let format = StreamFormat.detect(from: $0)
|
||||
return format != .hls && format != .dash
|
||||
}
|
||||
.sorted { s1, s2 in
|
||||
// Preferred quality first
|
||||
let s1Preferred = s1.resolution == maxRes
|
||||
let s2Preferred = s2.resolution == maxRes
|
||||
if s1Preferred != s2Preferred {
|
||||
return s1Preferred
|
||||
}
|
||||
// Then by resolution (higher first)
|
||||
let res1 = s1.resolution ?? .p360
|
||||
let res2 = s2.resolution ?? .p360
|
||||
if res1 != res2 {
|
||||
return res1 > res2
|
||||
}
|
||||
// Within same resolution, muxed streams first, then by codec quality
|
||||
if s1.isMuxed != s2.isMuxed {
|
||||
return s1.isMuxed
|
||||
}
|
||||
return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec)
|
||||
}
|
||||
|
||||
// When advanced details are hidden, show only best stream per resolution
|
||||
if !showAdvancedStreamDetails {
|
||||
var bestByResolution: [Int: Stream] = [:]
|
||||
for stream in allVideoStreams {
|
||||
let height = stream.resolution?.height ?? 0
|
||||
if let existing = bestByResolution[height] {
|
||||
// Prefer muxed, then better codec
|
||||
if stream.isMuxed && !existing.isMuxed {
|
||||
bestByResolution[height] = stream
|
||||
} else if stream.isMuxed == existing.isMuxed &&
|
||||
videoCodecPriority(stream.videoCodec) > videoCodecPriority(existing.videoCodec) {
|
||||
bestByResolution[height] = stream
|
||||
}
|
||||
} else {
|
||||
bestByResolution[height] = stream
|
||||
}
|
||||
}
|
||||
return bestByResolution.values.sorted { s1, s2 in
|
||||
let s1Preferred = s1.resolution == maxRes
|
||||
let s2Preferred = s2.resolution == maxRes
|
||||
if s1Preferred != s2Preferred {
|
||||
return s1Preferred
|
||||
}
|
||||
return (s1.resolution ?? .p360) > (s2.resolution ?? .p360)
|
||||
}
|
||||
}
|
||||
|
||||
return allVideoStreams
|
||||
}
|
||||
|
||||
/// Audio-only streams, deduplicated by language/codec
|
||||
private var audioStreams: [Stream] {
|
||||
let allAudio = availableStreams.filter { $0.isAudioOnly }
|
||||
|
||||
// When advanced details are hidden, show only best stream per language
|
||||
if !showAdvancedStreamDetails {
|
||||
var bestByLanguage: [String: Stream] = [:]
|
||||
for stream in allAudio {
|
||||
let lang = stream.audioLanguage ?? ""
|
||||
if let existing = bestByLanguage[lang] {
|
||||
if audioCodecPriority(stream.audioCodec) > audioCodecPriority(existing.audioCodec) {
|
||||
bestByLanguage[lang] = stream
|
||||
} else if audioCodecPriority(stream.audioCodec) == audioCodecPriority(existing.audioCodec),
|
||||
(stream.bitrate ?? 0) > (existing.bitrate ?? 0) {
|
||||
bestByLanguage[lang] = stream
|
||||
}
|
||||
} else {
|
||||
bestByLanguage[lang] = stream
|
||||
}
|
||||
}
|
||||
|
||||
return bestByLanguage.values.sorted { s1, s2 in
|
||||
// Preferred language first
|
||||
if let preferred = preferredAudioLanguage {
|
||||
let s1Preferred = (s1.audioLanguage ?? "").hasPrefix(preferred)
|
||||
let s2Preferred = (s2.audioLanguage ?? "").hasPrefix(preferred)
|
||||
if s1Preferred != s2Preferred {
|
||||
return s1Preferred
|
||||
}
|
||||
} else {
|
||||
// Original audio first
|
||||
if s1.isOriginalAudio != s2.isOriginalAudio {
|
||||
return s1.isOriginalAudio
|
||||
}
|
||||
}
|
||||
return (s1.audioLanguage ?? "") < (s2.audioLanguage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// Full details: group by language + codec
|
||||
var bestByKey: [String: Stream] = [:]
|
||||
for stream in allAudio {
|
||||
let lang = stream.audioLanguage ?? ""
|
||||
let codec = stream.audioCodec ?? ""
|
||||
let key = "\(lang)|\(codec)"
|
||||
if let existing = bestByKey[key] {
|
||||
if (stream.bitrate ?? 0) > (existing.bitrate ?? 0) {
|
||||
bestByKey[key] = stream
|
||||
}
|
||||
} else {
|
||||
bestByKey[key] = stream
|
||||
}
|
||||
}
|
||||
|
||||
return bestByKey.values.sorted { s1, s2 in
|
||||
if let preferred = preferredAudioLanguage {
|
||||
let s1Preferred = (s1.audioLanguage ?? "").hasPrefix(preferred)
|
||||
let s2Preferred = (s2.audioLanguage ?? "").hasPrefix(preferred)
|
||||
if s1Preferred != s2Preferred {
|
||||
return s1Preferred
|
||||
}
|
||||
} else if s1.isOriginalAudio != s2.isOriginalAudio {
|
||||
return s1.isOriginalAudio
|
||||
}
|
||||
let lang1 = s1.audioLanguage ?? ""
|
||||
let lang2 = s2.audioLanguage ?? ""
|
||||
if lang1 != lang2 { return lang1 < lang2 }
|
||||
return audioCodecPriority(s1.audioCodec) > audioCodecPriority(s2.audioCodec)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the selected video stream requires a separate audio track
|
||||
private var requiresAudioTrack: Bool {
|
||||
selectedVideoStream?.isVideoOnly == true
|
||||
}
|
||||
|
||||
/// Whether we have any video-only streams that would need audio
|
||||
private var hasVideoOnlyStreams: Bool {
|
||||
videoStreams.contains { $0.isVideoOnly }
|
||||
}
|
||||
|
||||
/// Best audio stream for auto-selection
|
||||
private var defaultAudioStream: Stream? {
|
||||
if let preferred = preferredAudioLanguage {
|
||||
if let match = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
if let original = audioStreams.first(where: { $0.isOriginalAudio }) {
|
||||
return original
|
||||
}
|
||||
return audioStreams.first
|
||||
}
|
||||
|
||||
/// Available tabs based on streams and captions
|
||||
private var availableTabs: [DownloadTab] {
|
||||
var tabs: [DownloadTab] = [.video]
|
||||
if hasVideoOnlyStreams && !audioStreams.isEmpty {
|
||||
tabs.append(.audio)
|
||||
}
|
||||
if !availableCaptions.isEmpty {
|
||||
tabs.append(.subtitles)
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
|
||||
/// Whether the download button should be enabled
|
||||
private var canDownload: Bool {
|
||||
guard let video = selectedVideoStream else { return false }
|
||||
if video.isVideoOnly && selectedAudioStream == nil {
|
||||
return false
|
||||
}
|
||||
return !isDownloading
|
||||
}
|
||||
|
||||
// MARK: - Codec Priority
|
||||
|
||||
private func videoCodecPriority(_ codec: String?) -> Int {
|
||||
guard let codec = codec?.lowercased() else { return 0 }
|
||||
if codec.contains("av1") || codec.contains("av01") { return 3 }
|
||||
if codec.contains("vp9") || codec.contains("vp09") { return 2 }
|
||||
if codec.contains("avc") || codec.contains("h264") { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
private func audioCodecPriority(_ codec: String?) -> Int {
|
||||
guard let codec = codec?.lowercased() else { return 0 }
|
||||
if codec.contains("opus") { return 2 }
|
||||
if codec.contains("aac") || codec.contains("mp4a") { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoadingStreams {
|
||||
loadingContent
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
#else
|
||||
.background(Color(.systemGroupedBackground))
|
||||
#endif
|
||||
.navigationTitle(String(localized: "download.selectQuality"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "common.cancel")) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button {
|
||||
startDownload()
|
||||
} label: {
|
||||
if isDownloading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(String(localized: "download.start"))
|
||||
}
|
||||
}
|
||||
.disabled(!canDownload)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Pre-select streams
|
||||
if selectedVideoStream == nil {
|
||||
selectedVideoStream = videoStreams.first
|
||||
}
|
||||
if selectedAudioStream == nil {
|
||||
selectedAudioStream = defaultAudioStream
|
||||
}
|
||||
|
||||
// WebDAV/local folder videos use direct file URL, no API fetch needed
|
||||
if video.isFromMediaSource {
|
||||
// Always create a proper download stream for media sources
|
||||
// The passed-in streams may have nil resolution and won't pass filters
|
||||
Task {
|
||||
await createStreamForMediaSource()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For Yattee Server, always fetch proxy streams for faster LAN downloads
|
||||
// even if playback streams were passed in (those point to YouTube CDN)
|
||||
let isYatteeServer = appEnvironment?.instancesManager.instance(for: video)?.isYatteeServerInstance ?? false
|
||||
|
||||
if isYatteeServer || (streams.isEmpty && fetchedStreams == nil) {
|
||||
Task {
|
||||
await fetchStreamsAndCaptions()
|
||||
}
|
||||
} else if captions.isEmpty && fetchedCaptions == nil {
|
||||
// Streams provided but no captions - fetch captions only
|
||||
Task {
|
||||
await fetchCaptionsOnly()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
// MARK: - Main Content Views
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
Text(String(localized: "download.selectQuality.loading"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
if let error = errorMessage {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.padding()
|
||||
.background(Color.red.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
// Tab picker
|
||||
if availableTabs.count > 1 {
|
||||
Picker("", selection: $selectedTab) {
|
||||
ForEach(availableTabs, id: \.self) { tab in
|
||||
Text(tab.label).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
// Content based on selected tab
|
||||
switch selectedTab {
|
||||
case .video:
|
||||
videoSection
|
||||
case .audio:
|
||||
audioSection
|
||||
case .subtitles:
|
||||
subtitlesSection
|
||||
}
|
||||
|
||||
// Selection summary
|
||||
if selectedVideoStream != nil {
|
||||
selectionSummary
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
@ViewBuilder
|
||||
private var videoSection: some View {
|
||||
if videoStreams.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "download.noStreams.title"), systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(String(localized: "download.noStreams.description"))
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(videoStreams.enumerated()), id: \.element.url) { index, stream in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
}
|
||||
videoStreamRow(stream)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
#else
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
#endif
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
if requiresAudioTrack {
|
||||
Text(String(localized: "download.videoOnly.hint"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var audioSection: some View {
|
||||
if audioStreams.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "download.noAudio.title"), systemImage: "speaker.slash")
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(audioStreams.enumerated()), id: \.element.url) { index, stream in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
}
|
||||
audioStreamRow(stream)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
#else
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
#endif
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var subtitlesSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
// "None" option
|
||||
captionRow(nil)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
ForEach(sortedCaptions) { caption in
|
||||
Divider()
|
||||
captionRow(caption)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
#else
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
#endif
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var sortedCaptions: [Caption] {
|
||||
availableCaptions.sorted { c1, c2 in
|
||||
if let preferred = preferredSubtitlesLanguage {
|
||||
let c1Preferred = c1.baseLanguageCode == preferred || c1.languageCode.hasPrefix(preferred)
|
||||
let c2Preferred = c2.baseLanguageCode == preferred || c2.languageCode.hasPrefix(preferred)
|
||||
if c1Preferred != c2Preferred {
|
||||
return c1Preferred
|
||||
}
|
||||
}
|
||||
return c1.displayName.localizedCaseInsensitiveCompare(c2.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectionSummary: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label(String(localized: "download.summary"), systemImage: "info.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let video = selectedVideoStream {
|
||||
HStack {
|
||||
Text(String(localized: "download.summary.video"))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(video.qualityLabel) \(video.isMuxed ? "(muxed)" : "")")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
if requiresAudioTrack, let audio = selectedAudioStream {
|
||||
HStack {
|
||||
Text(String(localized: "download.summary.audio"))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(formatAudioLabel(audio))
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
if let caption = selectedCaption {
|
||||
HStack {
|
||||
Text(String(localized: "download.summary.subtitles"))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(caption.displayName)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
#if os(macOS)
|
||||
.background(Color(nsColor: .unemphasizedSelectedContentBackgroundColor))
|
||||
#else
|
||||
.background(Color(.tertiarySystemGroupedBackground))
|
||||
#endif
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row Views
|
||||
|
||||
@ViewBuilder
|
||||
private func videoStreamRow(_ stream: Stream) -> some View {
|
||||
let isSelected = stream.url == selectedVideoStream?.url
|
||||
let isPreferred = stream.resolution == preferredQuality.maxResolution
|
||||
|
||||
Button {
|
||||
selectedVideoStream = stream
|
||||
// If switching to muxed stream, clear audio selection requirement
|
||||
// If switching to video-only, ensure audio is selected
|
||||
if stream.isVideoOnly && selectedAudioStream == nil {
|
||||
selectedAudioStream = defaultAudioStream
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
if isPreferred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Text(stream.qualityLabel)
|
||||
.font(.headline)
|
||||
|
||||
if showAdvancedStreamDetails {
|
||||
if stream.isMuxed {
|
||||
Text("MUXED")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.green.opacity(0.2))
|
||||
.foregroundStyle(.green)
|
||||
.clipShape(Capsule())
|
||||
} else if let codec = stream.videoCodec {
|
||||
Text(formatCodec(codec))
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(codecColor(codec).opacity(0.2))
|
||||
.foregroundStyle(codecColor(codec))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if showAdvancedStreamDetails {
|
||||
HStack(spacing: 4) {
|
||||
if stream.isMuxed {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let size = stream.formattedFileSize {
|
||||
Text(size)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let bitrate = stream.bitrate {
|
||||
Text(formatBitrate(bitrate))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: showAdvancedStreamDetails ? nil : 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func audioStreamRow(_ stream: Stream) -> some View {
|
||||
let isSelected = stream.url == selectedAudioStream?.url
|
||||
let isPreferred = preferredAudioLanguage.map { (stream.audioLanguage ?? "").hasPrefix($0) } ?? false
|
||||
|
||||
Button {
|
||||
selectedAudioStream = stream
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
if isPreferred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Text(formatAudioLabel(stream))
|
||||
.font(.headline)
|
||||
|
||||
if showAdvancedStreamDetails, 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())
|
||||
}
|
||||
}
|
||||
|
||||
if showAdvancedStreamDetails {
|
||||
HStack(spacing: 4) {
|
||||
if stream.isOriginalAudio {
|
||||
Text("ORIGINAL")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let bitrate = stream.bitrate {
|
||||
Text(formatBitrate(bitrate))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let size = stream.formattedFileSize {
|
||||
Text(size)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: showAdvancedStreamDetails ? nil : 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func captionRow(_ caption: Caption?) -> some View {
|
||||
let isSelected = caption?.id == selectedCaption?.id
|
||||
let isPreferred = caption.map { cap in
|
||||
preferredSubtitlesLanguage.map { cap.baseLanguageCode == $0 || cap.languageCode.hasPrefix($0) } ?? false
|
||||
} ?? false
|
||||
|
||||
Button {
|
||||
selectedCaption = caption
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
if isPreferred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Text(caption?.displayName ?? String(localized: "stream.subtitles.none"))
|
||||
.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatAudioLabel(_ stream: Stream) -> String {
|
||||
if let lang = stream.audioLanguage {
|
||||
return Locale.current.localizedString(forLanguageCode: lang) ?? lang.uppercased()
|
||||
}
|
||||
return stream.audioTrackName ?? String(localized: "stream.audio.default")
|
||||
}
|
||||
|
||||
private func formatCodec(_ codec: String) -> String {
|
||||
let lowercased = codec.lowercased()
|
||||
if lowercased.contains("avc") || lowercased.contains("h264") { return "H.264" }
|
||||
if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") { return "HEVC" }
|
||||
if lowercased.contains("vp9") || lowercased.contains("vp09") { return "VP9" }
|
||||
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 }
|
||||
if lowercased.contains("vp9") || lowercased.contains("vp09") { return .orange }
|
||||
if lowercased.contains("avc") || lowercased.contains("h264") { return .red }
|
||||
if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") { return .green }
|
||||
return .gray
|
||||
}
|
||||
|
||||
private func formatBitrate(_ bitrate: Int) -> String {
|
||||
if bitrate >= 1_000_000 {
|
||||
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
|
||||
}
|
||||
return "\(bitrate / 1000) kbps"
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func startDownload() {
|
||||
guard let videoStream = selectedVideoStream,
|
||||
let downloadManager = appEnvironment?.downloadManager else {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate audio selection for video-only streams
|
||||
if videoStream.isVideoOnly && selectedAudioStream == nil {
|
||||
errorMessage = String(localized: "download.error.audioRequired")
|
||||
return
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let audioURL = videoStream.isVideoOnly ? selectedAudioStream?.url : nil
|
||||
let audioLang = videoStream.isVideoOnly ? selectedAudioStream?.audioLanguage : nil
|
||||
// For muxed streams, use the stream's audio codec; for video-only, use selected audio stream's codec
|
||||
let audioCodec = videoStream.isMuxed ? videoStream.audioCodec : selectedAudioStream?.audioCodec
|
||||
let audioBitrate = videoStream.isMuxed ? nil : selectedAudioStream?.bitrate
|
||||
|
||||
// Select highest quality storyboard for download
|
||||
let preferredStoryboard = fetchedStoryboards?.highest()
|
||||
LoggingService.shared.logDownload(
|
||||
"[Downloads] Storyboard selection",
|
||||
details: "fetched: \(fetchedStoryboards?.count ?? 0), preferred: \(preferredStoryboard?.width ?? 0)x\(preferredStoryboard?.height ?? 0), sheets: \(preferredStoryboard?.storyboardCount ?? 0)"
|
||||
)
|
||||
|
||||
try await downloadManager.enqueue(
|
||||
videoForDownload,
|
||||
quality: videoStream.qualityLabel,
|
||||
formatID: videoStream.format,
|
||||
streamURL: videoStream.url,
|
||||
audioStreamURL: audioURL,
|
||||
captionURL: selectedCaption?.url,
|
||||
audioLanguage: audioLang,
|
||||
captionLanguage: selectedCaption?.languageCode,
|
||||
httpHeaders: videoStream.httpHeaders,
|
||||
storyboard: preferredStoryboard,
|
||||
dislikeCount: dislikeCount,
|
||||
videoCodec: videoStream.videoCodec,
|
||||
audioCodec: audioCodec,
|
||||
videoBitrate: videoStream.bitrate,
|
||||
audioBitrate: audioBitrate
|
||||
)
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createStreamForMediaSource() async {
|
||||
guard case .extracted(_, let originalURL) = video.id.source else { return }
|
||||
|
||||
var authHeaders: [String: String]?
|
||||
|
||||
// Get auth headers for WebDAV sources
|
||||
if video.isFromWebDAV,
|
||||
let sourceID = video.mediaSourceID,
|
||||
let appEnvironment,
|
||||
let source = appEnvironment.mediaSourcesManager.sources.first(where: { $0.id == sourceID }) {
|
||||
let password = appEnvironment.mediaSourcesManager.password(for: source)
|
||||
authHeaders = await appEnvironment.webDAVClient.authHeaders(for: source, password: password)
|
||||
}
|
||||
|
||||
let fileExtension = originalURL.pathExtension.lowercased()
|
||||
// Use a placeholder resolution so the stream passes the videoStreams filter
|
||||
// Mark as muxed (has audio) since local files typically have both tracks
|
||||
let stream = Stream(
|
||||
url: originalURL,
|
||||
resolution: .p1080, // Placeholder - actual resolution unknown
|
||||
format: fileExtension.isEmpty ? "video" : fileExtension,
|
||||
audioCodec: "aac", // Placeholder to mark as muxed
|
||||
httpHeaders: authHeaders
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
fetchedStreams = [stream]
|
||||
selectedVideoStream = stream
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchStreamsAndCaptions() async {
|
||||
guard let appEnvironment,
|
||||
let instance = appEnvironment.instancesManager.instance(for: video) else {
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingStreams = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
// For Yattee Server, use proxy streams for faster LAN downloads
|
||||
// Proxy URLs point to the server instead of YouTube CDN
|
||||
let loadedVideo: Video
|
||||
let loadedStreams: [Stream]
|
||||
let loadedCaptions: [Caption]
|
||||
let loadedStoryboards: [Storyboard]
|
||||
|
||||
if case .extracted(_, let originalURL) = video.id.source {
|
||||
// Extracted videos need re-extraction via /api/v1/extract
|
||||
let result = try await appEnvironment.contentService.extractURL(originalURL, instance: instance)
|
||||
loadedVideo = result.video
|
||||
loadedStreams = result.streams
|
||||
loadedCaptions = result.captions
|
||||
loadedStoryboards = []
|
||||
} else {
|
||||
let result = try await appEnvironment.contentService.videoWithProxyStreamsAndCaptionsAndStoryboards(
|
||||
id: video.id.videoID,
|
||||
instance: instance
|
||||
)
|
||||
loadedVideo = result.video
|
||||
loadedStreams = result.streams
|
||||
loadedCaptions = result.captions
|
||||
loadedStoryboards = result.storyboards
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
fetchedStreams = loadedStreams
|
||||
fetchedVideo = loadedVideo
|
||||
fetchedCaptions = loadedCaptions
|
||||
fetchedStoryboards = loadedStoryboards
|
||||
isLoadingStreams = false
|
||||
|
||||
// Pre-select streams after fetching
|
||||
if selectedVideoStream == nil {
|
||||
selectedVideoStream = videoStreams.first
|
||||
}
|
||||
if selectedAudioStream == nil {
|
||||
selectedAudioStream = defaultAudioStream
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
isLoadingStreams = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchCaptionsOnly() async {
|
||||
guard let appEnvironment,
|
||||
let instance = appEnvironment.instancesManager.instance(for: video) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let loadedCaptions = try await appEnvironment.contentService.captions(
|
||||
videoID: video.id.videoID,
|
||||
instance: instance
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
fetchedCaptions = loadedCaptions
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - captions are optional
|
||||
await MainActor.run {
|
||||
fetchedCaptions = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
DownloadQualitySheet(
|
||||
video: .preview,
|
||||
streams: [
|
||||
Stream(
|
||||
url: URL(string: "https://example.com/video.mp4")!,
|
||||
resolution: .p1080,
|
||||
format: "mp4",
|
||||
videoCodec: "avc1",
|
||||
audioCodec: "mp4a",
|
||||
fileSize: 500_000_000
|
||||
),
|
||||
Stream(
|
||||
url: URL(string: "https://example.com/video_only.webm")!,
|
||||
resolution: .p1080,
|
||||
format: "webm",
|
||||
videoCodec: "vp9",
|
||||
fileSize: 400_000_000
|
||||
),
|
||||
Stream(
|
||||
url: URL(string: "https://example.com/audio.m4a")!,
|
||||
resolution: nil,
|
||||
format: "m4a",
|
||||
audioCodec: "mp4a",
|
||||
fileSize: 50_000_000,
|
||||
isAudioOnly: true,
|
||||
audioLanguage: "en"
|
||||
)
|
||||
]
|
||||
)
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
#endif
|
||||
121
Yattee/Views/Video/ExternalVideoView.swift
Normal file
121
Yattee/Views/Video/ExternalVideoView.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// ExternalVideoView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Loading view for extracting and playing external site videos via Yattee Server.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// View for extracting and playing videos from external sites (non-YouTube/PeerTube).
|
||||
/// Uses Yattee Server's yt-dlp integration to extract video information.
|
||||
struct ExternalVideoView: View {
|
||||
let url: URL
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
@State private var shouldDismissWhenPlayerExpands = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
LoadingView(
|
||||
message: String(localized: "externalVideo.extracting"),
|
||||
subtext: url.host ?? url.absoluteString
|
||||
)
|
||||
} else if let error = errorMessage {
|
||||
ErrorStateView(
|
||||
title: String(localized: "externalVideo.couldNotExtract"),
|
||||
message: error,
|
||||
onRetry: { await extractAndPlay() },
|
||||
onDismiss: { dismiss() }
|
||||
)
|
||||
}
|
||||
// On success, view dismisses after player expands
|
||||
}
|
||||
.task {
|
||||
await extractAndPlay()
|
||||
}
|
||||
.onChange(of: appEnvironment?.navigationCoordinator.isPlayerExpanded) { _, isExpanded in
|
||||
// Dismiss this view after the player has expanded
|
||||
if isExpanded == true && shouldDismissWhenPlayerExpands {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extraction
|
||||
|
||||
private func extractAndPlay() async {
|
||||
guard let appEnvironment else {
|
||||
errorMessage = String(localized: "externalVideo.appNotReady")
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
// Find a Yattee Server instance
|
||||
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
|
||||
errorMessage = String(localized: "externalVideo.noYatteeServer")
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (video, _, _) = try await appEnvironment.contentService.extractURL(url, instance: instance)
|
||||
|
||||
// Play the video - view will dismiss when player expands
|
||||
await MainActor.run {
|
||||
// Set flag to dismiss when player expands
|
||||
shouldDismissWhenPlayerExpands = true
|
||||
|
||||
// Don't pass a specific stream - let the player's selectStreamAndBackend
|
||||
// choose the best video+audio combination. Using streams.first would
|
||||
// incorrectly select audio-only streams for sites like Bilibili.
|
||||
appEnvironment.playerService.openVideo(video)
|
||||
}
|
||||
|
||||
} catch let error as APIError {
|
||||
isLoading = false
|
||||
switch error {
|
||||
case .httpError(let statusCode, let message):
|
||||
if statusCode == 422 {
|
||||
errorMessage = message ?? String(localized: "externalVideo.error.unsupported")
|
||||
} else if statusCode == 400 {
|
||||
errorMessage = message ?? String(localized: "externalVideo.error.invalidUrl")
|
||||
} else {
|
||||
errorMessage = message ?? String(localized: "externalVideo.error.server \(statusCode)")
|
||||
}
|
||||
case .decodingError:
|
||||
errorMessage = String(localized: "externalVideo.error.parsing")
|
||||
case .noConnection:
|
||||
errorMessage = String(localized: "externalVideo.error.noConnection")
|
||||
case .timeout:
|
||||
errorMessage = String(localized: "externalVideo.error.timeout")
|
||||
default:
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
isLoading = false
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - InstancesManager Extension
|
||||
|
||||
extension InstancesManager {
|
||||
/// Returns the first enabled Yattee Server instance, if any.
|
||||
var yatteeServerInstance: Instance? {
|
||||
instances.first { $0.type == .yatteeServer && $0.isEnabled }
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ExternalVideoView(url: URL(string: "https://vimeo.com/123456")!)
|
||||
}
|
||||
213
Yattee/Views/Video/PlaylistSelectorSheet.swift
Normal file
213
Yattee/Views/Video/PlaylistSelectorSheet.swift
Normal file
@@ -0,0 +1,213 @@
|
||||
//
|
||||
// PlaylistSelectorSheet.swift
|
||||
// Yattee
|
||||
//
|
||||
// Sheet for selecting a playlist to add a video to.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import NukeUI
|
||||
|
||||
struct PlaylistSelectorSheet: View {
|
||||
let video: Video
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@State private var playlists: [LocalPlaylist] = []
|
||||
@State private var showingNewPlaylist = false
|
||||
@State private var pendingPlaylistTitle: String?
|
||||
@State private var pendingPlaylistDescription: String?
|
||||
@State private var addedToPlaylist: LocalPlaylist?
|
||||
|
||||
private var dataManager: DataManager? { appEnvironment?.dataManager }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Warning for local folder videos
|
||||
if video.isFromLocalFolder {
|
||||
Section {
|
||||
Label {
|
||||
Text(String(localized: "playlist.localFileWarning"))
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only show playlist options if not from local folder
|
||||
if !video.isFromLocalFolder {
|
||||
// Create new playlist section
|
||||
Section {
|
||||
Button {
|
||||
showingNewPlaylist = true
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: "playlist.new"),
|
||||
systemImage: "plus.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Existing playlists
|
||||
if !playlists.isEmpty {
|
||||
Section {
|
||||
ForEach(playlists, id: \.id) { playlist in
|
||||
PlaylistSelectionRow(
|
||||
playlist: playlist,
|
||||
video: video,
|
||||
wasAdded: addedToPlaylist?.id == playlist.id
|
||||
) {
|
||||
addVideoToPlaylist(playlist)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ContentUnavailableView {
|
||||
Label(
|
||||
String(localized: "playlist.empty.title"),
|
||||
systemImage: "music.note.list"
|
||||
)
|
||||
} description: {
|
||||
Text(String(localized: "playlist.empty.description"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "playlist.addTo"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(role: .cancel) {
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Close", systemImage: "xmark")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewPlaylist) {
|
||||
PlaylistFormSheet(mode: .create) { title, description in
|
||||
pendingPlaylistTitle = title
|
||||
pendingPlaylistDescription = description
|
||||
}
|
||||
}
|
||||
.onChange(of: pendingPlaylistTitle) { _, newValue in
|
||||
if newValue != nil {
|
||||
createAndAddToPlaylist()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadPlaylists()
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
private func loadPlaylists() {
|
||||
playlists = dataManager?.playlists() ?? []
|
||||
}
|
||||
|
||||
private func addVideoToPlaylist(_ playlist: LocalPlaylist) {
|
||||
guard let dataManager else { return }
|
||||
|
||||
dataManager.addToPlaylist(video, playlist: playlist)
|
||||
addedToPlaylist = playlist
|
||||
|
||||
// Auto-dismiss after a short delay to show the checkmark
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func createAndAddToPlaylist() {
|
||||
guard let dataManager, let title = pendingPlaylistTitle else { return }
|
||||
|
||||
let newPlaylist = dataManager.createPlaylist(title: title, description: pendingPlaylistDescription)
|
||||
dataManager.addToPlaylist(video, playlist: newPlaylist)
|
||||
pendingPlaylistTitle = nil
|
||||
pendingPlaylistDescription = nil
|
||||
addedToPlaylist = newPlaylist
|
||||
|
||||
// Refresh list and auto-dismiss
|
||||
loadPlaylists()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playlist Selection Row
|
||||
|
||||
private struct PlaylistSelectionRow: View {
|
||||
let playlist: LocalPlaylist
|
||||
let video: Video
|
||||
let wasAdded: Bool
|
||||
let onAdd: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onAdd) {
|
||||
HStack(spacing: 12) {
|
||||
// Thumbnail
|
||||
LazyImage(url: playlist.thumbnailURL) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(.quaternary)
|
||||
.overlay {
|
||||
Image(systemName: "music.note.list")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 60, height: 34)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
// Info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(playlist.title)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(String(localized: "playlist.videoCount \(playlist.videoCount)"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Status
|
||||
if wasAdded {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else if playlist.contains(videoID: video.id.videoID) {
|
||||
Text(String(localized: "playlist.alreadyAdded"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(playlist.contains(videoID: video.id.videoID) && !wasAdded)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
PlaylistSelectorSheet(video: .preview)
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
2146
Yattee/Views/Video/VideoInfoView.swift
Normal file
2146
Yattee/Views/Video/VideoInfoView.swift
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user