Files
yattee/Yattee/Views/Components/VideoSwipeActionsModifier.swift
Arkadiusz Fal d22abf6088 Add an option to share videos via privacy frontend URLs
When enabled (the default), share links to YouTube videos point to the
active source's web watch page instead of youtube.com. Invidious serves
its frontend at the API URL; Piped uses its configured frontend URL.
Videos the active source can't serve (PeerTube, extracted sources) and
sources without a web frontend keep their original links.

The toggle lives in Settings > Layout & Navigation > Sharing on iOS and
macOS, and is shown only when an enabled source has a web watch page.
2026-09-18 23:57:34 +02:00

302 lines
11 KiB
Swift

//
// VideoSwipeActionsModifier.swift
// Yattee
//
// View modifier that applies configurable swipe actions to video rows.
//
import SwiftUI
#if !os(tvOS)
/// View modifier that applies user-configurable swipe actions plus fixed context-specific actions.
struct VideoSwipeActionsModifier: ViewModifier {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.videoQueueContext) private var queueContext
let video: Video
var fixedActions: [SwipeAction] = []
@State private var showingPlaylistSheet = false
@State private var showingDownloadSheet = false
func body(content: Content) -> some View {
content
.swipeActions(actionsArray: allActions())
.sheet(isPresented: $showingPlaylistSheet) {
PlaylistSelectorSheet(video: video)
}
.sheet(isPresented: $showingDownloadSheet) {
DownloadQualitySheet(video: video)
}
}
private var visibleActions: [VideoSwipeAction] {
appEnvironment?.settingsManager.visibleVideoSwipeActions() ?? []
}
private func allActions() -> [SwipeAction] {
var actions = visibleActions.map { swipeAction(for: $0) }
actions.append(contentsOf: fixedActions)
return actions
}
private func swipeAction(for action: VideoSwipeAction) -> SwipeAction {
switch action {
case .playNext:
return SwipeAction(
symbolImage: action.symbolImage,
tint: action.tint,
background: action.backgroundColor
) { reset in
appEnvironment?.queueManager.playNext(video)
reset()
}
case .addToQueue:
return SwipeAction(
symbolImage: action.symbolImage,
tint: action.tint,
background: action.backgroundColor
) { reset in
appEnvironment?.queueManager.addToQueue(video)
reset()
}
case .download:
return downloadSwipeAction(action: action)
case .share:
return SwipeAction(
symbolImage: action.symbolImage,
tint: action.tint,
background: action.backgroundColor
) { reset in
shareVideo()
reset()
}
case .videoInfo:
return SwipeAction(
symbolImage: action.symbolImage,
tint: action.tint,
background: action.backgroundColor
) { reset in
appEnvironment?.navigationCoordinator.navigate(to: .video(.loaded(video)))
reset()
}
case .goToChannel:
return SwipeAction(
symbolImage: action.symbolImage,
tint: action.tint,
background: action.backgroundColor
) { reset in
appEnvironment?.navigationCoordinator.navigateToChannel(for: video)
reset()
}
case .addToBookmarks:
return bookmarkSwipeAction(action: action)
case .addToPlaylist:
return SwipeAction(
symbolImage: action.symbolImage,
tint: action.tint,
background: action.backgroundColor
) { reset in
showingPlaylistSheet = true
reset()
}
case .markWatched:
return watchedSwipeAction(action: action)
}
}
private func downloadSwipeAction(action: VideoSwipeAction) -> SwipeAction {
let isDownloading = appEnvironment?.downloadManager.isDownloading(video.id) ?? false
let isDownloaded = appEnvironment?.downloadManager.isDownloaded(video.id) ?? false
if isDownloading {
// Cancel download
return SwipeAction(
symbolImage: "xmark.circle",
tint: action.tint,
background: .red
) { reset in
if let download = appEnvironment?.downloadManager.download(for: video.id) {
Task {
await appEnvironment?.downloadManager.cancel(download)
}
}
reset()
}
} else if isDownloaded {
// Already downloaded - show checkmark
return SwipeAction(
symbolImage: "checkmark.circle.fill",
tint: action.tint,
background: action.backgroundColor
) { reset in
// No action - already downloaded
reset()
}
} else {
// Start download
return SwipeAction(
symbolImage: action.symbolImage,
tint: action.tint,
background: action.backgroundColor
) { reset in
startDownload()
reset()
}
}
}
private func bookmarkSwipeAction(action: VideoSwipeAction) -> SwipeAction {
let isBookmarked = appEnvironment?.dataManager.isBookmarked(videoID: video.id.videoID) ?? false
return SwipeAction(
symbolImage: isBookmarked ? "bookmark.slash" : action.symbolImage,
tint: action.tint,
background: action.backgroundColor
) { reset in
if isBookmarked {
appEnvironment?.dataManager.removeBookmark(for: video.id.videoID)
} else {
appEnvironment?.dataManager.addBookmark(for: video)
}
reset()
}
}
private func watchedSwipeAction(action: VideoSwipeAction) -> SwipeAction {
let isWatched = appEnvironment?.dataManager.watchEntry(for: video.id.videoID)?.isFinished ?? false
return SwipeAction(
symbolImage: isWatched ? "eye.slash" : action.symbolImage,
tint: action.tint,
background: action.backgroundColor
) { reset in
if isWatched {
appEnvironment?.dataManager.markAsUnwatched(videoID: video.id.videoID)
} else {
appEnvironment?.dataManager.markAsWatched(video: video)
}
reset()
}
}
private func shareVideo() {
#if os(iOS)
let activityVC = UIActivityViewController(
activityItems: [appEnvironment?.shareURL(for: video) ?? video.shareURL],
applicationActivities: nil
)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootVC = window.rootViewController {
// Find the top-most view controller
var topVC = rootVC
while let presented = topVC.presentedViewController {
topVC = presented
}
// On iPad UIActivityViewController is presented as a popover and
// requires an anchor; without sourceView/barButtonItem UIKit aborts
// in -[UIPopoverPresentationController presentationTransitionWillBegin].
if let popover = activityVC.popoverPresentationController {
popover.sourceView = topVC.view
popover.sourceRect = CGRect(x: topVC.view.bounds.midX,
y: topVC.view.bounds.midY,
width: 0, height: 0)
popover.permittedArrowDirections = []
}
topVC.present(activityVC, animated: true)
}
#elseif os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString((appEnvironment?.shareURL(for: video) ?? video.shareURL).absoluteString, forType: .string)
#endif
}
private func startDownload() {
guard let appEnvironment else {
showingDownloadSheet = true
return
}
// Media source videos use direct file URLs
if video.isFromMediaSource {
Task {
do {
try await appEnvironment.downloadManager.autoEnqueueMediaSource(
video,
mediaSourcesManager: appEnvironment.mediaSourcesManager,
webDAVClient: appEnvironment.webDAVClient,
smbClient: appEnvironment.smbClient
)
} catch {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "download.error.title"),
subtitle: error.localizedDescription,
icon: "exclamationmark.triangle",
iconColor: .red
)
}
}
return
}
let downloadSettings = appEnvironment.downloadSettings
// Check if auto-download mode
if downloadSettings.preferredDownloadQuality != .ask,
let instance = appEnvironment.instancesManager.instance(for: video) {
Task {
do {
try await appEnvironment.downloadManager.autoEnqueue(
video,
preferredQuality: downloadSettings.preferredDownloadQuality,
preferredAudioLanguage: appEnvironment.settingsManager.preferredAudioLanguage,
preferredSubtitlesLanguage: appEnvironment.settingsManager.preferredSubtitlesLanguage,
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload,
contentService: appEnvironment.contentService,
instance: instance
)
} catch {
appEnvironment.toastManager.show(
category: .error,
title: String(localized: "download.error.title"),
subtitle: error.localizedDescription,
icon: "exclamationmark.triangle",
iconColor: .red
)
}
}
} else {
showingDownloadSheet = true
}
}
}
// MARK: - View Extension
extension View {
/// Applies user-configurable video swipe actions with optional fixed context-specific actions.
///
/// - Parameters:
/// - video: The video to apply swipe actions for.
/// - fixedActions: Context-specific fixed actions (e.g., delete for history).
/// These appear to the right of configurable actions.
func videoSwipeActions(
video: Video,
fixedActions: [SwipeAction] = []
) -> some View {
modifier(VideoSwipeActionsModifier(video: video, fixedActions: fixedActions))
}
}
#endif