mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 10:44:06 +00:00
Player controls UI changes
WIP on controls Chapters working Add previews variable Add lists ids WIP
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
8
Model/Chapter.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
struct Chapter: Identifiable, Equatable {
|
||||
var id = UUID()
|
||||
var title: String
|
||||
var image: URL?
|
||||
var start: Double
|
||||
}
|
@@ -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() {
|
||||
|
@@ -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
|
||||
|
42
Model/NetworkStateModel.swift
Normal file
42
Model/NetworkStateModel.swift
Normal 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
|
||||
}
|
||||
}
|
@@ -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() {}
|
||||
}
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -13,4 +13,8 @@ enum PlayerBackendType: String, CaseIterable, Defaults.Serializable {
|
||||
return "AVPlayer"
|
||||
}
|
||||
}
|
||||
|
||||
var supportsNetworkStateBufferingDetails: Bool {
|
||||
self == .mpv
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
50
Model/Player/PlayerTimeModel.swift
Normal file
50
Model/Player/PlayerTimeModel.swift
Normal 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
|
||||
}
|
||||
}
|
@@ -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()
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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 ?? "?"
|
||||
|
@@ -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)
|
||||
|
@@ -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? {
|
||||
|
Reference in New Issue
Block a user