Add Piped support

This commit is contained in:
Arkadiusz Fal
2021-10-17 00:48:58 +02:00
parent a68d89cb6f
commit 62e17d5a18
44 changed files with 919 additions and 327 deletions

View File

@@ -3,6 +3,7 @@ import Siesta
import SwiftUI
final class AccountValidator: Service {
let app: Binding<Instance.App>
let url: String
let account: Instance.Account?
@@ -13,6 +14,7 @@ final class AccountValidator: Service {
var error: Binding<String?>?
init(
app: Binding<Instance.App>,
url: String,
account: Instance.Account? = nil,
id: Binding<String>,
@@ -21,6 +23,7 @@ final class AccountValidator: Service {
isValidating: Binding<Bool>,
error: Binding<String?>? = nil
) {
self.app = app
self.url = url
self.account = account
formObjectID = id
@@ -34,6 +37,10 @@ final class AccountValidator: Service {
}
func configure() {
configure {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure("/api/v1/auth/feed", requestMethods: [.get]) {
guard self.account != nil else {
return
@@ -46,15 +53,35 @@ final class AccountValidator: Service {
func validateInstance() {
reset()
// TODO: validation for Piped instances
guard app.wrappedValue == .invidious else {
isValid.wrappedValue = true
error?.wrappedValue = nil
isValidated.wrappedValue = true
isValidating.wrappedValue = false
return
}
stats
.load()
.onSuccess { _ in
.onSuccess { response in
guard self.url == self.formObjectID.wrappedValue else {
return
}
self.isValid.wrappedValue = true
self.error?.wrappedValue = nil
if response
.json
.dictionaryValue["software"]?
.dictionaryValue["name"]?
.stringValue == "invidious"
{
self.isValid.wrappedValue = true
self.error?.wrappedValue = nil
} else {
self.isValid.wrappedValue = false
self.error?.wrappedValue = "Not an Invidious Instance"
}
}
.onFailure { error in
guard self.url == self.formObjectID.wrappedValue else {
@@ -70,7 +97,7 @@ final class AccountValidator: Service {
}
}
func validateAccount() {
func validateInvidiousAccount() {
reset()
feed

45
Model/AccountsModel.swift Normal file
View File

@@ -0,0 +1,45 @@
import Combine
import Defaults
import Foundation
final class AccountsModel: ObservableObject {
@Published private(set) var account: Instance.Account!
@Published private(set) var invidious = InvidiousAPI()
@Published private(set) var piped = PipedAPI()
private var cancellables = [AnyCancellable]()
var all: [Instance.Account] {
Defaults[.instances].map(\.anonymousAccount) + Defaults[.accounts]
}
var signedIn: Bool {
!account.isNil && !account.anonymous
}
init() {
cancellables.append(
invidious.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
)
cancellables.append(
piped.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
)
}
func setAccount(_ account: Instance.Account) {
guard account != self.account else {
return
}
self.account = account
switch account.instance.app {
case .invidious:
invidious.setAccount(account)
case .piped:
piped.setAccount(account)
}
}
}

View File

@@ -2,22 +2,32 @@ import Defaults
import Foundation
struct Instance: Defaults.Serializable, Hashable, Identifiable {
enum App: String, CaseIterable {
case invidious, piped
var name: String {
rawValue.capitalized
}
}
struct Account: Defaults.Serializable, Hashable, Identifiable {
static var bridge = AccountsBridge()
static var empty = Account(instanceID: UUID(), name: "Signed Out", url: "", sid: "")
let id: UUID
let id: String
let instanceID: UUID
var name: String?
let url: String
let sid: String
let anonymous: Bool
init(id: UUID? = nil, instanceID: UUID, name: String? = nil, url: String, sid: String) {
self.id = id ?? UUID()
init(id: String? = nil, instanceID: UUID, name: String? = nil, url: String, sid: String? = nil, anonymous: Bool = false) {
self.anonymous = anonymous
self.id = id ?? (anonymous ? "anonymous-\(instanceID)" : UUID().uuidString)
self.instanceID = instanceID
self.name = name
self.url = url
self.sid = sid
self.sid = sid ?? ""
}
var instance: Instance {
@@ -37,10 +47,6 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
}
var isEmpty: Bool {
self == Account.empty
}
func hash(into hasher: inout Hasher) {
hasher.combine(sid)
}
@@ -55,7 +61,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
}
return [
"id": value.id.uuidString,
"id": value.id,
"instanceID": value.instanceID.uuidString,
"name": value.name ?? "",
"url": value.url,
@@ -74,37 +80,46 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
return nil
}
let uuid = UUID(uuidString: id)
let instanceUUID = UUID(uuidString: instanceID)!
let name = object["name"] ?? ""
return Account(id: uuid, instanceID: instanceUUID, name: name, url: url, sid: sid)
return Account(id: id, instanceID: instanceUUID, name: name, url: url, sid: sid)
}
}
}
static var bridge = InstancesBridge()
let app: App
let id: UUID
let name: String
let url: String
init(id: UUID? = nil, name: String, url: String) {
init(app: App, id: UUID? = nil, name: String, url: String) {
self.app = app
self.id = id ?? UUID()
self.name = name
self.url = url
}
var description: String {
name.isEmpty ? url : "\(name) (\(url))"
"\(app.name) - \(shortDescription)"
}
var longDescription: String {
name.isEmpty ? "\(app.name) - \(url)" : "\(app.name) - \(name) (\(url))"
}
var shortDescription: String {
name.isEmpty ? url : name
}
var supportsAccounts: Bool {
app == .invidious
}
var anonymousAccount: Account {
Account(instanceID: id, name: "Anonymous", url: url, sid: "")
Account(instanceID: id, name: "Anonymous", url: url, sid: "", anonymous: true)
}
struct InstancesBridge: Defaults.Bridge {
@@ -117,6 +132,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
}
return [
"app": value.app.rawValue,
"id": value.id.uuidString,
"name": value.name,
"url": value.url
@@ -126,6 +142,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let app = App(rawValue: object["app"] ?? ""),
let id = object["id"],
let url = object["url"]
else {
@@ -135,7 +152,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
let uuid = UUID(uuidString: id)
let name = object["name"] ?? ""
return Instance(id: uuid, name: name, url: url)
return Instance(app: app, id: uuid, name: name, url: url)
}
}

View File

@@ -4,14 +4,16 @@ import Foundation
final class InstancesModel: ObservableObject {
@Published var defaultAccount: Instance.Account?
var all: [Instance] {
Defaults[.instances]
}
init() {
guard let id = Defaults[.defaultAccountID],
let uuid = UUID(uuidString: id)
else {
guard let id = Defaults[.defaultAccountID] else {
return
}
defaultAccount = findAccount(uuid)
defaultAccount = findAccount(id)
}
func find(_ id: Instance.ID?) -> Instance? {
@@ -26,8 +28,8 @@ final class InstancesModel: ObservableObject {
Defaults[.accounts].filter { $0.instanceID == id }
}
func add(name: String, url: String) -> Instance {
let instance = Instance(name: name, url: url)
func add(app: Instance.App, name: String, url: String) -> Instance {
let instance = Instance(app: app, name: name, url: url)
Defaults[.instances].append(instance)
return instance
@@ -59,7 +61,7 @@ final class InstancesModel: ObservableObject {
}
func setDefaultAccount(_ account: Instance.Account?) {
Defaults[.defaultAccountID] = account?.id.uuidString
Defaults[.defaultAccountID] = account?.id
defaultAccount = account
}

View File

@@ -6,11 +6,21 @@ import SwiftyJSON
final class InvidiousAPI: Service, ObservableObject {
static let basePath = "/api/v1"
@Published var account: Instance.Account! = .empty
@Published var account: Instance.Account!
@Published var validInstance = false
@Published var validInstance = true
@Published var signedIn = false
init(account: Instance.Account? = nil) {
super.init()
guard !account.isNil else {
return
}
setAccount(account!)
}
func setAccount(_ account: Instance.Account) {
self.account = account
@@ -56,26 +66,11 @@ final class InvidiousAPI: Service, ObservableObject {
}
}
static func proxyURLForAsset(_ url: String) -> URL? {
URL(string: url)
// TODO: Switching instances, move up to player
// guard let instanceURLComponents = URLComponents(string: InvidiousAPI.instance),
// var urlComponents = URLComponents(string: url) else { return nil }
//
// urlComponents.scheme = instanceURLComponents.scheme
// urlComponents.host = instanceURLComponents.host
//
// return urlComponents.url
}
func configure() {
SiestaLog.Category.enabled = .common
let SwiftyJSONTransformer =
ResponseContentTransformer(transformErrors: true) { JSON($0.content as AnyObject) }
configure {
$0.headers["Cookie"] = self.cookieHeader
if !self.account.sid.isEmpty {
$0.headers["Cookie"] = self.cookieHeader
}
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}

99
Model/PipedAPI.swift Normal file
View File

@@ -0,0 +1,99 @@
import AVFoundation
import Foundation
import Siesta
import SwiftyJSON
final class PipedAPI: Service, ObservableObject {
@Published var account: Instance.Account!
var anonymousAccount: Instance.Account {
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.url)
}
init(account: Instance.Account? = nil) {
super.init()
guard account != nil else {
return
}
setAccount(account!)
}
func setAccount(_ account: Instance.Account) {
self.account = account
configure()
}
func configure() {
configure {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configureTransformer(pathPattern("streams/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Stream] in
self.extractStreams(content)
}
}
private func extractStreams(_ content: Entity<JSON>) -> [Stream] {
var streams = [Stream]()
if let hlsURL = content.json.dictionaryValue["hls"]?.url {
streams.append(Stream(hlsURL: hlsURL))
}
guard let audioStream = compatibleAudioStreams(content).first else {
return streams
}
let videoStreams = compatibleVideoStream(content)
videoStreams.forEach { videoStream in
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
if videoOnly {
streams.append(
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive)
)
} else {
streams.append(
SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream)
)
}
}
return streams
}
private func compatibleAudioStreams(_ content: Entity<JSON>) -> [JSON] {
content
.json
.dictionaryValue["audioStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
.sorted {
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
} ?? []
}
private func compatibleVideoStream(_ content: Entity<JSON>) -> [JSON] {
content
.json
.dictionaryValue["videoStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
}
private func pathPattern(_ path: String) -> String {
"**\(path)"
}
func streams(id: Video.ID) -> Resource {
resource(baseURL: account.instance.url, path: "streams/\(id)")
}
}

View File

@@ -5,6 +5,8 @@ import Logging
#if !os(macOS)
import UIKit
#endif
import Siesta
import SwiftyJSON
final class PlayerModel: ObservableObject {
let logger = Logger(label: "net.arekf.Pearvidious.ps")
@@ -20,6 +22,9 @@ final class PlayerModel: ObservableObject {
@Published var stream: Stream?
@Published var currentRate: Float?
@Published var availableStreams = [Stream]()
@Published var streamSelection: Stream?
@Published var queue = [PlayerQueueItem]()
@Published var currentItem: PlayerQueueItem!
@Published var live = false
@@ -27,24 +32,32 @@ final class PlayerModel: ObservableObject {
@Published var history = [PlayerQueueItem]()
var api: InvidiousAPI
var timeObserver: Any?
@Published var savedTime: CMTime?
@Published var composition = AVMutableComposition()
var accounts: AccountsModel
var instances: InstancesModel
var timeObserver: Any?
private var shouldResumePlaying = true
private var statusObservation: NSKeyValueObservation?
var isPlaying: Bool {
stream != nil && currentRate != 0.0
}
init(api: InvidiousAPI? = nil) {
self.api = api ?? InvidiousAPI()
init(accounts: AccountsModel? = nil, instances: InstancesModel? = nil) {
self.accounts = accounts ?? AccountsModel()
self.instances = instances ?? InstancesModel()
addItemDidPlayToEndTimeObserver()
addTimeObserver()
}
func presentPlayer() {
presentingPlayer = true
}
var isPlaying: Bool {
player.timeControlStatus == .playing
}
func togglePlay() {
isPlaying ? pause() : play()
}
@@ -66,118 +79,156 @@ final class PlayerModel: ObservableObject {
}
func playVideo(_ video: Video) {
if video.live {
self.stream = nil
savedTime = nil
shouldResumePlaying = true
playHlsUrl(video)
return
loadAvailableStreams(video) { streams in
guard let stream = streams.first else {
return
}
self.streamSelection = stream
self.playStream(stream, of: video, forcePlay: true)
}
}
guard let stream = video.streamWithResolution(Defaults[.quality].value) ?? video.defaultStream else {
return
func upgradeToStream(_ stream: Stream) {
if !self.stream.isNil, self.stream != stream {
playStream(stream, of: currentItem.video, preservingTime: true)
}
}
if stream.oneMeaningfullAsset {
playStream(stream, for: video)
func piped(_ instance: Instance) -> PipedAPI {
PipedAPI(account: instance.anonymousAccount)
}
func invidious(_ instance: Instance) -> InvidiousAPI {
InvidiousAPI(account: instance.anonymousAccount)
}
private func playStream(
_ stream: Stream,
of video: Video,
forcePlay: Bool = false,
preservingTime: Bool = false
) {
if let url = stream.singleAssetURL {
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
insertPlayerItem(stream, for: video, forcePlay: forcePlay, preservingTime: preservingTime)
} else {
logger.info("playing stream with many assets:")
logger.info("composition audio asset: \(stream.audioAsset.url)")
logger.info("composition video asset: \(stream.videoAsset.url)")
Task {
await playComposition(video, for: stream)
await self.loadComposition(stream, of: video, forcePlay: forcePlay, preservingTime: preservingTime)
}
}
}
private func playHlsUrl(_ video: Video) {
player.replaceCurrentItem(with: playerItemWithMetadata(video))
player.playImmediately(atRate: 1.0)
}
private func insertPlayerItem(
_ stream: Stream,
for video: Video,
forcePlay: Bool = false,
preservingTime: Bool = false
) {
let playerItem = playerItem(stream)
private func playStream(_ stream: Stream, for video: Video) {
logger.warning("loading \(stream.description) to player")
let playerItem: AVPlayerItem! = playerItemWithMetadata(video, for: stream)
guard playerItem != nil else {
return
}
if let index = queue.firstIndex(where: { $0.video.id == video.id }) {
queue[index].playerItems.append(playerItem)
}
attachMetadata(to: playerItem!, video: video, for: stream)
DispatchQueue.main.async {
self.stream = stream
self.player.replaceCurrentItem(with: playerItem)
self.composition = AVMutableComposition()
}
if timeObserver.isNil {
addTimeObserver()
}
}
shouldResumePlaying = forcePlay || isPlaying
private func playComposition(_ video: Video, for stream: Stream) async {
async let assetAudioTrack = stream.audioAsset.loadTracks(withMediaType: .audio)
async let assetVideoTrack = stream.videoAsset.loadTracks(withMediaType: .video)
if preservingTime {
saveTime {
self.player.replaceCurrentItem(with: playerItem)
logger.info("loading audio track")
if let audioTrack = composition(video, for: stream)?.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
let assetTrack = try? await assetAudioTrack.first
{
try! audioTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)),
of: assetTrack,
at: .zero
)
logger.critical("audio loaded")
} else {
logger.critical("NO audio track")
}
logger.info("loading video track")
if let videoTrack = composition(video, for: stream)?.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid),
let assetTrack = try? await assetVideoTrack.first
{
try! videoTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)),
of: assetTrack,
at: .zero
)
logger.critical("video loaded")
playStream(stream, for: video)
} else {
logger.critical("NO video track")
}
}
private func playerItem(_ video: Video, for stream: Stream? = nil) -> AVPlayerItem? {
if stream != nil {
if stream!.oneMeaningfullAsset {
logger.info("stream has one meaningfull asset")
return AVPlayerItem(asset: AVURLAsset(url: stream!.videoAsset.url))
self.seekToSavedTime { finished in
guard finished else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
forcePlay || self.shouldResumePlaying ? self.play() : self.pause()
self.shouldResumePlaying = false
}
}
}
if let composition = composition(video, for: stream!) {
logger.info("stream has MANY assets, using composition")
return AVPlayerItem(asset: composition)
} else {
return nil
} else {
player.replaceCurrentItem(with: playerItem)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
forcePlay || self.shouldResumePlaying ? self.play() : self.pause()
self.shouldResumePlaying = false
}
}
return AVPlayerItem(url: video.hlsUrl!)
}
private func playerItemWithMetadata(_ video: Video, for stream: Stream? = nil) -> AVPlayerItem? {
logger.info("building player item metadata")
let playerItemWithMetadata: AVPlayerItem! = playerItem(video, for: stream)
guard playerItemWithMetadata != nil else {
return nil
private func loadComposition(
_ stream: Stream,
of video: Video,
forcePlay: Bool = false,
preservingTime: Bool = false
) async {
await loadCompositionAsset(stream.audioAsset, type: .audio, of: video)
await loadCompositionAsset(stream.videoAsset, type: .video, of: video)
guard streamSelection == stream else {
logger.critical("IGNORING LOADED")
return
}
var externalMetadata = [
makeMetadataItem(.commonIdentifierTitle, value: video.title),
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre),
makeMetadataItem(.commonIdentifierDescription, value: video.description)
]
insertPlayerItem(stream, for: video, forcePlay: forcePlay, preservingTime: preservingTime)
}
private func loadCompositionAsset(_ asset: AVURLAsset, type: AVMediaType, of video: Video) async {
async let assetTracks = asset.loadTracks(withMediaType: type)
logger.info("loading \(type.rawValue) track")
guard let compositionTrack = composition.addMutableTrack(
withMediaType: type,
preferredTrackID: kCMPersistentTrackID_Invalid
) else {
logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
return
}
guard let assetTrack = try? await assetTracks.first else {
logger.critical("asset \(type.rawValue) track FAILED")
return
}
try! compositionTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1000)),
of: assetTrack,
at: .zero
)
logger.critical("\(type.rawValue) LOADED")
}
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
if let url = stream.singleAssetURL {
return AVPlayerItem(asset: AVURLAsset(url: url))
} else {
return AVPlayerItem(asset: composition)
}
}
private func attachMetadata(to item: AVPlayerItem, video: Video, for _: Stream? = nil) {
#if !os(macOS)
var externalMetadata = [
makeMetadataItem(.commonIdentifierTitle, value: video.title),
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre),
makeMetadataItem(.commonIdentifierDescription, value: video.description)
]
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
let image = UIImage(data: thumbnailData),
let pngData = image.pngData()
@@ -186,28 +237,41 @@ final class PlayerModel: ObservableObject {
externalMetadata.append(artworkItem)
}
playerItemWithMetadata.externalMetadata = externalMetadata
item.externalMetadata = externalMetadata
#endif
playerItemWithMetadata.preferredForwardBufferDuration = 15
item.preferredForwardBufferDuration = 5
statusObservation?.invalidate()
statusObservation = playerItemWithMetadata.observe(\.status, options: [.old, .new]) { playerItem, _ in
statusObservation = item.observe(\.status, options: [.old, .new]) { playerItem, _ in
switch playerItem.status {
case .readyToPlay:
if self.isAutoplaying(playerItem) {
self.player.play()
if self.isAutoplaying(playerItem), self.shouldResumePlaying {
self.play()
}
case .failed:
print("item error: \(String(describing: item.error))")
print((item.asset as! AVURLAsset).url)
default:
return
}
}
logger.info("item metadata retrieved")
return playerItemWithMetadata
}
func addItemDidPlayToEndTimeObserver() {
#if !os(macOS)
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.identifier = identifier
item.value = value as? NSCopying & NSObjectProtocol
item.extendedLanguageTag = "und"
return item.copy() as! AVMetadataItem
}
#endif
private func addItemDidPlayToEndTimeObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(itemDidPlayToEndTime),
@@ -230,15 +294,30 @@ final class PlayerModel: ObservableObject {
}
}
private func composition(_ video: Video, for stream: Stream) -> AVMutableComposition? {
if let index = queue.firstIndex(where: { $0.video == video }) {
if queue[index].compositions[stream].isNil {
queue[index].compositions[stream] = AVMutableComposition()
}
return queue[index].compositions[stream]!
private func saveTime(completionHandler: @escaping () -> Void = {}) {
let currentTime = player.currentTime()
guard currentTime.seconds > 0 else {
return
}
return nil
DispatchQueue.main.async {
self.savedTime = currentTime
completionHandler()
}
}
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = savedTime else {
return
}
player.seek(
to: time,
toleranceBefore: .init(seconds: 1, preferredTimescale: 1000),
toleranceAfter: .zero,
completionHandler: completionHandler
)
}
private func addTimeObserver() {
@@ -250,14 +329,4 @@ final class PlayerModel: ObservableObject {
self.time = self.player.currentTime()
}
}
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.identifier = identifier
item.value = value as? NSCopying & NSObjectProtocol
item.extendedLanguageTag = "und"
return item.copy() as! AVMetadataItem
}
}

View File

@@ -1,5 +1,6 @@
import AVFoundation
import Foundation
import Siesta
extension PlayerModel {
var currentVideo: Video? {
@@ -20,7 +21,7 @@ extension PlayerModel {
func playNext(_ video: Video) {
enqueueVideo(video, prepending: true) { _, item in
if self.currentItem == nil {
if self.currentItem.isNil {
self.advanceToItem(item)
}
}
@@ -103,6 +104,10 @@ extension PlayerModel {
return item
}
func videoResource(_ id: Video.ID) -> Resource {
accounts.invidious.video(id)
}
private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
guard video != nil else {
return
@@ -114,7 +119,7 @@ extension PlayerModel {
return
}
api.video(video!.videoID).load().onSuccess { response in
videoResource(video!.videoID).load().onSuccess { response in
if let video: Video = response.typedContent() {
onSuccess(video)
}

View File

@@ -10,5 +10,4 @@ struct PlayerQueueItem: Hashable, Identifiable {
}
var playerItems = [AVPlayerItem]()
var compositions = [Stream: AVMutableComposition]()
}

100
Model/PlayerStreams.swift Normal file
View File

@@ -0,0 +1,100 @@
import Foundation
import Siesta
extension PlayerModel {
var isLoadingAvailableStreams: Bool {
streamSelection.isNil || availableStreams.isEmpty
}
var isLoadingStream: Bool {
!stream.isNil && stream != streamSelection
}
func loadAvailableStreams(
_ video: Video,
completionHandler: @escaping ([Stream]) -> Void = { _ in }
) {
availableStreams = []
var instancesWithLoadedStreams = [Instance]()
instances.all.forEach { instance in
switch instance.app {
case .piped:
fetchPipedStreams(instance, video: video) { _ in
self.completeIfAllInstancesLoaded(
instance: instance,
streams: self.availableStreams,
instancesWithLoadedStreams: &instancesWithLoadedStreams,
completionHandler: completionHandler
)
}
case .invidious:
fetchInvidiousStreams(instance, video: video) { _ in
self.completeIfAllInstancesLoaded(
instance: instance,
streams: self.availableStreams,
instancesWithLoadedStreams: &instancesWithLoadedStreams,
completionHandler: completionHandler
)
}
}
}
}
private func fetchInvidiousStreams(
_ instance: Instance,
video: Video,
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
) {
invidious(instance)
.video(video.videoID)
.load()
.onSuccess { response in
if let video: Video = response.typedContent() {
self.availableStreams += self.streamsWithAssetsFromInstance(instance: instance, streams: video.streams)
}
}
.onCompletion(onCompletion)
}
private func fetchPipedStreams(
_ instance: Instance,
video: Video,
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
) {
piped(instance)
.streams(id: video.videoID)
.load()
.onSuccess { response in
if let pipedStreams: [Stream] = response.typedContent() {
self.availableStreams += self.streamsWithInstance(instance: instance, streams: pipedStreams)
}
}
.onCompletion(onCompletion)
}
private func completeIfAllInstancesLoaded(
instance: Instance,
streams: [Stream],
instancesWithLoadedStreams: inout [Instance],
completionHandler: @escaping ([Stream]) -> Void
) {
instancesWithLoadedStreams.append(instance)
if instances.all.count == instancesWithLoadedStreams.count {
completionHandler(streams.sorted { $0.kind < $1.kind })
}
}
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
streams.map { stream in
stream.instance = instance
return stream
}
}
func streamsWithAssetsFromInstance(instance: Instance, streams: [Stream]) -> [Stream] {
streams.map { stream in stream.withAssetsFrom(instance) }
}
}

View File

@@ -4,10 +4,15 @@ import SwiftUI
final class PlaylistsModel: ObservableObject {
@Published var playlists = [Playlist]()
@Published var api = InvidiousAPI()
@Published var selectedPlaylistID: Playlist.ID = ""
var accounts = AccountsModel()
var api: InvidiousAPI {
accounts.invidious
}
init(_ playlists: [Playlist] = [Playlist]()) {
self.playlists = playlists
}

View File

@@ -5,7 +5,7 @@ import SwiftUI
final class SearchModel: ObservableObject {
@Published var store = Store<[Video]>()
@Published var api = InvidiousAPI()
var accounts = AccountsModel()
@Published var query = SearchQuery()
@Published var queryText = ""
@Published var querySuggestions = Store<[String]>()
@@ -17,6 +17,10 @@ final class SearchModel: ObservableObject {
resource?.isLoading ?? false
}
var api: InvidiousAPI {
accounts.invidious
}
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)

View File

@@ -4,7 +4,7 @@ import Foundation
final class SingleAssetStream: Stream {
var avAsset: AVURLAsset
init(avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String) {
init(avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String = "") {
self.avAsset = avAsset
super.init(audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, kind: kind, encoding: encoding)

View File

@@ -3,7 +3,7 @@ import Defaults
import Foundation
// swiftlint:disable:next final_class
class Stream: Equatable, Hashable {
class Stream: Equatable, Hashable, Identifiable {
enum ResolutionSetting: String, Defaults.Serializable, CaseIterable {
case hd720pFirstThenBest, hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
@@ -21,20 +21,38 @@ class Stream: Equatable, Hashable {
case .hd720pFirstThenBest:
return "Default: adaptive"
default:
return "\(value.height)p".replacingOccurrences(of: " ", with: "")
return value.name
}
}
}
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
case hd1080p, hd720p, sd480p, sd360p, sd240p, sd144p
case hd1440p60, hd1440p, hd1080p60, hd1080p, hd720p60, hd720p, sd480p, sd360p, sd240p, sd144p, unknown
var height: Int {
Int(rawValue.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
var name: String {
"\(height)p\(refreshRate != -1 ? ", \(refreshRate) fps" : "")"
}
static func from(resolution: String) -> Resolution? {
allCases.first { "\($0)".contains(resolution) }
var height: Int {
if self == .unknown {
return -1
}
let resolutionPart = rawValue.components(separatedBy: "p").first!
return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
}
var refreshRate: Int {
if self == .unknown {
return -1
}
let refreshRatePart = rawValue.components(separatedBy: "p")[1]
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
}
static func from(resolution: String) -> Resolution {
allCases.first { "\($0)".contains(resolution) } ?? .unknown
}
static func < (lhs: Resolution, rhs: Resolution) -> Bool {
@@ -43,14 +61,16 @@ class Stream: Equatable, Hashable {
}
enum Kind: String, Comparable {
case stream, adaptive
case stream, adaptive, hls
private var sortOrder: Int {
switch self {
case .stream:
case .hls:
return 0
case .adaptive:
case .stream:
return 1
case .adaptive:
return 2
}
}
@@ -59,39 +79,98 @@ class Stream: Equatable, Hashable {
}
}
var audioAsset: AVURLAsset
var videoAsset: AVURLAsset
let id = UUID()
var resolution: Resolution
var kind: Kind
var instance: Instance!
var audioAsset: AVURLAsset!
var videoAsset: AVURLAsset!
var hlsURL: URL!
var encoding: String
var resolution: Resolution!
var kind: Kind!
init(audioAsset: AVURLAsset, videoAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String) {
var encoding: String!
init(
instance: Instance? = nil,
audioAsset: AVURLAsset? = nil,
videoAsset: AVURLAsset? = nil,
hlsURL: URL? = nil,
resolution: Resolution? = nil,
kind: Kind = .hls,
encoding: String? = nil
) {
self.instance = instance
self.audioAsset = audioAsset
self.videoAsset = videoAsset
self.hlsURL = hlsURL
self.resolution = resolution
self.kind = kind
self.encoding = encoding
}
var shortQuality: String {
kind == .hls ? "adaptive" : resolution.name
}
var quality: String {
kind == .hls ? "adaptive (HLS)" : "\(resolution.name) \(kind == .stream ? "(\(kind.rawValue))" : "")"
}
var description: String {
"\(resolution.height)p"
"\(quality) - \(instance?.description ?? "")"
}
var assets: [AVURLAsset] {
[audioAsset, videoAsset]
}
var oneMeaningfullAsset: Bool {
var videoAssetContainsAudio: Bool {
assets.dropFirst().allSatisfy { $0.url == assets.first!.url }
}
var singleAssetURL: URL? {
if kind == .hls {
return hlsURL
} else if videoAssetContainsAudio {
return videoAsset.url
}
return nil
}
static func == (lhs: Stream, rhs: Stream) -> Bool {
lhs.resolution == rhs.resolution && lhs.kind == rhs.kind
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(videoAsset.url)
hasher.combine(videoAsset?.url)
hasher.combine(audioAsset?.url)
hasher.combine(hlsURL)
}
func withAssetsFrom(_ instance: Instance) -> Stream {
if kind == .hls {
return Stream(instance: instance, hlsURL: hlsURL)
} else {
return Stream(
instance: instance,
audioAsset: AVURLAsset(url: assetURLFrom(instance: instance, url: (audioAsset ?? videoAsset).url)!),
videoAsset: AVURLAsset(url: assetURLFrom(instance: instance, url: videoAsset.url)!),
resolution: resolution,
kind: kind,
encoding: encoding
)
}
}
private func assetURLFrom(instance: Instance, url: URL) -> URL? {
guard let instanceURLComponents = URLComponents(string: instance.url),
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
urlComponents.scheme = instanceURLComponents.scheme
urlComponents.host = instanceURLComponents.host
return urlComponents.url
}
}

View File

@@ -4,14 +4,18 @@ import SwiftUI
final class SubscriptionsModel: ObservableObject {
@Published var channels = [Channel]()
@Published var api: InvidiousAPI! = InvidiousAPI()
var accounts: AccountsModel
var api: InvidiousAPI {
accounts.invidious
}
var resource: Resource {
api.subscriptions
}
init(api: InvidiousAPI? = nil) {
self.api = api
init(accounts: AccountsModel? = nil) {
self.accounts = accounts ?? AccountsModel()
}
var all: [Channel] {

View File

@@ -22,7 +22,6 @@ struct Video: Identifiable, Equatable, Hashable {
var upcoming: Bool
var streams = [Stream]()
var hlsUrl: URL?
var publishedAt: Date?
var likes: Int?
@@ -104,10 +103,13 @@ struct Video: Identifiable, Equatable, Hashable {
publishedAt = Date(timeIntervalSince1970: publishedInterval)
}
if let hlsURL = json["hlsUrl"].url {
streams.append(.init(hlsURL: hlsURL))
}
streams = Video.extractFormatStreams(from: json["formatStreams"].arrayValue)
streams.append(contentsOf: Video.extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue))
hlsUrl = json["hlsUrl"].url
channel = Channel(json: json)
}
@@ -179,8 +181,8 @@ struct Video: Identifiable, Equatable, Hashable {
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
streams.map {
SingleAssetStream(
avAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue)!,
avAsset: AVURLAsset(url: $0["url"].url!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
kind: .stream,
encoding: $0["encoding"].stringValue
)
@@ -197,9 +199,9 @@ struct Video: Identifiable, Equatable, Hashable {
return videoAssetsURLs.map {
Stream(
audioAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset(audioAssetURL!["url"].stringValue)!),
videoAsset: AVURLAsset(url: InvidiousAPI.proxyURLForAsset($0["url"].stringValue)!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue)!,
audioAsset: AVURLAsset(url: audioAssetURL!["url"].url!),
videoAsset: AVURLAsset(url: $0["url"].url!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
kind: .adaptive,
encoding: $0["encoding"].stringValue
)