Add channel tabs and pagination

Fix #135
This commit is contained in:
Arkadiusz Fal 2023-02-28 21:03:02 +01:00
parent d58026bcef
commit f1e132a909
11 changed files with 217 additions and 47 deletions

View File

@ -109,17 +109,22 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
content.json.arrayValue.map(self.extractChannel)
}
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
self.extractChannel(from: content.json)
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
self.extractChannelPage(from: content.json, forceNotLast: true)
}
configureTransformer(pathPattern("channels/*/videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
self.extractChannelPage(from: content.json)
}
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.dictionaryValue["videos"]?.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)
["latest", "playlists", "streams", "shorts", "channels", "videos"].forEach { type in
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
self.extractChannelPage(from: content.json)
}
}
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
@ -266,11 +271,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
.onCompletion { _ in onCompletion() }
}
func channel(_ id: String, contentType: Channel.ContentType, data _: String?) -> Resource {
if contentType == .playlists {
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)/playlists"))
func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page: String?) -> Resource {
if page.isNil, contentType == .videos {
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
}
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
var resource = resource(baseURL: account.url, path: basePathAppending("channels/\(id)/\(contentType.invidiousID)"))
if let page, !page.isEmpty {
resource = resource.withParam("continuation", page)
}
return resource
}
func channelByName(_: String) -> Resource? {
@ -504,6 +516,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
thumbnailURL = "\(accountUrlComponents.scheme ?? "https"):\(thumbnailURL)"
}
let tabs = json["tabs"].arrayValue.compactMap { name in
if let name = name.string, let type = Channel.ContentType.from(name) {
return Channel.Tab(contentType: type, data: "")
}
return nil
}
return Channel(
app: .invidious,
id: json["authorId"].stringValue,
@ -514,7 +534,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
subscriptionsCount: json["subCount"].int,
subscriptionsText: json["subCountText"].string,
totalViews: json["totalViews"].int,
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? []
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? [],
tabs: tabs
)
}
@ -552,6 +573,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
}
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
let nextPage = json.dictionaryValue["continuation"]?.string
var contentItems = [ContentItem]()
var items = [ContentItem]()
if let key = Self.contentItemsKeys.first(where: { json.dictionaryValue.keys.contains($0) }),
let items = json.dictionaryValue[key]
{
contentItems = extractContentItems(from: items)
}
var last = false
if !forceNotLast {
last = nextPage?.isEmpty ?? true
}
return ChannelPage(
results: contentItems,
channel: extractChannel(from: json),
nextPage: nextPage,
last: last
)
}
private func extractStreams(from json: JSON) -> [Stream] {
let hls = extractHLSStreams(from: json)
if json["liveNow"].boolValue {
@ -668,4 +716,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
)
}
}
private func extractContentItems(from json: JSON) -> [ContentItem] {
json.arrayValue.compactMap { extractContentItem(from: $0) }
}
private func extractContentItem(from json: JSON) -> ContentItem? {
let type = json.dictionaryValue["type"]?.string
if type == "channel" {
return ContentItem(channel: extractChannel(from: json))
} else if type == "playlist" {
return ContentItem(playlist: extractChannelPlaylist(from: json))
} else if type == "video" {
return ContentItem(video: extractVideo(from: json))
}
return nil
}
}
extension Channel.ContentType {
var invidiousID: String {
switch self {
case .livestreams:
return "streams"
default:
return rawValue
}
}
}

View File

@ -284,7 +284,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
.onCompletion { _ in onCompletion() }
}
func channel(_ id: String, contentType: Channel.ContentType, data _: String?) -> Resource {
func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page _: String?) -> Resource {
if contentType == .playlists {
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)/playlists"))
}

View File

@ -6,6 +6,7 @@ import SwiftyJSON
final class PipedAPI: Service, ObservableObject, VideosAPI {
static var disallowedVideoCodecs = ["av01"]
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
static var contentItemsKeys = ["items", "content", "relatedStreams"]
@Published var account: Account!
@ -40,8 +41,25 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
$0.headers["Authorization"] = self.account.token
}
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
self.extractChannel(from: content.json)
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> ChannelPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.string
let channel = self.extractChannel(from: content.json)
return ChannelPage(
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
channel: channel,
nextPage: nextPage,
last: nextPage.isNil
)
}
configureTransformer(pathPattern("/nextpage/channel/*")) { (content: Entity<JSON>) -> ChannelPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.string
return ChannelPage(
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
channel: self.extractChannel(from: content.json),
nextPage: nextPage,
last: nextPage.isNil
)
}
configureTransformer(pathPattern("channels/tabs*")) { (content: Entity<JSON>) -> [ContentItem] in
@ -159,13 +177,23 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: "login")
}
func channel(_ id: String, contentType: Channel.ContentType, data: String?) -> Resource {
if contentType == .videos {
return resource(baseURL: account.url, path: "channel/\(id)")
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource {
let path = page.isNil ? "channel" : "nextpage/channel"
var channel: Siesta.Resource
if contentType == .videos || data.isNil {
channel = resource(baseURL: account.url, path: "\(path)/\(id)")
} else {
channel = resource(baseURL: account.url, path: "channels/tabs")
.withParam("data", data)
}
return resource(baseURL: account.url, path: "channels/tabs")
.withParam("data", data)
if let page, !page.isEmpty {
channel = channel.withParam("nextpage", page)
}
return channel
}
func channelByName(_ name: String) -> Resource? {
@ -700,4 +728,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return Chapter(title: title, image: image, start: start)
}
}
private func contentItemsDictionary(from content: JSON) -> JSON {
if let key = Self.contentItemsKeys.first(where: { content.dictionaryValue.keys.contains($0) }),
let items = content.dictionaryValue[key]
{
return items
}
return .null
}
}

View File

@ -8,7 +8,7 @@ protocol VideosAPI {
static func withAnonymousAccountForInstanceURL(_ url: URL) -> Self
func channel(_ id: String, contentType: Channel.ContentType, data: String?) -> Resource
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource
func channelByName(_ name: String) -> Resource?
func channelByUsername(_ username: String) -> Resource?
func channelVideos(_ id: String) -> Resource
@ -72,8 +72,8 @@ protocol VideosAPI {
}
extension VideosAPI {
func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil) -> Resource {
channel(id, contentType: contentType, data: data)
func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil, page: String? = nil) -> Resource {
channel(id, contentType: contentType, data: data, page: page)
}
func loadDetails(

View File

@ -34,11 +34,11 @@ struct ChannelsCacheModel: CacheModel {
store(channel)
}
func retrieve(_ cacheKey: String) -> Channel? {
func retrieve(_ cacheKey: String) -> ChannelPage? {
logger.debug("retrieving cache for \(cacheKey)")
if let json = try? storage?.object(forKey: cacheKey) {
return Channel.from(json)
return ChannelPage(channel: Channel.from(json))
}
return nil

View File

@ -110,12 +110,12 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
if let json = try? storage?.object(forKey: channelsCacheKey(account)),
let channels = json.dictionaryValue["channels"]
{
return channels.arrayValue.map { json in
return channels.arrayValue.compactMap { json in
let channel = Channel.from(json)
if !channel.hasExtendedDetails,
let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey)
{
return cache
return cache.channel
}
return channel

View File

@ -11,6 +11,15 @@ struct Channel: Identifiable, Hashable {
case shorts
case channels
static func from(_ name: String) -> Self? {
let rawValueMatch = allCases.first { $0.rawValue == name }
guard rawValueMatch.isNil else { return rawValueMatch! }
if name == "streams" { return .livestreams }
return nil
}
var id: String {
rawValue
}
@ -110,7 +119,6 @@ struct Channel: Identifiable, Hashable {
}
func hasData(for contentType: ContentType) -> Bool {
guard contentType != .videos, contentType != .playlists else { return true }
return tabs.contains { $0.contentType == contentType }
}
@ -132,7 +140,7 @@ struct Channel: Identifiable, Hashable {
}
var thumbnailURLOrCached: URL? {
thumbnailURL ?? ChannelsCacheModel.shared.retrieve(cacheKey)?.thumbnailURL
thumbnailURL ?? ChannelsCacheModel.shared.retrieve(cacheKey)?.channel?.thumbnailURL
}
var json: JSON {

8
Model/ChannelPage.swift Normal file
View File

@ -0,0 +1,8 @@
import Foundation
struct ChannelPage {
var results = [ContentItem]()
var channel: Channel?
var nextPage: String?
var last = false
}

View File

@ -11,11 +11,12 @@ struct ChannelVideosView: View {
@State private var shareURL: URL?
@State private var subscriptionToggleButtonDisabled = false
@State private var page: ChannelPage?
@State private var contentType = Channel.ContentType.videos
@StateObject private var contentTypeItems = Store<[ContentItem]>()
@State private var descriptionExpanded = false
@StateObject private var store = Store<Channel>()
@StateObject private var store = Store<ChannelPage>()
@Environment(\.colorScheme) private var colorScheme
@ -35,14 +36,10 @@ struct ChannelVideosView: View {
@Default(.hideShorts) private var hideShorts
var presentedChannel: Channel? {
store.item ?? channel ?? recents.presentedChannel
store.item?.channel ?? channel ?? recents.presentedChannel
}
var contentItems: [ContentItem] {
guard contentType != .videos else {
return ContentItem.array(of: presentedChannel?.videos ?? [])
}
return contentTypeItems.collection
}
@ -101,6 +98,7 @@ struct ChannelVideosView: View {
banner
}
}
.environment(\.loadMoreContentHandler) { loadNextPage() }
.environment(\.inChannelView, true)
.environment(\.listingStyle, channelPlaylistListingStyle)
.environment(\.hideShorts, hideShorts)
@ -180,14 +178,10 @@ struct ChannelVideosView: View {
store.replace(cache)
}
resource?.loadIfNeeded()?.onSuccess { response in
if let channel: Channel = response.typedContent() {
ChannelsCacheModel.shared.store(channel)
}
}
load()
}
.onChange(of: contentType) { _ in
resource?.load()
load()
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
@ -218,7 +212,7 @@ struct ChannelVideosView: View {
}
var thumbnail: some View {
ChannelAvatarView(channel: store.item)
ChannelAvatarView(channel: store.item?.channel)
#if os(tvOS)
.frame(width: 80, height: 80, alignment: .trailing)
#else
@ -238,7 +232,7 @@ struct ChannelVideosView: View {
var subscriptionsLabel: some View {
Group {
if let subscribers = store.item?.subscriptionsString {
if let subscribers = store.item?.channel?.subscriptionsString {
HStack(spacing: 0) {
Text(subscribers)
Image(systemName: "person.2.fill")
@ -257,7 +251,7 @@ struct ChannelVideosView: View {
var viewsLabel: some View {
HStack(spacing: 0) {
if let views = store.item?.totalViewsString {
if let views = store.item?.channel?.totalViewsString {
Text(views)
Image(systemName: "eye.fill")
@ -328,7 +322,7 @@ struct ChannelVideosView: View {
Picker("Content type", selection: $contentType) {
if let channel = presentedChannel {
ForEach(Channel.ContentType.allCases, id: \.self) { type in
if channel.hasData(for: type) {
if type == .videos || type == .playlists || channel.hasData(for: type) {
Label(type.description, systemImage: type.systemImage).tag(type)
}
}
@ -341,11 +335,11 @@ struct ChannelVideosView: View {
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)
}
resource.addObserver(contentTypeItems)
return resource
}
@ -424,6 +418,42 @@ struct ChannelVideosView: View {
Label("Mark channel feed as unwatched", systemImage: "checkmark.circle")
}
}
func load() {
resource?.load().onSuccess { response in
if let page: ChannelPage = response.typedContent() {
if let channel = page.channel {
ChannelsCacheModel.shared.store(channel)
}
self.page = page
self.contentTypeItems.replace(page.results)
}
}
.onFailure { error in
navigation.presentAlert(title: "Could not load channel data", message: error.userMessage)
}
}
func loadNextPage() {
guard let channel = presentedChannel, let pageToLoad = page, !pageToLoad.last else {
return
}
var next = pageToLoad.nextPage
if contentType == .videos, !pageToLoad.last {
next = next ?? ""
}
let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil
accounts.api.channel(channel.id, contentType: contentType, data: data, page: next).load().onSuccess { response in
if let page: ChannelPage = response.typedContent() {
self.page = page
let keys = self.contentTypeItems.collection.map(\.cacheKey)
let items = self.contentTypeItems.collection + page.results.filter { !keys.contains($0.cacheKey) }
self.contentTypeItems.replace(items)
}
}
}
}
struct ChannelVideosView_Previews: PreviewProvider {

View File

@ -74,9 +74,10 @@ struct FavoriteItemView: View {
case let .channel(_, id, name):
var channel = Channel(app: .invidious, id: id, name: name)
if let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey),
!cache.videos.isEmpty
let cacheChannel = cache.channel,
!cacheChannel.videos.isEmpty
{
contentItems = ContentItem.array(of: cache.videos)
contentItems = ContentItem.array(of: cacheChannel.videos)
}
onSuccess = { response in

View File

@ -830,6 +830,9 @@
37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; };
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; };
37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17F267B4DFB00704544 /* TrendingCountry.swift */; };
37C7B21429ABD9F20013C196 /* ChannelPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7B21329ABD9F20013C196 /* ChannelPage.swift */; };
37C7B21529ABD9F20013C196 /* ChannelPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7B21329ABD9F20013C196 /* ChannelPage.swift */; };
37C7B21629ABD9F20013C196 /* ChannelPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7B21329ABD9F20013C196 /* ChannelPage.swift */; };
37C89322294532220032AFD3 /* PlayerOverlayModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C89321294532220032AFD3 /* PlayerOverlayModifier.swift */; };
37C89323294532220032AFD3 /* PlayerOverlayModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C89321294532220032AFD3 /* PlayerOverlayModifier.swift */; };
37C89324294532220032AFD3 /* PlayerOverlayModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C89321294532220032AFD3 /* PlayerOverlayModifier.swift */; };
@ -1432,6 +1435,7 @@
37C3A24C272360470087A57A /* ChannelPlaylist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelPlaylist+Fixtures.swift"; sourceTree = "<group>"; };
37C3A250272366440087A57A /* ChannelPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPlaylistView.swift; sourceTree = "<group>"; };
37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = "<group>"; };
37C7B21329ABD9F20013C196 /* ChannelPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPage.swift; sourceTree = "<group>"; };
37C89321294532220032AFD3 /* PlayerOverlayModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerOverlayModifier.swift; sourceTree = "<group>"; };
37C8E700294FC97D00EEAB14 /* QueueView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QueueView.swift; sourceTree = "<group>"; };
37CC3F44270CE30600608308 /* PlayerQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueItem.swift; sourceTree = "<group>"; };
@ -2380,6 +2384,7 @@
377F9F79294403DC0043F856 /* Cache */,
3776ADD5287381240078EBC4 /* Captions.swift */,
37AAF28F26740715007FC770 /* Channel.swift */,
37C7B21329ABD9F20013C196 /* ChannelPage.swift */,
37C3A24427235DA70087A57A /* ChannelPlaylist.swift */,
37520698285E8DD300CA655F /* Chapter.swift */,
371B7E5B27596B8400D21217 /* Comment.swift */,
@ -3237,6 +3242,7 @@
37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */,
377ABC48286E5887009C986F /* Sequence+Unique.swift in Sources */,
37520699285E8DD300CA655F /* Chapter.swift in Sources */,
37C7B21429ABD9F20013C196 /* ChannelPage.swift in Sources */,
37319F0527103F94004ECCD0 /* PlayerQueue.swift in Sources */,
376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */,
37C0697E2725C8D400F7F6CB /* CMTime+DefaultTimescale.swift in Sources */,
@ -3462,6 +3468,7 @@
37A362BB2953707F00BDF328 /* ClearQueueButton.swift in Sources */,
3752069E285E910600CA655F /* ChapterView.swift in Sources */,
37030FF827B0347C00ECDDAA /* MPVPlayerView.swift in Sources */,
37C7B21529ABD9F20013C196 /* ChannelPage.swift in Sources */,
378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */,
37169AA32729D98A0011DE61 /* InstancesBridge.swift in Sources */,
37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */,
@ -3882,6 +3889,7 @@
378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */,
37169AA82729E2CC0011DE61 /* AccountsBridge.swift in Sources */,
37C8E703294FC97D00EEAB14 /* QueueView.swift in Sources */,
37C7B21629ABD9F20013C196 /* ChannelPage.swift in Sources */,
3754B01728B7F84D009717C8 /* Constants.swift in Sources */,
37BC50AE2778BCBA00510953 /* HistoryModel.swift in Sources */,
37D526E02720AC4400ED2F5E /* VideosAPI.swift in Sources */,