import Combine import Defaults import Foundation import SwiftUI final class WatchNextViewModel: ObservableObject { enum Page: String, CaseIterable { case queue case related case history var title: String { rawValue.capitalized.localized() } var systemImageName: String { switch self { case .queue: return "list.and.film" case .related: return "rectangle.stack.fill" case .history: return "clock" } } } enum PresentationReason { case userInteracted case finishedWatching case closed } static let animation = Animation.easeIn(duration: 0.25) static let shared = WatchNextViewModel() @Published var item: PlayerQueueItem? @Published private(set) var isPresenting = false @Published var reason: PresentationReason? @Published var page = Page.queue @Published var countdown = 0.0 var countdownTimer: Timer? var player = PlayerModel.shared var autoplayTimer: Timer? var isAutoplaying: Bool { reason == .finishedWatching } var isHideable: Bool { reason == .userInteracted } var isRestartable: Bool { player.currentItem != nil && reason != .userInteracted } var canAutoplay: Bool { switch player.playbackMode { case .shuffle: return !player.queue.isEmpty default: return nextFromTheQueue != nil } } func userInteractedOpen(_ item: PlayerQueueItem?) { self.item = item open(reason: .userInteracted) } func finishedWatching(_ item: PlayerQueueItem?, timer: Timer? = nil) { if canAutoplay { countdown = TimeInterval(Defaults[.openWatchNextOnFinishedWatchingDelay]) ?? 5.0 resetCountdownTimer() autoplayTimer?.invalidate() autoplayTimer = timer } else { timer?.invalidate() } self.item = item open(reason: .finishedWatching) } func resetCountdownTimer() { countdownTimer?.invalidate() countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in guard self.countdown > 0 else { timer.invalidate() return } self.countdown = max(0, self.countdown - 1) } } func closed(_ item: PlayerQueueItem) { self.item = item open(reason: .closed) } func keepFromAutoplaying() { userInteractedOpen(item) cancelAutoplay() } func cancelAutoplay() { autoplayTimer?.invalidate() countdownTimer?.invalidate() } func restart() { cancelAutoplay() guard player.currentItem != nil else { return } if reason == .closed { hide() return } player.backend.seek(to: .zero, seekType: .loopRestart) { _ in self.hide() self.player.play() } } private func open(reason: PresentationReason) { self.reason = reason setPageAfterOpening() guard !isPresenting else { return } withAnimation(Self.animation) { isPresenting = true } } private func setPageAfterOpening() { let firstAvailable = Page.allCases.first { isAvailable($0) } ?? .history switch reason { case .finishedWatching: page = player.playbackMode == .related ? .queue : firstAvailable case .closed: page = player.playbackMode == .related ? .queue : firstAvailable default: page = firstAvailable } } func close() { let close = { self.player.closeCurrentItem() self.player.hide() Delay.by(0.5) { self.isPresenting = false } } if reason == .closed { close() return } if canAutoplay { cancelAutoplay() hide() } else { close() } } func hide() { guard isPresenting else { return } withAnimation(Self.animation) { isPresenting = false } } func resetItem() { item = nil } func isAvailable(_ page: Page) -> Bool { switch page { case .queue: return !player.queue.isEmpty case .related: guard let video = item?.video else { return false } return !video.related.isEmpty case .history: return true } } var nextFromTheQueue: PlayerQueueItem? { if player.playbackMode == .related { return player.autoplayItem } else if player.playbackMode == .queue { return player.queue.first } return nil } }