Player controls UI changes

WIP on controls

Chapters

working

Add previews variable

Add lists ids

WIP
This commit is contained in:
Arkadiusz Fal
2022-06-18 14:39:49 +02:00
parent a912079eac
commit ba1115fe2a
60 changed files with 2524 additions and 1320 deletions

View File

@@ -383,6 +383,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
id = videoID
}
let description = json["description"].stringValue
return Video(
id: id,
videoID: videoID,
@@ -391,7 +393,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
length: json["lengthSeconds"].doubleValue,
published: json["publishedText"].stringValue,
views: json["viewCount"].intValue,
description: json["description"].stringValue,
description: description,
genre: json["genre"].stringValue,
channel: extractChannel(from: json),
thumbnails: extractThumbnails(from: json),
@@ -403,7 +405,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
dislikes: json["dislikeCount"].int,
keywords: json["keywords"].arrayValue.compactMap { $0.string },
streams: extractStreams(from: json),
related: extractRelated(from: json)
related: extractRelated(from: json),
chapters: extractChapters(from: description)
)
}

View File

@@ -409,6 +409,13 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let live = details["livestream"]?.bool ?? (details["duration"]?.int == -1)
let description = extractDescription(from: content) ?? ""
var chapters = extractChapters(from: content)
if chapters.isEmpty, !description.isEmpty {
chapters = extractChapters(from: description)
}
return Video(
videoID: extractID(from: content),
title: details["title"]?.string ?? "",
@@ -416,14 +423,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
length: details["duration"]?.double ?? 0,
published: published ?? "",
views: details["views"]?.int ?? 0,
description: extractDescription(from: content),
description: description,
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
thumbnails: thumbnails,
live: live,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(from: content),
related: extractRelated(from: content)
related: extractRelated(from: content),
chapters: extractChapters(from: content)
)
}
@@ -571,4 +579,21 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
channel: Channel(id: channelId, name: author)
)
}
private func extractChapters(from content: JSON) -> [Chapter] {
guard let chapters = content.dictionaryValue["chapters"]?.array else {
return .init()
}
return chapters.compactMap { chapter in
guard let title = chapter["title"].string,
let image = chapter["image"].url,
let start = chapter["start"].double
else {
return nil
}
return Chapter(title: title, image: image, start: start)
}
}
}

View File

@@ -116,4 +116,54 @@ extension VideosAPI {
return urlComponents.url
}
func extractChapters(from description: String) -> [Chapter] {
guard let chaptersRegularExpression = try? NSRegularExpression(
pattern: "(?<start>(?:[0-9]+:){1,}(?:[0-9]+))(?:\\s)+(?:- ?)?(?<title>.*)",
options: .caseInsensitive
) else { return [] }
let chapterLines = chaptersRegularExpression.matches(
in: description,
range: NSRange(description.startIndex..., in: description)
)
return chapterLines.compactMap { line in
let titleRange = line.range(withName: "title")
let startRange = line.range(withName: "start")
guard let titleSubstringRange = Range(titleRange, in: description),
let startSubstringRange = Range(startRange, in: description),
let titleCapture = String(description[titleSubstringRange]),
let startCapture = String(description[startSubstringRange]) else { return nil }
let startComponents = startCapture.components(separatedBy: ":")
guard startComponents.count <= 3 else { return nil }
var hours: Double?
var minutes: Double?
var seconds: Double?
if startComponents.count == 3 {
hours = Double(startComponents[0])
minutes = Double(startComponents[1])
seconds = Double(startComponents[2])
} else if startComponents.count == 2 {
minutes = Double(startComponents[0])
seconds = Double(startComponents[1])
}
guard var startSeconds = seconds else { return nil }
if let minutes = minutes {
startSeconds += 60 * minutes
}
if let hours = hours {
startSeconds += 60 * 60 * hours
}
return .init(title: titleCapture, start: startSeconds)
}
}
}

8
Model/Chapter.swift Normal file
View File

@@ -0,0 +1,8 @@
import Foundation
struct Chapter: Identifiable, Equatable {
var id = UUID()
var title: String
var image: URL?
var start: Double
}

View File

@@ -40,12 +40,12 @@ final class CommentsModel: ObservableObject {
}
func load(page: String? = nil) {
guard Self.enabled else {
guard Self.enabled, !loaded else {
return
}
guard !Self.instance.isNil,
!(player?.currentVideo.isNil ?? true)
let video = player.currentVideo
else {
return
}
@@ -56,7 +56,7 @@ final class CommentsModel: ObservableObject {
firstPage = page.isNil || page!.isEmpty
api?.comments(player.currentVideo!.videoID, page: page)?
api?.comments(video.videoID, page: page)?
.load()
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {

View File

@@ -66,6 +66,10 @@ final class NavigationModel: ObservableObject {
@Published var presentingSettings = false
@Published var presentingWelcomeScreen = false
@Published var presentingAlert = false
@Published var alertTitle = ""
@Published var alertMessage = ""
static func openChannel(
_ channel: Channel,
player: PlayerModel,
@@ -181,6 +185,12 @@ final class NavigationModel: ObservableObject {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
#endif
}
func presentAlert(title: String, message: String) {
alertTitle = title
alertMessage = message
presentingAlert = true
}
}
typealias TabSelection = NavigationModel.TabSelection

View File

@@ -0,0 +1,42 @@
import Foundation
final class NetworkStateModel: ObservableObject {
@Published var pausedForCache = false
@Published var cacheDuration = 0.0
@Published var bufferingState = 0.0
var player: PlayerModel!
var fullStateText: String? {
guard let bufferingStateText = bufferingStateText,
let cacheDurationText = cacheDurationText
else {
return nil
}
return "\(bufferingStateText) (\(cacheDurationText))"
}
var bufferingStateText: String? {
guard detailsAvailable else { return nil }
return String(format: "%.0f%%", bufferingState)
}
var cacheDurationText: String? {
guard detailsAvailable else { return nil }
return String(format: "%.2fs", cacheDuration)
}
var detailsAvailable: Bool {
guard let player = player else { return false }
return player.activeBackend.supportsNetworkStateBufferingDetails
}
var needsUpdates: Bool {
if let player = player {
return pausedForCache || player.isSeeking || player.isLoadingVideo
}
return pausedForCache
}
}

View File

@@ -11,6 +11,8 @@ final class AVPlayerBackend: PlayerBackend {
var model: PlayerModel!
var controls: PlayerControlsModel!
var playerTime: PlayerTimeModel!
var networkState: NetworkStateModel!
var stream: Stream?
var video: Video?
@@ -31,6 +33,11 @@ final class AVPlayerBackend: PlayerBackend {
avPlayer.timeControlStatus == .playing
}
var isSeeking: Bool {
// TODO: implement this maybe?
false
}
var playerItemDuration: CMTime? {
avPlayer.currentItem?.asset.duration
}
@@ -52,9 +59,10 @@ final class AVPlayerBackend: PlayerBackend {
private var timeObserverThrottle = Throttle(interval: 2)
init(model: PlayerModel, controls: PlayerControlsModel?) {
init(model: PlayerModel, controls: PlayerControlsModel?, playerTime: PlayerTimeModel?) {
self.model = model
self.controls = controls
self.playerTime = playerTime
addFrequentTimeObserver()
addInfrequentTimeObserver()
@@ -493,8 +501,8 @@ final class AVPlayerBackend: PlayerBackend {
return
}
self.controls.duration = self.playerItemDuration ?? .zero
self.controls.currentTime = self.currentTime ?? .zero
self.playerTime.duration = self.playerItemDuration ?? .zero
self.playerTime.currentTime = self.currentTime ?? .zero
#if !os(tvOS)
self.model.updateNowPlayingInfo()
@@ -581,4 +589,5 @@ final class AVPlayerBackend: PlayerBackend {
func stopControlsUpdates() {}
func setNeedsDrawing(_: Bool) {}
func setSize(_: Double, _: Double) {}
func updateNetworkState() {}
}

View File

@@ -12,6 +12,8 @@ final class MPVBackend: PlayerBackend {
var model: PlayerModel!
var controls: PlayerControlsModel!
var playerTime: PlayerTimeModel!
var networkState: NetworkStateModel!
var stream: Stream?
var video: Video?
@@ -24,17 +26,22 @@ final class MPVBackend: PlayerBackend {
return
}
self.controls.isLoadingVideo = self.isLoadingVideo
self.controls?.isLoadingVideo = self.isLoadingVideo
self.updateNetworkState()
if !self.isLoadingVideo {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.handleEOF = true
}
}
self.model?.objectWillChange.send()
}
}}
var isPlaying = true { didSet {
updateNetworkState()
if isPlaying {
startClientUpdates()
} else {
@@ -49,6 +56,15 @@ final class MPVBackend: PlayerBackend {
}
#endif
}}
var isSeeking = false {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.model.isSeeking = self.isSeeking
}
}
}
var playerItemDuration: CMTime?
#if !os(macOS)
@@ -88,9 +104,16 @@ final class MPVBackend: PlayerBackend {
client?.cacheDuration ?? 0
}
init(model: PlayerModel, controls: PlayerControlsModel? = nil) {
init(
model: PlayerModel,
controls: PlayerControlsModel? = nil,
playerTime: PlayerTimeModel? = nil,
networkState: NetworkStateModel? = nil
) {
self.model = model
self.controls = controls
self.playerTime = playerTime
self.networkState = networkState
clientTimer = .init(timeInterval: Self.controlsUpdateInterval)
clientTimer.eventHandler = getClientUpdates
@@ -155,7 +178,6 @@ final class MPVBackend: PlayerBackend {
if !preservingTime,
let segment = self.model.sponsorBlock.segments.first,
segment.end > 4,
self.model.lastSkipped.isNil
{
self.seek(to: segment.endTime) { finished in
@@ -202,7 +224,7 @@ final class MPVBackend: PlayerBackend {
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
self.client.loadFile(fileToLoad, audio: audioTrack, time: time) { [weak self] _ in
self.client?.loadFile(fileToLoad, audio: audioTrack, time: time) { [weak self] _ in
self?.isLoadingVideo = true
self?.pause()
}
@@ -229,7 +251,7 @@ final class MPVBackend: PlayerBackend {
isPlaying = true
startClientUpdates()
if controls.presentingControls {
if controls?.presentingControls ?? false {
startControlsUpdates()
}
@@ -254,7 +276,7 @@ final class MPVBackend: PlayerBackend {
}
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
client.seek(to: time) { [weak self] _ in
client?.seek(to: time) { [weak self] _ in
self?.getClientUpdates()
self?.updateControls()
completionHandler?(true)
@@ -262,7 +284,7 @@ final class MPVBackend: PlayerBackend {
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
client.seek(relative: time) { [weak self] _ in
client?.seek(relative: time) { [weak self] _ in
self?.getClientUpdates()
self?.updateControls()
completionHandler?(true)
@@ -280,13 +302,7 @@ final class MPVBackend: PlayerBackend {
}
func enterFullScreen() {
model.toggleFullscreen(controls?.playingFullscreen ?? false)
#if os(iOS)
if Defaults[.lockOrientationInFullScreen] {
Orientation.lockOrientation(.landscape, andRotateTo: UIDevice.current.orientation.isLandscape ? nil : .landscapeRight)
}
#endif
model.toggleFullscreen(model?.playingFullScreen ?? false)
}
func exitFullScreen() {}
@@ -297,15 +313,13 @@ final class MPVBackend: PlayerBackend {
guard model.presentingPlayer else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else {
return
}
self.logger.info("updating controls")
self.controls.currentTime = self.currentTime ?? .zero
self.controls.duration = self.playerItemDuration ?? .zero
self.playerTime.currentTime = self.currentTime ?? .zero
self.playerTime.duration = self.playerItemDuration ?? .zero
}
}
@@ -375,13 +389,22 @@ final class MPVBackend: PlayerBackend {
case MPV_EVENT_PLAYBACK_RESTART:
isLoadingVideo = false
isSeeking = false
onFileLoaded?()
startClientUpdates()
onFileLoaded = nil
case MPV_EVENT_PAUSE:
updateNetworkState()
case MPV_EVENT_UNPAUSE:
isLoadingVideo = false
isSeeking = false
updateNetworkState()
case MPV_EVENT_SEEK:
isSeeking = true
case MPV_EVENT_END_FILE:
DispatchQueue.main.async { [weak self] in
@@ -417,18 +440,41 @@ final class MPVBackend: PlayerBackend {
}
func setSize(_ width: Double, _ height: Double) {
self.client?.setSize(width, height)
client?.setSize(width, height)
}
func addVideoTrack(_ url: URL) {
self.client?.addVideoTrack(url)
client?.addVideoTrack(url)
}
func setVideoToAuto() {
self.client?.setVideoToAuto()
client?.setVideoToAuto()
}
func setVideoToNo() {
self.client?.setVideoToNo()
client?.setVideoToNo()
}
func updateNetworkState() {
guard let client = client, let networkState = networkState else {
return
}
DispatchQueue.main.async {
networkState.pausedForCache = client.pausedForCache
networkState.cacheDuration = client.cacheDuration
networkState.bufferingState = client.bufferingState
}
if networkState.needsUpdates {
dispatchNetworkUpdate()
}
}
func dispatchNetworkUpdate() {
print("dispatching network update")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.updateNetworkState()
}
}
}

View File

@@ -45,6 +45,9 @@ final class MPVClient: ObservableObject {
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
#endif
checkError(mpv_set_option_string(mpv, "cache-pause-initial", "yes"))
checkError(mpv_set_option_string(mpv, "cache-secs", "20"))
checkError(mpv_set_option_string(mpv, "cache-pause-wait", "2"))
checkError(mpv_set_option_string(mpv, "hwdec", "auto-safe"))
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
@@ -167,6 +170,10 @@ final class MPVClient: ObservableObject {
CMTime.secondsInDefaultTimescale(mpv.isNil ? -1 : getDouble("duration"))
}
var pausedForCache: Bool {
mpv.isNil ? false : getFlag("paused-for-cache")
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
guard !seeking else {
logger.warning("ignoring seek, another in progress")
@@ -262,6 +269,12 @@ final class MPVClient: ObservableObject {
Int(getString("track-list/count") ?? "-1") ?? -1
}
private func getFlag(_ name: String) -> Bool {
var data = Int64()
mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
return data > 0
}
private func setFlagAsync(_ name: String, _ flag: Bool) {
var data: Int = flag ? 1 : 0
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_FLAG, &data)

View File

@@ -5,6 +5,8 @@ import Foundation
protocol PlayerBackend {
var model: PlayerModel! { get set }
var controls: PlayerControlsModel! { get set }
var playerTime: PlayerTimeModel! { get set }
var networkState: NetworkStateModel! { get set }
var stream: Stream? { get set }
var video: Video? { get set }
@@ -14,6 +16,7 @@ protocol PlayerBackend {
var isLoadingVideo: Bool { get }
var isPlaying: Bool { get }
var isSeeking: Bool { get }
var playerItemDuration: CMTime? { get }
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
@@ -49,6 +52,8 @@ protocol PlayerBackend {
func startControlsUpdates()
func stopControlsUpdates()
func updateNetworkState()
func setNeedsDrawing(_ needsDrawing: Bool)
func setSize(_ width: Double, _ height: Double)
}

View File

@@ -13,4 +13,8 @@ enum PlayerBackendType: String, CaseIterable, Defaults.Serializable {
return "AVPlayer"
}
}
var supportsNetworkStateBufferingDetails: Bool {
self == .mpv
}
}

View File

@@ -5,37 +5,26 @@ import SwiftUI
final class PlayerControlsModel: ObservableObject {
@Published var isLoadingVideo = false
@Published var isPlaying = true
@Published var currentTime = CMTime.zero
@Published var duration = CMTime.zero
@Published var presentingControls = false { didSet { handlePresentationChange() } }
@Published var presentingControlsOverlay = false
@Published var timer: Timer?
@Published var playingFullscreen = false
var player: PlayerModel!
var playbackTime: String {
guard let current = currentTime.seconds.formattedAsPlaybackTime(),
let duration = duration.seconds.formattedAsPlaybackTime()
else {
return "--:-- / --:--"
}
var withoutSegments = ""
if let withoutSegmentsDuration = playerItemDurationWithoutSponsorSegments,
self.duration.seconds != withoutSegmentsDuration
{
withoutSegments = " (\(withoutSegmentsDuration.formattedAsPlaybackTime() ?? "--:--"))"
}
return "\(current) / \(duration)\(withoutSegments)"
}
var playerItemDurationWithoutSponsorSegments: Double? {
guard let duration = player.playerItemDurationWithoutSponsorSegments else {
return nil
}
return duration.seconds
init(
isLoadingVideo: Bool = false,
isPlaying: Bool = true,
presentingControls: Bool = false,
presentingControlsOverlay: Bool = false,
timer: Timer? = nil,
player: PlayerModel? = nil
) {
self.isLoadingVideo = isLoadingVideo
self.isPlaying = isPlaying
self.presentingControls = presentingControls
self.presentingControlsOverlay = presentingControlsOverlay
self.timer = timer
self.player = player
}
func handlePresentationChange() {
@@ -45,7 +34,7 @@ final class PlayerControlsModel: ObservableObject {
self?.resetTimer()
}
} else {
player.backend.stopControlsUpdates()
player?.backend.stopControlsUpdates()
timer?.invalidate()
timer = nil
}
@@ -91,11 +80,6 @@ final class PlayerControlsModel: ObservableObject {
presentingControls ? hide() : show()
}
func reset() {
currentTime = .zero
duration = .zero
}
func resetTimer() {
#if os(tvOS)
if !presentingControls {

View File

@@ -53,6 +53,7 @@ final class PlayerModel: ObservableObject {
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
@Published var videoBeingOpened: Video?
@Published var historyVideos = [Video]()
@Published var preservedTime: CMTime?
@@ -65,6 +66,10 @@ final class PlayerModel: ObservableObject {
@Published var musicMode = false
@Published var returnYouTubeDislike = ReturnYouTubeDislikeAPI()
@Published var isSeeking = false { didSet {
backend.updateNetworkState()
}}
#if os(iOS)
@Published var motionManager: CMMotionManager!
@Published var lockedOrientation: UIInterfaceOrientation?
@@ -79,9 +84,24 @@ final class PlayerModel: ObservableObject {
backend.controls = controls
}
}}
var playerTime: PlayerTimeModel { didSet {
backends.forEach { backend in
var backend = backend
backend.playerTime = playerTime
backend.playerTime.player = self
}
}}
var networkState: NetworkStateModel { didSet {
backends.forEach { backend in
var backend = backend
backend.networkState = networkState
backend.networkState.player = self
}
}}
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
@Published var playingFullScreen = false
@Published var playingInPictureInPicture = false
var pipController: AVPictureInPictureController?
var pipDelegate = PiPDelegate()
@@ -108,13 +128,31 @@ final class PlayerModel: ObservableObject {
var playerLayerView: PlayerLayerView!
#endif
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil, controls: PlayerControlsModel? = nil) {
self.accounts = accounts ?? AccountsModel()
self.comments = comments ?? CommentsModel()
self.controls = controls ?? PlayerControlsModel()
var onPresentPlayer: (() -> Void)?
self.avPlayerBackend = AVPlayerBackend(model: self, controls: controls)
self.mpvBackend = MPVBackend(model: self)
init(
accounts: AccountsModel = AccountsModel(),
comments: CommentsModel = CommentsModel(),
controls: PlayerControlsModel = PlayerControlsModel(),
playerTime: PlayerTimeModel = PlayerTimeModel(),
networkState: NetworkStateModel = NetworkStateModel()
) {
self.accounts = accounts
self.comments = comments
self.controls = controls
self.playerTime = playerTime
self.networkState = networkState
self.avPlayerBackend = AVPlayerBackend(
model: self,
controls: controls,
playerTime: playerTime
)
self.mpvBackend = MPVBackend(
model: self,
playerTime: playerTime,
networkState: networkState
)
Defaults[.activeBackend] = .mpv
}
@@ -136,7 +174,7 @@ final class PlayerModel: ObservableObject {
}
func hide() {
controls.playingFullscreen = false
playingFullScreen = false
presentingPlayer = false
#if os(iOS)
@@ -176,11 +214,19 @@ final class PlayerModel: ObservableObject {
}
var playerItemDuration: CMTime? {
backend.playerItemDuration
guard !currentItem.isNil else {
return nil
}
return backend.playerItemDuration
}
var playerItemDurationWithoutSponsorSegments: CMTime? {
(backend.playerItemDuration ?? .zero) - .secondsInDefaultTimescale(
guard let playerItemDuration = playerItemDuration, !playerItemDuration.seconds.isZero else {
return nil
}
return playerItemDuration - .secondsInDefaultTimescale(
sponsorBlock.segments.reduce(0) { $0 + $1.duration }
)
}
@@ -212,18 +258,15 @@ final class PlayerModel: ObservableObject {
func play(_ video: Video, at time: CMTime? = nil, showingPlayer: Bool = true) {
pause()
var delay = 0.0
#if os(iOS)
delay = 0.5
#endif
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self else {
if !playingInPictureInPicture, showingPlayer {
onPresentPlayer = { [weak self] in self?.playNow(video, at: time) }
show()
return
}
#endif
self.playNow(video, at: time)
}
playNow(video, at: time)
guard !playingInPictureInPicture else {
return
@@ -260,7 +303,7 @@ final class PlayerModel: ObservableObject {
}
}
controls.reset()
playerTime.reset()
backend.playStream(
stream,
@@ -468,10 +511,13 @@ final class PlayerModel: ObservableObject {
func handleEnterBackground() {
setNeedsDrawing(false)
if !playingInPictureInPicture, !musicMode {
pause()
}
}
func enterFullScreen() {
guard !controls.playingFullscreen else {
guard !playingFullScreen else {
return
}
@@ -481,13 +527,13 @@ final class PlayerModel: ObservableObject {
}
func exitFullScreen() {
guard controls.playingFullscreen else {
guard playingFullScreen else {
return
}
logger.info("exiting fullscreen")
if controls.playingFullscreen {
if playingFullScreen {
toggleFullscreen(true)
}
@@ -559,14 +605,14 @@ final class PlayerModel: ObservableObject {
setNeedsDrawing(false)
#endif
controls.playingFullscreen = !isFullScreen
playingFullScreen = !isFullScreen
#if os(iOS)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.setNeedsDrawing(true)
}
if controls.playingFullscreen {
if playingFullScreen {
guard !(UIApplication.shared.windows.first?.windowScene?.interfaceOrientation.isLandscape ?? true) else {
return
}
@@ -590,12 +636,6 @@ final class PlayerModel: ObservableObject {
avPlayerBackend.switchToMPVOnPipClose = false
closePiP()
}
#if os(macOS)
// TODO: initialize mpv on startup on mac
if mpvBackend.client.isNil {
Windows.player.open()
}
#endif
changeActiveBackend(from: .appleAVPlayer, to: .mpv)
controls.presentingControls = true
controls.removeTimer()

View File

@@ -20,22 +20,14 @@ extension PlayerModel {
}
videosToPlay.dropFirst().reversed().forEach { video in
enqueueVideo(video, prepending: true) { _, item in
if item.video == first {
self.advanceToItem(item)
}
}
enqueueVideo(video, prepending: true, loadDetails: false)
}
show()
}
func playNext(_ video: Video) {
enqueueVideo(video, prepending: true) { _, item in
if self.currentItem.isNil {
self.advanceToItem(item)
}
}
enqueueVideo(video, play: currentItem.isNil, prepending: true)
}
func playNow(_ video: Video, at time: CMTime? = nil) {
@@ -45,12 +37,12 @@ extension PlayerModel {
prepareCurrentItemForHistory()
enqueueVideo(video, prepending: true) { _, item in
enqueueVideo(video, play: true, atTime: time, prepending: true) { _, item in
self.advanceToItem(item, at: time)
}
}
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: CMTime? = nil) {
func playItem(_ item: PlayerQueueItem, at time: CMTime? = nil) {
if !playingInPictureInPicture {
backend.closeItem()
}
@@ -65,32 +57,25 @@ extension PlayerModel {
currentItem.playbackTime = .zero
}
if video != nil {
currentItem.video = video!
}
preservedTime = currentItem.playbackTime
DispatchQueue.main.async { [weak self] in
guard let video = self?.currentVideo else {
return
}
self?.videoBeingOpened = nil
self?.loadAvailableStreams(video)
if video.streams.isEmpty {
self?.loadAvailableStreams(video)
} else {
guard let instance = self?.accounts.current?.instance ?? InstancesModel.forPlayer ?? InstancesModel.all.first else { return }
self?.availableStreams = self?.streamsWithInstance(instance: instance, streams: video.streams) ?? video.streams
}
}
}
func preferredStream(_ streams: [Stream]) -> Stream? {
let quality = Defaults[.quality]
var streams = streams
if let id = Defaults[.playerInstanceID] {
streams = streams.filter { $0.instance.id == id }
}
streams = streams.filter { backend.canPlay($0) }
return backend.bestPlayable(streams, maxResolution: quality)
backend.bestPlayable(streams.filter { backend.canPlay($0) }, maxResolution: Defaults[.quality])
}
func advanceToNextItem() {
@@ -109,7 +94,7 @@ extension PlayerModel {
currentItem = newItem
accounts.api.loadDetails(newItem) { newItem in
self.playItem(newItem, video: newItem.video, at: time)
self.playItem(newItem, at: time)
}
}
@@ -140,23 +125,29 @@ extension PlayerModel {
play: Bool = false,
atTime: CMTime? = nil,
prepending: Bool = false,
loadDetails: Bool = true,
videoDetailsLoadHandler: @escaping (Video, PlayerQueueItem) -> Void = { _, _ in }
) -> PlayerQueueItem? {
let item = PlayerQueueItem(video, playbackTime: atTime)
if play {
currentItem = item
// pause playing current video as it's going to be replaced with next one
videoBeingOpened = video
}
queue.insert(item, at: prepending ? 0 : queue.endIndex)
if loadDetails {
accounts.api.loadDetails(item) { [weak self] newItem in
guard let self = self else { return }
videoDetailsLoadHandler(newItem.video, newItem)
accounts.api.loadDetails(item) { newItem in
videoDetailsLoadHandler(newItem.video, newItem)
if play {
self.playItem(newItem, video: video)
if play {
self.playItem(newItem)
} else {
self.queue.insert(newItem, at: prepending ? 0 : self.queue.endIndex)
}
}
} else {
queue.insert(item, at: prepending ? 0 : queue.endIndex)
}
return item

View File

@@ -2,14 +2,16 @@ import AVFAudio
import CoreMedia
import Defaults
import Foundation
import SwiftUI
extension PlayerModel {
func handleSegments(at time: CMTime) {
if let segment = lastSkipped {
if time > .secondsInDefaultTimescale(segment.end + 10) {
if time > .secondsInDefaultTimescale(segment.end + 5) {
resetLastSegment()
}
}
guard let firstSegment = sponsorBlock.segments.first(where: { $0.timeInSegment(time) }) else {
return
}
@@ -60,7 +62,9 @@ extension PlayerModel {
backend.seek(to: segment.endTime)
DispatchQueue.main.async { [weak self] in
self?.lastSkipped = segment
withAnimation {
self?.lastSkipped = segment
}
self?.segmentRestorationTime = time
}
logger.info("SponsorBlock skipping to: \(segment.end)")
@@ -69,8 +73,7 @@ extension PlayerModel {
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
guard isPlaying,
!restoredSegments.contains(segment),
Defaults[.sponsorBlockCategories].contains(segment.category),
segment.end > 4
Defaults[.sponsorBlockCategories].contains(segment.category)
else {
return false
}
@@ -92,7 +95,9 @@ extension PlayerModel {
private func resetLastSegment() {
DispatchQueue.main.async { [weak self] in
self?.lastSkipped = nil
withAnimation {
self?.lastSkipped = nil
}
self?.segmentRestorationTime = nil
}
}

View File

@@ -17,20 +17,14 @@ extension PlayerModel {
func loadAvailableStreams(_ video: Video) {
availableStreams = []
let playerInstance = InstancesModel.forPlayer ?? InstancesModel.all.first
guard !playerInstance.isNil else {
guard let playerInstance = InstancesModel.forPlayer ?? InstancesModel.all.first else {
return
}
logger.info("loading streams from \(playerInstance!.description)")
logger.info("loading streams from \(playerInstance.description)")
fetchStreams(playerInstance!.anonymous.video(video.videoID), instance: playerInstance!, video: video) { _ in
InstancesModel.all.filter { $0 != playerInstance }.forEach { instance in
self.logger.info("loading streams from \(instance.description)")
self.fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video)
}
}
fetchStreams(playerInstance.anonymous.video(video.videoID), instance: playerInstance, video: video)
}
private func fetchStreams(
@@ -60,8 +54,12 @@ extension PlayerModel {
stream.instance = instance
if instance.app == .invidious {
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: stream.audioAsset)
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: stream.videoAsset)
if let audio = stream.audioAsset {
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
}
if let video = stream.videoAsset {
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
}
}
return stream

View File

@@ -0,0 +1,50 @@
import CoreMedia
import Foundation
final class PlayerTimeModel: ObservableObject {
static let timePlaceholder = "--:--"
@Published var currentTime = CMTime.zero
@Published var duration = CMTime.zero
var player: PlayerModel?
var currentPlaybackTime: String {
if player?.currentItem.isNil ?? true || duration.seconds.isZero {
return Self.timePlaceholder
}
return currentTime.seconds.formattedAsPlaybackTime(allowZero: true) ?? Self.timePlaceholder
}
var durationPlaybackTime: String {
if player?.currentItem.isNil ?? true {
return Self.timePlaceholder
}
return duration.seconds.formattedAsPlaybackTime() ?? Self.timePlaceholder
}
var withoutSegmentsPlaybackTime: String {
guard let withoutSegmentsDuration = player?.playerItemDurationWithoutSponsorSegments?.seconds else {
return Self.timePlaceholder
}
return withoutSegmentsDuration.formattedAsPlaybackTime() ?? Self.timePlaceholder
}
var durationAndWithoutSegmentsPlaybackTime: String {
var durationAndWithoutSegmentsPlaybackTime = "\(durationPlaybackTime)"
if withoutSegmentsPlaybackTime != durationPlaybackTime {
durationAndWithoutSegmentsPlaybackTime += " (\(withoutSegmentsPlaybackTime))"
}
return durationAndWithoutSegmentsPlaybackTime
}
func reset() {
currentTime = .zero
duration = .zero
}
}

View File

@@ -1,3 +1,4 @@
import Defaults
import Foundation
import Siesta
import SwiftUI
@@ -16,6 +17,10 @@ final class PlaylistsModel: ObservableObject {
playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }
}
var lastUsed: Playlist? {
find(id: Defaults[.lastUsedPlaylistID])
}
func find(id: Playlist.ID?) -> Playlist? {
if id.isNil {
return nil
@@ -57,9 +62,19 @@ final class PlaylistsModel: ObservableObject {
playlistID: Playlist.ID,
videoID: Video.ID,
onSuccess: @escaping () -> Void = {},
onFailure: @escaping (RequestError) -> Void = { _ in }
navigation: NavigationModel?,
onFailure: ((RequestError) -> Void)? = nil
) {
accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) {
accounts.api.addVideoToPlaylist(
videoID,
playlistID,
onFailure: onFailure ?? { requestError in
navigation?.presentAlert(
title: "Error when adding to playlist",
message: "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
)
}
) {
self.load(force: true) {
self.reloadPlaylists.toggle()
onSuccess()

View File

@@ -21,6 +21,14 @@ class Segment: ObservableObject, Hashable {
end - start
}
var durationText: String {
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter.string(from: NSNumber(value: duration)) ?? ""
}
var endTime: CMTime {
.secondsInDefaultTimescale(end)
}

View File

@@ -91,13 +91,13 @@ class Stream: Equatable, Hashable, Identifiable {
private var sortOrder: Int {
switch self {
case .webm:
return 0
case .mp4:
return 1
return 0
case .avc1:
return 2
return 1
case .av1:
return 2
case .webm:
return 3
case .unknown:
return 4
@@ -160,17 +160,11 @@ class Stream: Equatable, Hashable, Identifiable {
}
var quality: String {
if resolution == .hd2160p30 {
return "4K (2160p)"
}
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
}
var shortQuality: String {
if resolution?.height == 2160 {
return "4K"
} else if kind == .hls {
if kind == .hls {
return "HLS"
} else {
return resolution?.name ?? "?"

View File

@@ -16,7 +16,7 @@ final class ThumbnailsModel: ObservableObject {
}
func best(_ video: Video) -> URL? {
let qualities = [Thumbnail.Quality.maxresdefault, .medium, .default]
let qualities = [Thumbnail.Quality.default]
for quality in qualities {
let url = video.thumbnailURL(quality: quality)

View File

@@ -32,6 +32,7 @@ struct Video: Identifiable, Equatable, Hashable {
var channel: Channel
var related = [Video]()
var chapters = [Chapter]()
init(
id: String? = nil,
@@ -53,7 +54,8 @@ struct Video: Identifiable, Equatable, Hashable {
dislikes: Int? = nil,
keywords: [String] = [],
streams: [Stream] = [],
related: [Video] = []
related: [Video] = [],
chapters: [Chapter] = []
) {
self.id = id ?? UUID().uuidString
self.videoID = videoID
@@ -75,6 +77,7 @@ struct Video: Identifiable, Equatable, Hashable {
self.keywords = keywords
self.streams = streams
self.related = related
self.chapters = chapters
}
var publishedDate: String? {