diff --git a/Fixtures/Video+Fixtures.swift b/Fixtures/Video+Fixtures.swift index 5f2dc6b1..166a7a42 100644 --- a/Fixtures/Video+Fixtures.swift +++ b/Fixtures/Video+Fixtures.swift @@ -3,6 +3,7 @@ import Foundation extension Video { static var fixture: Video { let id = "D2sxamzaHkM" + let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo" return Video( videoID: UUID().uuidString, @@ -13,7 +14,13 @@ extension Video { views: 21534, description: "Some relaxing live piano music", genre: "Music", - channel: Channel(id: "AbCdEFgHI", name: "The Channel", subscriptionsCount: 2300, videos: []), + channel: Channel( + id: "AbCdEFgHI", + name: "The Channel", + thumbnailURL: URL(string: thumbnailURL)!, + subscriptionsCount: 2300, + videos: [] + ), thumbnails: Thumbnail.fixturesForAllQualities(videoId: id), live: false, upcoming: false, diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index d514b84d..ad1a2ad2 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -1,3 +1,4 @@ +import AVKit import Defaults import Foundation import Siesta @@ -80,15 +81,25 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity) -> [Video] in - content.json.arrayValue.map(Video.init) + content.json.arrayValue.map(InvidiousAPI.extractVideo) } configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity) -> [Video] in - content.json.arrayValue.map(Video.init) + content.json.arrayValue.map(InvidiousAPI.extractVideo) } - configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity) -> [Video] in - content.json.arrayValue.map(Video.init) + configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity) -> [ContentItem] in + content.json.arrayValue.map { + let type = $0.dictionaryValue["type"]?.stringValue + + if type == "channel" { + return ContentItem(channel: InvidiousAPI.extractChannel(from: $0)) + } else if type == "playlist" { + // TODO: fix playlists + return ContentItem(playlist: Playlist(JSON(parseJSON: "{}"))) + } + return ContentItem(video: InvidiousAPI.extractVideo($0)) + } } configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity) -> [String] in @@ -114,34 +125,34 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity) -> [Video] in if let feedVideos = content.json.dictionaryValue["videos"] { - return feedVideos.arrayValue.map(Video.init) + return feedVideos.arrayValue.map(InvidiousAPI.extractVideo) } return [] } configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity) -> [Channel] in - content.json.arrayValue.map(Channel.init) + content.json.arrayValue.map(InvidiousAPI.extractChannel) } configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity) -> Channel in - Channel(json: content.json) + InvidiousAPI.extractChannel(from: content.json) } configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity) -> [Video] in - content.json.arrayValue.map(Video.init) + content.json.arrayValue.map(InvidiousAPI.extractVideo) } configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity) -> Video in - Video(content.json) + InvidiousAPI.extractVideo(content.json) } } - fileprivate func pathPattern(_ path: String) -> String { + private func pathPattern(_ path: String) -> String { "**\(InvidiousAPI.basePath)/\(path)" } - fileprivate func basePathAppending(_ path: String) -> String { + private func basePathAppending(_ path: String) -> String { "\(InvidiousAPI.basePath)/\(path)" } @@ -207,6 +218,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { var resource = resource(baseURL: account.url, path: basePathAppending("search")) .withParam("q", searchQuery(query.query)) .withParam("sort_by", query.sortBy.parameter) + .withParam("type", "all") if let date = query.date, date != .any { resource = resource.withParam("date", date.rawValue) @@ -242,4 +254,106 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { return searchQuery } + + static func assetURLFrom(instance: Instance, url: URL) -> URL? { + guard let instanceURLComponents = URLComponents(string: instance.url), + var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + + urlComponents.scheme = instanceURLComponents.scheme + urlComponents.host = instanceURLComponents.host + + return urlComponents.url + } + + static func extractVideo(_ json: JSON) -> Video { + let indexID: String? + var id: Video.ID + var publishedAt: Date? + + if let publishedInterval = json["published"].double { + publishedAt = Date(timeIntervalSince1970: publishedInterval) + } + + let videoID = json["videoId"].stringValue + + if let index = json["indexId"].string { + indexID = index + id = videoID + index + } else { + indexID = nil + id = videoID + } + + return Video( + id: id, + videoID: videoID, + title: json["title"].stringValue, + author: json["author"].stringValue, + length: json["lengthSeconds"].doubleValue, + published: json["publishedText"].stringValue, + views: json["viewCount"].intValue, + description: json["description"].stringValue, + genre: json["genre"].stringValue, + channel: extractChannel(from: json), + thumbnails: extractThumbnails(from: json), + indexID: indexID, + live: json["liveNow"].boolValue, + upcoming: json["isUpcoming"].boolValue, + publishedAt: publishedAt, + likes: json["likeCount"].int, + dislikes: json["dislikeCount"].int, + keywords: json["keywords"].arrayValue.map { $0.stringValue }, + streams: extractFormatStreams(from: json["formatStreams"].arrayValue) + + extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) + ) + } + + static func extractChannel(from json: JSON) -> Channel { + let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")" + + return Channel( + id: json["authorId"].stringValue, + name: json["author"].stringValue, + thumbnailURL: URL(string: thumbnailURL), + subscriptionsCount: json["subCount"].int, + subscriptionsText: json["subCountText"].string, + videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(InvidiousAPI.extractVideo) ?? [] + ) + } + + private static func extractThumbnails(from details: JSON) -> [Thumbnail] { + details["videoThumbnails"].arrayValue.map { json in + Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!) + } + } + + private static func extractFormatStreams(from streams: [JSON]) -> [Stream] { + streams.map { + SingleAssetStream( + avAsset: AVURLAsset(url: $0["url"].url!), + resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue), + kind: .stream, + encoding: $0["encoding"].stringValue + ) + } + } + + private static 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: audioAssetURL!["url"].url!), + videoAsset: AVURLAsset(url: $0["url"].url!), + resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue), + kind: .adaptive, + encoding: $0["encoding"].stringValue + ) + } + } } diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index 530eb358..83bcc100 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -32,19 +32,19 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } configureTransformer(pathPattern("channel/*")) { (content: Entity) -> Channel? in - self.extractChannel(content.json) + PipedAPI.extractChannel(content.json) } configureTransformer(pathPattern("streams/*")) { (content: Entity) -> Video? in - self.extractVideo(content.json) + PipedAPI.extractVideo(content.json) } configureTransformer(pathPattern("trending")) { (content: Entity) -> [Video] in - self.extractVideos(content.json) + PipedAPI.extractVideos(content.json) } - configureTransformer(pathPattern("search")) { (content: Entity) -> [Video] in - self.extractVideos(content.json.dictionaryValue["items"]!) + configureTransformer(pathPattern("search")) { (content: Entity) -> [ContentItem] in + PipedAPI.extractContentItems(content.json.dictionaryValue["items"]!) } configureTransformer(pathPattern("suggestions")) { (content: Entity) -> [String] in @@ -52,154 +52,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } } - private func extractChannel(_ content: JSON) -> Channel? { - Channel( - id: content.dictionaryValue["id"]!.stringValue, - name: content.dictionaryValue["name"]!.stringValue, - subscriptionsCount: content.dictionaryValue["subscriberCount"]!.intValue, - videos: extractVideos(content.dictionaryValue["relatedStreams"]!) - ) - } - - private func extractVideo(_ content: JSON) -> Video? { - let details = content.dictionaryValue - let url = details["url"]?.string - - if !url.isNil { - guard url!.contains("/watch") else { - return nil - } - } - - let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last! - - let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap { - if let url = buildThumbnailURL(content, quality: $0) { - return Thumbnail(url: url, quality: $0) - } - - return nil - } - - let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue - - return Video( - videoID: extractID(content), - title: details["title"]!.stringValue, - author: author, - length: details["duration"]!.doubleValue, - published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue, - views: details["views"]!.intValue, - description: extractDescription(content), - channel: Channel(id: channelId, name: author), - thumbnails: thumbnails, - likes: details["likes"]?.int, - dislikes: details["dislikes"]?.int, - streams: extractStreams(content) - ) - } - - private func extractID(_ content: JSON) -> Video.ID { - content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ?? - extractThumbnailURL(content)!.relativeString.components(separatedBy: "/")[4] - } - - private func extractThumbnailURL(_ content: JSON) -> URL? { - content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url! - } - - private func buildThumbnailURL(_ content: JSON, quality: Thumbnail.Quality) -> URL? { - let thumbnailURL = extractThumbnailURL(content) - guard !thumbnailURL.isNil else { - return nil - } - - return URL(string: thumbnailURL! - .absoluteString - .replacingOccurrences(of: "_webp", with: "") - .replacingOccurrences(of: ".webp", with: ".jpg") - .replacingOccurrences(of: "hqdefault", with: quality.filename) - .replacingOccurrences(of: "maxresdefault", with: quality.filename) - )! - } - - private func extractDescription(_ content: JSON) -> String? { - guard var description = content.dictionaryValue["description"]?.string else { - return nil - } - - description = description.replacingOccurrences( - of: "
|
|
", - with: "\n", - options: .regularExpression, - range: nil - ) - - description = description.replacingOccurrences( - of: "<[^>]+>", - with: "", - options: .regularExpression, - range: nil - ) - - return description - } - - private func extractVideos(_ content: JSON) -> [Video] { - content.arrayValue.compactMap(extractVideo(_:)) - } - - private func extractStreams(_ content: JSON) -> [Stream] { - var streams = [Stream]() - - if let hlsURL = content.dictionaryValue["hls"]?.url { - streams.append(Stream(hlsURL: hlsURL)) - } - - guard let audioStream = compatibleAudioStreams(content).first else { - return streams - } - - let videoStreams = compatibleVideoStream(content) - - videoStreams.forEach { videoStream in - let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!) - let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!) - - let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true - let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue) - - if videoOnly { - streams.append( - Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive) - ) - } else { - streams.append( - SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream) - ) - } - } - - return streams - } - - private func compatibleAudioStreams(_ content: JSON) -> [JSON] { - content - .dictionaryValue["audioStreams"]? - .arrayValue - .filter { $0.dictionaryValue["format"]?.stringValue == "M4A" } - .sorted { - $0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0 - } ?? [] - } - - private func compatibleVideoStream(_ content: JSON) -> [JSON] { - content - .dictionaryValue["videoStreams"]? - .arrayValue - .filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? [] - } - func channel(_ id: String) -> Resource { resource(baseURL: account.url, path: "channel/\(id)") } @@ -240,4 +92,205 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { private func pathPattern(_ path: String) -> String { "**\(path)" } + + private static func extractContentItem(_ content: JSON) -> ContentItem? { + let details = content.dictionaryValue + let url: String! = details["url"]?.string + + let contentType: ContentItem.ContentType + + if !url.isNil { + if url.contains("/playlist") { + contentType = .playlist + } else if url.contains("/channel") { + contentType = .channel + } else { + contentType = .video + } + } else { + contentType = .video + } + + switch contentType { + case .video: + if let video = PipedAPI.extractVideo(content) { + return ContentItem(video: video) + } + + case .playlist: + return nil + + case .channel: + if let channel = PipedAPI.extractChannel(content) { + return ContentItem(channel: channel) + } + } + + return nil + } + + private static func extractContentItems(_ content: JSON) -> [ContentItem] { + content.arrayValue.compactMap { PipedAPI.extractContentItem($0) } + } + + private static func extractChannel(_ content: JSON) -> Channel? { + let attributes = content.dictionaryValue + guard let id = attributes["id"]?.stringValue ?? + attributes["url"]?.stringValue.components(separatedBy: "/").last + else { + return nil + } + + let subscriptionsCount = attributes["subscriberCount"]?.intValue ?? attributes["subscribers"]?.intValue + + var videos = [Video]() + if let relatedStreams = attributes["relatedStreams"] { + videos = PipedAPI.extractVideos(relatedStreams) + } + + return Channel( + id: id, + name: attributes["name"]!.stringValue, + thumbnailURL: attributes["thumbnail"]?.url, + subscriptionsCount: subscriptionsCount, + videos: videos + ) + } + + private static func extractVideo(_ content: JSON) -> Video? { + let details = content.dictionaryValue + let url = details["url"]?.string + + if !url.isNil { + guard url!.contains("/watch") else { + return nil + } + } + + let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last! + + let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap { + if let url = PipedAPI.buildThumbnailURL(content, quality: $0) { + return Thumbnail(url: url, quality: $0) + } + + return nil + } + + let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue + + return Video( + videoID: PipedAPI.extractID(content), + title: details["title"]!.stringValue, + author: author, + length: details["duration"]!.doubleValue, + published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue, + views: details["views"]!.intValue, + description: PipedAPI.extractDescription(content), + channel: Channel(id: channelId, name: author), + thumbnails: thumbnails, + likes: details["likes"]?.int, + dislikes: details["dislikes"]?.int, + streams: extractStreams(content) + ) + } + + private static func extractID(_ content: JSON) -> Video.ID { + content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ?? + extractThumbnailURL(content)!.relativeString.components(separatedBy: "/")[4] + } + + private static func extractThumbnailURL(_ content: JSON) -> URL? { + content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url! + } + + private static func buildThumbnailURL(_ content: JSON, quality: Thumbnail.Quality) -> URL? { + let thumbnailURL = extractThumbnailURL(content) + guard !thumbnailURL.isNil else { + return nil + } + + return URL(string: thumbnailURL! + .absoluteString + .replacingOccurrences(of: "hqdefault", with: quality.filename) + .replacingOccurrences(of: "maxresdefault", with: quality.filename) + )! + } + + private static func extractDescription(_ content: JSON) -> String? { + guard var description = content.dictionaryValue["description"]?.string else { + return nil + } + + description = description.replacingOccurrences( + of: "
|
|
", + with: "\n", + options: .regularExpression, + range: nil + ) + + description = description.replacingOccurrences( + of: "<[^>]+>", + with: "", + options: .regularExpression, + range: nil + ) + + return description + } + + private static func extractVideos(_ content: JSON) -> [Video] { + content.arrayValue.compactMap(extractVideo(_:)) + } + + private static func extractStreams(_ content: JSON) -> [Stream] { + var streams = [Stream]() + + if let hlsURL = content.dictionaryValue["hls"]?.url { + streams.append(Stream(hlsURL: hlsURL)) + } + + guard let audioStream = PipedAPI.compatibleAudioStreams(content).first else { + return streams + } + + let videoStreams = PipedAPI.compatibleVideoStream(content) + + videoStreams.forEach { videoStream in + let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!) + let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!) + + let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true + let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue) + + if videoOnly { + streams.append( + Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive) + ) + } else { + streams.append( + SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream) + ) + } + } + + return streams + } + + private static func compatibleAudioStreams(_ content: JSON) -> [JSON] { + content + .dictionaryValue["audioStreams"]? + .arrayValue + .filter { $0.dictionaryValue["format"]?.stringValue == "M4A" } + .sorted { + $0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0 + } ?? [] + } + + private static func compatibleVideoStream(_ content: JSON) -> [JSON] { + content + .dictionaryValue["videoStreams"]? + .arrayValue + .filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? [] + } } diff --git a/Model/Channel.swift b/Model/Channel.swift index dd74ecb6..9c7d27e9 100644 --- a/Model/Channel.swift +++ b/Model/Channel.swift @@ -6,31 +6,30 @@ import SwiftyJSON struct Channel: Identifiable, Hashable { var id: String var name: String + var thumbnailURL: URL? var videos = [Video]() private var subscriptionsCount: Int? private var subscriptionsText: String? - init(json: JSON) { - id = json["authorId"].stringValue - name = json["author"].stringValue - subscriptionsCount = json["subCount"].int - subscriptionsText = json["subCountText"].string - - if let channelVideos = json.dictionaryValue["latestVideos"] { - videos = channelVideos.arrayValue.map(Video.init) - } - } - - init(id: String, name: String, subscriptionsCount: Int? = nil, videos: [Video] = []) { + init( + id: String, + name: String, + thumbnailURL: URL? = nil, + subscriptionsCount: Int? = nil, + subscriptionsText: String? = nil, + videos: [Video] = [] + ) { self.id = id self.name = name + self.thumbnailURL = thumbnailURL self.subscriptionsCount = subscriptionsCount + self.subscriptionsText = subscriptionsText self.videos = videos } var subscriptionsString: String? { - if subscriptionsCount != nil { + if subscriptionsCount != nil, subscriptionsCount! > 0 { return subscriptionsCount!.formattedAsAbbreviation() } diff --git a/Model/ContentItem.swift b/Model/ContentItem.swift new file mode 100644 index 00000000..193c14fe --- /dev/null +++ b/Model/ContentItem.swift @@ -0,0 +1,48 @@ +import Foundation + +struct ContentItem: Identifiable { + enum ContentType: String { + case video, playlist, channel + + private var sortOrder: Int { + switch self { + case .channel: + return 1 + case .video: + return 2 + default: + return 3 + } + } + + static func < (lhs: ContentType, rhs: ContentType) -> Bool { + lhs.sortOrder < rhs.sortOrder + } + } + + var video: Video! + var playlist: Playlist! + var channel: Channel! + + static func array(of videos: [Video]) -> [ContentItem] { + videos.map { ContentItem(video: $0) } + } + + static func < (lhs: ContentItem, rhs: ContentItem) -> Bool { + lhs.contentType < rhs.contentType + } + + var id: String { + "\(contentType.rawValue)-\(video?.id ?? playlist?.id ?? channel?.id ?? "")" + } + + var contentType: ContentType { + if !playlist.isNil { + return .playlist + } else if !channel.isNil { + return .channel + } + + return .video + } +} diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index d7232dfe..19d532b4 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -98,14 +98,6 @@ final class PlayerModel: ObservableObject { } } - func piped(_ instance: Instance) -> PipedAPI { - PipedAPI(account: instance.anonymousAccount) - } - - func invidious(_ instance: Instance) -> InvidiousAPI { - InvidiousAPI(account: instance.anonymousAccount) - } - private func playStream( _ stream: Stream, of video: Video, diff --git a/Model/Player/PlayerStreams.swift b/Model/Player/PlayerStreams.swift index 74ec67e1..655c365c 100644 --- a/Model/Player/PlayerStreams.swift +++ b/Model/Player/PlayerStreams.swift @@ -1,6 +1,7 @@ import Foundation import Siesta import SwiftUI +import AVFoundation extension PlayerModel { var isLoadingAvailableStreams: Bool { @@ -101,14 +102,16 @@ extension PlayerModel { func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] { streams.map { stream in stream.instance = instance + + if instance.app == .invidious { + stream.audioAsset = AVURLAsset(url: InvidiousAPI.assetURLFrom(instance: instance, url: stream.audioAsset.url)!) + stream.videoAsset = AVURLAsset(url: InvidiousAPI.assetURLFrom(instance: instance, url: stream.videoAsset.url)!) + } + return stream } } - func streamsWithAssetsFromInstance(instance: Instance, streams: [Stream]) -> [Stream] { - streams.map { stream in stream.withAssetsFrom(instance) } - } - func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool { lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind) } diff --git a/Model/Playlist.swift b/Model/Playlist.swift index b790f4cb..67bde765 100644 --- a/Model/Playlist.swift +++ b/Model/Playlist.swift @@ -34,7 +34,7 @@ struct Playlist: Identifiable, Equatable, Hashable { title = json["title"].stringValue visibility = json["isListed"].boolValue ? .public : .private updated = json["updated"].doubleValue - videos = json["videos"].arrayValue.map { Video($0) } + videos = json["videos"].arrayValue.map { InvidiousAPI.extractVideo($0) } } static func == (lhs: Playlist, rhs: Playlist) -> Bool { diff --git a/Model/RecentsModel.swift b/Model/RecentsModel.swift index 4efe50a1..ca22c857 100644 --- a/Model/RecentsModel.swift +++ b/Model/RecentsModel.swift @@ -13,9 +13,11 @@ final class RecentsModel: ObservableObject { } func add(_ item: RecentItem) { - if !items.contains(where: { $0.id == item.id }) { - items.append(item) + if let index = items.firstIndex(where: { $0.id == item.id }) { + items.remove(at: index) } + + items.append(item) } func close(_ item: RecentItem) { diff --git a/Model/SearchModel.swift b/Model/Search/SearchModel.swift similarity index 89% rename from Model/SearchModel.swift rename to Model/Search/SearchModel.swift index b2ede26d..0981f719 100644 --- a/Model/SearchModel.swift +++ b/Model/Search/SearchModel.swift @@ -3,7 +3,7 @@ import Siesta import SwiftUI final class SearchModel: ObservableObject { - @Published var store = Store<[Video]>() + @Published var store = Store<[ContentItem]>() var accounts = AccountsModel() @Published var query = SearchQuery() @@ -62,8 +62,8 @@ final class SearchModel: ObservableObject { if let request = resource.loadIfNeeded() { request.onSuccess { response in - if let videos: [Video] = response.typedContent() { - self.replace(videos, for: currentResource) + if let results: [ContentItem] = response.typedContent() { + self.replace(results, for: currentResource) } } } else { @@ -71,9 +71,9 @@ final class SearchModel: ObservableObject { } } - func replace(_ videos: [Video], for resource: Resource) { + func replace(_ videos: [ContentItem], for resource: Resource) { if self.resource == resource { - store = Store<[Video]>(videos) + store = Store<[ContentItem]>(videos) } } diff --git a/Model/SearchQuery.swift b/Model/Search/SearchQuery.swift similarity index 100% rename from Model/SearchQuery.swift rename to Model/Search/SearchQuery.swift diff --git a/Model/Stream.swift b/Model/Stream.swift index 593ad636..4e2592fb 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -148,29 +148,4 @@ class Stream: Equatable, Hashable, Identifiable { hasher.combine(audioAsset?.url) hasher.combine(hlsURL) } - - func withAssetsFrom(_ instance: Instance) -> Stream { - if kind == .hls { - return Stream(instance: instance, hlsURL: hlsURL) - } else { - return Stream( - instance: instance, - audioAsset: AVURLAsset(url: assetURLFrom(instance: instance, url: (audioAsset ?? videoAsset).url)!), - videoAsset: AVURLAsset(url: assetURLFrom(instance: instance, url: videoAsset.url)!), - resolution: resolution, - kind: kind, - encoding: encoding - ) - } - } - - private func assetURLFrom(instance: Instance, url: URL) -> URL? { - guard let instanceURLComponents = URLComponents(string: instance.url), - var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } - - urlComponents.scheme = instanceURLComponents.scheme - urlComponents.host = instanceURLComponents.host - - return urlComponents.url - } } diff --git a/Model/Thumbnail.swift b/Model/Thumbnail.swift index ed0dc39e..7fb25b6f 100644 --- a/Model/Thumbnail.swift +++ b/Model/Thumbnail.swift @@ -32,11 +32,6 @@ struct Thumbnail { var url: URL var quality: Quality - init(_ json: JSON) { - url = json["url"].url! - quality = Quality(rawValue: json["quality"].string!)! - } - init(url: URL, quality: Quality) { self.url = url self.quality = quality diff --git a/Model/Video.swift b/Model/Video.swift index 64baca6f..aeb34b6f 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -72,49 +72,6 @@ struct Video: Identifiable, Equatable, Hashable { self.streams = streams } - init(_ json: JSON) { - videoID = json["videoId"].stringValue - - if let id = json["indexId"].string { - indexID = id - self.id = videoID + id - } else { - indexID = nil - id = videoID - } - - title = json["title"].stringValue - author = json["author"].stringValue - length = json["lengthSeconds"].doubleValue - published = json["publishedText"].stringValue - views = json["viewCount"].intValue - description = json["description"].stringValue - genre = json["genre"].stringValue - - thumbnails = Video.extractThumbnails(from: json) - - live = json["liveNow"].boolValue - upcoming = json["isUpcoming"].boolValue - - likes = json["likeCount"].int - dislikes = json["dislikeCount"].int - - keywords = json["keywords"].arrayValue.map { $0.stringValue } - - if let publishedInterval = json["published"].double { - publishedAt = Date(timeIntervalSince1970: publishedInterval) - } - - if let hlsURL = json["hlsUrl"].url { - streams.append(.init(hlsURL: hlsURL)) - } - - streams = Video.extractFormatStreams(from: json["formatStreams"].arrayValue) - streams.append(contentsOf: Video.extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)) - - channel = Channel(json: json) - } - var playTime: String? { guard !length.isZero else { return nil @@ -145,31 +102,6 @@ struct Video: Identifiable, Equatable, Hashable { dislikes?.formattedAsAbbreviation() } - var selectableStreams: [Stream] { - let streams = streams.sorted { $0.resolution > $1.resolution } - var selectable = [Stream]() - - Stream.Resolution.allCases.forEach { resolution in - if let stream = streams.filter({ $0.resolution == resolution }).min(by: { $0.kind < $1.kind }) { - selectable.append(stream) - } - } - - return selectable - } - - var defaultStream: Stream? { - selectableStreams.first { $0.kind == .stream } - } - - var bestStream: Stream? { - selectableStreams.min { $0.resolution > $1.resolution } - } - - func streamWithResolution(_ resolution: Stream.Resolution) -> Stream? { - selectableStreams.first { $0.resolution == resolution } ?? defaultStream - } - func thumbnailURL(quality: Thumbnail.Quality) -> URL? { if let url = thumbnails.first(where: { $0.quality == quality })?.url.absoluteString { return URL(string: url.replacingOccurrences(of: "hqdefault", with: quality.filename)) @@ -178,42 +110,6 @@ struct Video: Identifiable, Equatable, Hashable { return nil } - private static func extractThumbnails(from details: JSON) -> [Thumbnail] { - details["videoThumbnails"].arrayValue.map { json in - Thumbnail(json) - } - } - - private static func extractFormatStreams(from streams: [JSON]) -> [Stream] { - streams.map { - SingleAssetStream( - avAsset: AVURLAsset(url: $0["url"].url!), - resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue), - kind: .stream, - encoding: $0["encoding"].stringValue - ) - } - } - - private static 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: audioAssetURL!["url"].url!), - videoAsset: AVURLAsset(url: $0["url"].url!), - resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue), - kind: .adaptive, - encoding: $0["encoding"].stringValue - ) - } - } - static func == (lhs: Video, rhs: Video) -> Bool { lhs.id == rhs.id } diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index cc1f5edf..af50a253 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -79,6 +79,8 @@ 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; 373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; + 3743B86927216D3600261544 /* ChannelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743B86727216D3600261544 /* ChannelCell.swift */; }; + 3743B86A27216D3600261544 /* ChannelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743B86727216D3600261544 /* ChannelCell.swift */; }; 3743CA4E270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; }; 3743CA4F270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; }; 3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; }; @@ -113,6 +115,7 @@ 375168D62700FAFF008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; }; 375168D72700FDB8008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; }; 375168D82700FDB9008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; }; + 3758638A2721B0A9000CB14E /* ChannelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743B86727216D3600261544 /* ChannelCell.swift */; }; 375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; }; 375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; }; 375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; }; @@ -158,8 +161,8 @@ 377FC7DB267A080300A6BBAF /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 377FC7DA267A080300A6BBAF /* Logging */; }; 377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27D26737323007FC770 /* PopularView.swift */; }; 377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27D26737323007FC770 /* PopularView.swift */; }; - 377FC7E2267A084A00A6BBAF /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B18B26717B3800C925CA /* VideoView.swift */; }; - 377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B18B26717B3800C925CA /* VideoView.swift */; }; + 377FC7E2267A084A00A6BBAF /* VideoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B18B26717B3800C925CA /* VideoCell.swift */; }; + 377FC7E3267A084A00A6BBAF /* VideoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B18B26717B3800C925CA /* VideoCell.swift */; }; 377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.swift */; }; 377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.swift */; }; 377FC7ED267A0A0800A6BBAF /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 377FC7EC267A0A0800A6BBAF /* SwiftyJSON */; }; @@ -185,9 +188,9 @@ 379775932689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; }; 379775942689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; }; 379775952689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; }; - 37A9965A26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965926D6F8CA006E3224 /* VideosCellsHorizontal.swift */; }; - 37A9965B26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965926D6F8CA006E3224 /* VideosCellsHorizontal.swift */; }; - 37A9965C26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965926D6F8CA006E3224 /* VideosCellsHorizontal.swift */; }; + 37A9965A26D6F8CA006E3224 /* HorizontalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965926D6F8CA006E3224 /* HorizontalCells.swift */; }; + 37A9965B26D6F8CA006E3224 /* HorizontalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965926D6F8CA006E3224 /* HorizontalCells.swift */; }; + 37A9965C26D6F8CA006E3224 /* HorizontalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965926D6F8CA006E3224 /* HorizontalCells.swift */; }; 37A9965E26D6F9B9006E3224 /* WatchNowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965D26D6F9B9006E3224 /* WatchNowView.swift */; }; 37A9965F26D6F9B9006E3224 /* WatchNowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965D26D6F9B9006E3224 /* WatchNowView.swift */; }; 37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A9965D26D6F9B9006E3224 /* WatchNowView.swift */; }; @@ -291,7 +294,7 @@ 37D4B176267164B000C925CA /* PearvidiousUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B175267164B000C925CA /* PearvidiousUITests.swift */; }; 37D4B1802671650A00C925CA /* PearvidiousApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0C22671614700C925CA /* PearvidiousApp.swift */; }; 37D4B1862671691600C925CA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 37D4B0C42671614800C925CA /* Assets.xcassets */; }; - 37D4B18E26717B3800C925CA /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B18B26717B3800C925CA /* VideoView.swift */; }; + 37D4B18E26717B3800C925CA /* VideoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B18B26717B3800C925CA /* VideoCell.swift */; }; 37D4B19726717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; }; 37D4B19826717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; }; 37D4B19926717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; }; @@ -326,12 +329,27 @@ 37F49BA426CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */; }; 37F49BA526CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */; }; 37F49BA826CB0FCE00304AC0 /* PlaylistFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */; }; - 37F4AE7226828F0900BD60EA /* VideosCellsVertical.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */; }; - 37F4AE7326828F0900BD60EA /* VideosCellsVertical.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */; }; - 37F4AE7426828F0900BD60EA /* VideosCellsVertical.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */; }; + 37F4AE7226828F0900BD60EA /* VerticalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VerticalCells.swift */; }; + 37F4AE7326828F0900BD60EA /* VerticalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VerticalCells.swift */; }; + 37F4AE7426828F0900BD60EA /* VerticalCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F4AE7126828F0900BD60EA /* VerticalCells.swift */; }; 37F64FE426FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnViewModifier.swift */; }; 37F64FE526FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnViewModifier.swift */; }; 37F64FE626FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnViewModifier.swift */; }; + 37FB28412721B22200A57617 /* ContentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB28402721B22200A57617 /* ContentItem.swift */; }; + 37FB28422721B22200A57617 /* ContentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB28402721B22200A57617 /* ContentItem.swift */; }; + 37FB28432721B22200A57617 /* ContentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB28402721B22200A57617 /* ContentItem.swift */; }; + 37FB28462722054C00A57617 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37FB28452722054C00A57617 /* SDWebImageSwiftUI */; }; + 37FB2849272207F000A57617 /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = 37FB2848272207F000A57617 /* SDWebImageWebPCoder */; }; + 37FB284B2722099E00A57617 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37FB284A2722099E00A57617 /* SDWebImageSwiftUI */; }; + 37FB284D2722099E00A57617 /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = 37FB284C2722099E00A57617 /* SDWebImageWebPCoder */; }; + 37FB284F272209AB00A57617 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37FB284E272209AB00A57617 /* SDWebImageSwiftUI */; }; + 37FB2851272209AB00A57617 /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = 37FB2850272209AB00A57617 /* SDWebImageWebPCoder */; }; + 37FB285427220D8400A57617 /* SDWebImagePINPlugin in Frameworks */ = {isa = PBXBuildFile; productRef = 37FB285327220D8400A57617 /* SDWebImagePINPlugin */; }; + 37FB285627220D9000A57617 /* SDWebImagePINPlugin in Frameworks */ = {isa = PBXBuildFile; productRef = 37FB285527220D9000A57617 /* SDWebImagePINPlugin */; }; + 37FB285827220D9600A57617 /* SDWebImagePINPlugin in Frameworks */ = {isa = PBXBuildFile; productRef = 37FB285727220D9600A57617 /* SDWebImagePINPlugin */; }; + 37FB285E272225E800A57617 /* ContentItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB285D272225E800A57617 /* ContentItemView.swift */; }; + 37FB285F272225E800A57617 /* ContentItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB285D272225E800A57617 /* ContentItemView.swift */; }; + 37FB2860272225E800A57617 /* ContentItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB285D272225E800A57617 /* ContentItemView.swift */; }; 37FD43DC270470B70073EE42 /* InstancesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD43DB270470B70073EE42 /* InstancesSettingsView.swift */; }; 37FD43E32704847C0073EE42 /* View+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD43E22704847C0073EE42 /* View+Fixtures.swift */; }; 37FD43E42704847C0073EE42 /* View+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD43E22704847C0073EE42 /* View+Fixtures.swift */; }; @@ -388,6 +406,7 @@ 373CFADA269663F1003CB2C6 /* Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnail.swift; sourceTree = ""; }; 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFormView.swift; sourceTree = ""; }; 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToPlaylistView.swift; sourceTree = ""; }; + 3743B86727216D3600261544 /* ChannelCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelCell.swift; sourceTree = ""; }; 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueRow.swift; sourceTree = ""; }; 3743CA51270F284F00E4D32B /* View+Borders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Borders.swift"; sourceTree = ""; }; 3748186526A7627F0084E870 /* Video+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Video+Fixtures.swift"; sourceTree = ""; }; @@ -423,7 +442,7 @@ 3797758A2689345500DD52A8 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; 379775922689365600DD52A8 /* Array+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Next.swift"; sourceTree = ""; }; 37992DC726CC50BC003D4C27 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 37A9965926D6F8CA006E3224 /* VideosCellsHorizontal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosCellsHorizontal.swift; sourceTree = ""; }; + 37A9965926D6F8CA006E3224 /* HorizontalCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalCells.swift; sourceTree = ""; }; 37A9965D26D6F9B9006E3224 /* WatchNowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNowView.swift; sourceTree = ""; }; 37AAF27D26737323007FC770 /* PopularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularView.swift; sourceTree = ""; }; 37AAF27F26737550007FC770 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; @@ -473,7 +492,7 @@ 37D4B15E267164AF00C925CA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 37D4B171267164B000C925CA /* Tests (tvOS).xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests (tvOS).xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 37D4B175267164B000C925CA /* PearvidiousUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PearvidiousUITests.swift; sourceTree = ""; }; - 37D4B18B26717B3800C925CA /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; + 37D4B18B26717B3800C925CA /* VideoCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCell.swift; sourceTree = ""; }; 37D4B19626717E1500C925CA /* Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video.swift; sourceTree = ""; }; 37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 37D526DD2720AC4400ED2F5E /* VideosAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosAPI.swift; sourceTree = ""; }; @@ -486,8 +505,10 @@ 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockAPI.swift; sourceTree = ""; }; 37EAD86E267B9ED100D9E01B /* Segment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Segment.swift; sourceTree = ""; }; 37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playlist+Fixtures.swift"; sourceTree = ""; }; - 37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosCellsVertical.swift; sourceTree = ""; }; + 37F4AE7126828F0900BD60EA /* VerticalCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalCells.swift; sourceTree = ""; }; 37F64FE326FE70A60081B69E /* RedrawOnViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedrawOnViewModifier.swift; sourceTree = ""; }; + 37FB28402721B22200A57617 /* ContentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentItem.swift; sourceTree = ""; }; + 37FB285D272225E800A57617 /* ContentItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentItemView.swift; sourceTree = ""; }; 37FD43DB270470B70073EE42 /* InstancesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesSettingsView.swift; sourceTree = ""; }; 37FD43E22704847C0073EE42 /* View+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Fixtures.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -504,11 +525,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 37FB284B2722099E00A57617 /* SDWebImageSwiftUI in Frameworks */, 37BD07B72698AB2E003EBB87 /* Defaults in Frameworks */, + 37FB285627220D9000A57617 /* SDWebImagePINPlugin in Frameworks */, 37BADCA52699FB72009BE4FB /* Alamofire in Frameworks */, 377FC7D5267A080300A6BBAF /* SwiftyJSON in Frameworks */, 37BD07B92698AB2E003EBB87 /* Siesta in Frameworks */, 37BD07C72698B27B003EBB87 /* Introspect in Frameworks */, + 37FB284D2722099E00A57617 /* SDWebImageWebPCoder in Frameworks */, 377FC7DB267A080300A6BBAF /* Logging in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -517,6 +541,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 37FB2851272209AB00A57617 /* SDWebImageWebPCoder in Frameworks */, + 37FB285827220D9600A57617 /* SDWebImagePINPlugin in Frameworks */, + 37FB284F272209AB00A57617 /* SDWebImageSwiftUI in Frameworks */, 37BD07BE2698AC96003EBB87 /* Defaults in Frameworks */, 37BADCA7269A552E009BE4FB /* Alamofire in Frameworks */, 377FC7ED267A0A0800A6BBAF /* SwiftyJSON in Frameworks */, @@ -543,6 +570,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 37FB2849272207F000A57617 /* SDWebImageWebPCoder in Frameworks */, + 37FB285427220D8400A57617 /* SDWebImagePINPlugin in Frameworks */, + 37FB28462722054C00A57617 /* SDWebImageSwiftUI in Frameworks */, 372915E42687E33E00F5A35B /* Defaults in Frameworks */, 37BADCA9269A570B009BE4FB /* Alamofire in Frameworks */, 37D4B19D2671817900C925CA /* SwiftyJSON in Frameworks */, @@ -614,11 +644,10 @@ 371AAE2726CEBF4700901972 /* Videos */ = { isa = PBXGroup; children = ( - 3748186D26A769D60084E870 /* DetailBadge.swift */, + 37A9965926D6F8CA006E3224 /* HorizontalCells.swift */, + 37F4AE7126828F0900BD60EA /* VerticalCells.swift */, 37CC3F4F270D010D00608308 /* VideoBanner.swift */, - 37A9965926D6F8CA006E3224 /* VideosCellsHorizontal.swift */, - 37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */, - 37D4B18B26717B3800C925CA /* VideoView.swift */, + 37D4B18B26717B3800C925CA /* VideoCell.swift */, ); path = Videos; sourceTree = ""; @@ -626,7 +655,10 @@ 371AAE2826CEC7D900901972 /* Views */ = { isa = PBXGroup; children = ( + 3743B86727216D3600261544 /* ChannelCell.swift */, 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */, + 37FB285D272225E800A57617 /* ContentItemView.swift */, + 3748186D26A769D60084E870 /* DetailBadge.swift */, 37152EE926EFEB95004FB96D /* LazyView.swift */, 37E70926271CDDAE00D34DDE /* OpenSettingsButton.swift */, 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */, @@ -873,14 +905,14 @@ 3743B86627216A1E00261544 /* Accounts */, 3743B864272169E200261544 /* Applications */, 3743B86527216A0600261544 /* Player */, + 37FB283F2721B20800A57617 /* Search */, 37AAF28F26740715007FC770 /* Channel.swift */, + 37FB28402721B22200A57617 /* ContentItem.swift */, 37141672267A8E10006CA35D /* Country.swift */, 371F2F19269B43D300E4A7AB /* NavigationModel.swift */, 376578882685471400D4EA09 /* Playlist.swift */, 37BA794226DBA973002A0235 /* PlaylistsModel.swift */, 37C194C626F6A9C8005D3B96 /* RecentsModel.swift */, - 3711403E26B206A6005B3555 /* SearchModel.swift */, - 373CFACA26966264003CB2C6 /* SearchQuery.swift */, 37EAD86E267B9ED100D9E01B /* Segment.swift */, 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */, 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */, @@ -902,6 +934,15 @@ path = Gestures; sourceTree = ""; }; + 37FB283F2721B20800A57617 /* Search */ = { + isa = PBXGroup; + children = ( + 3711403E26B206A6005B3555 /* SearchModel.swift */, + 373CFACA26966264003CB2C6 /* SearchQuery.swift */, + ); + path = Search; + sourceTree = ""; + }; 37FD43E1270472060073EE42 /* Settings */ = { isa = PBXGroup; children = ( @@ -952,6 +993,9 @@ 37BD07B82698AB2E003EBB87 /* Siesta */, 37BD07C62698B27B003EBB87 /* Introspect */, 37BADCA42699FB72009BE4FB /* Alamofire */, + 37FB284A2722099E00A57617 /* SDWebImageSwiftUI */, + 37FB284C2722099E00A57617 /* SDWebImageWebPCoder */, + 37FB285527220D9000A57617 /* SDWebImagePINPlugin */, ); productName = "Pearvidious (iOS)"; productReference = 37D4B0C92671614900C925CA /* Pearvidious.app */; @@ -977,6 +1021,9 @@ 37BD07BD2698AC96003EBB87 /* Defaults */, 37BD07BF2698AC97003EBB87 /* Siesta */, 37BADCA6269A552E009BE4FB /* Alamofire */, + 37FB284E272209AB00A57617 /* SDWebImageSwiftUI */, + 37FB2850272209AB00A57617 /* SDWebImageWebPCoder */, + 37FB285727220D9600A57617 /* SDWebImagePINPlugin */, ); productName = "Pearvidious (macOS)"; productReference = 37D4B0CF2671614900C925CA /* Pearvidious.app */; @@ -1038,6 +1085,9 @@ 372915E32687E33E00F5A35B /* Defaults */, 3797757C268922D100DD52A8 /* Siesta */, 37BADCA8269A570B009BE4FB /* Alamofire */, + 37FB28452722054C00A57617 /* SDWebImageSwiftUI */, + 37FB2848272207F000A57617 /* SDWebImageWebPCoder */, + 37FB285327220D8400A57617 /* SDWebImagePINPlugin */, ); productName = Pearvidious; productReference = 37D4B158267164AE00C925CA /* Pearvidious (tvOS).app */; @@ -1120,6 +1170,9 @@ 3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */, 37BD07C52698B27B003EBB87 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 37BADCA32699FB72009BE4FB /* XCRemoteSwiftPackageReference "Alamofire" */, + 37FB28442722054B00A57617 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, + 37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, + 37FB285227220D8400A57617 /* XCRemoteSwiftPackageReference "SDWebImagePINPlugin" */, ); productRefGroup = 37D4B0CA2671614900C925CA /* Products */; projectDirPath = ""; @@ -1323,7 +1376,7 @@ 37BE0BD326A1D4780092E2DB /* Player.swift in Sources */, 37A9965E26D6F9B9006E3224 /* WatchNowView.swift in Sources */, 37CEE4C12677B697005A1EFE /* Stream.swift in Sources */, - 37F4AE7226828F0900BD60EA /* VideosCellsVertical.swift in Sources */, + 37F4AE7226828F0900BD60EA /* VerticalCells.swift in Sources */, 376578852685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, 3748186626A7627F0084E870 /* Video+Fixtures.swift in Sources */, 37BA794726DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */, @@ -1341,7 +1394,7 @@ 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 3700155B271B0D4D0049C794 /* PipedAPI.swift in Sources */, 37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */, - 377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */, + 377FC7E3267A084A00A6BBAF /* VideoCell.swift in Sources */, 37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */, 376A33E42720CB35000C1D6B /* Account.swift in Sources */, 37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */, @@ -1351,6 +1404,7 @@ 37B81AFF26D2CA3700675966 /* VideoDetails.swift in Sources */, 377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */, 376578912685490700D4EA09 /* PlaylistsView.swift in Sources */, + 37FB28412721B22200A57617 /* ContentItem.swift in Sources */, 37484C2D26FC844700287258 /* AccountsSettingsView.swift in Sources */, 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */, @@ -1364,7 +1418,7 @@ 37C7A1D5267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37BA794F26DC3E0E002A0235 /* Int+Format.swift in Sources */, 37F49BA326CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */, - 37A9965A26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */, + 37A9965A26D6F8CA006E3224 /* HorizontalCells.swift in Sources */, 37D526DE2720AC4400ED2F5E /* VideosAPI.swift in Sources */, 37484C2526FC83E000287258 /* InstanceFormView.swift in Sources */, 37B767DB2677C3CA0098BAA8 /* PlayerModel.swift in Sources */, @@ -1384,6 +1438,7 @@ 37E70927271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */, 371F2F1A269B43D300E4A7AB /* NavigationModel.swift in Sources */, 37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, + 3758638A2721B0A9000CB14E /* ChannelCell.swift in Sources */, 37001563271B1F250049C794 /* AccountsModel.swift in Sources */, 37CC3F50270D010D00608308 /* VideoBanner.swift in Sources */, 378E50FB26FE8B9F00F49626 /* Instance.swift in Sources */, @@ -1391,6 +1446,7 @@ 37484C1D26FC83A400287258 /* InstancesSettingsView.swift in Sources */, 37BD07BB2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */, 37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */, + 37FB285E272225E800A57617 /* ContentItemView.swift in Sources */, 3797758B2689345500DD52A8 /* Store.swift in Sources */, 37732FF02703A26300F04329 /* ValidationStatusView.swift in Sources */, ); @@ -1417,6 +1473,7 @@ 377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */, 375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */, 3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */, + 37FB285F272225E800A57617 /* ContentItemView.swift in Sources */, 37FD43DC270470B70073EE42 /* InstancesSettingsView.swift in Sources */, 376B2E0826F920D600B1D64D /* SignInRequiredView.swift in Sources */, 37CC3F4D270CFE1700608308 /* PlayerQueueView.swift in Sources */, @@ -1432,7 +1489,7 @@ 378E510026FE8EEE00F49626 /* AccountsMenuView.swift in Sources */, 37141670267A8ACC006CA35D /* TrendingView.swift in Sources */, 37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */, - 377FC7E2267A084A00A6BBAF /* VideoView.swift in Sources */, + 377FC7E2267A084A00A6BBAF /* VideoCell.swift in Sources */, 37CC3F51270D010D00608308 /* VideoBanner.swift in Sources */, 378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */, 37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */, @@ -1456,10 +1513,10 @@ 376A33E52720CB35000C1D6B /* Account.swift in Sources */, 376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, 37A9965F26D6F9B9006E3224 /* WatchNowView.swift in Sources */, - 37F4AE7326828F0900BD60EA /* VideosCellsVertical.swift in Sources */, + 37F4AE7326828F0900BD60EA /* VerticalCells.swift in Sources */, 37001560271B12DD0049C794 /* SiestaConfiguration.swift in Sources */, 37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */, - 37A9965B26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */, + 37A9965B26D6F8CA006E3224 /* HorizontalCells.swift in Sources */, 3761AC1026F0F9A600AA496F /* UnsubscribeAlertModifier.swift in Sources */, 37732FF52703D32400F04329 /* Sidebar.swift in Sources */, 379775942689365600DD52A8 /* Array+Next.swift in Sources */, @@ -1488,8 +1545,10 @@ 37BE0BD026A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, 373CFAEC26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */, 37977584268922F600DD52A8 /* InvidiousAPI.swift in Sources */, + 37FB28422721B22200A57617 /* ContentItem.swift in Sources */, 376CD21726FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */, 37B17DA1268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */, + 3743B86927216D3600261544 /* ChannelCell.swift in Sources */, 3748186B26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 373CFADC269663F1003CB2C6 /* Thumbnail.swift in Sources */, 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, @@ -1529,11 +1588,11 @@ 37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */, 3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */, 375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */, - 37F4AE7426828F0900BD60EA /* VideosCellsVertical.swift in Sources */, + 37F4AE7426828F0900BD60EA /* VerticalCells.swift in Sources */, 376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, 37D4B1802671650A00C925CA /* PearvidiousApp.swift in Sources */, 3748187026A769D60084E870 /* DetailBadge.swift in Sources */, - 37A9965C26D6F8CA006E3224 /* VideosCellsHorizontal.swift in Sources */, + 37A9965C26D6F8CA006E3224 /* HorizontalCells.swift in Sources */, 37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */, 376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */, 37141671267A8ACC006CA35D /* TrendingView.swift in Sources */, @@ -1552,11 +1611,12 @@ 373CFADD269663F1003CB2C6 /* Thumbnail.swift in Sources */, 37E64DD326D597EB00C71877 /* SubscriptionsModel.swift in Sources */, 37B044B926F7AB9000E1419D /* SettingsView.swift in Sources */, + 3743B86A27216D3600261544 /* ChannelCell.swift in Sources */, 37B767DD2677C3CA0098BAA8 /* PlayerModel.swift in Sources */, 373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 3761AC1126F0F9A600AA496F /* UnsubscribeAlertModifier.swift in Sources */, 3730D8A02712E2B70020ED53 /* NowPlayingView.swift in Sources */, - 37D4B18E26717B3800C925CA /* VideoView.swift in Sources */, + 37D4B18E26717B3800C925CA /* VideoCell.swift in Sources */, 37BE0BD126A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, 37AAF27E26737323007FC770 /* PopularView.swift in Sources */, 3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */, @@ -1583,6 +1643,7 @@ 37CEE4C32677B697005A1EFE /* Stream.swift in Sources */, 37F64FE626FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */, 37484C2B26FC83FF00287258 /* AccountFormView.swift in Sources */, + 37FB2860272225E800A57617 /* ContentItemView.swift in Sources */, 37BA793D26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */, 3711404126B206A6005B3555 /* SearchModel.swift in Sources */, 37FD43F02704A9C00073EE42 /* RecentsModel.swift in Sources */, @@ -1599,6 +1660,7 @@ 37D526E02720AC4400ED2F5E /* VideosAPI.swift in Sources */, 3705B184267B4E4900704544 /* TrendingCategory.swift in Sources */, 3761ABFF26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */, + 37FB28432721B22200A57617 /* ContentItem.swift in Sources */, 37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */, 37484C1B26FC837400287258 /* PlaybackSettingsView.swift in Sources */, 372915E82687E3B900F5A35B /* Defaults.swift in Sources */, @@ -2332,6 +2394,30 @@ minimumVersion = 5.0.0; }; }; + 37FB28442722054B00A57617 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; + 37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.8.4; + }; + }; + 37FB285227220D8400A57617 /* XCRemoteSwiftPackageReference "SDWebImagePINPlugin" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImagePINPlugin.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.3.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2415,6 +2501,51 @@ package = 37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */; productName = SwiftyJSON; }; + 37FB28452722054C00A57617 /* SDWebImageSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 37FB28442722054B00A57617 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; + }; + 37FB2848272207F000A57617 /* SDWebImageWebPCoder */ = { + isa = XCSwiftPackageProductDependency; + package = 37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; + productName = SDWebImageWebPCoder; + }; + 37FB284A2722099E00A57617 /* SDWebImageSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 37FB28442722054B00A57617 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; + }; + 37FB284C2722099E00A57617 /* SDWebImageWebPCoder */ = { + isa = XCSwiftPackageProductDependency; + package = 37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; + productName = SDWebImageWebPCoder; + }; + 37FB284E272209AB00A57617 /* SDWebImageSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 37FB28442722054B00A57617 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; + }; + 37FB2850272209AB00A57617 /* SDWebImageWebPCoder */ = { + isa = XCSwiftPackageProductDependency; + package = 37FB2847272207F000A57617 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; + productName = SDWebImageWebPCoder; + }; + 37FB285327220D8400A57617 /* SDWebImagePINPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = 37FB285227220D8400A57617 /* XCRemoteSwiftPackageReference "SDWebImagePINPlugin" */; + productName = SDWebImagePINPlugin; + }; + 37FB285527220D9000A57617 /* SDWebImagePINPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = 37FB285227220D8400A57617 /* XCRemoteSwiftPackageReference "SDWebImagePINPlugin" */; + productName = SDWebImagePINPlugin; + }; + 37FB285727220D9600A57617 /* SDWebImagePINPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = 37FB285227220D8400A57617 /* XCRemoteSwiftPackageReference "SDWebImagePINPlugin" */; + productName = SDWebImagePINPlugin; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 37D4B0BD2671614700C925CA /* Project object */; diff --git a/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e4e6775c..e3163b3f 100644 --- a/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pearvidious.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -19,6 +19,69 @@ "version": "5.0.0" } }, + { + "package": "libwebp", + "repositoryURL": "https://github.com/SDWebImage/libwebp-Xcode.git", + "state": { + "branch": null, + "revision": "2b3b43faaef54d1b897482428428357b7f7cd08b", + "version": "1.2.1" + } + }, + { + "package": "PINCache", + "repositoryURL": "https://github.com/pinterest/PINCache.git", + "state": { + "branch": null, + "revision": "875c654984fb52b47ca65ae70d24852b0003ccd9", + "version": "3.0.3" + } + }, + { + "package": "PINOperation", + "repositoryURL": "https://github.com/pinterest/PINOperation.git", + "state": { + "branch": null, + "revision": "44d8ca154a4e75a028a5548c31ff3a53b90cef15", + "version": "1.2.1" + } + }, + { + "package": "SDWebImage", + "repositoryURL": "https://github.com/SDWebImage/SDWebImage.git", + "state": { + "branch": null, + "revision": "a72df4849408da7e5d3c1b586797b7c601c41d1b", + "version": "5.12.1" + } + }, + { + "package": "SDWebImagePINPlugin", + "repositoryURL": "https://github.com/SDWebImage/SDWebImagePINPlugin.git", + "state": { + "branch": null, + "revision": "bd73a4fb30352ec311303d811559c9c46df4caa4", + "version": "0.3.0" + } + }, + { + "package": "SDWebImageSwiftUI", + "repositoryURL": "https://github.com/SDWebImage/SDWebImageSwiftUI.git", + "state": { + "branch": null, + "revision": "cd8625b7cf11a97698e180d28bb7d5d357196678", + "version": "2.0.2" + } + }, + { + "package": "SDWebImageWebPCoder", + "repositoryURL": "https://github.com/SDWebImage/SDWebImageWebPCoder.git", + "state": { + "branch": null, + "revision": "95a6838df13bc08d8064cf7e048b787b6e52348d", + "version": "0.8.4" + } + }, { "package": "Siesta", "repositoryURL": "https://github.com/bustoutsolutions/siesta", diff --git a/Shared/Assets.xcassets/PlaceholderColor.colorset/Contents.json b/Shared/Assets.xcassets/PlaceholderColor.colorset/Contents.json new file mode 100644 index 00000000..2baafaab --- /dev/null +++ b/Shared/Assets.xcassets/PlaceholderColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.781", + "green" : "0.781", + "red" : "0.781" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.311", + "green" : "0.311", + "red" : "0.311" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Navigation/AppSidebarRecents.swift b/Shared/Navigation/AppSidebarRecents.swift index a9f4943b..c69abca2 100644 --- a/Shared/Navigation/AppSidebarRecents.swift +++ b/Shared/Navigation/AppSidebarRecents.swift @@ -73,6 +73,7 @@ struct RecentNavigationLink: View { Image(systemName: "xmark.circle.fill") } .foregroundColor(.secondary) + .opacity(0.5) .buttonStyle(.plain) } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 43a5db4e..ec966aa7 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,4 +1,7 @@ import Defaults +import SDWebImage +import SDWebImagePINPlugin +import SDWebImageWebPCoder import Siesta import SwiftUI @@ -83,6 +86,8 @@ struct ContentView: View { func configure() { SiestaLog.Category.enabled = .common + SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) + SDWebImageManager.defaultImageCache = PINCache(name: "net.yattee.app") // TODO: Remove when piped supports videos information if let account = accounts.lastUsed ?? diff --git a/Shared/Playlists/PlaylistsView.swift b/Shared/Playlists/PlaylistsView.swift index 79102187..7d4c70c7 100644 --- a/Shared/Playlists/PlaylistsView.swift +++ b/Shared/Playlists/PlaylistsView.swift @@ -14,8 +14,8 @@ struct PlaylistsView: View { @Namespace private var focusNamespace - var videos: [Video] { - model.currentPlaylist?.videos ?? [] + var items: [ContentItem] { + ContentItem.array(of: model.currentPlaylist?.videos ?? []) } var body: some View { @@ -26,17 +26,17 @@ struct PlaylistsView: View { toolbar #endif - if model.currentPlaylist != nil, videos.isEmpty { + if model.currentPlaylist != nil, items.isEmpty { hintText("Playlist is empty\n\nTap and hold on a video and then tap \"Add to Playlist\"") } else if model.all.isEmpty { hintText("You have no playlists\n\nTap on \"New Playlist\" to create one") } else { #if os(tvOS) - VideosCellsHorizontal(videos: videos) + HorizontalCells(items: items) .padding(.top, 40) Spacer() #else - VideosCellsVertical(videos: videos) + VerticalCells(items: items) #endif } } @@ -117,7 +117,7 @@ struct PlaylistsView: View { } Button { - player.playAll(videos) + player.playAll(items.compactMap(\.video)) player.presentPlayer() } label: { HStack(spacing: 15) { diff --git a/Shared/Trending/TrendingView.swift b/Shared/Trending/TrendingView.swift index 662569d2..fd51e402 100644 --- a/Shared/Trending/TrendingView.swift +++ b/Shared/Trending/TrendingView.swift @@ -13,6 +13,10 @@ struct TrendingView: View { @EnvironmentObject private var accounts + var popular: [ContentItem] { + ContentItem.array(of: store.collection) + } + init(_ videos: [Video] = [Video]()) { self.videos = videos } @@ -32,12 +36,12 @@ struct TrendingView: View { VStack(alignment: .center, spacing: 0) { #if os(tvOS) toolbar - VideosCellsHorizontal(videos: store.collection) + HorizontalCells(items: popular) .padding(.top, 40) Spacer() #else - VideosCellsVertical(videos: store.collection) + VerticalCells(items: popular) #endif } } diff --git a/Shared/Videos/VideosCellsHorizontal.swift b/Shared/Videos/HorizontalCells.swift similarity index 80% rename from Shared/Videos/VideosCellsHorizontal.swift rename to Shared/Videos/HorizontalCells.swift index 34d31f22..b9aa78f2 100644 --- a/Shared/Videos/VideosCellsHorizontal.swift +++ b/Shared/Videos/HorizontalCells.swift @@ -1,18 +1,18 @@ import Defaults import SwiftUI -struct VideosCellsHorizontal: View { +struct HorizontalCells: View { #if os(iOS) @Environment(\.verticalSizeClass) private var verticalSizeClass #endif - var videos = [Video]() + var items = [ContentItem]() var body: some View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 20) { - ForEach(videos) { video in - VideoView(video: video) + ForEach(items) { item in + ContentItemView(item: item) .environment(\.horizontalCells, true) #if os(tvOS) .frame(width: 580) @@ -42,9 +42,9 @@ struct VideosCellsHorizontal: View { } } -struct VideoCellsHorizontal_Previews: PreviewProvider { +struct HorizontalCells_Previews: PreviewProvider { static var previews: some View { - VideosCellsHorizontal(videos: Video.allFixtures) + HorizontalCells(items: ContentItem.array(of: Video.allFixtures)) .injectFixtureEnvironmentObjects() } } diff --git a/Shared/Videos/VideosCellsVertical.swift b/Shared/Videos/VerticalCells.swift similarity index 66% rename from Shared/Videos/VideosCellsVertical.swift rename to Shared/Videos/VerticalCells.swift index 86682a8f..94b9e159 100644 --- a/Shared/Videos/VideosCellsVertical.swift +++ b/Shared/Videos/VerticalCells.swift @@ -1,29 +1,23 @@ import Defaults import SwiftUI -struct VideosCellsVertical: View { +struct VerticalCells: View { #if os(iOS) @Environment(\.verticalSizeClass) private var verticalSizeClass #endif - var videos = [Video]() + var items = [ContentItem]() var body: some View { ScrollView(.vertical, showsIndicators: scrollViewShowsIndicators) { - LazyVGrid(columns: items, alignment: .center) { - ForEach(videos) { video in - VideoView(video: video) - #if os(tvOS) - .padding(.horizontal) - #endif + LazyVGrid(columns: columns, alignment: .center) { + ForEach(items.sorted { $0 < $1 }) { item in + ContentItemView(item: item) } } .padding() } .id(UUID()) - #if os(tvOS) - .padding(.horizontal, 10) - #endif .edgesIgnoringSafeArea(.horizontal) #if os(macOS) .background() @@ -31,9 +25,9 @@ struct VideosCellsVertical: View { #endif } - var items: [GridItem] { + var columns: [GridItem] { #if os(tvOS) - videos.count < 3 ? Array(repeating: GridItem(.fixed(540)), count: [videos.count, 1].max()!) : adaptiveItem + items.count < 3 ? Array(repeating: GridItem(.fixed(540)), count: [items.count, 1].max()!) : adaptiveItem #else adaptiveItem #endif @@ -64,7 +58,7 @@ struct VideosCellsVertical: View { struct VideoCellsVertical_Previews: PreviewProvider { static var previews: some View { - VideosCellsVertical(videos: Video.allFixtures) + VerticalCells(items: ContentItem.array(of: Video.allFixtures)) .injectFixtureEnvironmentObjects() } } diff --git a/Shared/Videos/VideoBanner.swift b/Shared/Videos/VideoBanner.swift index b7e6a520..0deb777c 100644 --- a/Shared/Videos/VideoBanner.swift +++ b/Shared/Videos/VideoBanner.swift @@ -1,4 +1,5 @@ import Foundation +import SDWebImageSwiftUI import SwiftUI struct VideoBanner: View { @@ -35,22 +36,12 @@ struct VideoBanner: View { } var smallThumbnail: some View { - Group { - if let url = video.thumbnailURL(quality: .medium) { - AsyncImage(url: url) { image in - image - .resizable() - } placeholder: { - HStack { - ProgressView() - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - } - } - } else { - Image(systemName: "exclamationmark.square") + WebImage(url: video.thumbnailURL(quality: .medium)) + .resizable() + .placeholder { + ProgressView() } - } - .background(.gray) + .indicator(.activity) #if os(tvOS) .frame(width: 177, height: 100) .mask(RoundedRectangle(cornerRadius: 12)) diff --git a/Shared/Videos/VideoView.swift b/Shared/Videos/VideoCell.swift similarity index 90% rename from Shared/Videos/VideoView.swift rename to Shared/Videos/VideoCell.swift index 819f8a60..f4d25ed7 100644 --- a/Shared/Videos/VideoView.swift +++ b/Shared/Videos/VideoCell.swift @@ -1,10 +1,12 @@ import Defaults +import SDWebImageSwiftUI import SwiftUI -struct VideoView: View { +struct VideoCell: View { var video: Video @State private var playerNavigationLinkActive = false + @State private var lowQualityThumbnail = false @Environment(\.inNavigationView) private var inNavigationView @@ -181,7 +183,7 @@ struct VideoView: View { var thumbnail: some View { ZStack(alignment: .leading) { - thumbnailImage(quality: .maxresdefault) + thumbnailImage(quality: lowQualityThumbnail ? .medium : .maxresdefault) VStack { HStack(alignment: .top) { @@ -212,27 +214,20 @@ struct VideoView: View { } func thumbnailImage(quality: Thumbnail.Quality) -> some View { - Group { - if let url = video.thumbnailURL(quality: quality) { - AsyncImage(url: url) { image in - image - .resizable() - } placeholder: { - HStack { - ProgressView() - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - } - } - } else { - Image(systemName: "exclamationmark.square") + WebImage(url: video.thumbnailURL(quality: quality)) + .resizable() + .placeholder { + Rectangle().fill(Color("PlaceholderColor")) } - } - .background(.gray) - .mask(RoundedRectangle(cornerRadius: 12)) + .onFailure { _ in + lowQualityThumbnail = true + } + .indicator(.progress) + .mask(RoundedRectangle(cornerRadius: 12)) + .modifier(AspectRatioModifier()) #if os(tvOS) .frame(minHeight: 320) #endif - .modifier(AspectRatioModifier()) } func videoDetail(_ text: String, lineLimit: Int = 1) -> some View { @@ -257,3 +252,13 @@ struct VideoView: View { } } } + +struct VideoView_Preview: PreviewProvider { + static var previews: some View { + Group { + VideoCell(video: Video.fixture) + } + .frame(maxWidth: 300, maxHeight: 200) + .injectFixtureEnvironmentObjects() + } +} diff --git a/Shared/Views/ChannelCell.swift b/Shared/Views/ChannelCell.swift new file mode 100644 index 00000000..a9597ff4 --- /dev/null +++ b/Shared/Views/ChannelCell.swift @@ -0,0 +1,73 @@ +import Foundation +import SDWebImageSwiftUI +import SwiftUI + +struct ChannelCell: View { + let channel: Channel + + @Environment(\.navigationStyle) private var navigationStyle + + @EnvironmentObject private var navigation + @EnvironmentObject private var recents + + var body: some View { + Button { + let recent = RecentItem(from: channel) + recents.add(recent) + navigation.isChannelOpen = true + + if navigationStyle == .sidebar { + navigation.sidebarSectionChanged.toggle() + navigation.tabSelection = .recentlyOpened(recent.tag) + } + } label: { + content + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .contentShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + } + + var content: some View { + VStack { + Text("Channel".uppercased()) + .foregroundColor(.secondary) + .fontWeight(.light) + .opacity(0.6) + + WebImage(url: channel.thumbnailURL) + .resizable() + .placeholder { + Rectangle().fill(Color("PlaceholderColor")) + } + .indicator(.progress) + .frame(width: 88, height: 88) + .clipShape(Circle()) + + Group { + DetailBadge(text: channel.name, style: .prominent) + + Group { + if let subscriptions = channel.subscriptionsString { + Text("\(subscriptions) subscribers") + .foregroundColor(.secondary) + } else { + Text("") + } + } + .frame(height: 20) + } + .offset(x: 0, y: -15) + } + } +} + +struct ChannelSearchItem_Preview: PreviewProvider { + static var previews: some View { + Group { + ChannelCell(channel: Video.fixture.channel) + } + .frame(maxWidth: 300, maxHeight: 200) + .injectFixtureEnvironmentObjects() + } +} diff --git a/Shared/Views/ChannelVideosView.swift b/Shared/Views/ChannelVideosView.swift index 7004d87c..43b4db96 100644 --- a/Shared/Views/ChannelVideosView.swift +++ b/Shared/Views/ChannelVideosView.swift @@ -19,6 +19,10 @@ struct ChannelVideosView: View { @Namespace private var focusNamespace + var videos: [ContentItem] { + ContentItem.array(of: store.item?.videos ?? []) + } + var body: some View { #if os(iOS) if inNavigationView { @@ -55,7 +59,7 @@ struct ChannelVideosView: View { .frame(maxWidth: .infinity) #endif - VideosCellsVertical(videos: store.item?.videos ?? []) + VerticalCells(items: videos) #if !os(iOS) .prefersDefaultFocus(in: focusNamespace) diff --git a/Shared/Views/ContentItemView.swift b/Shared/Views/ContentItemView.swift new file mode 100644 index 00000000..a118f37e --- /dev/null +++ b/Shared/Views/ContentItemView.swift @@ -0,0 +1,19 @@ +import Foundation +import SwiftUI + +struct ContentItemView: View { + let item: ContentItem + + var body: some View { + Group { + switch item.contentType { + case .playlist: + VideoCell(video: item.video) + case .channel: + ChannelCell(channel: item.channel) + default: + VideoCell(video: item.video) + } + } + } +} diff --git a/Shared/Videos/DetailBadge.swift b/Shared/Views/DetailBadge.swift similarity index 100% rename from Shared/Videos/DetailBadge.swift rename to Shared/Views/DetailBadge.swift diff --git a/Shared/Views/PlaylistVideosView.swift b/Shared/Views/PlaylistVideosView.swift index ec1be3f6..a4de858e 100644 --- a/Shared/Views/PlaylistVideosView.swift +++ b/Shared/Views/PlaylistVideosView.swift @@ -4,13 +4,17 @@ import SwiftUI struct PlaylistVideosView: View { let playlist: Playlist + var videos: [ContentItem] { + ContentItem.array(of: playlist.videos) + } + init(_ playlist: Playlist) { self.playlist = playlist } var body: some View { PlayerControlsView { - VideosCellsVertical(videos: playlist.videos) + VerticalCells(items: videos) #if !os(tvOS) .navigationTitle("\(playlist.title) Playlist") #endif diff --git a/Shared/Views/PopularView.swift b/Shared/Views/PopularView.swift index dfae5110..946840b0 100644 --- a/Shared/Views/PopularView.swift +++ b/Shared/Views/PopularView.swift @@ -10,9 +10,13 @@ struct PopularView: View { accounts.api.popular } + var videos: [ContentItem] { + ContentItem.array(of: store.collection) + } + var body: some View { PlayerControlsView { - VideosCellsVertical(videos: store.collection) + VerticalCells(items: videos) .onAppear { resource?.addObserver(store) resource?.loadIfNeeded() diff --git a/Shared/Views/SearchView.swift b/Shared/Views/SearchView.swift index 7d05ecc0..01ff8fe8 100644 --- a/Shared/Views/SearchView.swift +++ b/Shared/Views/SearchView.swift @@ -42,11 +42,11 @@ struct SearchView: View { filtersHorizontalStack } - VideosCellsHorizontal(videos: state.store.collection) + HorizontalCells(items: state.store.collection) } .edgesIgnoringSafeArea(.horizontal) #else - VideosCellsVertical(videos: state.store.collection) + VerticalCells(items: state.store.collection) #endif if noResults { @@ -95,7 +95,7 @@ struct SearchView: View { } if !videos.isEmpty { - state.store.replace(videos) + state.store.replace(ContentItem.array(of: videos)) } } .searchable(text: $state.queryText, placement: searchFieldPlacement) { diff --git a/Shared/Views/SubscriptionsView.swift b/Shared/Views/SubscriptionsView.swift index 52a0c219..e023fdac 100644 --- a/Shared/Views/SubscriptionsView.swift +++ b/Shared/Views/SubscriptionsView.swift @@ -10,10 +10,14 @@ struct SubscriptionsView: View { accounts.api.feed } + var videos: [ContentItem] { + ContentItem.array(of: store.collection) + } + var body: some View { PlayerControlsView { SignInRequiredView(title: "Subscriptions") { - VideosCellsVertical(videos: store.collection) + VerticalCells(items: videos) .onAppear { loadResources() } diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index 55a9dc4d..61fbe40d 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -7,6 +7,7 @@ struct VideoContextMenuView: View { @Binding var playerNavigationLinkActive: Bool @Environment(\.inNavigationView) private var inNavigationView + @Environment(\.navigationStyle) private var navigationStyle @EnvironmentObject private var accounts @EnvironmentObject private var navigation @@ -85,8 +86,11 @@ struct VideoContextMenuView: View { let recent = RecentItem(from: video.channel) recents.add(recent) navigation.isChannelOpen = true - navigation.sidebarSectionChanged.toggle() - navigation.tabSelection = .recentlyOpened(recent.tag) + + if navigationStyle == .sidebar { + navigation.sidebarSectionChanged.toggle() + navigation.tabSelection = .recentlyOpened(recent.tag) + } } label: { Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop") } diff --git a/Shared/Watch Now/WatchNowSectionBody.swift b/Shared/Watch Now/WatchNowSectionBody.swift index 212ce4da..c3515204 100644 --- a/Shared/Watch Now/WatchNowSectionBody.swift +++ b/Shared/Watch Now/WatchNowSectionBody.swift @@ -15,7 +15,7 @@ struct WatchNowSectionBody: View { .padding(.leading, 15) #endif - VideosCellsHorizontal(videos: videos) + HorizontalCells(items: ContentItem.array(of: videos)) } } }