Resolution switching support

This commit is contained in:
Arkadiusz Fal 2021-06-14 20:05:02 +02:00
parent 65e5f0f426
commit 4535853ac3
20 changed files with 396 additions and 117 deletions

View File

@ -5,25 +5,16 @@ import SwiftUI
struct PlayerView: View { struct PlayerView: View {
@ObservedObject private var provider: VideoDetailsProvider @ObservedObject private var provider: VideoDetailsProvider
private var id: String
init(id: String) { init(id: String) {
self.id = id
provider = VideoDetailsProvider(id) provider = VideoDetailsProvider(id)
} }
var body: some View { var body: some View {
ZStack { ZStack {
if let video = provider.video { if let video = provider.video {
if video.url != nil { PlayerViewController(video: video)
PlayerViewController(video)
.edgesIgnoringSafeArea(.all) .edgesIgnoringSafeArea(.all)
} }
if video.error {
Text("Video can not be loaded")
}
}
} }
.task { .task {
async { async {
@ -32,37 +23,3 @@ struct PlayerView: View {
} }
} }
} }
struct PlayerViewController: UIViewControllerRepresentable {
var video: Video
init(_ video: Video) {
self.video = video
}
private var player: AVPlayer {
let item = AVPlayerItem(url: video.url!)
item.externalMetadata = [makeMetadataItem(.commonIdentifierTitle, value: video.title)]
return AVPlayer(playerItem: item)
}
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
}
func makeUIViewController(context _: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
controller.modalPresentationStyle = .fullScreen
controller.player = player
controller.title = video.title
controller.player?.play()
return controller
}
func updateUIViewController(_: AVPlayerViewController, context _: Context) {}
}

View File

@ -0,0 +1,142 @@
import AVKit
import Foundation
import SwiftUI
struct PlayerViewController: UIViewControllerRepresentable {
@ObservedObject private var state = PlayerState()
@ObservedObject var video: Video
var player = AVPlayer()
var composition = AVMutableComposition()
var audioTrack: AVMutableCompositionTrack {
composition.tracks(withMediaType: .audio).first ?? composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)!
}
var videoTrack: AVMutableCompositionTrack {
composition.tracks(withMediaType: .video).first ?? composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)!
}
var playerItem: AVPlayerItem {
let playerItem = AVPlayerItem(asset: composition)
playerItem.externalMetadata = [makeMetadataItem(.commonIdentifierTitle, value: video.title)]
return playerItem
}
init(video: Video) {
self.video = video
state.currentStream = video.defaultStream
addTracksAndLoadAssets(state.currentStream!)
}
func addTracksAndLoadAssets(_ stream: Stream) {
composition.removeTrack(audioTrack)
composition.removeTrack(videoTrack)
let keys = ["playable"]
stream.audioAsset.loadValuesAsynchronously(forKeys: keys) {
DispatchQueue.main.async {
guard let track = stream.audioAsset.tracks(withMediaType: .audio).first else {
return
}
try? audioTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1)),
of: track,
at: .zero
)
handleAssetLoad(stream)
}
}
stream.videoAsset.loadValuesAsynchronously(forKeys: keys) {
DispatchQueue.main.async {
guard let track = stream.videoAsset.tracks(withMediaType: .video).first else {
return
}
try? videoTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime(seconds: video.length, preferredTimescale: 1)),
of: track,
at: .zero
)
handleAssetLoad(stream)
}
}
}
func handleAssetLoad(_ stream: Stream) {
var error: NSError?
let status = stream.videoAsset.statusOfValue(forKey: "playable", error: &error)
switch status {
case .loaded:
let resumeAt = player.currentTime()
if resumeAt.seconds > 0 {
state.seekTo = resumeAt
}
state.currentStream = stream
player.replaceCurrentItem(with: playerItem)
if let time = state.seekTo {
player.seek(to: time)
}
player.play()
default:
if error != nil {
print("loading error: \(error!)")
}
}
}
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
}
func makeUIViewController(context _: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
controller.transportBarCustomMenuItems = [streamingQualityMenu]
controller.modalPresentationStyle = .fullScreen
controller.player = player
return controller
}
func updateUIViewController(_ controller: AVPlayerViewController, context _: Context) {
controller.transportBarCustomMenuItems = [streamingQualityMenu]
}
var streamingQualityMenu: UIMenu {
UIMenu(title: "Streaming quality", image: UIImage(systemName: "4k.tv"), children: streamingQualityMenuActions)
}
var streamingQualityMenuActions: [UIAction] {
video.selectableStreams.map { stream in
let image = self.state.currentStream == stream ? UIImage(systemName: "checkmark") : nil
return UIAction(title: stream.description, image: image) { _ in
DispatchQueue.main.async {
addTracksAndLoadAssets(stream)
}
}
}
}
}

View File

@ -16,7 +16,7 @@ struct PopularVideosView: View {
} }
var videos: [Video] { var videos: [Video] {
if (provider.videos.isEmpty) { if provider.videos.isEmpty {
provider.load() provider.load()
} }

View File

@ -14,19 +14,7 @@ struct SearchView: View {
} }
var videos: [Video] { var videos: [Video] {
var newQuery = query provider.load(query)
if let url = URLComponents(string: query),
let queryItem = url.queryItems?.first(where: { item in item.name == "v" }),
let id = queryItem.value
{
newQuery = id
}
if newQuery != provider.query {
provider.query = newQuery
provider.load()
}
return provider.videos return provider.videos
} }

View File

@ -16,6 +16,6 @@ struct SubscriptionsView: View {
} }
var videos: [Video] { var videos: [Video] {
return provider.videos provider.videos
} }
} }

View File

@ -67,16 +67,16 @@ struct VideoThumbnailView: View {
} }
} }
struct VideoThumbnailView_Previews: PreviewProvider { // struct VideoThumbnailView_Previews: PreviewProvider {
static var previews: some View { // static var previews: some View {
VideoThumbnailView(video: Video( // VideoThumbnailView(video: Video(
id: "A", // id: "A",
title: "A very very long text which", // title: "A very very long text which",
thumbnailURL: URL(string: "https://invidious.home.arekf.net/vi/yXohcxCKqvo/maxres.jpg")!, // thumbnailURL: URL(string: "https://invidious.home.arekf.net/vi/yXohcxCKqvo/maxres.jpg")!,
author: "Bear", // author: "Bear",
length: 240, // length: 240,
published: "2 days ago", // published: "2 days ago",
channelID: "" // channelID: ""
)).frame(maxWidth: 350) // )).frame(maxWidth: 350)
} // }
} // }

View File

@ -27,17 +27,17 @@ struct VideosView: View {
} }
func openChannelButton(from video: Video) -> some View { func openChannelButton(from video: Video) -> some View {
Button("\(video.author) Channel", action: { Button("\(video.author) Channel") {
state.openChannel(from: video) state.openChannel(from: video)
tabSelection = .channel tabSelection = .channel
}) }
} }
func closeChannelButton(name: String) -> some View { func closeChannelButton(name: String) -> some View {
Button("Close \(name) Channel", action: { Button("Close \(name) Channel") {
tabSelection = .popular tabSelection = .popular
state.closeChannel() state.closeChannel()
}) }
} }
var listRowInsets: EdgeInsets { var listRowInsets: EdgeInsets {

View File

@ -1,6 +1,7 @@
import AVFoundation
import Foundation import Foundation
class AppState: ObservableObject { final class AppState: ObservableObject {
@Published var showingChannel = false @Published var showingChannel = false
@Published var channelID: String = "" @Published var channelID: String = ""
@Published var channel: String = "" @Published var channel: String = ""

View File

@ -1,13 +1,15 @@
import Foundation import Foundation
import SwiftyJSON import SwiftyJSON
class ChannelVideosProvider: DataProvider { final class ChannelVideosProvider: DataProvider {
@Published var videos = [Video]() @Published var videos = [Video]()
var channelID: String? = "" var channelID: String? = ""
func load() { func load() {
guard channelID != nil else { return } guard channelID != nil else {
return
}
let searchPath = "channels/\(channelID!)" let searchPath = "channels/\(channelID!)"
DataProvider.request(searchPath).responseJSON { response in DataProvider.request(searchPath).responseJSON { response in

View File

@ -1,9 +1,20 @@
import Alamofire import Alamofire
import Foundation import Foundation
// swiftlint:disable:next final_class
class DataProvider: ObservableObject { class DataProvider: ObservableObject {
static let instance = "https://invidious.home.arekf.net" static let instance = "https://invidious.home.arekf.net"
static func proxyURLForAsset(_ url: String) -> URL? {
guard let instanceURLComponents = URLComponents(string: DataProvider.instance),
var urlComponents = URLComponents(string: url) else { return nil }
urlComponents.scheme = instanceURLComponents.scheme
urlComponents.host = instanceURLComponents.host
return urlComponents.url
}
static func request(_ path: String, headers: HTTPHeaders? = nil) -> DataRequest { static func request(_ path: String, headers: HTTPHeaders? = nil) -> DataRequest {
AF.request(apiURLString(path), headers: headers) AF.request(apiURLString(path), headers: headers)
} }

12
Model/MuxedStream.swift Normal file
View File

@ -0,0 +1,12 @@
import AVFoundation
import Foundation
final class MuxedStream: Stream {
var muxedAsset: AVURLAsset
init(muxedAsset: AVURLAsset, resolution: StreamResolution, type: StreamType, encoding: String) {
self.muxedAsset = muxedAsset
super.init(audioAsset: muxedAsset, videoAsset: muxedAsset, resolution: resolution, type: type, encoding: encoding)
}
}

7
Model/PlayerState.swift Normal file
View File

@ -0,0 +1,7 @@
import AVFoundation
import Foundation
final class PlayerState: ObservableObject {
@Published var currentStream: Stream!
@Published var seekTo: CMTime?
}

View File

@ -1,13 +1,28 @@
import Foundation import Foundation
import SwiftyJSON import SwiftyJSON
class SearchedVideosProvider: DataProvider { final class SearchedVideosProvider: DataProvider {
@Published var videos = [Video]() @Published var videos = [Video]()
var query: String = "" var currentQuery: String = ""
func load() { func load(_ query: String) {
let searchPath = "search?q=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)" var newQuery = query
if let url = URLComponents(string: query),
let queryItem = url.queryItems?.first(where: { item in item.name == "v" }),
let id = queryItem.value
{
newQuery = id
}
if newQuery == currentQuery {
return
}
currentQuery = newQuery
let searchPath = "search?q=\(currentQuery.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)"
DataProvider.request(searchPath).responseJSON { response in DataProvider.request(searchPath).responseJSON { response in
switch response.result { switch response.result {
case let .success(value): case let .success(value):

29
Model/Stream.swift Normal file
View File

@ -0,0 +1,29 @@
import AVFoundation
import Foundation
// swiftlint:disable:next final_class
class Stream: Equatable {
var audioAsset: AVURLAsset
var videoAsset: AVURLAsset
var resolution: StreamResolution
var type: StreamType
var encoding: String
init(audioAsset: AVURLAsset, videoAsset: AVURLAsset, resolution: StreamResolution, type: StreamType, encoding: String) {
self.audioAsset = audioAsset
self.videoAsset = videoAsset
self.resolution = resolution
self.type = type
self.encoding = encoding
}
var description: String {
"\(resolution.height)p"
}
static func == (lhs: Stream, rhs: Stream) -> Bool {
lhs.resolution == rhs.resolution && lhs.type == rhs.type
}
}

View File

@ -0,0 +1,17 @@
import Foundation
enum StreamResolution: String, CaseIterable, Comparable {
case hd_1080p, hd_720p, sd_480p, sd_360p, sd_240p, sd_144p
var height: Int {
Int(rawValue.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
}
static func from(resolution: String) -> StreamResolution? {
allCases.first { "\($0)".contains(resolution) }
}
static func < (lhs: StreamResolution, rhs: StreamResolution) -> Bool {
lhs.height < rhs.height
}
}

18
Model/StreamType.swift Normal file
View File

@ -0,0 +1,18 @@
import Foundation
enum StreamType: String, Comparable {
case stream, adaptive
private var sortOrder: Int {
switch self {
case .stream:
return 0
case .adaptive:
return 1
}
}
static func < (lhs: StreamType, rhs: StreamType) -> Bool {
lhs.sortOrder < rhs.sortOrder
}
}

View File

@ -2,7 +2,7 @@ import Alamofire
import Foundation import Foundation
import SwiftyJSON import SwiftyJSON
class SubscriptionVideosProvider: DataProvider { final class SubscriptionVideosProvider: DataProvider {
@Published var videos = [Video]() @Published var videos = [Video]()
var sid: String = "RpoS7YPPK2-QS81jJF9z4KSQAjmzsOnMpn84c73-GQ8=" var sid: String = "RpoS7YPPK2-QS81jJF9z4KSQAjmzsOnMpn84c73-GQ8="

View File

@ -1,4 +1,5 @@
import Alamofire import Alamofire
import AVKit
import Foundation import Foundation
import SwiftyJSON import SwiftyJSON
@ -10,11 +11,9 @@ final class Video: Identifiable, ObservableObject {
var length: TimeInterval var length: TimeInterval
var published: String var published: String
var views: Int var views: Int
var channelID: String var channelID: String
@Published var url: URL? var streams = [Stream]()
@Published var error: Bool = false
init( init(
id: String, id: String,
@ -23,8 +22,8 @@ final class Video: Identifiable, ObservableObject {
author: String, author: String,
length: TimeInterval, length: TimeInterval,
published: String, published: String,
channelID: String, views: Int,
views: Int = 0 channelID: String
) { ) {
self.id = id self.id = id
self.title = title self.title = title
@ -32,41 +31,22 @@ final class Video: Identifiable, ObservableObject {
self.author = author self.author = author
self.length = length self.length = length
self.published = published self.published = published
self.channelID = channelID
self.views = views self.views = views
self.channelID = channelID
} }
init(_ json: JSON) { init(_ json: JSON) {
func extractThumbnailURL(from details: JSON) -> URL? {
if details["videoThumbnails"].arrayValue.isEmpty {
return nil
}
let thumbnail = details["videoThumbnails"].arrayValue.first(where: { $0["quality"].stringValue == "medium" })!
return thumbnail["url"].url!
}
func extractFormatStreamURL(from streams: [JSON]) -> URL? {
if streams.isEmpty {
error = true
return nil
}
let stream = streams.last!
return stream["url"].url
}
id = json["videoId"].stringValue id = json["videoId"].stringValue
title = json["title"].stringValue title = json["title"].stringValue
thumbnailURL = extractThumbnailURL(from: json)
author = json["author"].stringValue author = json["author"].stringValue
length = json["lengthSeconds"].doubleValue length = json["lengthSeconds"].doubleValue
published = json["publishedText"].stringValue published = json["publishedText"].stringValue
views = json["viewCount"].intValue views = json["viewCount"].intValue
channelID = json["authorId"].stringValue channelID = json["authorId"].stringValue
thumbnailURL = extractThumbnailURL(from: json)
url = extractFormatStreamURL(from: json["formatStreams"].arrayValue) streams = extractFormatStreams(from: json["formatStreams"].arrayValue)
streams.append(contentsOf: extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue))
} }
var playTime: String? { var playTime: String? {
@ -97,4 +77,60 @@ final class Video: Identifiable, ObservableObject {
return "\(formatter.string(from: number)!)\(unit)" return "\(formatter.string(from: number)!)\(unit)"
} }
var selectableStreams: [Stream] {
let streams = streams.sorted { $0.resolution > $1.resolution }
var selectable = [Stream]()
StreamResolution.allCases.forEach { resolution in
if let stream = streams.filter({ $0.resolution == resolution }).min(by: { $0.type < $1.type }) {
selectable.append(stream)
}
}
return selectable
}
var defaultStream: Stream? {
selectableStreams.first { $0.type == .stream }
}
private func extractThumbnailURL(from details: JSON) -> URL? {
if details["videoThumbnails"].arrayValue.isEmpty {
return nil
}
let thumbnail = details["videoThumbnails"].arrayValue.first { $0["quality"].stringValue == "medium" }!
return thumbnail["url"].url!
}
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
streams.map {
MuxedStream(
muxedAsset: AVURLAsset(url: DataProvider.proxyURLForAsset($0["url"].stringValue)!),
resolution: StreamResolution.from(resolution: $0["resolution"].stringValue)!,
type: .stream,
encoding: $0["encoding"].stringValue
)
}
}
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
guard audioAssetURL != nil else {
return []
}
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/mp4") && $0["encoding"].stringValue == "h264" }
return videoAssetsURLs.map {
Stream(
audioAsset: AVURLAsset(url: DataProvider.proxyURLForAsset(audioAssetURL!["url"].stringValue)!),
videoAsset: AVURLAsset(url: DataProvider.proxyURLForAsset($0["url"].stringValue)!),
resolution: StreamResolution.from(resolution: $0["resolution"].stringValue)!,
type: .adaptive,
encoding: $0["encoding"].stringValue
)
}
}
} }

View File

@ -7,6 +7,7 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
3741B5302676213400125C5E /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3741B52F2676213400125C5E /* PlayerViewController.swift */; };
37AAF27E26737323007FC770 /* PopularVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27D26737323007FC770 /* PopularVideosView.swift */; }; 37AAF27E26737323007FC770 /* PopularVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27D26737323007FC770 /* PopularVideosView.swift */; };
37AAF28026737550007FC770 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.swift */; }; 37AAF28026737550007FC770 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.swift */; };
37AAF2822673791F007FC770 /* SearchedVideosProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */; }; 37AAF2822673791F007FC770 /* SearchedVideosProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */; };
@ -29,6 +30,21 @@
37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */; }; 37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */; };
37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */; }; 37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */; };
37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */; }; 37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */; };
37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; };
37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; };
37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; };
37CEE4B52677B628005A1EFE /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B42677B628005A1EFE /* StreamType.swift */; };
37CEE4B62677B628005A1EFE /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B42677B628005A1EFE /* StreamType.swift */; };
37CEE4B72677B628005A1EFE /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B42677B628005A1EFE /* StreamType.swift */; };
37CEE4B92677B63F005A1EFE /* StreamResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */; };
37CEE4BA2677B63F005A1EFE /* StreamResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */; };
37CEE4BB2677B63F005A1EFE /* StreamResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */; };
37CEE4BD2677B670005A1EFE /* MuxedStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* MuxedStream.swift */; };
37CEE4BE2677B670005A1EFE /* MuxedStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* MuxedStream.swift */; };
37CEE4BF2677B670005A1EFE /* MuxedStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* MuxedStream.swift */; };
37CEE4C12677B697005A1EFE /* Stream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4C02677B697005A1EFE /* Stream.swift */; };
37CEE4C22677B697005A1EFE /* Stream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4C02677B697005A1EFE /* Stream.swift */; };
37CEE4C32677B697005A1EFE /* Stream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4C02677B697005A1EFE /* Stream.swift */; };
37D4B0D92671614900C925CA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0D82671614900C925CA /* Tests_iOS.swift */; }; 37D4B0D92671614900C925CA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0D82671614900C925CA /* Tests_iOS.swift */; };
37D4B0E32671614900C925CA /* Tests_macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0E22671614900C925CA /* Tests_macOS.swift */; }; 37D4B0E32671614900C925CA /* Tests_macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0E22671614900C925CA /* Tests_macOS.swift */; };
37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0C22671614700C925CA /* PearvidiousApp.swift */; }; 37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0C22671614700C925CA /* PearvidiousApp.swift */; };
@ -87,6 +103,7 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
3741B52F2676213400125C5E /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = "<group>"; };
37AAF27D26737323007FC770 /* PopularVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularVideosView.swift; sourceTree = "<group>"; }; 37AAF27D26737323007FC770 /* PopularVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularVideosView.swift; sourceTree = "<group>"; };
37AAF27F26737550007FC770 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; }; 37AAF27F26737550007FC770 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedVideosProvider.swift; sourceTree = "<group>"; }; 37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedVideosProvider.swift; sourceTree = "<group>"; };
@ -97,6 +114,11 @@
37AAF29926740A01007FC770 /* VideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosView.swift; sourceTree = "<group>"; }; 37AAF29926740A01007FC770 /* VideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosView.swift; sourceTree = "<group>"; };
37AAF29B26741B5F007FC770 /* SubscriptionVideosProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionVideosProvider.swift; sourceTree = "<group>"; }; 37AAF29B26741B5F007FC770 /* SubscriptionVideosProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionVideosProvider.swift; sourceTree = "<group>"; };
37AAF29F26741C97007FC770 /* SubscriptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsView.swift; sourceTree = "<group>"; }; 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsView.swift; sourceTree = "<group>"; };
37B767DA2677C3CA0098BAA8 /* PlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerState.swift; sourceTree = "<group>"; };
37CEE4B42677B628005A1EFE /* StreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamType.swift; sourceTree = "<group>"; };
37CEE4B82677B63F005A1EFE /* StreamResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolution.swift; sourceTree = "<group>"; };
37CEE4BC2677B670005A1EFE /* MuxedStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuxedStream.swift; sourceTree = "<group>"; };
37CEE4C02677B697005A1EFE /* Stream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stream.swift; sourceTree = "<group>"; };
37D4B0C22671614700C925CA /* PearvidiousApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PearvidiousApp.swift; sourceTree = "<group>"; }; 37D4B0C22671614700C925CA /* PearvidiousApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PearvidiousApp.swift; sourceTree = "<group>"; };
37D4B0C32671614700C925CA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 37D4B0C32671614700C925CA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
37D4B0C42671614800C925CA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 37D4B0C42671614800C925CA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -230,6 +252,7 @@
37AAF2892673AB89007FC770 /* ChannelView.swift */, 37AAF2892673AB89007FC770 /* ChannelView.swift */,
37AAF27F26737550007FC770 /* SearchView.swift */, 37AAF27F26737550007FC770 /* SearchView.swift */,
37D4B1822671681B00C925CA /* PlayerView.swift */, 37D4B1822671681B00C925CA /* PlayerView.swift */,
3741B52F2676213400125C5E /* PlayerViewController.swift */,
37AAF29926740A01007FC770 /* VideosView.swift */, 37AAF29926740A01007FC770 /* VideosView.swift */,
37D4B18B26717B3800C925CA /* VideoThumbnailView.swift */, 37D4B18B26717B3800C925CA /* VideoThumbnailView.swift */,
37D4B1AE26729DEB00C925CA /* Info.plist */, 37D4B1AE26729DEB00C925CA /* Info.plist */,
@ -250,13 +273,18 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
37AAF28F26740715007FC770 /* AppState.swift */, 37AAF28F26740715007FC770 /* AppState.swift */,
37D4B19626717E1500C925CA /* Video.swift */, 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */,
37AAF29B26741B5F007FC770 /* SubscriptionVideosProvider.swift */, 37AAF29B26741B5F007FC770 /* SubscriptionVideosProvider.swift */,
37D4B19226717CE100C925CA /* PopularVideosProvider.swift */, 37D4B19226717CE100C925CA /* PopularVideosProvider.swift */,
37AAF28B2673ABD3007FC770 /* ChannelVideosProvider.swift */, 37AAF28B2673ABD3007FC770 /* ChannelVideosProvider.swift */,
37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */, 37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */,
37D4B1B32672A30700C925CA /* VideoDetailsProvider.swift */, 37D4B1B32672A30700C925CA /* VideoDetailsProvider.swift */,
37D4B1AF2672A01000C925CA /* DataProvider.swift */, 37D4B1AF2672A01000C925CA /* DataProvider.swift */,
37D4B19626717E1500C925CA /* Video.swift */,
37CEE4C02677B697005A1EFE /* Stream.swift */,
37CEE4B42677B628005A1EFE /* StreamType.swift */,
37CEE4B82677B63F005A1EFE /* StreamResolution.swift */,
37CEE4BC2677B670005A1EFE /* MuxedStream.swift */,
); );
path = Model; path = Model;
sourceTree = "<group>"; sourceTree = "<group>";
@ -490,18 +518,23 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
37CEE4BD2677B670005A1EFE /* MuxedStream.swift in Sources */,
37D4B19326717CE100C925CA /* PopularVideosProvider.swift in Sources */, 37D4B19326717CE100C925CA /* PopularVideosProvider.swift in Sources */,
37AAF29C26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */, 37AAF29C26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */,
37CEE4C12677B697005A1EFE /* Stream.swift in Sources */,
37D4B0E62671614900C925CA /* ContentView.swift in Sources */, 37D4B0E62671614900C925CA /* ContentView.swift in Sources */,
37CEE4B52677B628005A1EFE /* StreamType.swift in Sources */,
37AAF2822673791F007FC770 /* SearchedVideosProvider.swift in Sources */, 37AAF2822673791F007FC770 /* SearchedVideosProvider.swift in Sources */,
37AAF29026740715007FC770 /* AppState.swift in Sources */, 37AAF29026740715007FC770 /* AppState.swift in Sources */,
37AAF2942674086B007FC770 /* TabSelection.swift in Sources */, 37AAF2942674086B007FC770 /* TabSelection.swift in Sources */,
37D4B1B02672A01000C925CA /* DataProvider.swift in Sources */, 37D4B1B02672A01000C925CA /* DataProvider.swift in Sources */,
37AAF28C2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */, 37AAF28C2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */,
37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
37D4B1B42672A30700C925CA /* VideoDetailsProvider.swift in Sources */, 37D4B1B42672A30700C925CA /* VideoDetailsProvider.swift in Sources */,
37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */, 37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */,
37D4B19726717E1500C925CA /* Video.swift in Sources */, 37D4B19726717E1500C925CA /* Video.swift in Sources */,
37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */, 37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */,
37CEE4B92677B63F005A1EFE /* StreamResolution.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -509,18 +542,23 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
37CEE4BE2677B670005A1EFE /* MuxedStream.swift in Sources */,
37D4B19426717CE100C925CA /* PopularVideosProvider.swift in Sources */, 37D4B19426717CE100C925CA /* PopularVideosProvider.swift in Sources */,
37AAF29D26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */, 37AAF29D26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */,
37CEE4C22677B697005A1EFE /* Stream.swift in Sources */,
37D4B0E72671614900C925CA /* ContentView.swift in Sources */, 37D4B0E72671614900C925CA /* ContentView.swift in Sources */,
37CEE4B62677B628005A1EFE /* StreamType.swift in Sources */,
37AAF2832673791F007FC770 /* SearchedVideosProvider.swift in Sources */, 37AAF2832673791F007FC770 /* SearchedVideosProvider.swift in Sources */,
37AAF29126740715007FC770 /* AppState.swift in Sources */, 37AAF29126740715007FC770 /* AppState.swift in Sources */,
37AAF2952674086B007FC770 /* TabSelection.swift in Sources */, 37AAF2952674086B007FC770 /* TabSelection.swift in Sources */,
37D4B1B12672A01000C925CA /* DataProvider.swift in Sources */, 37D4B1B12672A01000C925CA /* DataProvider.swift in Sources */,
37AAF28D2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */, 37AAF28D2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */,
37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
37D4B1B52672A30700C925CA /* VideoDetailsProvider.swift in Sources */, 37D4B1B52672A30700C925CA /* VideoDetailsProvider.swift in Sources */,
37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */, 37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */,
37D4B19826717E1500C925CA /* Video.swift in Sources */, 37D4B19826717E1500C925CA /* Video.swift in Sources */,
37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */, 37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */,
37CEE4BA2677B63F005A1EFE /* StreamResolution.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -545,23 +583,29 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
37AAF28026737550007FC770 /* SearchView.swift in Sources */, 37AAF28026737550007FC770 /* SearchView.swift in Sources */,
37CEE4BF2677B670005A1EFE /* MuxedStream.swift in Sources */,
37CEE4B72677B628005A1EFE /* StreamType.swift in Sources */,
37D4B19526717CE100C925CA /* PopularVideosProvider.swift in Sources */, 37D4B19526717CE100C925CA /* PopularVideosProvider.swift in Sources */,
37AAF29E26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */, 37AAF29E26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */,
37D4B1842671684E00C925CA /* PlayerView.swift in Sources */, 37D4B1842671684E00C925CA /* PlayerView.swift in Sources */,
37D4B1802671650A00C925CA /* PearvidiousApp.swift in Sources */, 37D4B1802671650A00C925CA /* PearvidiousApp.swift in Sources */,
37D4B1B22672A01000C925CA /* DataProvider.swift in Sources */, 37D4B1B22672A01000C925CA /* DataProvider.swift in Sources */,
37AAF29226740715007FC770 /* AppState.swift in Sources */, 37AAF29226740715007FC770 /* AppState.swift in Sources */,
3741B5302676213400125C5E /* PlayerViewController.swift in Sources */,
37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
37D4B18E26717B3800C925CA /* VideoThumbnailView.swift in Sources */, 37D4B18E26717B3800C925CA /* VideoThumbnailView.swift in Sources */,
37D4B1B62672A30700C925CA /* VideoDetailsProvider.swift in Sources */, 37D4B1B62672A30700C925CA /* VideoDetailsProvider.swift in Sources */,
37AAF27E26737323007FC770 /* PopularVideosView.swift in Sources */, 37AAF27E26737323007FC770 /* PopularVideosView.swift in Sources */,
37AAF29A26740A01007FC770 /* VideosView.swift in Sources */, 37AAF29A26740A01007FC770 /* VideosView.swift in Sources */,
37AAF2962674086B007FC770 /* TabSelection.swift in Sources */, 37AAF2962674086B007FC770 /* TabSelection.swift in Sources */,
37CEE4C32677B697005A1EFE /* Stream.swift in Sources */,
37AAF28A2673AB89007FC770 /* ChannelView.swift in Sources */, 37AAF28A2673AB89007FC770 /* ChannelView.swift in Sources */,
37AAF28E2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */, 37AAF28E2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */,
37D4B19926717E1500C925CA /* Video.swift in Sources */, 37D4B19926717E1500C925CA /* Video.swift in Sources */,
37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */, 37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */,
37D4B1812671653A00C925CA /* ContentView.swift in Sources */, 37D4B1812671653A00C925CA /* ContentView.swift in Sources */,
37AAF2842673791F007FC770 /* SearchedVideosProvider.swift in Sources */, 37AAF2842673791F007FC770 /* SearchedVideosProvider.swift in Sources */,
37CEE4BB2677B63F005A1EFE /* StreamResolution.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -1,9 +1,9 @@
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@StateObject var state = AppState() @StateObject private var state = AppState()
@State var tabSelection: TabSelection = .subscriptions @State private var tabSelection: TabSelection = .subscriptions
var body: some View { var body: some View {
NavigationView { NavigationView {