mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
264 lines
10 KiB
Swift
264 lines
10 KiB
Swift
//
|
|
// TappableVideoModifier.swift
|
|
// Yattee
|
|
//
|
|
// View modifier that makes content tappable to play a video.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// Data for the resume action sheet, used with sheet(item:) to ensure data availability.
|
|
struct ResumeSheetData: Identifiable {
|
|
let id = UUID()
|
|
let video: Video
|
|
let resumeTime: TimeInterval
|
|
}
|
|
|
|
/// View modifier that wraps content in a button that plays a video when tapped.
|
|
/// When queue is enabled and not empty, shows a sheet with queue options.
|
|
/// When queue is empty or disabled, plays the video directly and queues subsequent videos.
|
|
struct TappableVideoModifier: ViewModifier {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
let video: Video
|
|
var startTime: Double? = nil
|
|
var customActions: [VideoContextAction] = []
|
|
var context: VideoContextMenuContext = .default
|
|
var includeContextMenu: Bool = true
|
|
var queueSource: QueueSource? = nil
|
|
/// Display label for the queue source (e.g., playlist title, channel name)
|
|
var sourceLabel: String? = nil
|
|
|
|
/// All videos in the list (for auto-queuing subsequent videos)
|
|
var videoList: [Video]? = nil
|
|
/// Index of this video in the list
|
|
var videoIndex: Int? = nil
|
|
|
|
/// Callback to load more videos via continuation
|
|
nonisolated(unsafe) var loadMoreVideos: LoadMoreVideosCallback? = nil
|
|
|
|
@State private var showingQueueSheet = false
|
|
|
|
// Resume action sheet state - using item-based sheet to ensure data is available when presented
|
|
@State private var resumeSheetData: ResumeSheetData? = nil
|
|
|
|
// Password alert state (for WebDAV sources)
|
|
@State private var showingPasswordAlert = false
|
|
@State private var sourceNeedingPassword: MediaSource?
|
|
@State private var passwordInput = ""
|
|
|
|
/// Whether queue feature is enabled and queue has items
|
|
private var shouldShowQueueSheet: Bool {
|
|
guard let env = appEnvironment else { return false }
|
|
let queueEnabled = env.settingsManager.queueEnabled
|
|
let queueHasItems = !env.playerService.state.queue.isEmpty
|
|
return queueEnabled && queueHasItems
|
|
}
|
|
|
|
func body(content: Content) -> some View {
|
|
Button {
|
|
dismissKeyboard()
|
|
checkPasswordAndPlay()
|
|
} label: {
|
|
content
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.if(includeContextMenu) { view in
|
|
view.videoContextMenu(
|
|
video: video,
|
|
customActions: customActions,
|
|
context: context,
|
|
startTime: startTime
|
|
)
|
|
}
|
|
.sheet(isPresented: $showingQueueSheet) {
|
|
QueueActionSheet(video: video, queueSource: queueSource)
|
|
}
|
|
.sheet(item: $resumeSheetData) { data in
|
|
ResumeActionSheet(
|
|
video: data.video,
|
|
resumeTime: data.resumeTime,
|
|
onContinue: { playVideoWithStartTime(data.resumeTime) },
|
|
onStartOver: { playVideoWithStartTime(0) }
|
|
)
|
|
}
|
|
.alert(String(localized: "common.authenticationRequired"), isPresented: $showingPasswordAlert) {
|
|
SecureField(String(localized: "common.password"), text: $passwordInput)
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
passwordInput = ""
|
|
sourceNeedingPassword = nil
|
|
}
|
|
Button(String(localized: "common.connect")) {
|
|
savePasswordAndContinue()
|
|
}
|
|
} message: {
|
|
if let source = sourceNeedingPassword {
|
|
Text(String(localized: "common.enterPassword \(source.name)"))
|
|
}
|
|
}
|
|
.videoQueueContext(queueContext)
|
|
}
|
|
|
|
/// Creates a VideoQueueContext from the modifier's parameters
|
|
private var queueContext: VideoQueueContext {
|
|
VideoQueueContext(
|
|
video: video,
|
|
queueSource: queueSource,
|
|
sourceLabel: sourceLabel,
|
|
videoList: videoList,
|
|
videoIndex: videoIndex,
|
|
startTime: startTime,
|
|
loadMoreVideos: loadMoreVideos
|
|
)
|
|
}
|
|
|
|
// MARK: - Keyboard Handling
|
|
|
|
/// Dismisses the iOS software keyboard before playing video
|
|
private func dismissKeyboard() {
|
|
#if os(iOS)
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Password Check & Playback
|
|
|
|
/// Checks if video is from WebDAV source needing password, shows alert or plays directly
|
|
private func checkPasswordAndPlay() {
|
|
// Check if video is from WebDAV source needing password
|
|
if let sourceID = video.mediaSourceID,
|
|
let source = appEnvironment?.mediaSourcesManager.source(byID: sourceID),
|
|
appEnvironment?.mediaSourcesManager.needsPassword(for: source) == true {
|
|
sourceNeedingPassword = source
|
|
showingPasswordAlert = true
|
|
} else if shouldShowQueueSheet {
|
|
showingQueueSheet = true
|
|
} else {
|
|
playVideoAndQueueRest()
|
|
}
|
|
}
|
|
|
|
/// Saves password for WebDAV source and continues with playback
|
|
private func savePasswordAndContinue() {
|
|
guard let source = sourceNeedingPassword, !passwordInput.isEmpty else { return }
|
|
appEnvironment?.mediaSourcesManager.setPassword(passwordInput, for: source)
|
|
passwordInput = ""
|
|
sourceNeedingPassword = nil
|
|
|
|
// Now check if we should show queue sheet or play directly
|
|
if shouldShowQueueSheet {
|
|
showingQueueSheet = true
|
|
} else {
|
|
playVideoAndQueueRest()
|
|
}
|
|
}
|
|
|
|
/// Plays the tapped video and queues all subsequent videos from the list.
|
|
/// Checks resume action setting for partially watched videos.
|
|
private func playVideoAndQueueRest() {
|
|
guard let env = appEnvironment else { return }
|
|
|
|
// Determine the saved progress: prefer explicitly passed startTime, then query database
|
|
// This handles cases where startTime is passed from views like Continue Watching/History
|
|
// that already have the watch position, avoiding issues with data not being synced yet
|
|
let savedProgress: TimeInterval?
|
|
if let passedStartTime = startTime, passedStartTime > 0 {
|
|
// Use the startTime that was passed to the modifier (e.g., from Continue Watching)
|
|
savedProgress = passedStartTime
|
|
} else {
|
|
// Query database for watch progress
|
|
savedProgress = env.dataManager.watchProgress(for: video.id.videoID)
|
|
}
|
|
|
|
let videoDuration = video.duration
|
|
// When duration is 0 (not yet loaded), use a large threshold to avoid false "completed" detection
|
|
let completionThreshold = videoDuration > 0 ? videoDuration * 0.9 : Double.greatestFiniteMagnitude
|
|
// Minimum threshold - treat < 5 seconds as "not watched" to avoid asking for very short progress
|
|
let minimumThreshold: TimeInterval = 5
|
|
|
|
// Only consider resume logic if there's meaningful saved progress (>5s) and video wasn't completed
|
|
if let savedProgress, savedProgress >= minimumThreshold, savedProgress < completionThreshold {
|
|
let resumeActionSetting = env.settingsManager.resumeAction
|
|
|
|
switch resumeActionSetting {
|
|
case .continueWatching:
|
|
// Use saved progress as start time
|
|
playVideoWithStartTime(savedProgress)
|
|
case .startFromBeginning:
|
|
// Always start from beginning
|
|
playVideoWithStartTime(0)
|
|
case .ask:
|
|
// Show the resume action sheet with data bundled together
|
|
resumeSheetData = ResumeSheetData(video: video, resumeTime: savedProgress)
|
|
}
|
|
} else {
|
|
// No saved progress or video was completed - play from beginning
|
|
playVideoWithStartTime(0)
|
|
}
|
|
}
|
|
|
|
/// Plays the video with the specified start time.
|
|
private func playVideoWithStartTime(_ time: TimeInterval) {
|
|
guard let env = appEnvironment else { return }
|
|
|
|
// If we have a video list, use centralized playFromList
|
|
if let list = videoList, let index = videoIndex {
|
|
env.queueManager.playFromList(
|
|
videos: list,
|
|
index: index,
|
|
queueSource: queueSource,
|
|
sourceLabel: sourceLabel,
|
|
startTime: time
|
|
)
|
|
} else if time > 0 {
|
|
env.playerService.openVideo(video, startTime: time)
|
|
} else {
|
|
env.playerService.openVideo(video)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - View Extension
|
|
|
|
extension View {
|
|
/// Makes the view tappable to play a video with optional context menu.
|
|
///
|
|
/// - Parameters:
|
|
/// - video: The video to play when tapped.
|
|
/// - startTime: Optional start time in seconds.
|
|
/// - customActions: Custom actions to display at the top of the context menu.
|
|
/// - context: The view context for customizing built-in menu items.
|
|
/// - includeContextMenu: Whether to include the video context menu (default: true).
|
|
/// - queueSource: Optional source for continuation loading when adding to queue.
|
|
/// - sourceLabel: Display label for the queue source (e.g., playlist title, channel name).
|
|
/// - videoList: All videos in the current list (for auto-queuing subsequent videos).
|
|
/// - videoIndex: Index of this video in the list.
|
|
/// - loadMoreVideos: Callback to load more videos via continuation.
|
|
func tappableVideo(
|
|
_ video: Video,
|
|
startTime: Double? = nil,
|
|
customActions: [VideoContextAction] = [],
|
|
context: VideoContextMenuContext = .default,
|
|
includeContextMenu: Bool = true,
|
|
queueSource: QueueSource? = nil,
|
|
sourceLabel: String? = nil,
|
|
videoList: [Video]? = nil,
|
|
videoIndex: Int? = nil,
|
|
loadMoreVideos: LoadMoreVideosCallback? = nil
|
|
) -> some View {
|
|
modifier(TappableVideoModifier(
|
|
video: video,
|
|
startTime: startTime,
|
|
customActions: customActions,
|
|
context: context,
|
|
includeContextMenu: includeContextMenu,
|
|
queueSource: queueSource,
|
|
sourceLabel: sourceLabel,
|
|
videoList: videoList,
|
|
videoIndex: videoIndex,
|
|
loadMoreVideos: loadMoreVideos
|
|
))
|
|
}
|
|
}
|