jellyflood/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift

288 lines
8.8 KiB
Swift

//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Combine
import Defaults
import Foundation
import JellyfinAPI
import UIKit
import VLCUI
// TODO: better online/offline handling
// TODO: proper error catching
// TODO: better solution for previous/next/queuing
// TODO: should view models handle progress reports instead, with a protocol
// for other types of media handling
class VideoPlayerManager: ViewModel {
class CurrentProgressHandler: ObservableObject {
@Published
var progress: CGFloat = 0
@Published
var scrubbedProgress: CGFloat = 0
@Published
var seconds: Int = 0
@Published
var scrubbedSeconds: Int = 0
}
@Published
var audioTrackIndex: Int = -1
@Published
var state: VLCVideoPlayer.State = .opening
@Published
var subtitleTrackIndex: Int = -1
@Published
var playbackSpeed: PlaybackSpeed = PlaybackSpeed.one
// MARK: ViewModel
@Published
var previousViewModel: VideoPlayerViewModel?
@Published
var currentViewModel: VideoPlayerViewModel! {
willSet {
guard let newValue else { return }
hasSentStart = false
getAdjacentEpisodes(for: newValue.item)
}
}
@Published
var nextViewModel: VideoPlayerViewModel?
var currentProgressHandler: CurrentProgressHandler = .init()
let proxy: VLCVideoPlayer.Proxy = .init()
private var currentProgressWorkItem: DispatchWorkItem?
private var hasSentStart = false
func selectNextViewModel() {
guard let nextViewModel else { return }
currentViewModel = nextViewModel
previousViewModel = nil
self.nextViewModel = nil
}
func selectPreviousViewModel() {
guard let previousViewModel else { return }
currentViewModel = previousViewModel
self.previousViewModel = nil
nextViewModel = nil
}
func onTicksUpdated(ticks: Int, playbackInformation: VLCVideoPlayer.PlaybackInformation) {
if audioTrackIndex != playbackInformation.currentAudioTrack.index {
audioTrackIndex = playbackInformation.currentAudioTrack.index
}
if subtitleTrackIndex != playbackInformation.currentSubtitleTrack.index {
subtitleTrackIndex = playbackInformation.currentSubtitleTrack.index
}
}
func onStateUpdated(newState: VLCVideoPlayer.State) {
guard state != newState else { return }
state = newState
if !hasSentStart, newState == .playing {
hasSentStart = true
sendStartReport()
}
if hasSentStart, newState == .paused {
hasSentStart = false
sendPauseReport()
}
if newState == .stopped || newState == .ended {
sendStopReport()
}
}
func getAdjacentEpisodes(for item: BaseItemDto) {
Task { @MainActor in
guard let seriesID = item.seriesID, item.type == .episode else { return }
let parameters = Paths.GetEpisodesParameters(
userID: userSession.user.id,
fields: .MinimumFields,
adjacentTo: item.id!,
limit: 3
)
let request = Paths.getEpisodes(seriesID: seriesID, parameters: parameters)
let response = try await userSession.client.send(request)
// 4 possible states:
// 1 - only current episode
// 2 - two episodes with next episode
// 3 - two episodes with previous episode
// 4 - three episodes with current in middle
// 1
guard let items = response.value.items, items.count > 1 else { return }
var previousItem: BaseItemDto?
var nextItem: BaseItemDto?
if items.count == 2 {
if items[0].id == item.id {
// 2
nextItem = items[1]
} else {
// 3
previousItem = items[0]
}
} else {
nextItem = items[2]
previousItem = items[0]
}
var nextViewModel: VideoPlayerViewModel?
var previousViewModel: VideoPlayerViewModel?
if let nextItem, let nextItemMediaSource = nextItem.mediaSources?.first {
nextViewModel = try await nextItem.videoPlayerViewModel(with: nextItemMediaSource)
}
if let previousItem, let previousItemMediaSource = previousItem.mediaSources?.first {
previousViewModel = try await previousItem.videoPlayerViewModel(with: previousItemMediaSource)
}
await MainActor.run {
self.nextViewModel = nextViewModel
self.previousViewModel = previousViewModel
}
}
}
func sendStartReport() {
#if DEBUG
guard Defaults[.sendProgressReports] else { return }
#endif
currentProgressWorkItem?.cancel()
logger.debug("sent start report")
Task {
let startInfo = PlaybackStartInfo(
audioStreamIndex: audioTrackIndex,
itemID: currentViewModel.item.id,
mediaSourceID: currentViewModel.mediaSource.id,
playbackStartTimeTicks: Int(Date().timeIntervalSince1970) * 10_000_000,
positionTicks: currentProgressHandler.seconds * 10_000_000,
sessionID: currentViewModel.playSessionID,
subtitleStreamIndex: subtitleTrackIndex
)
let request = Paths.reportPlaybackStart(startInfo)
let _ = try await userSession.client.send(request)
let progressTask = DispatchWorkItem {
self.sendProgressReport()
}
currentProgressWorkItem = progressTask
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: progressTask)
}
}
func sendStopReport() {
let ids = ["itemID": currentViewModel.item.id, "seriesID": currentViewModel.item.parentID]
Notifications[.itemMetadataDidChange].post(object: ids)
#if DEBUG
guard Defaults[.sendProgressReports] else { return }
#endif
logger.debug("sent stop report")
currentProgressWorkItem?.cancel()
Task {
let stopInfo = PlaybackStopInfo(
itemID: currentViewModel.item.id,
mediaSourceID: currentViewModel.mediaSource.id,
positionTicks: currentProgressHandler.seconds * 10_000_000,
sessionID: currentViewModel.playSessionID
)
let request = Paths.reportPlaybackStopped(stopInfo)
let _ = try await userSession.client.send(request)
}
}
func sendPauseReport() {
#if DEBUG
guard Defaults[.sendProgressReports] else { return }
#endif
logger.debug("sent pause report")
currentProgressWorkItem?.cancel()
Task {
let startInfo = PlaybackStartInfo(
audioStreamIndex: audioTrackIndex,
isPaused: true,
itemID: currentViewModel.item.id,
mediaSourceID: currentViewModel.mediaSource.id,
positionTicks: currentProgressHandler.seconds * 10_000_000,
sessionID: currentViewModel.playSessionID,
subtitleStreamIndex: subtitleTrackIndex
)
let request = Paths.reportPlaybackStart(startInfo)
let _ = try await userSession.client.send(request)
}
}
func sendProgressReport() {
#if DEBUG
guard Defaults[.sendProgressReports] else { return }
#endif
let progressTask = DispatchWorkItem {
self.sendProgressReport()
}
currentProgressWorkItem = progressTask
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: progressTask)
Task {
let progressInfo = PlaybackProgressInfo(
audioStreamIndex: audioTrackIndex,
isPaused: false,
itemID: currentViewModel.item.id,
mediaSourceID: currentViewModel.item.id,
playSessionID: currentViewModel.playSessionID,
positionTicks: currentProgressHandler.seconds * 10_000_000,
sessionID: currentViewModel.playSessionID,
subtitleStreamIndex: subtitleTrackIndex
)
let request = Paths.reportPlaybackProgress(progressInfo)
let _ = try await userSession.client.send(request)
logger.debug("sent progress task")
}
}
}