mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
Watch Next behavior and settings
This commit is contained in:
@@ -107,7 +107,7 @@ struct OpenVideosModel {
|
||||
prepending: playbackMode == .playNow || playbackMode == .playNext
|
||||
)
|
||||
|
||||
WatchNextViewModel.shared.presentingOutro = false
|
||||
WatchNextViewModel.shared.hide()
|
||||
|
||||
if playbackMode == .playNow || playbackMode == .shuffleAll {
|
||||
#if os(iOS)
|
||||
|
@@ -531,10 +531,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
if Defaults[.closeLastItemOnPlaybackEnd] {
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
}
|
||||
|
||||
eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
|
@@ -94,37 +94,59 @@ extension PlayerBackend {
|
||||
}
|
||||
|
||||
func eofPlaybackModeAction() {
|
||||
let timer = Delay.by(5) {
|
||||
let loopAction = {
|
||||
model.backend.seek(to: .zero, seekType: .loopRestart) { _ in
|
||||
self.model.play()
|
||||
}
|
||||
}
|
||||
|
||||
guard model.playbackMode != .loopOne else {
|
||||
loopAction()
|
||||
return
|
||||
}
|
||||
|
||||
let action = {
|
||||
switch model.playbackMode {
|
||||
case .queue, .shuffle:
|
||||
if Defaults[.closeLastItemOnPlaybackEnd] {
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
}
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
if Defaults[.closeLastItemOnPlaybackEnd] {
|
||||
#if os(tvOS)
|
||||
if model.activeBackend == .appleAVPlayer {
|
||||
model.avPlayerBackend.controller?.dismiss(animated: false)
|
||||
}
|
||||
#endif
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
}
|
||||
#if os(tvOS)
|
||||
if model.activeBackend == .appleAVPlayer {
|
||||
model.avPlayerBackend.controller?.dismiss(animated: false)
|
||||
}
|
||||
#endif
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
} else {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
case .loopOne:
|
||||
model.backend.seek(to: .zero, seekType: .loopRestart) { _ in
|
||||
self.model.play()
|
||||
}
|
||||
loopAction()
|
||||
case .related:
|
||||
guard let item = model.autoplayItem else { return }
|
||||
model.resetAutoplay()
|
||||
model.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
WatchNextViewModel.shared.prepareForNextItem(model.currentItem, timer: timer)
|
||||
let actionAndHideWatchNext: (Bool) -> Void = { delay in
|
||||
WatchNextViewModel.shared.hide()
|
||||
if delay {
|
||||
Delay.by(0.3) {
|
||||
action()
|
||||
}
|
||||
} else {
|
||||
action()
|
||||
}
|
||||
}
|
||||
if Defaults[.openWatchNextOnFinishedWatching], model.presentingPlayer {
|
||||
let timer = Delay.by(TimeInterval(Defaults[.openWatchNextOnFinishedWatchingDelay]) ?? 5.0) {
|
||||
actionAndHideWatchNext(true)
|
||||
}
|
||||
WatchNextViewModel.shared.finishedWatching(model.currentItem, timer: timer)
|
||||
} else {
|
||||
actionAndHideWatchNext(false)
|
||||
}
|
||||
}
|
||||
|
||||
func updateControls(completionHandler: (() -> Void)? = nil) {
|
||||
|
@@ -80,6 +80,9 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
@Published var playerSize: CGSize = .zero { didSet {
|
||||
#if !os(tvOS)
|
||||
#if os(macOS)
|
||||
guard videoForDisplay != nil else { return }
|
||||
#endif
|
||||
backend.setSize(playerSize.width, playerSize.height)
|
||||
#endif
|
||||
}}
|
||||
@@ -162,7 +165,6 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
#if !os(macOS)
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
||||
@Default(.closePlayerOnItemClose) private var closePlayerOnItemClose
|
||||
#endif
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
@@ -324,7 +326,7 @@ final class PlayerModel: ObservableObject {
|
||||
pause()
|
||||
videoBeingOpened = video
|
||||
|
||||
WatchNextViewModel.shared.presentingOutro = false
|
||||
WatchNextViewModel.shared.hide()
|
||||
|
||||
var changeBackendHandler: (() -> Void)?
|
||||
|
||||
|
@@ -14,7 +14,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func play(_ videos: [Video], shuffling: Bool = false) {
|
||||
WatchNextViewModel.shared.presentingOutro = false
|
||||
WatchNextViewModel.shared.hide()
|
||||
playbackMode = shuffling ? .shuffle : .queue
|
||||
|
||||
videos.forEach { enqueueVideo($0, loadDetails: false) }
|
||||
@@ -55,7 +55,7 @@ extension PlayerModel {
|
||||
|
||||
comments.reset()
|
||||
stream = nil
|
||||
WatchNextViewModel.shared.close()
|
||||
WatchNextViewModel.shared.hide()
|
||||
|
||||
withAnimation {
|
||||
aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||
@@ -175,7 +175,7 @@ extension PlayerModel {
|
||||
|
||||
remove(newItem)
|
||||
|
||||
WatchNextViewModel.shared.close()
|
||||
WatchNextViewModel.shared.hide()
|
||||
currentItem = newItem
|
||||
currentItem.playbackTime = time
|
||||
|
||||
@@ -221,7 +221,7 @@ extension PlayerModel {
|
||||
if play {
|
||||
withAnimation {
|
||||
aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||
WatchNextViewModel.shared.close()
|
||||
WatchNextViewModel.shared.hide()
|
||||
currentItem = item
|
||||
}
|
||||
videoBeingOpened = video
|
||||
|
@@ -25,7 +25,7 @@ struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
|
||||
}
|
||||
|
||||
init(
|
||||
_ video: Video? = nil,
|
||||
_ video: Video? = .fixture,
|
||||
videoID: Video.ID? = nil,
|
||||
app: VideosApp? = nil,
|
||||
instanceURL: URL? = nil,
|
||||
|
@@ -9,10 +9,12 @@ final class UnwatchedFeedCountModel: ObservableObject {
|
||||
|
||||
private var accounts = AccountsModel.shared
|
||||
|
||||
// swiftlint:disable empty_count
|
||||
var unwatchedText: Text? {
|
||||
if let account = accounts.current,
|
||||
!account.anonymous,
|
||||
let count = unwatched[account]
|
||||
let count = unwatched[account],
|
||||
count > 0
|
||||
{
|
||||
return Text(String(count))
|
||||
}
|
||||
@@ -23,7 +25,8 @@ final class UnwatchedFeedCountModel: ObservableObject {
|
||||
func unwatchedByChannelText(_ channel: Channel) -> Text? {
|
||||
if let account = accounts.current,
|
||||
!account.anonymous,
|
||||
let count = unwatchedByChannel[account]?[channel.id]
|
||||
let count = unwatchedByChannel[account]?[channel.id],
|
||||
count > 0
|
||||
{
|
||||
return Text(String(count))
|
||||
}
|
||||
|
@@ -327,6 +327,10 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
return path.contains(".") ? path.components(separatedBy: ".").last?.uppercased() : nil
|
||||
}
|
||||
|
||||
var isShareable: Bool {
|
||||
!isLocal || localStreamIsRemoteURL
|
||||
}
|
||||
|
||||
private var localStreamURLComponents: URLComponents? {
|
||||
guard let localStream else { return nil }
|
||||
return URLComponents(url: localStream.localURL, resolvingAgainstBaseURL: false)
|
||||
|
@@ -1,47 +1,192 @@
|
||||
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 var presentingOutro = true
|
||||
@Published var isAutoplaying = true
|
||||
var timer: Timer?
|
||||
@Published private(set) var isPresenting = true
|
||||
@Published var reason: PresentationReason?
|
||||
@Published var page = Page.queue
|
||||
|
||||
func prepareForEmptyPlayerPlaceholder(_ item: PlayerQueueItem? = nil) {
|
||||
self.item = item
|
||||
@Published var countdown = 0.0
|
||||
var countdownTimer: Timer?
|
||||
|
||||
private var player = PlayerModel.shared
|
||||
|
||||
var autoplayTimer: Timer?
|
||||
|
||||
var isAutoplaying: Bool {
|
||||
reason == .finishedWatching
|
||||
}
|
||||
|
||||
func prepareForNextItem(_ item: PlayerQueueItem? = nil, timer: Timer? = nil) {
|
||||
self.item = item
|
||||
self.timer?.invalidate()
|
||||
self.timer = timer
|
||||
isAutoplaying = true
|
||||
withAnimation(Self.animation) {
|
||||
presentingOutro = true
|
||||
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 cancelAutoplay() {
|
||||
timer?.invalidate()
|
||||
isAutoplaying = false
|
||||
func userInteractedOpen(_ item: PlayerQueueItem?) {
|
||||
self.item = item
|
||||
open(reason: .userInteracted)
|
||||
}
|
||||
|
||||
func open() {
|
||||
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
|
||||
page = Page.allCases.first { isAvailable($0) } ?? .history
|
||||
|
||||
guard !isPresenting else { return }
|
||||
withAnimation(Self.animation) {
|
||||
presentingOutro = true
|
||||
isPresenting = true
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
presentingOutro = false
|
||||
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
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user