mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
262
Yattee/Views/Components/TappableVideoModifier.swift
Normal file
262
Yattee/Views/Components/TappableVideoModifier.swift
Normal file
@@ -0,0 +1,262 @@
|
||||
//
|
||||
// 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 {
|
||||
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
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@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
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user