Fixes #30 — Downloaded videos include audio via adaptive-stream muxing
YouTubeKit returns stream URLs only; its own README punts on the progressive-vs-adaptive problem. Most modern YouTube videos above 360p serve video and audio as separate DASH tracks, so the previous "any MP4" fallback silently grabbed a video-only track and the downloaded file had no audio. VideoDownloadService now resolves a DownloadPlan up front. Progressive MP4 (when one exists) still streams straight to the final destination. Otherwise the service downloads the best MP4 video track + best M4A audio track to temp files, then combines them into a single MP4 via AVMutableComposition + AVAssetExportSession. Passthrough preset first (lossless container rewrite) with a fallback to highestQuality if the codec combination requires re-encoding. Temp files are cleaned up on both success and failure. DownloadStatus replaces the bare Double progress so the UI can show per-phase labels (Video %, Audio %, Finalizing…). Muxing progress is rendered as an indeterminate spinner since AVAssetExportSession's progress property doesn't cross actor boundaries cleanly. Also retires the deprecated Stream.subtype comparison in favor of Stream.fileExtension, matching YouTubeKit's current API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,41 +1,57 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SharedModels
|
import SharedModels
|
||||||
|
import AVFoundation
|
||||||
import YouTubeKit
|
import YouTubeKit
|
||||||
|
|
||||||
/// Downloads YouTube videos for offline viewing (Issue #21, phase 3).
|
/// Downloads YouTube videos for offline viewing (Issue #21, updated for #30).
|
||||||
///
|
///
|
||||||
/// Uses YouTubeKit to resolve stream URLs, then a `URLSession` download task
|
/// Two-path strategy since YouTube phased out high-quality progressive streams:
|
||||||
/// to persist the MP4 under the app's documents directory. Metadata is
|
/// 1. **Progressive** — single MP4 with audio+video combined (rare, ≤360p).
|
||||||
/// recorded in SwiftData via `DownloadedVideo` so the app knows what's on
|
/// Download directly to the final path.
|
||||||
/// disk across launches.
|
/// 2. **Adaptive** — DASH: separate video + audio tracks. Download each to
|
||||||
|
/// temp files, then mux with AVAssetExportSession (`.passthrough` preset,
|
||||||
|
/// no re-encoding) into the final MP4.
|
||||||
|
///
|
||||||
|
/// YouTubeKit returns stream URLs only; combining tracks is the app's job.
|
||||||
|
/// See the library's README Example 3 for the progressive-only pattern.
|
||||||
///
|
///
|
||||||
/// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and
|
/// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and
|
||||||
/// will break when YouTube changes their internal format. When it does, the
|
/// will break when YouTube changes their internal format. Streaming (iframe
|
||||||
/// service throws `DownloadError.extractionFailed` and the UI should fall
|
/// embed elsewhere) keeps working regardless.
|
||||||
/// back to streaming (phase 2) which remains available.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class VideoDownloadService {
|
final class VideoDownloadService {
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
/// Per-download state surfaced to the UI. `progress == nil` means show an
|
||||||
|
/// indeterminate spinner (e.g. muxing phase).
|
||||||
|
struct DownloadStatus: Equatable, Sendable {
|
||||||
|
var progress: Double?
|
||||||
|
var label: String
|
||||||
|
}
|
||||||
|
|
||||||
enum DownloadError: Error, LocalizedError {
|
enum DownloadError: Error, LocalizedError {
|
||||||
case extractionFailed(String)
|
case extractionFailed(String)
|
||||||
case noSuitableStream
|
case noSuitableStream
|
||||||
case downloadFailed(String)
|
case downloadFailed(String)
|
||||||
|
case muxFailed(String)
|
||||||
case fileWriteFailed(String)
|
case fileWriteFailed(String)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .extractionFailed(let why): "Could not extract video: \(why)"
|
case .extractionFailed(let why): "Could not extract video: \(why)"
|
||||||
case .noSuitableStream: "No downloadable stream found for this video."
|
case .noSuitableStream: "No downloadable video+audio streams found for this video."
|
||||||
case .downloadFailed(let why): "Download failed: \(why)"
|
case .downloadFailed(let why): "Download failed: \(why)"
|
||||||
|
case .muxFailed(let why): "Could not combine audio and video: \(why)"
|
||||||
case .fileWriteFailed(let why): "Could not save video: \(why)"
|
case .fileWriteFailed(let why): "Could not save video: \(why)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-flight downloads by videoId. Progress is Double in [0, 1].
|
/// In-flight downloads by videoId, with a phase label and progress.
|
||||||
var activeDownloads: [String: Double] = [:]
|
var activeDownloads: [String: DownloadStatus] = [:]
|
||||||
|
|
||||||
static let shared = VideoDownloadService()
|
static let shared = VideoDownloadService()
|
||||||
|
|
||||||
@@ -46,6 +62,10 @@ final class VideoDownloadService {
|
|||||||
return docs.appendingPathComponent("videos", isDirectory: true)
|
return docs.appendingPathComponent("videos", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static var tempDirectory: URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
}
|
||||||
|
|
||||||
private static func ensureDirectory() throws {
|
private static func ensureDirectory() throws {
|
||||||
let url = videosDirectory
|
let url = videosDirectory
|
||||||
if !FileManager.default.fileExists(atPath: url.path) {
|
if !FileManager.default.fileExists(atPath: url.path) {
|
||||||
@@ -57,64 +77,93 @@ final class VideoDownloadService {
|
|||||||
videosDirectory.appendingPathComponent("\(videoId).mp4")
|
videosDirectory.appendingPathComponent("\(videoId).mp4")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func tempURL(videoId: String, kind: String, ext: String) -> URL {
|
||||||
|
tempDirectory.appendingPathComponent("\(videoId).\(kind).\(ext)")
|
||||||
|
}
|
||||||
|
|
||||||
/// True if a downloaded MP4 exists for this videoId.
|
/// True if a downloaded MP4 exists for this videoId.
|
||||||
static func isDownloaded(videoId: String) -> Bool {
|
static func isDownloaded(videoId: String) -> Bool {
|
||||||
FileManager.default.fileExists(atPath: fileURL(for: videoId).path)
|
FileManager.default.fileExists(atPath: fileURL(for: videoId).path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Download
|
// MARK: - Public download entry point
|
||||||
|
|
||||||
/// Downloads a YouTube video to local storage and records it in SwiftData.
|
|
||||||
/// Throws on any failure. Caller is responsible for showing errors.
|
|
||||||
func download(
|
func download(
|
||||||
videoId: String,
|
videoId: String,
|
||||||
title: String,
|
title: String,
|
||||||
into modelContext: ModelContext
|
into modelContext: ModelContext
|
||||||
) async throws {
|
) async throws {
|
||||||
guard !activeDownloads.keys.contains(videoId) else { return }
|
guard activeDownloads[videoId] == nil else { return }
|
||||||
activeDownloads[videoId] = 0
|
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Preparing…")
|
||||||
|
|
||||||
defer { activeDownloads.removeValue(forKey: videoId) }
|
defer { activeDownloads.removeValue(forKey: videoId) }
|
||||||
|
|
||||||
try Self.ensureDirectory()
|
try Self.ensureDirectory()
|
||||||
|
|
||||||
// 1. Resolve stream URL via YouTubeKit. Run off the main actor because
|
let destURL = Self.fileURL(for: videoId)
|
||||||
// YouTubeKit.YouTube isn't Sendable and does synchronous work we don't
|
|
||||||
// want blocking UI.
|
// Resolve streams once; decide progressive vs adaptive.
|
||||||
let streamURL: URL
|
let plan: DownloadPlan
|
||||||
do {
|
do {
|
||||||
streamURL = try await Self.resolveStreamURL(videoId: videoId)
|
plan = try await Self.resolvePlan(videoId: videoId)
|
||||||
} catch let e as DownloadError {
|
} catch let e as DownloadError {
|
||||||
throw e
|
throw e
|
||||||
} catch {
|
} catch {
|
||||||
throw DownloadError.extractionFailed(error.localizedDescription)
|
throw DownloadError.extractionFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Download the stream to disk with progress tracking.
|
switch plan {
|
||||||
let destURL = Self.fileURL(for: videoId)
|
case .progressive(let url):
|
||||||
do {
|
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Downloading…")
|
||||||
let (tempURL, response) = try await URLSession.shared.download(
|
try await downloadStream(from: url, to: destURL) { [weak self] p in
|
||||||
for: URLRequest(url: streamURL),
|
Task { @MainActor [weak self] in
|
||||||
delegate: DownloadProgressDelegate { [weak self] progress in
|
self?.activeDownloads[videoId] = DownloadStatus(
|
||||||
Task { @MainActor in
|
progress: p,
|
||||||
self?.activeDownloads[videoId] = progress
|
label: "Downloading \(Int(p * 100))%"
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
_ = response
|
|
||||||
|
|
||||||
// Move the temp file to our persistent location (atomic).
|
|
||||||
if FileManager.default.fileExists(atPath: destURL.path) {
|
|
||||||
try FileManager.default.removeItem(at: destURL)
|
|
||||||
}
|
}
|
||||||
try FileManager.default.moveItem(at: tempURL, to: destURL)
|
|
||||||
} catch let e as DownloadError {
|
case .adaptive(let videoURL, let audioURL):
|
||||||
throw e
|
let tempVideo = Self.tempURL(videoId: videoId, kind: "video", ext: "mp4")
|
||||||
} catch {
|
let tempAudio = Self.tempURL(videoId: videoId, kind: "audio", ext: "m4a")
|
||||||
throw DownloadError.downloadFailed(error.localizedDescription)
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: tempVideo)
|
||||||
|
try? FileManager.default.removeItem(at: tempAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1 of 3: video track (0–55% of overall progress)
|
||||||
|
try await downloadStream(from: videoURL, to: tempVideo) { [weak self] p in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.activeDownloads[videoId] = DownloadStatus(
|
||||||
|
progress: p * 0.55,
|
||||||
|
label: "Video \(Int(p * 100))%"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 of 3: audio track (55–80%)
|
||||||
|
try await downloadStream(from: audioURL, to: tempAudio) { [weak self] p in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.activeDownloads[videoId] = DownloadStatus(
|
||||||
|
progress: 0.55 + p * 0.25,
|
||||||
|
label: "Audio \(Int(p * 100))%"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3 of 3: mux (indeterminate)
|
||||||
|
activeDownloads[videoId] = DownloadStatus(progress: nil, label: "Finalizing…")
|
||||||
|
do {
|
||||||
|
try await Self.mux(videoURL: tempVideo, audioURL: tempAudio, to: destURL)
|
||||||
|
} catch let e as DownloadError {
|
||||||
|
throw e
|
||||||
|
} catch {
|
||||||
|
throw DownloadError.muxFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Record in SwiftData.
|
// Record in SwiftData.
|
||||||
let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path)
|
let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path)
|
||||||
let byteCount = (attrs?[.size] as? Int) ?? 0
|
let byteCount = (attrs?[.size] as? Int) ?? 0
|
||||||
let entry = DownloadedVideo(
|
let entry = DownloadedVideo(
|
||||||
@@ -143,20 +192,6 @@ final class VideoDownloadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the best progressive-MP4 stream URL for a YouTube videoId.
|
|
||||||
/// Runs off the main actor because `YouTube` isn't Sendable.
|
|
||||||
nonisolated private static func resolveStreamURL(videoId: String) async throws -> URL {
|
|
||||||
let youtube = YouTube(videoID: videoId)
|
|
||||||
let streams = try await youtube.streams
|
|
||||||
let candidate = streams
|
|
||||||
.filter { $0.isProgressive && $0.subtype == "mp4" }
|
|
||||||
.sorted { ($0.bitrate ?? 0) > ($1.bitrate ?? 0) }
|
|
||||||
.first
|
|
||||||
?? streams.filter({ $0.subtype == "mp4" }).first
|
|
||||||
guard let stream = candidate else { throw DownloadError.noSuitableStream }
|
|
||||||
return stream.url
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total bytes used by all downloads.
|
/// Total bytes used by all downloads.
|
||||||
static func totalBytesUsed() -> Int {
|
static func totalBytesUsed() -> Int {
|
||||||
let url = videosDirectory
|
let url = videosDirectory
|
||||||
@@ -168,14 +203,142 @@ final class VideoDownloadService {
|
|||||||
return acc + size
|
return acc + size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Stream resolution
|
||||||
|
|
||||||
|
private enum DownloadPlan {
|
||||||
|
case progressive(URL)
|
||||||
|
case adaptive(video: URL, audio: URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks progressive MP4 if available (single download); otherwise the
|
||||||
|
/// best adaptive MP4 video + M4A audio pair for later muxing.
|
||||||
|
nonisolated private static func resolvePlan(videoId: String) async throws -> DownloadPlan {
|
||||||
|
let youtube = YouTube(videoID: videoId)
|
||||||
|
let streams = try await youtube.streams
|
||||||
|
|
||||||
|
// 1. Progressive path — uses the library's own recommended filter chain.
|
||||||
|
if let progressive = streams
|
||||||
|
.filterVideoAndAudio()
|
||||||
|
.filter({ $0.fileExtension == .mp4 && $0.isNativelyPlayable })
|
||||||
|
.highestResolutionStream() {
|
||||||
|
return .progressive(progressive.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Adaptive path — highest-resolution MP4 video track + highest-bitrate M4A audio.
|
||||||
|
let videoStream = streams
|
||||||
|
.filterVideoOnly()
|
||||||
|
.filter { $0.fileExtension == .mp4 && $0.isNativelyPlayable }
|
||||||
|
.highestResolutionStream()
|
||||||
|
let audioStream = streams
|
||||||
|
.filterAudioOnly()
|
||||||
|
.filter {
|
||||||
|
($0.fileExtension == .m4a || $0.fileExtension == .mp4) && $0.isNativelyPlayable
|
||||||
|
}
|
||||||
|
.highestAudioBitrateStream()
|
||||||
|
|
||||||
|
if let v = videoStream, let a = audioStream {
|
||||||
|
return .adaptive(video: v.url, audio: a.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw DownloadError.noSuitableStream
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Download helper
|
||||||
|
|
||||||
|
nonisolated private func downloadStream(
|
||||||
|
from url: URL,
|
||||||
|
to dest: URL,
|
||||||
|
onProgress: @escaping @Sendable (Double) -> Void
|
||||||
|
) async throws {
|
||||||
|
let delegate = DownloadProgressDelegate(onProgress: onProgress)
|
||||||
|
do {
|
||||||
|
let (tempURL, _) = try await URLSession.shared.download(
|
||||||
|
for: URLRequest(url: url),
|
||||||
|
delegate: delegate
|
||||||
|
)
|
||||||
|
if FileManager.default.fileExists(atPath: dest.path) {
|
||||||
|
try FileManager.default.removeItem(at: dest)
|
||||||
|
}
|
||||||
|
try FileManager.default.moveItem(at: tempURL, to: dest)
|
||||||
|
} catch {
|
||||||
|
throw DownloadError.downloadFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Muxing
|
||||||
|
|
||||||
|
/// Combines a video-only file and an audio-only file into a single MP4.
|
||||||
|
/// Uses `.passthrough` preset — no re-encoding, just container rewrite.
|
||||||
|
/// Falls back to `.highestQuality` (which re-encodes) if passthrough
|
||||||
|
/// rejects the composition due to codec incompatibility.
|
||||||
|
nonisolated private static func mux(
|
||||||
|
videoURL: URL,
|
||||||
|
audioURL: URL,
|
||||||
|
to outputURL: URL
|
||||||
|
) async throws {
|
||||||
|
let videoAsset = AVURLAsset(url: videoURL)
|
||||||
|
let audioAsset = AVURLAsset(url: audioURL)
|
||||||
|
|
||||||
|
let videoTracks = try await videoAsset.loadTracks(withMediaType: .video)
|
||||||
|
let audioTracks = try await audioAsset.loadTracks(withMediaType: .audio)
|
||||||
|
|
||||||
|
guard let srcVideo = videoTracks.first,
|
||||||
|
let srcAudio = audioTracks.first else {
|
||||||
|
throw DownloadError.muxFailed("Downloaded streams missing expected tracks")
|
||||||
|
}
|
||||||
|
|
||||||
|
let composition = AVMutableComposition()
|
||||||
|
guard let compVideo = composition.addMutableTrack(
|
||||||
|
withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid
|
||||||
|
),
|
||||||
|
let compAudio = composition.addMutableTrack(
|
||||||
|
withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid
|
||||||
|
) else {
|
||||||
|
throw DownloadError.muxFailed("Could not create composition tracks")
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoDuration = try await videoAsset.load(.duration)
|
||||||
|
let audioDuration = try await audioAsset.load(.duration)
|
||||||
|
let duration = CMTimeMinimum(videoDuration, audioDuration)
|
||||||
|
let range = CMTimeRange(start: .zero, duration: duration)
|
||||||
|
|
||||||
|
try compVideo.insertTimeRange(range, of: srcVideo, at: .zero)
|
||||||
|
try compAudio.insertTimeRange(range, of: srcAudio, at: .zero)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: outputURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try passthrough (no re-encode) first; fall back to quality export on failure.
|
||||||
|
let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality]
|
||||||
|
var lastError: Error?
|
||||||
|
|
||||||
|
for preset in presets {
|
||||||
|
guard let exporter = AVAssetExportSession(asset: composition, presetName: preset) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await exporter.export(to: outputURL, as: .mp4)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
// Clean up partial output before retrying with next preset.
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw DownloadError.muxFailed(lastError?.localizedDescription ?? "Unknown export failure")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - URLSession progress delegate
|
// MARK: - URLSession progress delegate
|
||||||
|
|
||||||
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate {
|
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||||
let onProgress: (Double) -> Void
|
let onProgress: @Sendable (Double) -> Void
|
||||||
|
|
||||||
init(onProgress: @escaping (Double) -> Void) {
|
init(onProgress: @escaping @Sendable (Double) -> Void) {
|
||||||
self.onProgress = onProgress
|
self.onProgress = onProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +350,7 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega
|
|||||||
totalBytesExpectedToWrite: Int64
|
totalBytesExpectedToWrite: Int64
|
||||||
) {
|
) {
|
||||||
guard totalBytesExpectedToWrite > 0 else { return }
|
guard totalBytesExpectedToWrite > 0 else { return }
|
||||||
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
onProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
|
||||||
onProgress(progress)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(
|
func urlSession(
|
||||||
@@ -196,6 +358,6 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega
|
|||||||
downloadTask: URLSessionDownloadTask,
|
downloadTask: URLSessionDownloadTask,
|
||||||
didFinishDownloadingTo location: URL
|
didFinishDownloadingTo location: URL
|
||||||
) {
|
) {
|
||||||
// Not used — `URLSession.download(for:delegate:)` already returns the temp URL.
|
// Not used — `URLSession.download(for:delegate:)` returns the temp URL directly.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ struct VideoActionsButtonRow: View {
|
|||||||
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
|
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var activeProgress: Double? {
|
private var activeStatus: VideoDownloadService.DownloadStatus? {
|
||||||
downloadService.activeDownloads[video.videoId]
|
downloadService.activeDownloads[video.videoId]
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isDownloading: Bool {
|
private var isDownloading: Bool {
|
||||||
activeProgress != nil
|
activeStatus != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -103,13 +103,19 @@ struct VideoActionsButtonRow: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var downloadButton: some View {
|
private var downloadButton: some View {
|
||||||
// Single slot whose role flips through the download lifecycle:
|
// Single slot whose role flips through the download lifecycle:
|
||||||
// Download → progress (disabled) → Delete.
|
// Download → progress/label (disabled) → Delete.
|
||||||
if isDownloading, let progress = activeProgress {
|
if let status = activeStatus {
|
||||||
Button {} label: {
|
Button {} label: {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ProgressView(value: progress).frame(width: 40)
|
if let progress = status.progress {
|
||||||
Text("\(Int(progress * 100))%")
|
ProgressView(value: progress).frame(width: 40)
|
||||||
|
} else {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
Text(status.label)
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user