mirror of
https://github.com/yattee/yattee.git
synced 2026-05-14 03:15:03 +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
|
/// Closure type for loading more videos via continuation
|
||||||
typealias LoadMoreVideosCallback = @Sendable () async throws -> ([Video], String?)
|
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.
|
/// Context information for playing a video with queue support.
|
||||||
/// Used when navigating from list views (subscriptions, search, etc.) to video info pages.
|
/// Used when navigating from list views (subscriptions, search, etc.) to video info pages.
|
||||||
struct VideoQueueContext {
|
struct VideoQueueContext {
|
||||||
/// The video being viewed
|
/// The video being viewed
|
||||||
let video: Video
|
let video: Video
|
||||||
|
|
||||||
/// Queue source for continuation loading
|
/// Queue source for continuation loading
|
||||||
let queueSource: QueueSource?
|
let queueSource: QueueSource?
|
||||||
|
|
||||||
/// Display label for the queue source (e.g., "Subscriptions", "Search Results")
|
/// Display label for the queue source (e.g., "Subscriptions", "Search Results")
|
||||||
let sourceLabel: String?
|
let sourceLabel: String?
|
||||||
|
|
||||||
/// All videos in the current list
|
/// All videos in the current list
|
||||||
let videoList: [Video]?
|
let videoList: [Video]?
|
||||||
|
|
||||||
/// Index of the current video in the list
|
/// Index of the current video in the list
|
||||||
let videoIndex: Int?
|
let videoIndex: Int?
|
||||||
|
|
||||||
/// Optional start time in seconds
|
/// Optional start time in seconds
|
||||||
let startTime: TimeInterval?
|
let startTime: TimeInterval?
|
||||||
|
|
||||||
/// Callback to load more videos when reaching the end of the current list
|
/// Callback to load more videos when reaching the end of the current list
|
||||||
/// Returns new videos and updated continuation token
|
/// Returns new videos and updated continuation token
|
||||||
let loadMoreVideos: LoadMoreVideosCallback?
|
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
|
/// Whether this context has valid queue information
|
||||||
var hasQueueInfo: Bool {
|
var hasQueueInfo: Bool {
|
||||||
@@ -77,7 +90,8 @@ extension VideoQueueContext: Equatable {
|
|||||||
lhs.sourceLabel == rhs.sourceLabel &&
|
lhs.sourceLabel == rhs.sourceLabel &&
|
||||||
lhs.videoList == rhs.videoList &&
|
lhs.videoList == rhs.videoList &&
|
||||||
lhs.videoIndex == rhs.videoIndex &&
|
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(videoList)
|
||||||
hasher.combine(videoIndex)
|
hasher.combine(videoIndex)
|
||||||
hasher.combine(startTime)
|
hasher.combine(startTime)
|
||||||
|
hasher.combine(mediaBrowserPlayback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,12 +162,10 @@ struct MediaBrowserView: View {
|
|||||||
MediaFileRow(file: file, sortOrder: sortOrder)
|
MediaFileRow(file: file, sortOrder: sortOrder)
|
||||||
}
|
}
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
|
} else if file.isPlayable {
|
||||||
|
playableFileRow(for: file)
|
||||||
} else {
|
} else {
|
||||||
MediaFileRow(file: file, sortOrder: sortOrder) {
|
MediaFileRow(file: file, sortOrder: sortOrder)
|
||||||
if file.isPlayable {
|
|
||||||
playFile(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
// 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) {
|
private func playFile(_ file: MediaFile) {
|
||||||
guard let appEnvironment else { return }
|
guard let appEnvironment else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,22 @@ import SwiftUI
|
|||||||
struct MediaFileRow: View {
|
struct MediaFileRow: View {
|
||||||
let file: MediaFile
|
let file: MediaFile
|
||||||
let sortOrder: MediaBrowserSortOrder
|
let sortOrder: MediaBrowserSortOrder
|
||||||
let action: (() -> Void)?
|
|
||||||
|
|
||||||
/// Initialize with an action (for playable files).
|
/// Optional transforms applied to the icon and text regions so callers
|
||||||
init(file: MediaFile, sortOrder: MediaBrowserSortOrder = .name, action: @escaping () -> Void) {
|
/// (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.file = file
|
||||||
self.sortOrder = sortOrder
|
self.sortOrder = sortOrder
|
||||||
self.action = action
|
self.iconAreaModifier = iconAreaModifier
|
||||||
}
|
self.textAreaModifier = textAreaModifier
|
||||||
|
|
||||||
/// Initialize without action (for use inside NavigationLink).
|
|
||||||
init(file: MediaFile, sortOrder: MediaBrowserSortOrder = .name) {
|
|
||||||
self.file = file
|
|
||||||
self.sortOrder = sortOrder
|
|
||||||
self.action = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The date to display based on current sort order.
|
/// The date to display based on current sort order.
|
||||||
@@ -37,60 +39,44 @@ struct MediaFileRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let action {
|
HStack(spacing: 12) {
|
||||||
Button(action: action) {
|
iconAreaModifier(AnyView(iconView))
|
||||||
rowContent
|
textAreaModifier(AnyView(textView))
|
||||||
}
|
Spacer(minLength: 0)
|
||||||
.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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private var rowContent: some View {
|
private var iconView: some View {
|
||||||
HStack(spacing: 12) {
|
Image(systemName: file.systemImage)
|
||||||
// Icon
|
.font(.title2)
|
||||||
Image(systemName: file.systemImage)
|
.foregroundStyle(iconColor)
|
||||||
.font(.title2)
|
.frame(width: 44, height: 44)
|
||||||
.foregroundStyle(iconColor)
|
.contentShape(Rectangle())
|
||||||
.frame(width: 32)
|
}
|
||||||
|
|
||||||
// File info
|
private var textView: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(file.name)
|
Text(file.name)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let size = file.formattedSize {
|
if let size = file.formattedSize {
|
||||||
Text(size)
|
Text(size)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let date = displayDate {
|
if let date = displayDate {
|
||||||
Text(date, style: .date)
|
Text(date, style: .date)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +98,8 @@ struct MediaFileRow: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
List {
|
List {
|
||||||
MediaFileRow(file: .folderPreview) {}
|
MediaFileRow(file: .folderPreview)
|
||||||
MediaFileRow(file: .preview) {}
|
MediaFileRow(file: .preview)
|
||||||
MediaFileRow(
|
MediaFileRow(
|
||||||
file: MediaFile(
|
file: MediaFile(
|
||||||
source: .webdav(name: "NAS", url: URL(string: "https://nas.local")!),
|
source: .webdav(name: "NAS", url: URL(string: "https://nas.local")!),
|
||||||
@@ -123,6 +109,6 @@ struct MediaFileRow: View {
|
|||||||
size: 5_000_000,
|
size: 5_000_000,
|
||||||
modifiedDate: Date()
|
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.
|
/// Plays the video with the specified start time.
|
||||||
private func playVideoWithStartTime(_ time: TimeInterval) {
|
private func playVideoWithStartTime(_ time: TimeInterval) {
|
||||||
guard let video = displayedVideo else { return }
|
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,
|
guard let context = videoQueueContext,
|
||||||
context.hasQueueInfo,
|
context.hasQueueInfo,
|
||||||
let queueManager = queueManager,
|
let queueManager = queueManager,
|
||||||
|
|||||||
Reference in New Issue
Block a user