diff --git a/Apple TV/PlayerView.swift b/Apple TV/PlayerView.swift index 276a60e4..bb410821 100644 --- a/Apple TV/PlayerView.swift +++ b/Apple TV/PlayerView.swift @@ -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) {} -} diff --git a/Apple TV/PlayerViewController.swift b/Apple TV/PlayerViewController.swift new file mode 100644 index 00000000..802cae42 --- /dev/null +++ b/Apple TV/PlayerViewController.swift @@ -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) + } + } + } + } +} diff --git a/Apple TV/PopularVideosView.swift b/Apple TV/PopularVideosView.swift index 55a5a749..19ccee23 100644 --- a/Apple TV/PopularVideosView.swift +++ b/Apple TV/PopularVideosView.swift @@ -16,10 +16,10 @@ struct PopularVideosView: View { } var videos: [Video] { - if (provider.videos.isEmpty) { + if provider.videos.isEmpty { provider.load() } - + return provider.videos } } diff --git a/Apple TV/SearchView.swift b/Apple TV/SearchView.swift index 40ea0e33..66a1a14d 100644 --- a/Apple TV/SearchView.swift +++ b/Apple TV/SearchView.swift @@ -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 } diff --git a/Apple TV/SubscriptionsView.swift b/Apple TV/SubscriptionsView.swift index 8c1d4601..61970245 100644 --- a/Apple TV/SubscriptionsView.swift +++ b/Apple TV/SubscriptionsView.swift @@ -16,6 +16,6 @@ struct SubscriptionsView: View { } var videos: [Video] { - return provider.videos + provider.videos } } diff --git a/Apple TV/VideoThumbnailView.swift b/Apple TV/VideoThumbnailView.swift index e6ef6a93..5ee1bcd8 100644 --- a/Apple TV/VideoThumbnailView.swift +++ b/Apple TV/VideoThumbnailView.swift @@ -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) +// } +// } diff --git a/Apple TV/VideosView.swift b/Apple TV/VideosView.swift index 718c60be..32409e70 100644 --- a/Apple TV/VideosView.swift +++ b/Apple TV/VideosView.swift @@ -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 { diff --git a/Model/AppState.swift b/Model/AppState.swift index 84636d78..3e24d896 100644 --- a/Model/AppState.swift +++ b/Model/AppState.swift @@ -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 = "" diff --git a/Model/ChannelVideosProvider.swift b/Model/ChannelVideosProvider.swift index f3ebf264..13e3ec09 100644 --- a/Model/ChannelVideosProvider.swift +++ b/Model/ChannelVideosProvider.swift @@ -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 diff --git a/Model/DataProvider.swift b/Model/DataProvider.swift index 3cab34f3..8616e7fd 100644 --- a/Model/DataProvider.swift +++ b/Model/DataProvider.swift @@ -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) } diff --git a/Model/MuxedStream.swift b/Model/MuxedStream.swift new file mode 100644 index 00000000..6d48b766 --- /dev/null +++ b/Model/MuxedStream.swift @@ -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) + } +} diff --git a/Model/PlayerState.swift b/Model/PlayerState.swift new file mode 100644 index 00000000..41d87841 --- /dev/null +++ b/Model/PlayerState.swift @@ -0,0 +1,7 @@ +import AVFoundation +import Foundation + +final class PlayerState: ObservableObject { + @Published var currentStream: Stream! + @Published var seekTo: CMTime? +} diff --git a/Model/SearchedVideosProvider.swift b/Model/SearchedVideosProvider.swift index 86261ac2..abeccef3 100644 --- a/Model/SearchedVideosProvider.swift +++ b/Model/SearchedVideosProvider.swift @@ -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): diff --git a/Model/Stream.swift b/Model/Stream.swift new file mode 100644 index 00000000..c5a0c65d --- /dev/null +++ b/Model/Stream.swift @@ -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 + } +} diff --git a/Model/StreamResolution.swift b/Model/StreamResolution.swift new file mode 100644 index 00000000..6145e436 --- /dev/null +++ b/Model/StreamResolution.swift @@ -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 + } +} diff --git a/Model/StreamType.swift b/Model/StreamType.swift new file mode 100644 index 00000000..f6edfb77 --- /dev/null +++ b/Model/StreamType.swift @@ -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 + } +} diff --git a/Model/SubscriptionVideosProvider.swift b/Model/SubscriptionVideosProvider.swift index dcf153b3..9e602fce 100644 --- a/Model/SubscriptionVideosProvider.swift +++ b/Model/SubscriptionVideosProvider.swift @@ -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=" diff --git a/Model/Video.swift b/Model/Video.swift index 1656deda..1fc0ca72 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -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 + ) + } + } } diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index 06079807..99692e99 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -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 = ""; }; 37AAF27D26737323007FC770 /* PopularVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularVideosView.swift; sourceTree = ""; }; 37AAF27F26737550007FC770 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 37AAF2812673791F007FC770 /* SearchedVideosProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedVideosProvider.swift; sourceTree = ""; }; @@ -97,6 +114,11 @@ 37AAF29926740A01007FC770 /* VideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosView.swift; sourceTree = ""; }; 37AAF29B26741B5F007FC770 /* SubscriptionVideosProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionVideosProvider.swift; sourceTree = ""; }; 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsView.swift; sourceTree = ""; }; + 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerState.swift; sourceTree = ""; }; + 37CEE4B42677B628005A1EFE /* StreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamType.swift; sourceTree = ""; }; + 37CEE4B82677B63F005A1EFE /* StreamResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolution.swift; sourceTree = ""; }; + 37CEE4BC2677B670005A1EFE /* MuxedStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuxedStream.swift; sourceTree = ""; }; + 37CEE4C02677B697005A1EFE /* Stream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stream.swift; sourceTree = ""; }; 37D4B0C22671614700C925CA /* PearvidiousApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PearvidiousApp.swift; sourceTree = ""; }; 37D4B0C32671614700C925CA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 37D4B0C42671614800C925CA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -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 = ""; @@ -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; }; diff --git a/Shared/ContentView.swift b/Shared/ContentView.swift index 9061d134..1520018c 100644 --- a/Shared/ContentView.swift +++ b/Shared/ContentView.swift @@ -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 {