mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 18:54:11 +00:00
Persistence for queue, history and last played
This commit is contained in:
@@ -23,4 +23,26 @@ protocol VideosAPI {
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
|
||||
}
|
||||
|
||||
extension VideosAPI {
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }) {
|
||||
guard (item.video?.streams ?? []).isEmpty else {
|
||||
completionHandler(item)
|
||||
return
|
||||
}
|
||||
|
||||
video(item.videoID).load().onSuccess { response in
|
||||
guard let video: Video = response.typedContent() else {
|
||||
return
|
||||
}
|
||||
|
||||
var newItem = item
|
||||
newItem.video = video
|
||||
|
||||
completionHandler(newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -26,10 +26,10 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var availableStreams = [Stream]() { didSet { rebuildTVMenu() } }
|
||||
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
|
||||
|
||||
@Published var queue = [PlayerQueueItem]()
|
||||
@Published var currentItem: PlayerQueueItem!
|
||||
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
|
||||
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
|
||||
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
|
||||
|
||||
@Published var history = [PlayerQueueItem]()
|
||||
@Published var savedTime: CMTime?
|
||||
|
||||
@Published var playerNavigationLinkActive = false
|
||||
@@ -43,23 +43,54 @@ final class PlayerModel: ObservableObject {
|
||||
var instances: InstancesModel
|
||||
|
||||
var composition = AVMutableComposition()
|
||||
var timeObserver: Any?
|
||||
private var shouldResumePlaying = true
|
||||
|
||||
private var frequentTimeObserver: Any?
|
||||
private var infrequentTimeObserver: Any?
|
||||
private var playerTimeControlStatusObserver: Any?
|
||||
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
#if os(macOS)
|
||||
var playerTimeControlStatusObserver: Any?
|
||||
#endif
|
||||
var autoPlayItems = false
|
||||
|
||||
init(accounts: AccountsModel? = nil, instances: InstancesModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
self.instances = instances ?? InstancesModel()
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
addTimeObserver()
|
||||
addFrequentTimeObserver()
|
||||
addInfrequentTimeObserver()
|
||||
addPlayerTimeControlStatusObserver()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
addPlayerTimeControlStatusObserver()
|
||||
#endif
|
||||
func loadHistoryDetails() {
|
||||
guard !accounts.current.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
queue = Defaults[.queue]
|
||||
queue.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
if let index = self.queue.firstIndex(where: { $0.id == item.id }) {
|
||||
self.queue[index] = newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
history = Defaults[.history]
|
||||
history.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
if let index = self.history.firstIndex(where: { $0.id == item.id }) {
|
||||
self.history[index] = newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let item = Defaults[.lastPlayed] {
|
||||
accounts.api.loadDetails(item) { [weak self] newItem in
|
||||
self?.playNow(newItem.video, at: newItem.playbackTime?.seconds)
|
||||
}
|
||||
} else {
|
||||
autoPlayItems = true
|
||||
}
|
||||
}
|
||||
|
||||
func presentPlayer() {
|
||||
@@ -75,7 +106,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
var live: Bool {
|
||||
currentItem?.video.live ?? false
|
||||
currentItem?.video?.live ?? false
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
@@ -91,7 +122,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard !isPlaying else {
|
||||
guard player.timeControlStatus != .playing else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,34 +130,20 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard isPlaying else {
|
||||
guard player.timeControlStatus != .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func playVideo(_ video: Video, time: CMTime? = nil) {
|
||||
savedTime = time
|
||||
shouldResumePlaying = true
|
||||
|
||||
loadAvailableStreams(video) { streams in
|
||||
guard let stream = streams.first else {
|
||||
return
|
||||
}
|
||||
|
||||
self.streamSelection = stream
|
||||
self.playStream(stream, of: video, preservingTime: !time.isNil)
|
||||
}
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream) {
|
||||
if !self.stream.isNil, self.stream != stream {
|
||||
playStream(stream, of: currentVideo!, preservingTime: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func playStream(
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
@@ -164,11 +181,33 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
attachMetadata(to: playerItem!, video: video, for: stream)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
self.composition = AVMutableComposition()
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard self.autoPlayItems else {
|
||||
return
|
||||
}
|
||||
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
|
||||
let replaceItemAndSeek = {
|
||||
self.player.replaceCurrentItem(with: playerItem)
|
||||
self.seekToSavedTime { finished in
|
||||
@@ -176,19 +215,8 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
self.savedTime = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
self.play()
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +252,11 @@ final class PlayerModel: ObservableObject {
|
||||
insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
|
||||
private func loadCompositionAsset(_ asset: AVURLAsset, type: AVMediaType, of video: Video) async {
|
||||
private func loadCompositionAsset(
|
||||
_ asset: AVURLAsset,
|
||||
type: AVMediaType,
|
||||
of video: Video
|
||||
) async {
|
||||
async let assetTracks = asset.loadTracks(withMediaType: type)
|
||||
|
||||
logger.info("loading \(type.rawValue) track")
|
||||
@@ -242,7 +274,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1_000_000)),
|
||||
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
@@ -279,10 +311,14 @@ final class PlayerModel: ObservableObject {
|
||||
item.preferredForwardBufferDuration = 5
|
||||
|
||||
statusObservation?.invalidate()
|
||||
statusObservation = item.observe(\.status, options: [.old, .new]) { playerItem, _ in
|
||||
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch playerItem.status {
|
||||
case .readyToPlay:
|
||||
if self.isAutoplaying(playerItem), self.shouldResumePlaying {
|
||||
if self.isAutoplaying(playerItem) {
|
||||
self.play()
|
||||
}
|
||||
case .failed:
|
||||
@@ -321,12 +357,14 @@ final class PlayerModel: ObservableObject {
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
|
||||
currentItem.playbackTime = playerItemDuration
|
||||
|
||||
if queue.isEmpty {
|
||||
addCurrentItemToHistory()
|
||||
resetQueue()
|
||||
#if os(tvOS)
|
||||
avPlayerViewController!.dismiss(animated: true) {
|
||||
self.controller!.dismiss(animated: true)
|
||||
avPlayerViewController!.dismiss(animated: true) { [weak self] in
|
||||
self?.controller!.dismiss(animated: true)
|
||||
}
|
||||
#endif
|
||||
presentingPlayer = false
|
||||
@@ -342,8 +380,8 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.savedTime = currentTime
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.savedTime = currentTime
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
@@ -355,43 +393,74 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
player.seek(
|
||||
to: time,
|
||||
toleranceBefore: .init(seconds: 1, preferredTimescale: 1_000_000),
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
private func addTimeObserver() {
|
||||
let interval = CMTime(seconds: 0.5, preferredTimescale: 1_000_000)
|
||||
private func addFrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(0.5)
|
||||
|
||||
frequentTimeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in
|
||||
self.currentRate = self.player.rate
|
||||
|
||||
guard !self.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
let time = self.player.currentTime()
|
||||
|
||||
self.currentItem!.playbackTime = time
|
||||
self.currentItem!.videoDuration = self.player.currentItem?.asset.duration.seconds
|
||||
|
||||
self.handleSegments(at: time)
|
||||
self.handleSegments(at: self.player.currentTime())
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func addPlayerTimeControlStatusObserver() {
|
||||
playerTimeControlStatusObserver = player.observe(\.timeControlStatus) { player, _ in
|
||||
guard self.player == player else {
|
||||
return
|
||||
}
|
||||
private func addInfrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(5)
|
||||
|
||||
infrequentTimeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.updateCurrentItemIntervals()
|
||||
}
|
||||
}
|
||||
|
||||
private func addPlayerTimeControlStatusObserver() {
|
||||
playerTimeControlStatusObserver = player.observe(\.timeControlStatus) { [weak self] player, _ in
|
||||
guard let self = self,
|
||||
self.player == player
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if player.timeControlStatus == .playing {
|
||||
ScreenSaverManager.shared.disable(reason: "Yattee is playing video")
|
||||
} else {
|
||||
ScreenSaverManager.shared.enable()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
self.updateCurrentItemIntervals()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func updateCurrentItemIntervals() {
|
||||
currentItem?.playbackTime = player.currentTime()
|
||||
currentItem?.videoDuration = player.currentItem?.asset.duration.seconds
|
||||
}
|
||||
}
|
||||
|
@@ -39,7 +39,7 @@ extension PlayerModel {
|
||||
currentItem = item
|
||||
|
||||
if !time.isNil {
|
||||
currentItem.playbackTime = CMTime(seconds: time!, preferredTimescale: 1_000_000)
|
||||
currentItem.playbackTime = .secondsInDefaultTimescale(time!)
|
||||
} else if currentItem.playbackTime.isNil {
|
||||
currentItem.playbackTime = .zero
|
||||
}
|
||||
@@ -48,7 +48,20 @@ extension PlayerModel {
|
||||
currentItem.video = video!
|
||||
}
|
||||
|
||||
playVideo(currentVideo!, time: currentItem.playbackTime)
|
||||
savedTime = currentItem.playbackTime
|
||||
|
||||
loadAvailableStreams(currentVideo!) { streams in
|
||||
guard let stream = streams.first else {
|
||||
return
|
||||
}
|
||||
|
||||
self.streamSelection = stream
|
||||
self.playStream(
|
||||
stream,
|
||||
of: self.currentVideo!,
|
||||
preservingTime: !self.currentItem.playbackTime.isNil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
@@ -62,9 +75,10 @@ extension PlayerModel {
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
|
||||
addCurrentItemToHistory()
|
||||
|
||||
let item = remove(newItem)!
|
||||
loadDetails(newItem.video) { video in
|
||||
self.playItem(item, video: video, at: time)
|
||||
remove(newItem)
|
||||
|
||||
accounts.api.loadDetails(newItem) { newItem in
|
||||
self.playItem(newItem, video: newItem.video, at: time)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,18 +91,30 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func resetQueue() {
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.currentItem = nil
|
||||
self.stream = nil
|
||||
self.removeQueueItems()
|
||||
self.timeObserver = nil
|
||||
}
|
||||
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
player.currentItem == item
|
||||
guard player.currentItem == item else {
|
||||
return false
|
||||
}
|
||||
|
||||
if !autoPlayItems {
|
||||
autoPlayItems = true
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult func enqueueVideo(
|
||||
@@ -102,36 +128,24 @@ extension PlayerModel {
|
||||
|
||||
queue.insert(item, at: prepending ? 0 : queue.endIndex)
|
||||
|
||||
loadDetails(video) { video in
|
||||
videoDetailsLoadHandler(video, item)
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
videoDetailsLoadHandler(newItem.video, newItem)
|
||||
|
||||
if play {
|
||||
self.playItem(item, video: video)
|
||||
self.playItem(newItem, video: video)
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
|
||||
guard video != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.api.video(video!.videoID).load().onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
onSuccess(video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addCurrentItemToHistory() {
|
||||
if let item = currentItem {
|
||||
if let index = history.firstIndex(where: { $0.video.videoID == item.video.videoID }) {
|
||||
if let index = history.firstIndex(where: { $0.video.videoID == item.video?.videoID }) {
|
||||
history.remove(at: index)
|
||||
}
|
||||
|
||||
history.insert(item, at: 0)
|
||||
history.insert(currentItem, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,14 +1,19 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct PlayerQueueItem: Hashable, Identifiable {
|
||||
struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
|
||||
static let bridge = PlayerQueueItemBridge()
|
||||
|
||||
var id = UUID()
|
||||
var video: Video
|
||||
var video: Video!
|
||||
var videoID: Video.ID
|
||||
var playbackTime: CMTime?
|
||||
var videoDuration: TimeInterval?
|
||||
|
||||
init(_ video: Video, playbackTime: CMTime? = nil, videoDuration: TimeInterval? = nil) {
|
||||
init(_ video: Video? = nil, videoID: Video.ID? = nil, playbackTime: CMTime? = nil, videoDuration: TimeInterval? = nil) {
|
||||
self.video = video
|
||||
self.videoID = videoID ?? video!.videoID
|
||||
self.playbackTime = playbackTime
|
||||
self.videoDuration = videoDuration
|
||||
}
|
||||
|
67
Model/Player/PlayerQueueItemBridge.swift
Normal file
67
Model/Player/PlayerQueueItemBridge.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct PlayerQueueItemBridge: Defaults.Bridge {
|
||||
typealias Value = PlayerQueueItem
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let videoID = value.videoID.isEmpty ? value.video!.videoID : value.videoID
|
||||
|
||||
var playbackTime = ""
|
||||
if let time = value.playbackTime {
|
||||
if time.seconds.isFinite {
|
||||
playbackTime = String(time.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
var videoDuration = ""
|
||||
if let duration = value.videoDuration {
|
||||
if duration.isFinite {
|
||||
videoDuration = String(duration)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"videoID": videoID,
|
||||
"playbackTime": playbackTime,
|
||||
"videoDuration": videoDuration
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object = object,
|
||||
let videoID = object["videoID"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var playbackTime: CMTime?
|
||||
var videoDuration: TimeInterval?
|
||||
|
||||
if let time = object["playbackTime"],
|
||||
!time.isEmpty,
|
||||
let seconds = TimeInterval(time)
|
||||
{
|
||||
playbackTime = .secondsInDefaultTimescale(seconds)
|
||||
}
|
||||
|
||||
if let duration = object["videoDuration"],
|
||||
!duration.isEmpty
|
||||
{
|
||||
videoDuration = TimeInterval(duration)
|
||||
}
|
||||
|
||||
return PlayerQueueItem(
|
||||
videoID: videoID,
|
||||
playbackTime: playbackTime,
|
||||
videoDuration: videoDuration
|
||||
)
|
||||
}
|
||||
}
|
@@ -5,7 +5,7 @@ import Foundation
|
||||
extension PlayerModel {
|
||||
func handleSegments(at time: CMTime) {
|
||||
if let segment = lastSkipped {
|
||||
if time > CMTime(seconds: segment.end + 10, preferredTimescale: 1_000_000) {
|
||||
if time > .secondsInDefaultTimescale(segment.end + 10) {
|
||||
resetLastSegment()
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ extension PlayerModel {
|
||||
var nextSegments = [firstSegment]
|
||||
|
||||
while let segment = sponsorBlock.segments.first(where: {
|
||||
$0.timeInSegment(CMTime(seconds: nextSegments.last!.end + 2, preferredTimescale: 1_000_000))
|
||||
$0.timeInSegment(.secondsInDefaultTimescale(nextSegments.last!.end + 2))
|
||||
}) {
|
||||
nextSegments.append(segment)
|
||||
}
|
Reference in New Issue
Block a user