From b479d632956f74dcc9ea45bac3ac868c69296a31 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 17 Apr 2026 04:54:25 +0200 Subject: [PATCH] Respect video tap action settings in media browser Playable files in the media source browser now honor tvOSVideoTapAction on tvOS and thumbnailTapAction/textAreaTapAction on iOS/macOS, matching other video lists. When openInfo navigates to VideoInfoView, playback routes through QueueManager.playFromMediaBrowser so stream and caption resolution keep working for Samba/WebDAV files. --- Yattee/Models/Player/VideoQueueContext.swift | 29 +++-- .../Views/MediaBrowser/MediaBrowserView.swift | 77 ++++++++++++- Yattee/Views/MediaBrowser/MediaFileRow.swift | 104 ++++++++---------- .../MediaBrowser/MediaFileTapModifier.swift | 76 +++++++++++++ Yattee/Views/Video/VideoInfoView.swift | 22 +++- 5 files changed, 236 insertions(+), 72 deletions(-) create mode 100644 Yattee/Views/MediaBrowser/MediaFileTapModifier.swift diff --git a/Yattee/Models/Player/VideoQueueContext.swift b/Yattee/Models/Player/VideoQueueContext.swift index 23beb1b5..aef3b38a 100644 --- a/Yattee/Models/Player/VideoQueueContext.swift +++ b/Yattee/Models/Player/VideoQueueContext.swift @@ -10,30 +10,43 @@ import Foundation /// Closure type for loading more videos via continuation typealias LoadMoreVideosCallback = @Sendable () async throws -> ([Video], String?) +/// Extra payload required to play files from a media source (WebDAV/SMB/local folder) +/// via `QueueManager.playFromMediaBrowser`. Without this, playback falls back to +/// `openVideo` / `playFromList`, which do not set up on-demand stream and caption +/// resolution for media-browser files. +struct MediaBrowserPlaybackInfo: Equatable, Hashable { + let source: MediaSource + let allFilesInFolder: [MediaFile] +} + /// Context information for playing a video with queue support. /// Used when navigating from list views (subscriptions, search, etc.) to video info pages. struct VideoQueueContext { /// The video being viewed let video: Video - + /// Queue source for continuation loading let queueSource: QueueSource? - + /// Display label for the queue source (e.g., "Subscriptions", "Search Results") let sourceLabel: String? - + /// All videos in the current list let videoList: [Video]? - + /// Index of the current video in the list let videoIndex: Int? - + /// Optional start time in seconds let startTime: TimeInterval? - + /// Callback to load more videos when reaching the end of the current list /// Returns new videos and updated continuation token let loadMoreVideos: LoadMoreVideosCallback? + + /// When set, playback from VideoInfoView must route through + /// `QueueManager.playFromMediaBrowser` using this payload. + var mediaBrowserPlayback: MediaBrowserPlaybackInfo? = nil /// Whether this context has valid queue information var hasQueueInfo: Bool { @@ -77,7 +90,8 @@ extension VideoQueueContext: Equatable { lhs.sourceLabel == rhs.sourceLabel && lhs.videoList == rhs.videoList && lhs.videoIndex == rhs.videoIndex && - lhs.startTime == rhs.startTime + lhs.startTime == rhs.startTime && + lhs.mediaBrowserPlayback == rhs.mediaBrowserPlayback } } @@ -89,5 +103,6 @@ extension VideoQueueContext: Hashable { hasher.combine(videoList) hasher.combine(videoIndex) hasher.combine(startTime) + hasher.combine(mediaBrowserPlayback) } } diff --git a/Yattee/Views/MediaBrowser/MediaBrowserView.swift b/Yattee/Views/MediaBrowser/MediaBrowserView.swift index 10927f47..e6614411 100644 --- a/Yattee/Views/MediaBrowser/MediaBrowserView.swift +++ b/Yattee/Views/MediaBrowser/MediaBrowserView.swift @@ -162,12 +162,10 @@ struct MediaBrowserView: View { MediaFileRow(file: file, sortOrder: sortOrder) } .foregroundStyle(.primary) + } else if file.isPlayable { + playableFileRow(for: file) } else { - MediaFileRow(file: file, sortOrder: sortOrder) { - if file.isPlayable { - playFile(file) - } - } + MediaFileRow(file: file, sortOrder: sortOrder) } } } @@ -274,8 +272,77 @@ struct MediaBrowserView: View { } } + // MARK: - Playable row composition + + @ViewBuilder + private func playableFileRow(for file: MediaFile) -> some View { + #if os(tvOS) + MediaFileTVOSTapButton( + onPlay: { playFile(file) }, + onOpenInfo: { openInfo(for: file) } + ) { + MediaFileRow(file: file, sortOrder: sortOrder) + } + .videoContextMenu(video: file.toVideo(), context: .mediaBrowser) + #else + MediaFileRow( + file: file, + sortOrder: sortOrder, + iconAreaModifier: { view in + AnyView( + view.mediaFileRegionTap( + action: appEnvironment?.settingsManager.thumbnailTapAction ?? .playVideo, + onPlay: { playFile(file) }, + onOpenInfo: { openInfo(for: file) } + ) + ) + }, + textAreaModifier: { view in + AnyView( + view.mediaFileRegionTap( + action: appEnvironment?.settingsManager.textAreaTapAction ?? .openInfo, + onPlay: { playFile(file) }, + onOpenInfo: { openInfo(for: file) } + ) + ) + } + ) + .contentShape(Rectangle()) + .onTapGesture { playFile(file) } + .videoContextMenu(video: file.toVideo(), context: .mediaBrowser) + #endif + } + // MARK: - Playback + private func openInfo(for file: MediaFile) { + guard let appEnvironment else { return } + + let playableFiles = displayedFiles.filter { $0.isPlayable } + let videos = playableFiles.map { $0.toVideo() } + let index = playableFiles.firstIndex(where: { $0.id == file.id }) ?? 0 + let folderPath = (file.path as NSString).deletingLastPathComponent + let folderName = (folderPath as NSString).lastPathComponent + + let context = VideoQueueContext( + video: file.toVideo(), + queueSource: .mediaBrowser(sourceID: source.id, folderPath: folderPath), + sourceLabel: folderName.isEmpty ? source.name : folderName, + videoList: videos, + videoIndex: index, + startTime: nil, + loadMoreVideos: nil, + mediaBrowserPlayback: MediaBrowserPlaybackInfo( + source: source, + allFilesInFolder: files + ) + ) + + appEnvironment.navigationCoordinator.navigate( + to: .video(.loaded(file.toVideo()), queueContext: context) + ) + } + private func playFile(_ file: MediaFile) { guard let appEnvironment else { return } diff --git a/Yattee/Views/MediaBrowser/MediaFileRow.swift b/Yattee/Views/MediaBrowser/MediaFileRow.swift index 69cc9b69..bc3fbf35 100644 --- a/Yattee/Views/MediaBrowser/MediaFileRow.swift +++ b/Yattee/Views/MediaBrowser/MediaFileRow.swift @@ -10,20 +10,22 @@ import SwiftUI struct MediaFileRow: View { let file: MediaFile let sortOrder: MediaBrowserSortOrder - let action: (() -> Void)? - /// Initialize with an action (for playable files). - init(file: MediaFile, sortOrder: MediaBrowserSortOrder = .name, action: @escaping () -> Void) { + /// Optional transforms applied to the icon and text regions so callers + /// (e.g. MediaFileTapModifier) can attach per-region gestures. + var iconAreaModifier: (AnyView) -> AnyView = { $0 } + var textAreaModifier: (AnyView) -> AnyView = { $0 } + + init( + file: MediaFile, + sortOrder: MediaBrowserSortOrder = .name, + iconAreaModifier: @escaping (AnyView) -> AnyView = { $0 }, + textAreaModifier: @escaping (AnyView) -> AnyView = { $0 } + ) { self.file = file self.sortOrder = sortOrder - self.action = action - } - - /// Initialize without action (for use inside NavigationLink). - init(file: MediaFile, sortOrder: MediaBrowserSortOrder = .name) { - self.file = file - self.sortOrder = sortOrder - self.action = nil + self.iconAreaModifier = iconAreaModifier + self.textAreaModifier = textAreaModifier } /// The date to display based on current sort order. @@ -37,60 +39,44 @@ struct MediaFileRow: View { } var body: some View { - if let action { - Button(action: action) { - rowContent - } - .buttonStyle(.plain) - .if(file.isPlayable) { view in - view.videoContextMenu( - video: file.toVideo(), - context: .mediaBrowser - ) - } - } else { - rowContent - .if(file.isPlayable) { view in - view.videoContextMenu( - video: file.toVideo(), - context: .mediaBrowser - ) - } + HStack(spacing: 12) { + iconAreaModifier(AnyView(iconView)) + textAreaModifier(AnyView(textView)) + Spacer(minLength: 0) } + .contentShape(Rectangle()) } - private var rowContent: some View { - HStack(spacing: 12) { - // Icon - Image(systemName: file.systemImage) - .font(.title2) - .foregroundStyle(iconColor) - .frame(width: 32) + private var iconView: some View { + Image(systemName: file.systemImage) + .font(.title2) + .foregroundStyle(iconColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } - // File info - VStack(alignment: .leading, spacing: 2) { - Text(file.name) - .font(.body) - .lineLimit(2) - .foregroundStyle(.primary) + private var textView: some View { + VStack(alignment: .leading, spacing: 2) { + Text(file.name) + .font(.body) + .lineLimit(2) + .foregroundStyle(.primary) - HStack(spacing: 8) { - if let size = file.formattedSize { - Text(size) - .font(.caption) - .foregroundStyle(.secondary) - } + HStack(spacing: 8) { + if let size = file.formattedSize { + Text(size) + .font(.caption) + .foregroundStyle(.secondary) + } - if let date = displayDate { - Text(date, style: .date) - .font(.caption) - .foregroundStyle(.secondary) - } + if let date = displayDate { + Text(date, style: .date) + .font(.caption) + .foregroundStyle(.secondary) } } - - Spacer() } + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } @@ -112,8 +98,8 @@ struct MediaFileRow: View { #Preview { List { - MediaFileRow(file: .folderPreview) {} - MediaFileRow(file: .preview) {} + MediaFileRow(file: .folderPreview) + MediaFileRow(file: .preview) MediaFileRow( file: MediaFile( source: .webdav(name: "NAS", url: URL(string: "https://nas.local")!), @@ -123,6 +109,6 @@ struct MediaFileRow: View { size: 5_000_000, modifiedDate: Date() ) - ) {} + ) } } diff --git a/Yattee/Views/MediaBrowser/MediaFileTapModifier.swift b/Yattee/Views/MediaBrowser/MediaFileTapModifier.swift new file mode 100644 index 00000000..2a740e31 --- /dev/null +++ b/Yattee/Views/MediaBrowser/MediaFileTapModifier.swift @@ -0,0 +1,76 @@ +// +// MediaFileTapModifier.swift +// Yattee +// +// Helpers that route taps on a playable MediaFileRow according to the +// user's per-platform tap-action settings. Mirrors the split used by +// VideoRowView (iOS/macOS per-region) and TappableVideoModifier (tvOS). +// + +import SwiftUI + +#if os(tvOS) +/// tvOS-only: wraps the row in a Button that honors `tvOSVideoTapAction`. +struct MediaFileTVOSTapButton: View { + @Environment(\.appEnvironment) private var appEnvironment + + let onPlay: () -> Void + let onOpenInfo: () -> Void + @ViewBuilder let label: () -> Label + + var body: some View { + Button { + let action = appEnvironment?.settingsManager.tvOSVideoTapAction ?? .openInfo + switch action { + case .playVideo: + onPlay() + case .openInfo: + onOpenInfo() + case .none: + break + } + } label: { + label() + } + .buttonStyle(.plain) + } +} +#else +/// iOS/macOS: per-region gesture used by MediaFileRow's icon and text areas. +/// Only attaches a gesture when the action differs from `.playVideo`, letting +/// the row's outer `onTapGesture { onPlay() }` handle the default case. +struct MediaFileRegionTapGesture: ViewModifier { + let action: VideoTapAction + let onPlay: () -> Void + let onOpenInfo: () -> Void + + func body(content: Content) -> some View { + if action == .playVideo { + content + } else { + content.highPriorityGesture( + TapGesture().onEnded { + switch action { + case .playVideo: + onPlay() + case .openInfo: + onOpenInfo() + case .none: + break + } + } + ) + } + } +} + +extension View { + func mediaFileRegionTap( + action: VideoTapAction, + onPlay: @escaping () -> Void, + onOpenInfo: @escaping () -> Void + ) -> some View { + modifier(MediaFileRegionTapGesture(action: action, onPlay: onPlay, onOpenInfo: onOpenInfo)) + } +} +#endif diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index 5af353a2..a780d821 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -2144,7 +2144,27 @@ struct VideoInfoView: View { /// Plays the video with the specified start time. private func playVideoWithStartTime(_ time: TimeInterval) { guard let video = displayedVideo else { return } - + + // Media-browser playback must go through `playFromMediaBrowser` so the + // queue manager sets up on-demand stream/caption resolution — otherwise + // Samba/WebDAV files cannot play. + if let ctx = videoQueueContext, + let mb = ctx.mediaBrowserPlayback, + let queueManager = queueManager { + let playableFiles = mb.allFilesInFolder.filter { $0.isPlayable } + let index = playableFiles.firstIndex(where: { $0.toVideo().id.videoID == video.id.videoID }) + ?? currentVideoIndex + ?? ctx.videoIndex + ?? 0 + queueManager.playFromMediaBrowser( + files: playableFiles, + index: index, + source: mb.source, + allFilesInFolder: mb.allFilesInFolder + ) + return + } + guard let context = videoQueueContext, context.hasQueueInfo, let queueManager = queueManager,