Channel pages

This commit is contained in:
Arkadiusz Fal 2022-11-27 11:42:16 +01:00
parent 909f035399
commit 33abe4d487
13 changed files with 354 additions and 107 deletions

View File

@ -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: [],

View File

@ -154,6 +154,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("channels/*/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [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<JSON>) -> 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
)
}

View File

@ -40,6 +40,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("channels/tabs*")) { (content: Entity<JSON>) -> [ContentItem] in
(content.json.dictionaryValue["content"]?.arrayValue ?? []).compactMap { self.extractContentItem(from: $0) }
}
configureTransformer(pathPattern("c/*")) { (content: Entity<JSON>) -> 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
)
}

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -138,6 +138,7 @@ final class NavigationModel: ObservableObject {
#endif
hideKeyboard()
presentingChannel = false
let presentingPlayer = player.presentingPlayer
player.hide()

View File

@ -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)

View File

@ -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) {

View File

@ -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 {

View File

@ -1,7 +1,7 @@
import Defaults
import SwiftUI
struct VerticalCells: View {
struct VerticalCells<Header: View>: View {
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@ -12,14 +12,27 @@ 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) {
Section(header: header) {
ForEach(contentItems) { item in
ContentItemView(item: item)
.onAppear { loadMoreContentItemsIfNeeded(current: item) }
}
}
}
.padding()
#if !os(tvOS)
Color.clear.padding(.bottom, scrollViewBottomPadding)

View File

@ -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)

View File

@ -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<Channel>()
@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,29 +56,35 @@ struct ChannelVideosView: View {
var content: some View {
let content = VStack {
#if os(tvOS)
HStack {
VStack {
HStack(spacing: 24) {
thumbnail
Text(navigationTitle)
.font(.title2)
.frame(alignment: .leading)
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)
VerticalCells(items: contentItems) {
banner
}
.environment(\.inChannelView, true)
#if os(tvOS)
.prefersDefaultFocus(in: focusNamespace)
@ -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(spacing: 3) {
subscriptionsLabel
viewsLabel
}
}
ToolbarItem {
HStack {
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
}
if let contentItem = presentedChannel?.contentItem {
ShareButton(contentItem: contentItem)
}
}
ToolbarItem {
subscriptionToggleButton
.layoutPriority(2)
}
if let channel = presentedChannel {
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
}
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)
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())
}
let resource = accounts.api.channel(channel.id)
@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)
}
}
}