mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
) {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
76
Yattee/Views/MediaBrowser/MediaFileTapModifier.swift
Normal file
76
Yattee/Views/MediaBrowser/MediaFileTapModifier.swift
Normal 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user