Files
yattee/Yattee/Views/Components/VideoContextMenu.swift
Arkadiusz Fal 612dce6b9f Refactor views
2026-02-09 01:13:02 +01:00

675 lines
27 KiB
Swift

//
// VideoContextMenu.swift
// Yattee
//
// Shared context menu for video items.
//
import SwiftUI
// MARK: - Types
/// A custom action to display in the video context menu.
struct VideoContextAction {
let label: String
let systemImage: String
let role: ButtonRole?
let action: () -> Void
init(_ label: String, systemImage: String, role: ButtonRole? = nil, action: @escaping () -> Void) {
self.label = label
self.systemImage = systemImage
self.role = role
self.action = action
}
}
/// Context indicating which view the menu is being shown from.
/// Used to customize built-in menu items based on the current view.
enum VideoContextMenuContext {
case `default` // Standard behavior (show all items)
case history // Viewing history list
case bookmarks // Viewing bookmarks (hide bookmark toggle)
case playlist // Viewing a playlist
case continueWatching // Continue watching section
case downloads // Viewing downloads (hide download option, bookmark toggle)
case mediaBrowser // Browsing media source files (hide bookmark toggle, playlist, download)
case player // In player view (hide play action - video already playing)
}
// MARK: - View Modifier
/// View modifier that attaches VideoContextMenu and its required sheets.
struct VideoContextMenuModifier: ViewModifier {
@Environment(\.appEnvironment) private var appEnvironment
let video: Video
var customActions: [VideoContextAction] = []
var context: VideoContextMenuContext = .default
var startTime: Double? = nil
var watchProgress: Double? = nil
@State private var showingPlaylistSheet = false
@State private var showingDownloadSheet = false
@State private var showingDeleteDownloadConfirmation = false
@State private var downloadToDelete: Download?
@State private var menuRefreshID = UUID()
func body(content: Content) -> some View {
content
#if os(tvOS)
.contextMenu {
VideoContextMenuContent(
video: video,
customActions: customActions,
context: context,
startTime: startTime,
showingPlaylistSheet: $showingPlaylistSheet,
showingDownloadSheet: $showingDownloadSheet,
showingDeleteDownloadConfirmation: $showingDeleteDownloadConfirmation,
downloadToDelete: $downloadToDelete,
appEnvironment: appEnvironment
)
.id(menuRefreshID)
}
#else
.contextMenu(menuItems: {
VideoContextMenuContent(
video: video,
customActions: customActions,
context: context,
startTime: startTime,
showingPlaylistSheet: $showingPlaylistSheet,
showingDownloadSheet: $showingDownloadSheet,
showingDeleteDownloadConfirmation: $showingDeleteDownloadConfirmation,
downloadToDelete: $downloadToDelete,
appEnvironment: appEnvironment
)
.id(menuRefreshID)
}, preview: {
VideoRowView(
video: video,
style: .regular,
watchProgress: watchProgress,
disableInternalTapHandling: true
)
.frame(width: 320)
.padding()
.environment(\.appEnvironment, appEnvironment)
})
#endif
.sheet(isPresented: $showingPlaylistSheet) {
PlaylistSelectorSheet(video: video)
}
#if !os(tvOS)
.sheet(isPresented: $showingDownloadSheet) {
DownloadQualitySheet(video: video)
}
.alert(String(localized: "videoInfo.download.remove.title"), isPresented: $showingDeleteDownloadConfirmation) {
Button(String(localized: "common.cancel"), role: .cancel) {}
Button(String(localized: "videoInfo.download.remove.confirm"), role: .destructive) {
if let download = downloadToDelete {
Task {
await appEnvironment?.downloadManager.delete(download)
}
}
}
} message: {
Text(String(localized: "videoInfo.download.remove.message"))
}
#endif
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
menuRefreshID = UUID()
}
}
}
// MARK: - Menu Content
/// The actual menu content (uses bindings from parent for sheet presentation).
/// All observable values are snapshotted at init time to prevent redraws during playback.
struct VideoContextMenuContent: View {
@Environment(\.appEnvironment) private var appEnvironment
let video: Video
var customActions: [VideoContextAction] = []
var context: VideoContextMenuContext = .default
var startTime: Double? = nil
@Binding var showingPlaylistSheet: Bool
@Binding var showingDownloadSheet: Bool
@Binding var showingDeleteDownloadConfirmation: Bool
@Binding var downloadToDelete: Download?
// MARK: - Snapshotted Values (captured at init to prevent observation)
/// Snapshotted remote control enabled state.
private let remoteControlEnabled: Bool
/// Snapshotted discovered devices list.
private let snapshotDevices: [DiscoveredDevice]
/// Snapshotted queue enabled setting.
private let queueEnabled: Bool
/// Snapshotted bookmark state.
private let isBookmarked: Bool
/// Snapshotted downloading state.
private let isDownloading: Bool
/// Snapshotted active download.
private let activeDownload: Download?
/// Snapshotted downloaded state.
private let isDownloaded: Bool
/// Snapshotted download object for deletion.
private let snapshotDownload: Download?
/// Snapshotted state indicating if queue has items (video playing or queued).
private let hasQueueItems: Bool
// MARK: - Init
init(
video: Video,
customActions: [VideoContextAction] = [],
context: VideoContextMenuContext = .default,
startTime: Double? = nil,
showingPlaylistSheet: Binding<Bool>,
showingDownloadSheet: Binding<Bool>,
showingDeleteDownloadConfirmation: Binding<Bool> = .constant(false),
downloadToDelete: Binding<Download?> = .constant(nil),
appEnvironment: AppEnvironment? = nil
) {
self.video = video
self.customActions = customActions
self.context = context
self.startTime = startTime
self._showingPlaylistSheet = showingPlaylistSheet
self._showingDownloadSheet = showingDownloadSheet
self._showingDeleteDownloadConfirmation = showingDeleteDownloadConfirmation
self._downloadToDelete = downloadToDelete
// Snapshot observable values to prevent view updates during playback
self.remoteControlEnabled = appEnvironment?.remoteControlCoordinator.isEnabled ?? false
self.snapshotDevices = appEnvironment?.remoteControlCoordinator.discoveredDevices ?? []
self.queueEnabled = appEnvironment?.settingsManager.queueEnabled ?? true
self.isBookmarked = appEnvironment?.dataManager.isBookmarked(videoID: video.id.videoID) ?? false
self.isDownloading = appEnvironment?.downloadManager.isDownloading(video.id) ?? false
self.activeDownload = appEnvironment?.downloadManager.download(for: video.id)
self.isDownloaded = appEnvironment?.downloadManager.isDownloaded(video.id) ?? false
// Snapshot download for potential deletion
self.snapshotDownload = appEnvironment?.downloadManager.download(for: video.id)
// Queue actions only make sense when there's already a video playing or queued
let playerState = appEnvironment?.playerService.state
self.hasQueueItems = playerState?.currentVideo != nil || playerState?.hasNext == true
}
// MARK: - Computed Properties (context-based, not observable)
/// Whether to show the bookmark toggle based on context
private var showBookmarkToggle: Bool {
!video.isFromLocalFolder && context != .bookmarks && context != .downloads && context != .mediaBrowser
}
/// Whether to show add to playlist based on context
private var showAddToPlaylist: Bool {
!video.isFromLocalFolder && context != .mediaBrowser
}
/// Whether to show the download option based on context
private var showDownloadOption: Bool {
context != .downloads
}
/// Whether to show Go to channel based on context
private var showGoToChannel: Bool {
context != .mediaBrowser
}
/// Whether to show the play action based on context
private var showPlayAction: Bool {
context != .player
}
/// Whether to show queue actions based on context, settings, and queue state
private var showQueueActions: Bool {
context != .player && queueEnabled && hasQueueItems
}
/// Computed at render time to always show current watch state
private var isWatched: Bool {
appEnvironment?.dataManager.watchEntry(for: video.id.videoID)?.isFinished ?? false
}
var body: some View {
// Custom actions at the top
ForEach(customActions.indices, id: \.self) { index in
let action = customActions[index]
Button(role: action.role) {
action.action()
} label: {
Label(action.label, systemImage: action.systemImage)
}
}
// Divider after custom actions (if any)
if !customActions.isEmpty {
Divider()
}
ControlGroup {
// Play (hidden in player context since video is already playing)
if showPlayAction {
Button {
if let startTime {
appEnvironment?.playerService.openVideo(video, startTime: startTime)
} else {
appEnvironment?.playerService.openVideo(video)
}
} label: {
Label(String(localized: "video.context.play"), systemImage: "play.fill")
}
}
#if !os(tvOS)
// Download / Cancel download / Downloaded
if showDownloadOption {
if isDownloading, let download = activeDownload {
Button(role: .destructive) {
Task {
await appEnvironment?.downloadManager.cancel(download)
}
} label: {
Label(String(localized: "video.context.cancelDownload"), systemImage: "xmark.circle")
}
} else if isDownloaded, let download = snapshotDownload {
Button {
downloadToDelete = download
showingDeleteDownloadConfirmation = true
} label: {
Label(String(localized: "video.context.downloaded"), systemImage: "checkmark.circle.fill")
}
} else {
Button {
startDownload(for: video)
} label: {
Label(String(localized: "video.context.download"), systemImage: "arrow.down.circle")
}
}
}
#endif
// Share
#if !os(tvOS)
ShareLink(item: video.shareURL) {
Label(String(localized: "video.context.share"), systemImage: "square.and.arrow.up")
}
#endif
}
// Play from Beginning (only shown when there's saved progress)
if showPlayAction, let startTime, startTime > 0 {
Button {
appEnvironment?.playerService.openVideo(video, startTime: 0)
} label: {
Label(String(localized: "video.context.playFromBeginning"), systemImage: "arrow.counterclockwise")
}
}
// Mark as Watched / Unwatched
if !video.isFromLocalFolder {
Button {
if isWatched {
appEnvironment?.dataManager.markAsUnwatched(videoID: video.id.videoID)
} else {
appEnvironment?.dataManager.markAsWatched(video: video)
}
} label: {
if isWatched {
Label(String(localized: "video.context.markUnwatched"), systemImage: "eye.slash")
} else {
Label(String(localized: "video.context.markWatched"), systemImage: "eye")
}
}
}
// Queue actions (hidden in player context and when queue feature is disabled)
if showQueueActions {
Button {
appEnvironment?.queueManager.playNext(video)
} label: {
Label(String(localized: "video.context.playNext"), systemImage: "text.line.first.and.arrowtriangle.forward")
}
Button {
appEnvironment?.queueManager.addToQueue(video)
} label: {
Label(String(localized: "video.context.addToQueue"), systemImage: "text.append")
}
}
// Play on remote devices (for non-player contexts - play video directly on remote device)
if context != .player, remoteControlEnabled, !snapshotDevices.isEmpty {
Divider()
ForEach(snapshotDevices) { device in
Button {
playOnRemoteDevice(device)
} label: {
Label(
String(format: String(localized: "video.context.playOn %@"), device.name),
systemImage: device.platform.iconName
)
}
}
}
// Move to remote devices (only in player context where video is playing)
if context == .player, remoteControlEnabled, !snapshotDevices.isEmpty {
Divider()
ForEach(snapshotDevices) { device in
Button {
moveToRemoteDevice(device)
} label: {
Label(
String(format: String(localized: "video.context.moveTo %@"), device.name),
systemImage: device.platform.iconName
)
}
}
}
Divider()
// Video Info
Button {
// Dismiss player if in player context
if context == .player {
// Set collapsing first so mini player shows video immediately
appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
}
appEnvironment?.navigationCoordinator.navigate(to: .video(.loaded(video)))
} label: {
Label(String(localized: "video.context.info"), systemImage: "info.circle")
}
// Go to channel (hidden for media browser, or when no real channel info)
if showGoToChannel && video.author.hasRealChannelInfo {
Button {
appEnvironment?.navigationCoordinator.navigateToChannel(for: video, collapsePlayer: context == .player)
} label: {
Label(String(localized: "video.context.goToChannel"), systemImage: "person.circle")
}
}
// Add to bookmarks / Remove from bookmarks
if showBookmarkToggle {
Button {
if isBookmarked {
appEnvironment?.dataManager.removeBookmark(for: video.id.videoID)
} else {
appEnvironment?.dataManager.addBookmark(for: video)
}
} label: {
if isBookmarked {
Label(String(localized: "video.context.removeFromBookmarks"), systemImage: "bookmark.slash")
} else {
Label(String(localized: "video.context.addToBookmarks"), systemImage: "bookmark")
}
}
}
// Add to playlist
if showAddToPlaylist {
Button {
showingPlaylistSheet = true
} label: {
Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus")
}
}
}
// MARK: - Download
#if !os(tvOS)
/// Starts a download either automatically or by showing the quality sheet.
private func startDownload(for video: Video) {
guard let appEnvironment else {
showingDownloadSheet = true
return
}
// Media source videos (SMB/WebDAV/local) use direct file URLs - no API call needed
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
}
}
#endif
// MARK: - Remote Control
/// Play a video on a remote device (from non-player context).
/// Sends the video to play from the beginning on the remote device.
private func playOnRemoteDevice(_ device: DiscoveredDevice) {
LoggingService.shared.logRemoteControl("[VideoContextMenu] playOnRemoteDevice called for device: \(device.name)")
guard let remoteControl = appEnvironment?.remoteControlCoordinator else {
LoggingService.shared.logRemoteControlError("[VideoContextMenu] No remoteControlCoordinator available", error: nil)
return
}
LoggingService.shared.logRemoteControl("[VideoContextMenu] Starting Task for remote playback")
Task {
LoggingService.shared.logRemoteControl("[VideoContextMenu] Task started, checking connection")
// Connect to device if not already connected
if !remoteControl.controllingDevices.contains(device.id) {
LoggingService.shared.logRemoteControl("[VideoContextMenu] Connecting to device: \(device.name)")
try? await remoteControl.connect(to: device)
}
// Get the appropriate instance URL for the video's content type
let instanceURL = appEnvironment?.instancesManager.instance(for: video.id.source)?.url.absoluteString
LoggingService.shared.logRemoteControl("[VideoContextMenu] Calling loadVideo on remoteControl")
// Send load video command (starts from beginning, doesn't pause local since nothing is playing)
await remoteControl.loadVideo(
videoID: video.id.videoID,
videoTitle: video.title,
instanceURL: instanceURL,
startTime: startTime,
pauseLocalPlayback: false,
on: device
)
LoggingService.shared.logRemoteControl("[VideoContextMenu] loadVideo call completed")
}
LoggingService.shared.logRemoteControl("[VideoContextMenu] playOnRemoteDevice returning (Task launched)")
}
/// Move the currently playing video to a remote device.
/// Sends the video with current playback time and pauses local playback when remote device starts playing.
private func moveToRemoteDevice(_ device: DiscoveredDevice) {
guard let remoteControl = appEnvironment?.remoteControlCoordinator else { return }
// Get current playback time from player service
let currentTime = appEnvironment?.playerService.state.currentTime ?? 0
Task {
// Connect to device if not already connected
if !remoteControl.controllingDevices.contains(device.id) {
try? await remoteControl.connect(to: device)
}
// Get the appropriate instance URL for the video's content type
let instanceURL = appEnvironment?.instancesManager.instance(for: video.id.source)?.url.absoluteString
// Send load video command with current playback time
// pauseLocalPlayback: true will pause local playback when remote device confirms it started playing
await remoteControl.loadVideo(
videoID: video.id.videoID,
videoTitle: video.title,
instanceURL: instanceURL,
startTime: currentTime,
pauseLocalPlayback: true,
on: device
)
}
}
}
// MARK: - View Extension
extension View {
/// Attaches a video context menu with optional custom actions and context.
///
/// - Parameters:
/// - video: The video to show the context menu for.
/// - customActions: Custom actions to display at the top of the menu.
/// - context: The view context, used to customize built-in menu items.
/// - startTime: Optional start time in seconds for the Play action.
/// - watchProgress: Optional watch progress (0-1) for the preview thumbnail.
func videoContextMenu(
video: Video,
customActions: [VideoContextAction] = [],
context: VideoContextMenuContext = .default,
startTime: Double? = nil,
watchProgress: Double? = nil
) -> some View {
modifier(VideoContextMenuModifier(
video: video,
customActions: customActions,
context: context,
startTime: startTime,
watchProgress: watchProgress
))
}
}
// MARK: - Dropdown Menu View
#if !os(tvOS)
/// A dropdown menu view for videos, showing context menu actions.
/// Used in player views where the video is already playing.
struct VideoContextMenuView: View {
@Environment(\.appEnvironment) private var appEnvironment
let video: Video
let accentColor: Color
var buttonSize: CGFloat = 32
var buttonBackgroundStyle: ButtonBackgroundStyle = .none
var theme: ControlsTheme = .dark
@State private var showingPlaylistSheet = false
@State private var showingDownloadSheet = false
@State private var showingDeleteDownloadConfirmation = false
@State private var downloadToDelete: Download?
@State private var refreshID = UUID()
private var frameSize: CGFloat {
buttonBackgroundStyle.glassStyle != nil ? buttonSize * 1.15 : buttonSize
}
var body: some View {
Menu {
VideoContextMenuContent(
video: video,
context: .player,
showingPlaylistSheet: $showingPlaylistSheet,
showingDownloadSheet: $showingDownloadSheet,
showingDeleteDownloadConfirmation: $showingDeleteDownloadConfirmation,
downloadToDelete: $downloadToDelete,
appEnvironment: appEnvironment
)
} label: {
contextMenuLabel
}
.id(refreshID)
.menuIndicator(.hidden)
.sheet(isPresented: $showingPlaylistSheet) {
PlaylistSelectorSheet(video: video)
}
.sheet(isPresented: $showingDownloadSheet) {
DownloadQualitySheet(video: video)
}
.alert(String(localized: "videoInfo.download.remove.title"), isPresented: $showingDeleteDownloadConfirmation) {
Button(String(localized: "common.cancel"), role: .cancel) {}
Button(String(localized: "videoInfo.download.remove.confirm"), role: .destructive) {
if let download = downloadToDelete {
Task {
await appEnvironment?.downloadManager.delete(download)
}
}
}
} message: {
Text(String(localized: "videoInfo.download.remove.message"))
}
.onReceive(NotificationCenter.default.publisher(for: .bookmarksDidChange)) { _ in
refreshID = UUID()
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
refreshID = UUID()
}
}
@ViewBuilder
private var contextMenuLabel: some View {
if let glassStyle = buttonBackgroundStyle.glassStyle {
Image(systemName: "ellipsis")
.font(.title2)
.foregroundStyle(accentColor)
.frame(width: frameSize, height: frameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial, colorScheme: theme.colorScheme)
.contentShape(Circle())
} else {
Image(systemName: "ellipsis")
.font(.title3)
.foregroundStyle(accentColor)
.frame(width: buttonSize, height: buttonSize)
.contentShape(Rectangle())
}
}
}
#endif