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,24 +5,15 @@ import SwiftUI
struct PlayerView: View {
@ObservedObject private var provider: VideoDetailsProvider
private var id: String
init(id: String) {
self.id = id
provider = VideoDetailsProvider(id)
}
var body: some View {
ZStack {
if let video = provider.video {
if video.url != nil {
PlayerViewController(video)
.edgesIgnoringSafeArea(.all)
}
if video.error {
Text("Video can not be loaded")
}
PlayerViewController(video: video)
.edgesIgnoringSafeArea(.all)
}
}
.task {
@ -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,10 +16,10 @@ struct PopularVideosView: View {
}
var videos: [Video] {
if (provider.videos.isEmpty) {
if provider.videos.isEmpty {
provider.load()
}
return provider.videos
}
}

View File

@ -14,19 +14,7 @@ struct SearchView: View {
}
var videos: [Video] {
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 != provider.query {
provider.query = newQuery
provider.load()
}
provider.load(query)
return provider.videos
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,20 @@
import Alamofire
import Foundation
// swiftlint:disable:next final_class
class DataProvider: ObservableObject {
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 {
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 SwiftyJSON
class SearchedVideosProvider: DataProvider {
final class SearchedVideosProvider: DataProvider {
@Published var videos = [Video]()
var query: String = ""
var currentQuery: String = ""
func load() {
let searchPath = "search?q=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)"
func load(_ query: String) {
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
switch response.result {
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 SwiftyJSON
class SubscriptionVideosProvider: DataProvider {
final class SubscriptionVideosProvider: DataProvider {
@Published var videos = [Video]()
var sid: String = "RpoS7YPPK2-QS81jJF9z4KSQAjmzsOnMpn84c73-GQ8="

View File

@ -1,4 +1,5 @@
import Alamofire
import AVKit
import Foundation
import SwiftyJSON
@ -10,11 +11,9 @@ final class Video: Identifiable, ObservableObject {
var length: TimeInterval
var published: String
var views: Int
var channelID: String
@Published var url: URL?
@Published var error: Bool = false
var streams = [Stream]()
init(
id: String,
@ -23,8 +22,8 @@ final class Video: Identifiable, ObservableObject {
author: String,
length: TimeInterval,
published: String,
channelID: String,
views: Int = 0
views: Int,
channelID: String
) {
self.id = id
self.title = title
@ -32,41 +31,22 @@ final class Video: Identifiable, ObservableObject {
self.author = author
self.length = length
self.published = published
self.channelID = channelID
self.views = views
self.channelID = channelID
}
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
title = json["title"].stringValue
thumbnailURL = extractThumbnailURL(from: json)
author = json["author"].stringValue
length = json["lengthSeconds"].doubleValue
published = json["publishedText"].stringValue
views = json["viewCount"].intValue
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? {
@ -97,4 +77,60 @@ final class Video: Identifiable, ObservableObject {
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 = {
/* 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 */; };
37AAF28026737550007FC770 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.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 */; };
37AAF2A126741C97007FC770 /* 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 */; };
37D4B0E32671614900C925CA /* Tests_macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0E22671614900C925CA /* Tests_macOS.swift */; };
37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0C22671614700C925CA /* PearvidiousApp.swift */; };
@ -87,6 +103,7 @@
/* End PBXContainerItemProxy 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>"; };
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>"; };
@ -97,6 +114,11 @@
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>"; };
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>"; };
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>"; };
@ -230,6 +252,7 @@
37AAF2892673AB89007FC770 /* ChannelView.swift */,
37AAF27F26737550007FC770 /* SearchView.swift */,
37D4B1822671681B00C925CA /* PlayerView.swift */,
3741B52F2676213400125C5E /* PlayerViewController.swift */,
37AAF29926740A01007FC770 /* VideosView.swift */,
37D4B18B26717B3800C925CA /* VideoThumbnailView.swift */,
37D4B1AE26729DEB00C925CA /* Info.plist */,
@ -250,13 +273,18 @@
isa = PBXGroup;
children = (
37AAF28F26740715007FC770 /* AppState.swift */,
37D4B19626717E1500C925CA /* Video.swift */,
37B767DA2677C3CA0098BAA8 /* PlayerState.swift */,
37AAF29B26741B5F007FC770 /* SubscriptionVideosProvider.swift */,
37D4B19226717CE100C925CA /* PopularVideosProvider.swift */,
37AAF28B2673ABD3007FC770 /* ChannelVideosProvider.swift */,
37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */,
37D4B1B32672A30700C925CA /* VideoDetailsProvider.swift */,
37D4B1AF2672A01000C925CA /* DataProvider.swift */,
37D4B19626717E1500C925CA /* Video.swift */,
37CEE4C02677B697005A1EFE /* Stream.swift */,
37CEE4B42677B628005A1EFE /* StreamType.swift */,
37CEE4B82677B63F005A1EFE /* StreamResolution.swift */,
37CEE4BC2677B670005A1EFE /* MuxedStream.swift */,
);
path = Model;
sourceTree = "<group>";
@ -490,18 +518,23 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
37CEE4BD2677B670005A1EFE /* MuxedStream.swift in Sources */,
37D4B19326717CE100C925CA /* PopularVideosProvider.swift in Sources */,
37AAF29C26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */,
37CEE4C12677B697005A1EFE /* Stream.swift in Sources */,
37D4B0E62671614900C925CA /* ContentView.swift in Sources */,
37CEE4B52677B628005A1EFE /* StreamType.swift in Sources */,
37AAF2822673791F007FC770 /* SearchedVideosProvider.swift in Sources */,
37AAF29026740715007FC770 /* AppState.swift in Sources */,
37AAF2942674086B007FC770 /* TabSelection.swift in Sources */,
37D4B1B02672A01000C925CA /* DataProvider.swift in Sources */,
37AAF28C2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */,
37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
37D4B1B42672A30700C925CA /* VideoDetailsProvider.swift in Sources */,
37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */,
37D4B19726717E1500C925CA /* Video.swift in Sources */,
37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */,
37CEE4B92677B63F005A1EFE /* StreamResolution.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -509,18 +542,23 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
37CEE4BE2677B670005A1EFE /* MuxedStream.swift in Sources */,
37D4B19426717CE100C925CA /* PopularVideosProvider.swift in Sources */,
37AAF29D26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */,
37CEE4C22677B697005A1EFE /* Stream.swift in Sources */,
37D4B0E72671614900C925CA /* ContentView.swift in Sources */,
37CEE4B62677B628005A1EFE /* StreamType.swift in Sources */,
37AAF2832673791F007FC770 /* SearchedVideosProvider.swift in Sources */,
37AAF29126740715007FC770 /* AppState.swift in Sources */,
37AAF2952674086B007FC770 /* TabSelection.swift in Sources */,
37D4B1B12672A01000C925CA /* DataProvider.swift in Sources */,
37AAF28D2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */,
37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
37D4B1B52672A30700C925CA /* VideoDetailsProvider.swift in Sources */,
37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */,
37D4B19826717E1500C925CA /* Video.swift in Sources */,
37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */,
37CEE4BA2677B63F005A1EFE /* StreamResolution.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -545,23 +583,29 @@
buildActionMask = 2147483647;
files = (
37AAF28026737550007FC770 /* SearchView.swift in Sources */,
37CEE4BF2677B670005A1EFE /* MuxedStream.swift in Sources */,
37CEE4B72677B628005A1EFE /* StreamType.swift in Sources */,
37D4B19526717CE100C925CA /* PopularVideosProvider.swift in Sources */,
37AAF29E26741B5F007FC770 /* SubscriptionVideosProvider.swift in Sources */,
37D4B1842671684E00C925CA /* PlayerView.swift in Sources */,
37D4B1802671650A00C925CA /* PearvidiousApp.swift in Sources */,
37D4B1B22672A01000C925CA /* DataProvider.swift in Sources */,
37AAF29226740715007FC770 /* AppState.swift in Sources */,
3741B5302676213400125C5E /* PlayerViewController.swift in Sources */,
37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
37D4B18E26717B3800C925CA /* VideoThumbnailView.swift in Sources */,
37D4B1B62672A30700C925CA /* VideoDetailsProvider.swift in Sources */,
37AAF27E26737323007FC770 /* PopularVideosView.swift in Sources */,
37AAF29A26740A01007FC770 /* VideosView.swift in Sources */,
37AAF2962674086B007FC770 /* TabSelection.swift in Sources */,
37CEE4C32677B697005A1EFE /* Stream.swift in Sources */,
37AAF28A2673AB89007FC770 /* ChannelView.swift in Sources */,
37AAF28E2673ABD3007FC770 /* ChannelVideosProvider.swift in Sources */,
37D4B19926717E1500C925CA /* Video.swift in Sources */,
37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */,
37D4B1812671653A00C925CA /* ContentView.swift in Sources */,
37AAF2842673791F007FC770 /* SearchedVideosProvider.swift in Sources */,
37CEE4BB2677B63F005A1EFE /* StreamResolution.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -1,9 +1,9 @@
import SwiftUI
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 {
NavigationView {