Files
Spanish/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift
T
Trey t 0a099c3fc9 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>
2026-04-23 07:00:16 -05:00

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() }
}
}
}
}
}