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.
This commit is contained in:
Arkadiusz Fal
2026-04-17 04:54:25 +02:00
parent 3126f5bc3e
commit b479d63295
5 changed files with 236 additions and 72 deletions

View File

@@ -10,6 +10,15 @@ 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 {
@@ -35,6 +44,10 @@ struct VideoQueueContext {
/// 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 {
videoList != nil && videoIndex != nil
@@ -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)
}
}

View File

@@ -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 }

View File

@@ -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,37 +39,23 @@ 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
private var iconView: some View {
Image(systemName: file.systemImage)
.font(.title2)
.foregroundStyle(iconColor)
.frame(width: 32)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
// File info
private var textView: some View {
VStack(alignment: .leading, spacing: 2) {
Text(file.name)
.font(.body)
@@ -88,9 +76,7 @@ struct MediaFileRow: View {
}
}
}
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()
)
) {}
)
}
}

View File

@@ -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<Label: View>: 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

View File

@@ -2145,6 +2145,26 @@ struct VideoInfoView: View {
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,