From 33abe4d487ebb29410d16a5fd99d69c76a802ace Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 27 Nov 2022 11:42:16 +0100 Subject: [PATCH] Channel pages --- Fixtures/Video+Fixtures.swift | 3 + Model/Applications/InvidiousAPI.swift | 18 +- Model/Applications/PipedAPI.swift | 30 ++- Model/Applications/VideosAPI.swift | 6 +- Model/Channel.swift | 75 +++++-- Model/ContentItem.swift | 8 + Model/NavigationModel.swift | 1 + Shared/Home/HomeView.swift | 3 - Shared/OpenURLHandler.swift | 2 +- Shared/Search/SearchView.swift | 10 +- Shared/Videos/VerticalCells.swift | 21 +- Shared/Views/ChannelPlaylistCell.swift | 9 +- Shared/Views/ChannelVideosView.swift | 275 ++++++++++++++++++++----- 13 files changed, 354 insertions(+), 107 deletions(-) diff --git a/Fixtures/Video+Fixtures.swift b/Fixtures/Video+Fixtures.swift index 3a694284..27030523 100644 --- a/Fixtures/Video+Fixtures.swift +++ b/Fixtures/Video+Fixtures.swift @@ -5,6 +5,7 @@ extension Video { static var fixtureChannelID: Channel.ID = "channel-fixture" static var fixture: Video { + let bannerURL = "https://yt3.ggpht.com/SQiRareBDrV2Z6A30HSD0iUABOGysanmKLtaJq7lJ_ME-MtoLb3O61QdlJfH2KhSOA0eKPr_=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj" let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo" let chapterImageURL = URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")! @@ -20,8 +21,10 @@ extension Video { channel: Channel( id: fixtureChannelID, name: "The Channel", + bannerURL: URL(string: bannerURL)!, thumbnailURL: URL(string: thumbnailURL)!, subscriptionsCount: 2300, + totalViews: 3_260_378_817, videos: [] ), thumbnails: [], diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index a2cef790..c30e311c 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -154,6 +154,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { content.json.arrayValue.map(self.extractVideo) } + configureTransformer(pathPattern("channels/*/playlists"), requestMethods: [.get]) { (content: Entity) -> [ContentItem] in + let playlists = (content.json.dictionaryValue["playlists"]?.arrayValue ?? []).compactMap { self.extractChannelPlaylist(from: $0) } + return ContentItem.array(of: playlists) + } + configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity) -> ChannelPlaylist in self.extractChannelPlaylist(from: content.json) } @@ -287,8 +292,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { .onCompletion { _ in onCompletion() } } - func channel(_ id: String) -> Resource { - resource(baseURL: account.url, path: basePathAppending("channels/\(id)")) + func channel(_ id: String, contentType: Channel.ContentType, data _: String?) -> Resource { + if contentType == .playlists { + return resource(baseURL: account.url, path: basePathAppending("channels/\(id)/playlists")) + } + return resource(baseURL: account.url, path: basePathAppending("channels/\(id)")) } func channelByName(_: String) -> Resource? { @@ -518,9 +526,12 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { return Channel( id: json["authorId"].stringValue, name: json["author"].stringValue, + bannerURL: json["authorBanners"].arrayValue.first?.dictionaryValue["url"]?.url, thumbnailURL: URL(string: thumbnailURL), + description: json["description"].stringValue, subscriptionsCount: json["subCount"].int, subscriptionsText: json["subCountText"].string, + totalViews: json["totalViews"].int, videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? [] ) } @@ -532,7 +543,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { title: details["title"]?.stringValue ?? "", thumbnailURL: details["playlistThumbnail"]?.url, channel: extractChannel(from: json), - videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [] + videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [], + videosCount: details["videoCount"]?.int ) } diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index 1ff35b6b..2a0223ed 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -40,6 +40,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { self.extractChannel(from: content.json) } + configureTransformer(pathPattern("channels/tabs*")) { (content: Entity) -> [ContentItem] in + (content.json.dictionaryValue["content"]?.arrayValue ?? []).compactMap { self.extractContentItem(from: $0) } + } + configureTransformer(pathPattern("c/*")) { (content: Entity) -> Channel? in self.extractChannel(from: content.json) } @@ -147,8 +151,13 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { resource(baseURL: account.url, path: "login") } - func channel(_ id: String) -> Resource { - resource(baseURL: account.url, path: "channel/\(id)") + func channel(_ id: String, contentType: Channel.ContentType, data: String?) -> Resource { + if contentType == .videos { + return resource(baseURL: account.url, path: "channel/\(id)") + } + + return resource(baseURL: account.url, path: "channels/tabs") + .withParam("data", data) } func channelByName(_ name: String) -> Resource? { @@ -160,7 +169,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } func channelVideos(_ id: String) -> Resource { - channel(id) + channel(id, contentType: .videos) } func channelPlaylist(_ id: String) -> Resource? { @@ -385,12 +394,25 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { attributes["avatar"]?.url ?? attributes["thumbnail"]?.url + let tabs = attributes["tabs"]?.arrayValue.compactMap { tab in + let name = tab["name"].string + let data = tab["data"].string + if let name, let data, let type = Channel.ContentType(rawValue: name) { + return Channel.Tab(contentType: type, data: data) + } + + return nil + } ?? [Channel.Tab]() + return Channel( id: id, name: name, + bannerURL: attributes["bannerUrl"]?.url, thumbnailURL: thumbnailURL, subscriptionsCount: subscriptionsCount, - videos: videos + verified: attributes["verified"]?.bool, + videos: videos, + tabs: tabs ) } diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index c7b5932b..a3484386 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -6,7 +6,7 @@ protocol VideosAPI { var account: Account! { get } var signedIn: Bool { get } - func channel(_ id: String) -> Resource + func channel(_ id: String, contentType: Channel.ContentType, data: String?) -> Resource func channelByName(_ name: String) -> Resource? func channelByUsername(_ username: String) -> Resource? func channelVideos(_ id: String) -> Resource @@ -70,6 +70,10 @@ protocol VideosAPI { } extension VideosAPI { + func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil) -> Resource { + channel(id, contentType: contentType, data: data) + } + func loadDetails( _ item: PlayerQueueItem, failureHandler: ((RequestError) -> Void)? = nil, diff --git a/Model/Channel.swift b/Model/Channel.swift index e4c72100..c623a0d6 100644 --- a/Model/Channel.swift +++ b/Model/Channel.swift @@ -4,29 +4,56 @@ import Foundation import SwiftyJSON struct Channel: Identifiable, Hashable { + enum ContentType: String, Identifiable { + case videos + case playlists + case livestreams + case shorts + case channels + + var id: String { + rawValue + } + + var contentItemType: ContentItem.ContentType { + switch self { + case .videos: + return .video + case .playlists: + return .playlist + case .livestreams: + return .video + case .shorts: + return .video + case .channels: + return .channel + } + } + } + + struct Tab: Identifiable, Hashable { + var contentType: ContentType + var data: String + + var id: String { + contentType.id + } + } + var id: String var name: String + var bannerURL: URL? var thumbnailURL: URL? + var description = "" + + var subscriptionsCount: Int? + var subscriptionsText: String? + + var totalViews: Int? + var verified: Bool? // swiftlint:disable discouraged_optional_boolean + var videos = [Video]() - - private var subscriptionsCount: Int? - private var subscriptionsText: String? - - 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 tabs = [Tab]() var detailsLoaded: Bool { !subscriptionsString.isNil @@ -40,7 +67,17 @@ struct Channel: Identifiable, Hashable { return subscriptionsText } + var totalViewsString: String? { + guard let totalViews, totalViews > 0 else { return nil } + + return totalViews.formattedAsAbbreviation() + } + func hash(into hasher: inout Hasher) { hasher.combine(id) } + + var contentItem: ContentItem { + ContentItem(channel: self) + } } diff --git a/Model/ContentItem.swift b/Model/ContentItem.swift index a113fdca..c3d020d6 100644 --- a/Model/ContentItem.swift +++ b/Model/ContentItem.swift @@ -30,6 +30,14 @@ struct ContentItem: Identifiable { videos.map { ContentItem(video: $0) } } + static func array(of playlists: [ChannelPlaylist]) -> [ContentItem] { + playlists.map { ContentItem(playlist: $0) } + } + + static func array(of channels: [Channel]) -> [ContentItem] { + channels.map { ContentItem(channel: $0) } + } + static func < (lhs: ContentItem, rhs: ContentItem) -> Bool { lhs.contentType < rhs.contentType } diff --git a/Model/NavigationModel.swift b/Model/NavigationModel.swift index f57244d0..7856fe73 100644 --- a/Model/NavigationModel.swift +++ b/Model/NavigationModel.swift @@ -138,6 +138,7 @@ final class NavigationModel: ObservableObject { #endif hideKeyboard() + presentingChannel = false let presentingPlayer = player.presentingPlayer player.hide() diff --git a/Shared/Home/HomeView.swift b/Shared/Home/HomeView.swift index d81f9f28..f57201a9 100644 --- a/Shared/Home/HomeView.swift +++ b/Shared/Home/HomeView.swift @@ -79,9 +79,6 @@ struct HomeView: View { FavoriteItemView(item: item, dragging: $dragging) } #else - #if os(iOS) - let first = favorites.first - #endif ForEach(favorites) { item in FavoriteItemView(item: item, dragging: $dragging) #if os(macOS) diff --git a/Shared/OpenURLHandler.swift b/Shared/OpenURLHandler.swift index 82f4be5b..3acb2e1c 100644 --- a/Shared/OpenURLHandler.swift +++ b/Shared/OpenURLHandler.swift @@ -207,7 +207,7 @@ struct OpenURLHandler { private func resourceForChannelUrl(_ parser: URLParser) -> Resource? { if let id = parser.channelID { - return accounts.api.channel(id) + return accounts.api.channel(id, contentType: .videos) } if let resource = resourceForUsernameUrl(parser) { diff --git a/Shared/Search/SearchView.swift b/Shared/Search/SearchView.swift index 234ea64d..2524d129 100644 --- a/Shared/Search/SearchView.swift +++ b/Shared/Search/SearchView.swift @@ -31,10 +31,6 @@ struct SearchView: View { private var videos = [Video]() - var items: [ContentItem] { - state.store.collection.sorted { $0 < $1 } - } - init(_ query: SearchQuery? = nil, videos: [Video] = []) { self.query = query self.videos = videos @@ -233,12 +229,12 @@ struct SearchView: View { .font(.system(size: 25)) } - HorizontalCells(items: items) + HorizontalCells(items: state.store.collection) .environment(\.loadMoreContentHandler) { state.loadNextPage() } } .edgesIgnoringSafeArea(.horizontal) #else - VerticalCells(items: items, allowEmpty: state.query.isEmpty) + VerticalCells(items: state.store.collection, allowEmpty: state.query.isEmpty) .environment(\.loadMoreContentHandler) { state.loadNextPage() } #endif @@ -278,7 +274,7 @@ struct SearchView: View { } private var noResults: Bool { - items.isEmpty && !state.isLoading && !state.query.isEmpty + state.store.collection.isEmpty && !state.isLoading && !state.query.isEmpty } private var recentQueries: some View { diff --git a/Shared/Videos/VerticalCells.swift b/Shared/Videos/VerticalCells.swift index 96ed973c..346b45db 100644 --- a/Shared/Videos/VerticalCells.swift +++ b/Shared/Videos/VerticalCells.swift @@ -1,7 +1,7 @@ import Defaults import SwiftUI -struct VerticalCells: View { +struct VerticalCells: View { #if os(iOS) @Environment(\.verticalSizeClass) private var verticalSizeClass #endif @@ -12,12 +12,25 @@ struct VerticalCells: View { var items = [ContentItem]() var allowEmpty = false + let header: Header? + init(items: [ContentItem], allowEmpty: Bool = false, @ViewBuilder header: @escaping () -> Header? = { nil }) { + self.items = items + self.allowEmpty = allowEmpty + self.header = header() + } + + init(items: [ContentItem], allowEmpty: Bool = false) where Header == EmptyView { + self.init(items: items, allowEmpty: allowEmpty) { EmptyView() } + } + var body: some View { ScrollView(.vertical, showsIndicators: scrollViewShowsIndicators) { LazyVGrid(columns: columns, alignment: .center) { - ForEach(contentItems) { item in - ContentItemView(item: item) - .onAppear { loadMoreContentItemsIfNeeded(current: item) } + Section(header: header) { + ForEach(contentItems) { item in + ContentItemView(item: item) + .onAppear { loadMoreContentItemsIfNeeded(current: item) } + } } } .padding() diff --git a/Shared/Views/ChannelPlaylistCell.swift b/Shared/Views/ChannelPlaylistCell.swift index 395f8d84..d2b210fa 100644 --- a/Shared/Views/ChannelPlaylistCell.swift +++ b/Shared/Views/ChannelPlaylistCell.swift @@ -10,14 +10,7 @@ struct ChannelPlaylistCell: View { var body: some View { Button { - let recent = RecentItem(from: playlist) - RecentsModel.shared.add(recent) - navigation.presentingPlaylist = true - - if navigationStyle == .sidebar { - navigation.sidebarSectionChanged.toggle() - navigation.tabSelection = .recentlyOpened(recent.tag) - } + NavigationModel.shared.openChannelPlaylist(playlist, navigationStyle: navigationStyle) } label: { content .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) diff --git a/Shared/Views/ChannelVideosView.swift b/Shared/Views/ChannelVideosView.swift index 4911afbe..e9117012 100644 --- a/Shared/Views/ChannelVideosView.swift +++ b/Shared/Views/ChannelVideosView.swift @@ -1,3 +1,4 @@ +import SDWebImageSwiftUI import Siesta import SwiftUI @@ -8,6 +9,9 @@ struct ChannelVideosView: View { @State private var shareURL: URL? @State private var subscriptionToggleButtonDisabled = false + @State private var contentType = Channel.ContentType.videos + @StateObject private var contentTypeItems = Store<[ContentItem]>() + @StateObject private var store = Store() @Environment(\.colorScheme) private var colorScheme @@ -24,11 +28,15 @@ struct ChannelVideosView: View { @Namespace private var focusNamespace var presentedChannel: Channel? { - channel ?? recents.presentedChannel + store.item ?? channel ?? recents.presentedChannel } - var videos: [ContentItem] { - ContentItem.array(of: store.item?.videos ?? []) + var contentItems: [ContentItem] { + guard contentType != .videos else { + return ContentItem.array(of: presentedChannel?.videos ?? []) + } + + return contentTypeItems.collection } var body: some View { @@ -48,30 +56,36 @@ struct ChannelVideosView: View { var content: some View { let content = VStack { #if os(tvOS) - HStack { - Text(navigationTitle) - .font(.title2) - .frame(alignment: .leading) + VStack { + HStack(spacing: 24) { + thumbnail - Spacer() + Text(navigationTitle) + .font(.title2) + .frame(alignment: .leading) - if let channel = presentedChannel { - FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name))) - .labelStyle(.iconOnly) + Spacer() + + subscriptionsLabel + viewsLabel + + subscriptionToggleButton + + if let channel = presentedChannel { + FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name))) + .labelStyle(.iconOnly) + } } - - if let subscribers = store.item?.subscriptionsString { - Text("**\(subscribers)** subscribers") - .foregroundColor(.secondary) - } - - subscriptionToggleButton + contentTypePicker + .pickerStyle(.automatic) } .frame(maxWidth: .infinity) #endif - VerticalCells(items: videos) - .environment(\.inChannelView, true) + VerticalCells(items: contentItems) { + banner + } + .environment(\.inChannelView, true) #if os(tvOS) .prefersDefaultFocus(in: focusNamespace) #endif @@ -79,6 +93,11 @@ struct ChannelVideosView: View { #if !os(tvOS) .toolbar { + #if os(iOS) + ToolbarItem(placement: .principal) { + channelMenu + } + #endif ToolbarItem(placement: .cancellationAction) { if navigationStyle == .tab { Button { @@ -88,38 +107,41 @@ struct ChannelVideosView: View { } label: { Label("Close", systemImage: "xmark") } + .buttonStyle(.plain) } } + #if !os(iOS) + ToolbarItem(placement: .navigation) { + thumbnail + } + ToolbarItem { + contentTypePicker + } - ToolbarItem { - HStack { + ToolbarItem { HStack(spacing: 3) { - Text("\(store.item?.subscriptionsString ?? "")") - .fontWeight(.bold) - - let subscribers = Text(" subscribers") - .allowsTightening(true) - .foregroundColor(.secondary) - .opacity(store.item?.subscriptionsString != nil ? 1 : 0) - - #if os(iOS) - if navigationStyle == .sidebar { - subscribers - } - #else - subscribers - #endif - } - - ShareButton(contentItem: contentItem) - - subscriptionToggleButton - - if let channel = presentedChannel { - FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name))) + subscriptionsLabel + viewsLabel } } - } + + ToolbarItem { + if let contentItem = presentedChannel?.contentItem { + ShareButton(contentItem: contentItem) + } + } + + ToolbarItem { + subscriptionToggleButton + .layoutPriority(2) + } + + ToolbarItem { + if let presentedChannel { + FavoriteButton(item: FavoriteItem(section: .channel(presentedChannel.id, presentedChannel.name))) + } + } + #endif } #endif .onAppear { @@ -131,6 +153,12 @@ struct ChannelVideosView: View { resource?.loadIfNeeded() } } + .onChange(of: contentType) { _ in + resource?.load() + } + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif #if !os(tvOS) .navigationTitle(navigationTitle) #endif @@ -150,13 +178,143 @@ struct ChannelVideosView: View { } } - private var resource: Resource? { - guard let channel = presentedChannel else { - return nil - } + var thumbnail: some View { + Group { + if let thumbnail = store.item?.thumbnailURL { + WebImage(url: thumbnail) + .resizable() + } else { + ZStack { + Color(white: 0.6) + .opacity(0.5) - let resource = accounts.api.channel(channel.id) - resource.addObserver(store) + Image(systemName: "play.rectangle") + .foregroundColor(.accentColor) + .imageScale(.small) + .contentShape(Rectangle()) + } + } + } + #if os(tvOS) + .frame(width: 80, height: 80, alignment: .trailing) + #else + .frame(width: 30, height: 30, alignment: .trailing) + #endif + .clipShape(Circle()) + } + + @ViewBuilder var banner: some View { + if let banner = presentedChannel?.bannerURL { + WebImage(url: banner) + .resizable() + .placeholder { Color.clear.frame(height: 0) } + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + } + + var subscriptionsLabel: some View { + HStack(spacing: 0) { + if let subscribers = presentedChannel?.subscriptionsString { + Text(subscribers) + } else { + Text("1234") + .redacted(reason: .placeholder) + } + + Image(systemName: "person.2.fill") + .imageScale(.small) + } + .foregroundColor(.secondary) + } + + var viewsLabel: some View { + HStack(spacing: 0) { + if let views = presentedChannel?.totalViewsString { + Text(views) + + Image(systemName: "eye.fill") + .imageScale(.small) + } + } + .foregroundColor(.secondary) + } + + #if !os(tvOS) + var channelMenu: some View { + Menu { + if let channel = presentedChannel { + contentTypePicker + Section { + subscriptionToggleButton + FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name))) + } + } + } label: { + HStack(spacing: 12) { + thumbnail + + VStack(alignment: .leading) { + Text(presentedChannel?.name ?? "Channel") + .font(.headline) + .foregroundColor(.primary) + .layoutPriority(1) + .frame(minWidth: 120, alignment: .leading) + + Group { + HStack(spacing: 12) { + subscriptionsLabel + + if presentedChannel?.verified ?? false { + Text("Verified") + } + + viewsLabel + } + .frame(minWidth: 120, alignment: .leading) + } + .font(.caption2.bold()) + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.down.circle.fill") + .foregroundColor(.accentColor) + .imageScale(.small) + } + .frame(maxWidth: 300) + } + } + #endif + + private var contentTypePicker: some View { + Picker("Content type", selection: $contentType) { + if let channel = presentedChannel { + Text("Videos").tag(Channel.ContentType.videos) + Text("Playlists").tag(Channel.ContentType.playlists) + + if channel.tabs.contains(where: { $0.contentType == .livestreams }) { + Text("Live streams").tag(Channel.ContentType.livestreams) + } + if channel.tabs.contains(where: { $0.contentType == .shorts }) { + Text("Shorts").tag(Channel.ContentType.shorts) + } + if channel.tabs.contains(where: { $0.contentType == .channels }) { + Text("Channels").tag(Channel.ContentType.channels) + } + } + } + } + + private var resource: Resource? { + guard let channel = presentedChannel else { return nil } + + let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil + let resource = accounts.api.channel(channel.id, contentType: contentType, data: data) + if contentType == .videos { + resource.addObserver(store) + } else { + resource.addObserver(contentTypeItems) + } return resource } @@ -203,18 +361,21 @@ struct ChannelVideosView: View { } } - private var contentItem: ContentItem { - ContentItem(channel: presentedChannel) - } - private var navigationTitle: String { - presentedChannel?.name ?? store.item?.name ?? "No channel" + presentedChannel?.name ?? "No channel" } } struct ChannelVideosView_Previews: PreviewProvider { static var previews: some View { ChannelVideosView(channel: Video.fixture.channel) + .environment(\.navigationStyle, .tab) .injectFixtureEnvironmentObjects() + + NavigationView { + Spacer() + ChannelVideosView(channel: Video.fixture.channel) + .environment(\.navigationStyle, .sidebar) + } } }