Files
yattee/Yattee/Views/Components/VideoContextMenu.swift
Arkadiusz Fal 908a916f76 Fix sending extracted videos (Twitch streams) to other devices
The remote control loadVideo command only carried the raw video ID and
an instance URL, so extracted videos (Twitch live streams and other
yt-dlp sites) failed on the receiver: it tried to fetch the ID from the
/api/v1/videos endpoint, which rejects non-YouTube IDs.

The command now carries the full ContentSource and title. The receiver
rebuilds the VideoID from the source and opens a placeholder Video -
the player then re-extracts streams and full metadata from the original
URL, same as when opening the video locally. Media-source extractors
(WebDAV/SMB/local) keep using the existing UUID:path branch.

Both fields are optional for protocol compatibility: old receivers
ignore them, commands from old senders keep the legacy behavior.

Also stop sending a start time for live streams - there is no shared
timeline, the receiver joins at the live edge.
2026-08-03 23:34:06 +02:00

693 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
}
@ViewBuilder
private var playDownloadShareButtons: some View {
// 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
}
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()
}
#if os(iOS)
ControlGroup {
playDownloadShareButtons
}
#else
playDownloadShareButtons
#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,
videoSource: video.id.source,
instanceURL: instanceURL,
startTime: video.isLive ? nil : 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
// Live streams have no shared timeline - the remote device joins the stream at the live edge
await remoteControl.loadVideo(
videoID: video.id.videoID,
videoTitle: video.title,
videoSource: video.id.source,
instanceURL: instanceURL,
startTime: video.isLive ? nil : 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)
#if os(macOS)
// The default macOS menu style renders as a bordered pull-down that
// stretches to fill available width on macOS 15 (Sequoia).
.menuStyle(.borderlessButton)
.fixedSize()
#endif
.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