0a099c3fc9
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>
222 lines
7.1 KiB
Swift
222 lines
7.1 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import AVKit
|
|
import SharedModels
|
|
|
|
/// Three-button row for a curated YouTube video (Issue #21):
|
|
/// - **Stream** — opens in the YouTube app (falls back to Safari).
|
|
/// - **Download** — pulls the MP4 via YouTubeKit, shows progress, then enables Play.
|
|
/// - **Play** — enabled only when the video exists on disk; plays via AVPlayer.
|
|
///
|
|
/// Used by both `GuideDetailView` and `GrammarNoteDetailView` to keep the
|
|
/// video affordances consistent.
|
|
struct VideoActionsButtonRow: View {
|
|
let video: YouTubeVideoStore.VideoEntry
|
|
|
|
@Environment(\.openURL) private var openURL
|
|
@Environment(\.modelContext) private var modelContext
|
|
|
|
@State private var downloadService = VideoDownloadService.shared
|
|
@State private var isDownloaded: Bool
|
|
@State private var playerVideoId: String?
|
|
@State private var downloadError: String?
|
|
@State private var confirmDelete: Bool = false
|
|
|
|
init(video: YouTubeVideoStore.VideoEntry) {
|
|
self.video = video
|
|
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
|
|
}
|
|
|
|
private var activeStatus: VideoDownloadService.DownloadStatus? {
|
|
downloadService.activeDownloads[video.videoId]
|
|
}
|
|
|
|
private var isDownloading: Bool {
|
|
activeStatus != nil
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(video.title)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
|
|
HStack(spacing: 10) {
|
|
streamButton
|
|
downloadButton
|
|
playButton
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
|
.fullScreenCover(item: Binding(
|
|
get: { playerVideoId.map { LocalVideoID(videoId: $0) } },
|
|
set: { playerVideoId = $0?.videoId }
|
|
)) { id in
|
|
LocalVideoPlayerSheet(videoId: id.videoId, title: video.title)
|
|
}
|
|
.alert("Download failed", isPresented: .init(
|
|
get: { downloadError != nil },
|
|
set: { if !$0 { downloadError = nil } }
|
|
)) {
|
|
Button("OK") { downloadError = nil }
|
|
} message: {
|
|
Text(downloadError ?? "")
|
|
}
|
|
.confirmationDialog(
|
|
"Delete this downloaded video?",
|
|
isPresented: $confirmDelete,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Delete", role: .destructive) {
|
|
downloadService.delete(videoId: video.videoId, modelContext: modelContext)
|
|
isDownloaded = false
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("You can re-download it at any time.")
|
|
}
|
|
.onAppear {
|
|
// Refresh on appear in case the user deleted the file via Settings.
|
|
isDownloaded = VideoDownloadService.isDownloaded(videoId: video.videoId)
|
|
}
|
|
}
|
|
|
|
// MARK: - Buttons
|
|
|
|
private var streamButton: some View {
|
|
Button {
|
|
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
|
|
openURL(url)
|
|
}
|
|
} label: {
|
|
Label("Stream", systemImage: "play.rectangle.fill")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.red)
|
|
.controlSize(.large)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var downloadButton: some View {
|
|
// Single slot whose role flips through the download lifecycle:
|
|
// Download → progress/label (disabled) → Delete.
|
|
if let status = activeStatus {
|
|
Button {} label: {
|
|
HStack(spacing: 6) {
|
|
if let progress = status.progress {
|
|
ProgressView(value: progress).frame(width: 40)
|
|
} else {
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
Text(status.label)
|
|
.font(.caption.monospacedDigit())
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.blue)
|
|
.controlSize(.large)
|
|
.disabled(true)
|
|
} else if isDownloaded {
|
|
Button(role: .destructive) {
|
|
confirmDelete = true
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.red)
|
|
.controlSize(.large)
|
|
} else {
|
|
Button {
|
|
Task { await startDownload() }
|
|
} label: {
|
|
Label("Download", systemImage: "arrow.down.to.line")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.blue)
|
|
.controlSize(.large)
|
|
}
|
|
}
|
|
|
|
private var playButton: some View {
|
|
Button {
|
|
playerVideoId = video.videoId
|
|
} label: {
|
|
Label("Play", systemImage: "play.fill")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.green)
|
|
.controlSize(.large)
|
|
.disabled(!isDownloaded)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func startDownload() async {
|
|
do {
|
|
try await downloadService.download(
|
|
videoId: video.videoId,
|
|
title: video.title,
|
|
into: modelContext
|
|
)
|
|
isDownloaded = true
|
|
} catch {
|
|
downloadError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper identifiable wrapper so .sheet(item:) can use a plain String
|
|
|
|
private struct LocalVideoID: Identifiable {
|
|
let videoId: String
|
|
var id: String { videoId }
|
|
}
|
|
|
|
// MARK: - Local playback sheet
|
|
|
|
struct LocalVideoPlayerSheet: View {
|
|
let videoId: String
|
|
let title: String
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var player: AVPlayer
|
|
|
|
init(videoId: String, title: String) {
|
|
self.videoId = videoId
|
|
self.title = title
|
|
self._player = State(initialValue: AVPlayer(url: VideoDownloadService.fileURL(for: videoId)))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color.black.ignoresSafeArea()
|
|
VideoPlayer(player: player)
|
|
.ignoresSafeArea()
|
|
.onAppear { player.play() }
|
|
.onDisappear { player.pause() }
|
|
}
|
|
.navigationTitle(title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbarBackground(.black, for: .navigationBar)
|
|
.toolbarBackground(.visible, for: .navigationBar)
|
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|