From 734bb31260e60911c4b178b6b544d6e824c06d68 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 23 Oct 2021 01:04:03 +0200 Subject: [PATCH] Channel playlists support --- Fixtures/ChannelPlaylist+Fixtures.swift | 12 +++ Model/Applications/InvidiousAPI.swift | 26 ++++++- Model/Applications/PipedAPI.swift | 36 ++++++++- Model/Applications/VideosAPI.swift | 2 + Model/ChannelPlaylist.swift | 10 +++ Model/ContentItem.swift | 4 +- Model/NavigationModel.swift | 3 +- Model/Player/PlayerModel.swift | 10 ++- Model/RecentsModel.swift | 24 +++++- Pearvidious.xcodeproj/project.pbxproj | 32 ++++++++ Shared/Navigation/AppSidebarRecents.swift | 7 ++ Shared/Navigation/AppTabNavigation.swift | 95 +++++++++++++++-------- Shared/Videos/VideoCell.swift | 21 ++--- Shared/Views/ChannelCell.swift | 32 ++++---- Shared/Views/ChannelPlaylistCell.swift | 68 ++++++++++++++++ Shared/Views/ChannelPlaylistView.swift | 74 ++++++++++++++++++ Shared/Views/ChannelVideosView.swift | 13 ++-- Shared/Views/ContentItemView.swift | 2 +- Shared/Views/DetailBadge.swift | 1 - Shared/Views/SearchView.swift | 10 ++- Shared/Views/VideoContextMenuView.swift | 2 +- tvOS/TVNavigationView.swift | 7 +- 22 files changed, 402 insertions(+), 89 deletions(-) create mode 100644 Fixtures/ChannelPlaylist+Fixtures.swift create mode 100644 Model/ChannelPlaylist.swift create mode 100644 Shared/Views/ChannelPlaylistCell.swift create mode 100644 Shared/Views/ChannelPlaylistView.swift diff --git a/Fixtures/ChannelPlaylist+Fixtures.swift b/Fixtures/ChannelPlaylist+Fixtures.swift new file mode 100644 index 00000000..7c72add2 --- /dev/null +++ b/Fixtures/ChannelPlaylist+Fixtures.swift @@ -0,0 +1,12 @@ +import Foundation + +extension ChannelPlaylist { + static var fixture: ChannelPlaylist { + ChannelPlaylist( + title: "Playlist with a very long title that will not fit easily in the screen", + thumbnailURL: URL(string: "https://i.ytimg.com/vi/hT_nvWreIhg/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLAAD21_-Bo6Td1z3cV-UFyoi1flEg")!, + channel: Video.fixture.channel, + videos: Video.allFixtures + ) + } +} diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index 90f2a050..c168e22a 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -95,8 +95,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { 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(playlist: InvidiousAPI.extractChannelPlaylist(from: $0)) } return ContentItem(video: InvidiousAPI.extractVideo($0)) } @@ -143,6 +142,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { content.json.arrayValue.map(InvidiousAPI.extractVideo) } + configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity) -> ChannelPlaylist in + InvidiousAPI.extractChannelPlaylist(from: content.json) + } + configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity) -> Video in InvidiousAPI.extractVideo(content.json) } @@ -214,6 +217,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { playlist(playlistID)?.child("videos").child(videoID) } + func channelPlaylist(_ id: String) -> Resource? { + resource(baseURL: account.url, path: basePathAppending("playlists/\(id)")) + } + func search(_ query: SearchQuery) -> Resource { var resource = resource(baseURL: account.url, path: basePathAppending("search")) .withParam("q", searchQuery(query.query)) @@ -325,6 +332,21 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { ) } + static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist { + let details = json.dictionaryValue + return ChannelPlaylist( + id: details["playlistId"]!.stringValue, + title: details["title"]!.stringValue, + thumbnailURL: details["playlistThumbnail"]?.url, + channel: extractChannel(from: json), + videos: details["videos"]?.arrayValue.compactMap(InvidiousAPI.extractVideo) ?? [] + ) + } + + static func extractChannelPlaylists(from json: JSON) -> [ChannelPlaylist] { + json.arrayValue.map(InvidiousAPI.extractChannelPlaylist) + } + 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!)!) diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index 83bcc100..4f7514e6 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -35,6 +35,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { PipedAPI.extractChannel(content.json) } + configureTransformer(pathPattern("playlists/*")) { (content: Entity) -> ChannelPlaylist? in + PipedAPI.extractChannelPlaylist(from: content.json) + } + configureTransformer(pathPattern("streams/*")) { (content: Entity) -> Video? in PipedAPI.extractVideo(content.json) } @@ -56,6 +60,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { resource(baseURL: account.url, path: "channel/\(id)") } + func channelPlaylist(_ id: String) -> Resource? { + resource(baseURL: account.url, path: "playlists/\(id)") + } + func trending(country: Country, category _: TrendingCategory? = nil) -> Resource { resource(baseURL: account.instance.url, path: "trending") .withParam("region", country.rawValue) @@ -118,7 +126,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } case .playlist: - return nil + if let playlist = PipedAPI.extractChannelPlaylist(from: content) { + return ContentItem(playlist: playlist) + } case .channel: if let channel = PipedAPI.extractChannel(content) { @@ -136,7 +146,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { private static func extractChannel(_ content: JSON) -> Channel? { let attributes = content.dictionaryValue guard let id = attributes["id"]?.stringValue ?? - attributes["url"]?.stringValue.components(separatedBy: "/").last + (attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last else { return nil } @@ -157,6 +167,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { ) } + static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? { + let details = json.dictionaryValue + let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString + let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url + var videos = [Video]() + if let relatedStreams = details["relatedStreams"] { + videos = PipedAPI.extractVideos(relatedStreams) + } + return ChannelPlaylist( + id: id, + title: details["name"]!.stringValue, + thumbnailURL: thumbnailURL, + channel: extractChannel(json)!, + videos: videos, + videosCount: details["videos"]?.int + ) + } + + static func extractChannelPlaylists(from json: JSON) -> [ChannelPlaylist] { + json.arrayValue.compactMap(PipedAPI.extractChannelPlaylist) + } + private static func extractVideo(_ content: JSON) -> Video? { let details = content.dictionaryValue let url = details["url"]?.string diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index f26bd5ce..c23017f9 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -21,4 +21,6 @@ protocol VideosAPI { func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? func playlistVideos(_ id: String) -> Resource? + + func channelPlaylist(_ id: String) -> Resource? } diff --git a/Model/ChannelPlaylist.swift b/Model/ChannelPlaylist.swift new file mode 100644 index 00000000..c10104ac --- /dev/null +++ b/Model/ChannelPlaylist.swift @@ -0,0 +1,10 @@ +import Foundation + +struct ChannelPlaylist: Identifiable { + var id: String = UUID().uuidString + var title: String + var thumbnailURL: URL? + var channel: Channel? + var videos = [Video]() + var videosCount: Int? +} diff --git a/Model/ContentItem.swift b/Model/ContentItem.swift index 193c14fe..2d7fd13e 100644 --- a/Model/ContentItem.swift +++ b/Model/ContentItem.swift @@ -8,7 +8,7 @@ struct ContentItem: Identifiable { switch self { case .channel: return 1 - case .video: + case .playlist: return 2 default: return 3 @@ -21,7 +21,7 @@ struct ContentItem: Identifiable { } var video: Video! - var playlist: Playlist! + var playlist: ChannelPlaylist! var channel: Channel! static func array(of videos: [Video]) -> [ContentItem] { diff --git a/Model/NavigationModel.swift b/Model/NavigationModel.swift index 6628abed..9da95200 100644 --- a/Model/NavigationModel.swift +++ b/Model/NavigationModel.swift @@ -26,7 +26,8 @@ final class NavigationModel: ObservableObject { @Published var presentingUnsubscribeAlert = false @Published var channelToUnsubscribe: Channel! - @Published var isChannelOpen = false + @Published var presentingChannel = false + @Published var presentingPlaylist = false @Published var sidebarSectionChanged = false @Published var presentingSettings = false diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 64b53ea4..41b232f0 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -27,17 +27,16 @@ final class PlayerModel: ObservableObject { @Published var queue = [PlayerQueueItem]() @Published var currentItem: PlayerQueueItem! - @Published var live = false @Published var history = [PlayerQueueItem]() - @Published var savedTime: CMTime? - @Published var composition = AVMutableComposition() + @Published var playerNavigationLinkActive = false var accounts: AccountsModel var instances: InstancesModel + var composition = AVMutableComposition() var timeObserver: Any? private var shouldResumePlaying = true private var statusObservation: NSKeyValueObservation? @@ -61,6 +60,10 @@ final class PlayerModel: ObservableObject { currentItem?.playbackTime } + var live: Bool { + currentItem?.video.live ?? false + } + var playerItemDuration: CMTime? { player.currentItem?.asset.duration } @@ -335,7 +338,6 @@ final class PlayerModel: ObservableObject { timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in self.currentRate = self.player.rate - self.live = self.currentVideo?.live ?? false self.currentItem?.playbackTime = self.player.currentTime() self.currentItem?.videoDuration = self.player.currentItem?.asset.duration.seconds } diff --git a/Model/RecentsModel.swift b/Model/RecentsModel.swift index ca22c857..550063bd 100644 --- a/Model/RecentsModel.swift +++ b/Model/RecentsModel.swift @@ -39,13 +39,21 @@ final class RecentsModel: ObservableObject { return nil } + + var presentedPlaylist: ChannelPlaylist? { + if let recent = items.last(where: { $0.type == .playlist }) { + return recent.playlist + } + + return nil + } } struct RecentItem: Defaults.Serializable, Identifiable { static var bridge = RecentItemBridge() enum ItemType: String { - case channel, query + case channel, playlist, query } var type: ItemType @@ -72,6 +80,14 @@ struct RecentItem: Defaults.Serializable, Identifiable { return Channel(id: id, name: title) } + var playlist: ChannelPlaylist? { + guard type == .playlist else { + return nil + } + + return ChannelPlaylist(id: id, title: title) + } + init(type: ItemType, identifier: String, title: String) { self.type = type id = identifier @@ -89,6 +105,12 @@ struct RecentItem: Defaults.Serializable, Identifiable { id = query title = query } + + init(from playlist: ChannelPlaylist) { + type = .playlist + id = playlist.id + title = playlist.title + } } struct RecentItemBridge: Defaults.Bridge { diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index 1491f8ef..583ffdb6 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -268,6 +268,18 @@ 37C3A241272359900087A57A /* Double+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A240272359900087A57A /* Double+Format.swift */; }; 37C3A242272359900087A57A /* Double+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A240272359900087A57A /* Double+Format.swift */; }; 37C3A243272359900087A57A /* Double+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A240272359900087A57A /* Double+Format.swift */; }; + 37C3A24527235DA70087A57A /* ChannelPlaylist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24427235DA70087A57A /* ChannelPlaylist.swift */; }; + 37C3A24627235DA70087A57A /* ChannelPlaylist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24427235DA70087A57A /* ChannelPlaylist.swift */; }; + 37C3A24727235DA70087A57A /* ChannelPlaylist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24427235DA70087A57A /* ChannelPlaylist.swift */; }; + 37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24827235FAA0087A57A /* ChannelPlaylistCell.swift */; }; + 37C3A24A27235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24827235FAA0087A57A /* ChannelPlaylistCell.swift */; }; + 37C3A24B27235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24827235FAA0087A57A /* ChannelPlaylistCell.swift */; }; + 37C3A24D272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24C272360470087A57A /* ChannelPlaylist+Fixtures.swift */; }; + 37C3A24E272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24C272360470087A57A /* ChannelPlaylist+Fixtures.swift */; }; + 37C3A24F272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A24C272360470087A57A /* ChannelPlaylist+Fixtures.swift */; }; + 37C3A251272366440087A57A /* ChannelPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A250272366440087A57A /* ChannelPlaylistView.swift */; }; + 37C3A252272366440087A57A /* ChannelPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A250272366440087A57A /* ChannelPlaylistView.swift */; }; + 37C3A253272366440087A57A /* ChannelPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C3A250272366440087A57A /* ChannelPlaylistView.swift */; }; 37C7A1D5267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; @@ -477,6 +489,10 @@ 37BE0BDB26A2367F0092E2DB /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = ""; }; 37C194C626F6A9C8005D3B96 /* RecentsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentsModel.swift; sourceTree = ""; }; 37C3A240272359900087A57A /* Double+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Format.swift"; sourceTree = ""; }; + 37C3A24427235DA70087A57A /* ChannelPlaylist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPlaylist.swift; sourceTree = ""; }; + 37C3A24827235FAA0087A57A /* ChannelPlaylistCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPlaylistCell.swift; sourceTree = ""; }; + 37C3A24C272360470087A57A /* ChannelPlaylist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelPlaylist+Fixtures.swift"; sourceTree = ""; }; + 37C3A250272366440087A57A /* ChannelPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPlaylistView.swift; sourceTree = ""; }; 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = ""; }; 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueItem.swift; sourceTree = ""; }; 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueView.swift; sourceTree = ""; }; @@ -660,6 +676,8 @@ isa = PBXGroup; children = ( 3743B86727216D3600261544 /* ChannelCell.swift */, + 37C3A24827235FAA0087A57A /* ChannelPlaylistCell.swift */, + 37C3A250272366440087A57A /* ChannelPlaylistView.swift */, 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */, 37FB285D272225E800A57617 /* ContentItemView.swift */, 3748186D26A769D60084E870 /* DetailBadge.swift */, @@ -715,6 +733,7 @@ 3748186426A762300084E870 /* Fixtures */ = { isa = PBXGroup; children = ( + 37C3A24C272360470087A57A /* ChannelPlaylist+Fixtures.swift */, 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */, 37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */, 3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */, @@ -927,6 +946,7 @@ 373CFADA269663F1003CB2C6 /* Thumbnail.swift */, 3705B181267B4E4900704544 /* TrendingCategory.swift */, 37D4B19626717E1500C925CA /* Video.swift */, + 37C3A24427235DA70087A57A /* ChannelPlaylist.swift */, ); path = Model; sourceTree = ""; @@ -1400,6 +1420,7 @@ 3700155B271B0D4D0049C794 /* PipedAPI.swift in Sources */, 37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */, 377FC7E3267A084A00A6BBAF /* VideoCell.swift in Sources */, + 37C3A251272366440087A57A /* ChannelPlaylistView.swift in Sources */, 37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */, 376A33E42720CB35000C1D6B /* Account.swift in Sources */, 37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */, @@ -1409,7 +1430,9 @@ 37B81AFF26D2CA3700675966 /* VideoDetails.swift in Sources */, 377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */, 376578912685490700D4EA09 /* PlaylistsView.swift in Sources */, + 37C3A24527235DA70087A57A /* ChannelPlaylist.swift in Sources */, 37FB28412721B22200A57617 /* ContentItem.swift in Sources */, + 37C3A24D272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */, 37484C2D26FC844700287258 /* AccountsSettingsView.swift in Sources */, 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */, @@ -1429,6 +1452,7 @@ 37B767DB2677C3CA0098BAA8 /* PlayerModel.swift in Sources */, 3788AC2726F6840700F6BAA9 /* WatchNowSection.swift in Sources */, 375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */, + 37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */, 373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */, 37141673267A8E10006CA35D /* Country.swift in Sources */, 3748186E26A769D60084E870 /* DetailBadge.swift in Sources */, @@ -1465,11 +1489,13 @@ 37C194C826F6A9C8005D3B96 /* RecentsModel.swift in Sources */, 37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */, 3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */, + 37C3A24627235DA70087A57A /* ChannelPlaylist.swift in Sources */, 3788AC2C26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */, 37CEE4BE2677B670005A1EFE /* SingleAssetStream.swift in Sources */, 37BA794826DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */, 37E70928271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */, 37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, + 37C3A24E272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */, 37CEE4C22677B697005A1EFE /* Stream.swift in Sources */, 371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */, 37001564271B1F250049C794 /* AccountsModel.swift in Sources */, @@ -1557,7 +1583,9 @@ 37B17DA1268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */, 3743B86927216D3600261544 /* ChannelCell.swift in Sources */, 3748186B26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, + 37C3A252272366440087A57A /* ChannelPlaylistView.swift in Sources */, 373CFADC269663F1003CB2C6 /* Thumbnail.swift in Sources */, + 37C3A24A27235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */, 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */, 37BA794426DBA973002A0235 /* PlaylistsModel.swift in Sources */, @@ -1603,6 +1631,7 @@ 37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */, 376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */, 37141671267A8ACC006CA35D /* TrendingView.swift in Sources */, + 37C3A24727235DA70087A57A /* ChannelPlaylist.swift in Sources */, 3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */, 37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */, 37E70925271CD43000D34DDE /* WelcomeScreen.swift in Sources */, @@ -1641,6 +1670,7 @@ 3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */, + 37C3A253272366440087A57A /* ChannelPlaylistView.swift in Sources */, 3743CA54270F284F00E4D32B /* View+Borders.swift in Sources */, 371F2F1C269B43D300E4A7AB /* NavigationModel.swift in Sources */, 37E2EEAD270656EC00170416 /* PlayerControlsView.swift in Sources */, @@ -1658,6 +1688,7 @@ 379775952689365600DD52A8 /* Array+Next.swift in Sources */, 3705B180267B4DFB00704544 /* TrendingCountry.swift in Sources */, 373CFACD26966264003CB2C6 /* SearchQuery.swift in Sources */, + 37C3A24B27235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */, 37FD43E52704847C0073EE42 /* View+Fixtures.swift in Sources */, 37141675267A8E10006CA35D /* Country.swift in Sources */, 37152EEC26EFEB95004FB96D /* LazyView.swift in Sources */, @@ -1668,6 +1699,7 @@ 37D526E02720AC4400ED2F5E /* VideosAPI.swift in Sources */, 3705B184267B4E4900704544 /* TrendingCategory.swift in Sources */, 3761ABFF26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */, + 37C3A24F272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */, 37FB28432721B22200A57617 /* ContentItem.swift in Sources */, 37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */, 37484C1B26FC837400287258 /* PlaybackSettingsView.swift in Sources */, diff --git a/Shared/Navigation/AppSidebarRecents.swift b/Shared/Navigation/AppSidebarRecents.swift index c69abca2..513fc32a 100644 --- a/Shared/Navigation/AppSidebarRecents.swift +++ b/Shared/Navigation/AppSidebarRecents.swift @@ -17,6 +17,12 @@ struct AppSidebarRecents: View { RecentNavigationLink(recent: recent) { LazyView(ChannelVideosView(channel: recent.channel!)) } + + case .playlist: + RecentNavigationLink(recent: recent, systemImage: "list.and.film") { + LazyView(ChannelPlaylistView(playlist: recent.playlist!)) + } + case .query: RecentNavigationLink(recent: recent, systemImage: "magnifyingglass") { LazyView(SearchView(recent.query!)) @@ -64,6 +70,7 @@ struct RecentNavigationLink: View { } label: { HStack { Label(recent.title, systemImage: labelSystemImage) + .lineLimit(1) Spacer() diff --git a/Shared/Navigation/AppTabNavigation.swift b/Shared/Navigation/AppTabNavigation.swift index 459c1975..899e2971 100644 --- a/Shared/Navigation/AppTabNavigation.swift +++ b/Shared/Navigation/AppTabNavigation.swift @@ -2,9 +2,11 @@ import Defaults import SwiftUI struct AppTabNavigation: View { + @EnvironmentObject private var accounts @EnvironmentObject private var navigation - @EnvironmentObject private var search + @EnvironmentObject private var player @EnvironmentObject private var recents + @EnvironmentObject private var search var body: some View { TabView(selection: navigation.tabSelectionBinding) { @@ -18,27 +20,30 @@ struct AppTabNavigation: View { } .tag(TabSelection.watchNow) - NavigationView { - LazyView(SubscriptionsView()) - .toolbar { toolbarContent } + if accounts.app.supportsSubscriptions { + NavigationView { + LazyView(SubscriptionsView()) + .toolbar { toolbarContent } + } + .tabItem { + Label("Subscriptions", systemImage: "star.circle.fill") + .accessibility(label: Text("Subscriptions")) + } + .tag(TabSelection.subscriptions) } - .tabItem { - Label("Subscriptions", systemImage: "star.circle.fill") - .accessibility(label: Text("Subscriptions")) - } - .tag(TabSelection.subscriptions) -// TODO: reenable with settings -// ============================ -// NavigationView { -// LazyView(PopularView()) -// .toolbar { toolbarContent } -// } -// .tabItem { -// Label("Popular", systemImage: "chart.bar") -// .accessibility(label: Text("Popular")) -// } -// .tag(TabSelection.popular) + // TODO: reenable with settings + if accounts.app.supportsPopular && false { + NavigationView { + LazyView(PopularView()) + .toolbar { toolbarContent } + } + .tabItem { + Label("Popular", systemImage: "chart.bar") + .accessibility(label: Text("Popular")) + } + .tag(TabSelection.popular) + } NavigationView { LazyView(TrendingView()) @@ -50,15 +55,17 @@ struct AppTabNavigation: View { } .tag(TabSelection.trending) - NavigationView { - LazyView(PlaylistsView()) - .toolbar { toolbarContent } + if accounts.app.supportsUserPlaylists { + NavigationView { + LazyView(PlaylistsView()) + .toolbar { toolbarContent } + } + .tabItem { + Label("Playlists", systemImage: "list.and.film") + .accessibility(label: Text("Playlists")) + } + .tag(TabSelection.playlists) } - .tabItem { - Label("Playlists", systemImage: "list.and.film") - .accessibility(label: Text("Playlists")) - } - .tag(TabSelection.playlists) NavigationView { LazyView( @@ -89,19 +96,41 @@ struct AppTabNavigation: View { .tag(TabSelection.search) } .environment(\.navigationStyle, .tab) - .sheet(isPresented: $navigation.isChannelOpen, onDismiss: { + .sheet(isPresented: $navigation.presentingChannel, onDismiss: { if let channel = recents.presentedChannel { - let recent = RecentItem(from: channel) - recents.close(recent) + recents.close(RecentItem(from: channel)) } }) { - if recents.presentedChannel != nil { + if let channel = recents.presentedChannel { NavigationView { - ChannelVideosView(channel: recents.presentedChannel!) + ChannelVideosView(channel: channel) .environment(\.inNavigationView, true) + .background(playerNavigationLink) } } } + .sheet(isPresented: $navigation.presentingPlaylist, onDismiss: { + if let playlist = recents.presentedPlaylist { + recents.close(RecentItem(from: playlist)) + } + }) { + if let playlist = recents.presentedPlaylist { + NavigationView { + ChannelPlaylistView(playlist: playlist) + .environment(\.inNavigationView, true) + .background(playerNavigationLink) + } + } + } + } + + private var playerNavigationLink: some View { + NavigationLink(isActive: $player.playerNavigationLinkActive, destination: { + VideoPlayerView() + .environment(\.inNavigationView, true) + }) { + EmptyView() + } } var toolbarContent: some ToolbarContent { diff --git a/Shared/Videos/VideoCell.swift b/Shared/Videos/VideoCell.swift index fe47a929..7ad3fdef 100644 --- a/Shared/Videos/VideoCell.swift +++ b/Shared/Videos/VideoCell.swift @@ -4,8 +4,6 @@ import SwiftUI struct VideoCell: View { var video: Video - - @State private var playerNavigationLinkActive = false @State private var lowQualityThumbnail = false @Environment(\.inNavigationView) private var inNavigationView @@ -23,24 +21,17 @@ struct VideoCell: View { player.playNow(video) if inNavigationView { - playerNavigationLinkActive = true + player.playerNavigationLinkActive = true } else { player.presentPlayer() } }) { content } - - NavigationLink(isActive: $playerNavigationLinkActive, destination: { - VideoPlayerView() - .environment(\.inNavigationView, true) - }) { - EmptyView() - } } .buttonStyle(.plain) .contentShape(RoundedRectangle(cornerRadius: 12)) - .contextMenu { VideoContextMenuView(video: video, playerNavigationLinkActive: $playerNavigationLinkActive) } + .contextMenu { VideoContextMenuView(video: video, playerNavigationLinkActive: $player.playerNavigationLinkActive) } } var content: some View { @@ -90,7 +81,7 @@ struct VideoCell: View { } } - if video.views != 0 { + if video.views > 0 { VStack { Image(systemName: "eye") Text(video.viewsCount!) @@ -125,6 +116,7 @@ struct VideoCell: View { Spacer() } + .lineLimit(1) } #endif } @@ -154,7 +146,7 @@ struct VideoCell: View { Text(date) } - if video.views != 0 { + if video.views > 0 { Image(systemName: "eye") Text(video.viewsCount!) } @@ -210,6 +202,7 @@ struct VideoCell: View { } .padding(10) } + .lineLimit(1) } } @@ -253,7 +246,7 @@ struct VideoCell: View { } } -struct VideoView_Preview: PreviewProvider { +struct VideoCell_Preview: PreviewProvider { static var previews: some View { Group { VideoCell(video: Video.fixture) diff --git a/Shared/Views/ChannelCell.swift b/Shared/Views/ChannelCell.swift index a9597ff4..d9367af1 100644 --- a/Shared/Views/ChannelCell.swift +++ b/Shared/Views/ChannelCell.swift @@ -14,7 +14,7 @@ struct ChannelCell: View { Button { let recent = RecentItem(from: channel) recents.add(recent) - navigation.isChannelOpen = true + navigation.presentingChannel = true if navigationStyle == .sidebar { navigation.sidebarSectionChanged.toggle() @@ -30,10 +30,13 @@ struct ChannelCell: View { var content: some View { VStack { - Text("Channel".uppercased()) - .foregroundColor(.secondary) - .fontWeight(.light) - .opacity(0.6) + HStack(alignment: .top, spacing: 3) { + Image(systemName: "person.crop.rectangle") + Text("Channel".uppercased()) + .fontWeight(.light) + .opacity(0.6) + } + .foregroundColor(.secondary) WebImage(url: channel.thumbnailURL) .resizable() @@ -44,20 +47,17 @@ struct ChannelCell: View { .frame(width: 88, height: 88) .clipShape(Circle()) - Group { - DetailBadge(text: channel.name, style: .prominent) + DetailBadge(text: channel.name, style: .prominent) - Group { - if let subscriptions = channel.subscriptionsString { - Text("\(subscriptions) subscribers") - .foregroundColor(.secondary) - } else { - Text("") - } + Group { + if let subscriptions = channel.subscriptionsString { + Text("\(subscriptions) subscribers") + .foregroundColor(.secondary) + } else { + Text("") } - .frame(height: 20) } - .offset(x: 0, y: -15) + .frame(height: 20) } } } diff --git a/Shared/Views/ChannelPlaylistCell.swift b/Shared/Views/ChannelPlaylistCell.swift new file mode 100644 index 00000000..582077b1 --- /dev/null +++ b/Shared/Views/ChannelPlaylistCell.swift @@ -0,0 +1,68 @@ +import SDWebImageSwiftUI +import SwiftUI + +struct ChannelPlaylistCell: View { + let playlist: ChannelPlaylist + + @Environment(\.navigationStyle) private var navigationStyle + + @EnvironmentObject private var navigation + @EnvironmentObject private var recents + + var body: some View { + Button { + let recent = RecentItem(from: playlist) + recents.add(recent) + navigation.presentingPlaylist = 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 { + HStack(alignment: .top, spacing: 3) { + Image(systemName: "list.and.film") + Text("Playlist".uppercased()) + .fontWeight(.light) + .opacity(0.6) + } + .foregroundColor(.secondary) + + WebImage(url: playlist.thumbnailURL) + .resizable() + .placeholder { + Rectangle().fill(Color("PlaceholderColor")) + } + .indicator(.progress) + .frame(width: 165, height: 88) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + Group { + DetailBadge(text: playlist.title, style: .prominent) + .lineLimit(2) + + Text("\(playlist.videosCount ?? playlist.videos.count) videos") + .foregroundColor(.secondary) + + .frame(height: 20) + } + } + } +} + +struct ChannelPlaylistCell_Previews: PreviewProvider { + static var previews: some View { + ChannelPlaylistCell(playlist: ChannelPlaylist.fixture) + .frame(maxWidth: 320) + .injectFixtureEnvironmentObjects() + } +} diff --git a/Shared/Views/ChannelPlaylistView.swift b/Shared/Views/ChannelPlaylistView.swift new file mode 100644 index 00000000..ad8521aa --- /dev/null +++ b/Shared/Views/ChannelPlaylistView.swift @@ -0,0 +1,74 @@ +import Siesta +import SwiftUI + +struct ChannelPlaylistView: View { + var playlist: ChannelPlaylist + + @StateObject private var store = Store() + + @Environment(\.dismiss) private var dismiss + @Environment(\.inNavigationView) private var inNavigationView + + @EnvironmentObject private var accounts + + var items: [ContentItem] { + ContentItem.array(of: store.item?.videos ?? []) + } + + var resource: Resource? { + accounts.api.channelPlaylist(playlist.id) + } + + var body: some View { + #if os(iOS) + if inNavigationView { + content + } else { + PlayerControlsView { + content + } + } + #else + PlayerControlsView { + content + } + #endif + } + + var content: some View { + VStack(alignment: .leading) { + #if os(tvOS) + Text(playlist.title) + .font(.title2) + .frame(alignment: .leading) + #endif + VerticalCells(items: items) + } + .onAppear { + resource?.addObserver(store) + resource?.loadIfNeeded() + } + #if !os(tvOS) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + if inNavigationView { + Button("Done") { + dismiss() + } + } + } + } + .navigationTitle(playlist.title) + + #else + .background(.thickMaterial) + #endif + } +} + +struct ChannelPlaylistView_Previews: PreviewProvider { + static var previews: some View { + ChannelPlaylistView(playlist: ChannelPlaylist.fixture) + .injectFixtureEnvironmentObjects() + } +} diff --git a/Shared/Views/ChannelVideosView.swift b/Shared/Views/ChannelVideosView.swift index 43b4db96..55356923 100644 --- a/Shared/Views/ChannelVideosView.swift +++ b/Shared/Views/ChannelVideosView.swift @@ -6,17 +6,17 @@ struct ChannelVideosView: View { @StateObject private var store = Store() - @EnvironmentObject private var accounts - @EnvironmentObject private var navigation - @EnvironmentObject private var subscriptions - + @Environment(\.dismiss) private var dismiss @Environment(\.inNavigationView) private var inNavigationView - @Environment(\.dismiss) private var dismiss #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass #endif + @EnvironmentObject private var accounts + @EnvironmentObject private var navigation + @EnvironmentObject private var subscriptions + @Namespace private var focusNamespace var videos: [ContentItem] { @@ -88,8 +88,7 @@ struct ChannelVideosView: View { } } } - #endif - #if os(tvOS) + #else .background(.thickMaterial) #endif .modifier(UnsubscribeAlertModifier()) diff --git a/Shared/Views/ContentItemView.swift b/Shared/Views/ContentItemView.swift index a118f37e..94a82d6f 100644 --- a/Shared/Views/ContentItemView.swift +++ b/Shared/Views/ContentItemView.swift @@ -8,7 +8,7 @@ struct ContentItemView: View { Group { switch item.contentType { case .playlist: - VideoCell(video: item.video) + ChannelPlaylistCell(playlist: item.playlist) case .channel: ChannelCell(channel: item.channel) default: diff --git a/Shared/Views/DetailBadge.swift b/Shared/Views/DetailBadge.swift index 30d5dd01..dfc1fbba 100644 --- a/Shared/Views/DetailBadge.swift +++ b/Shared/Views/DetailBadge.swift @@ -73,7 +73,6 @@ struct DetailBadge: View { var body: some View { Text(text) - .lineLimit(1) .truncationMode(.middle) .padding(10) .modifier(StyleModifier(style: style)) diff --git a/Shared/Views/SearchView.swift b/Shared/Views/SearchView.swift index 01ff8fe8..3c3bd8b9 100644 --- a/Shared/Views/SearchView.swift +++ b/Shared/Views/SearchView.swift @@ -25,6 +25,10 @@ struct SearchView: View { private var videos = [Video]() + var items: [ContentItem] { + state.store.collection.sorted { $0 < $1 } + } + init(_ query: SearchQuery? = nil, videos: [Video] = [Video]()) { self.query = query self.videos = videos @@ -42,11 +46,11 @@ struct SearchView: View { filtersHorizontalStack } - HorizontalCells(items: state.store.collection) + HorizontalCells(items: items) } .edgesIgnoringSafeArea(.horizontal) #else - VerticalCells(items: state.store.collection) + VerticalCells(items: items) #endif if noResults { @@ -173,7 +177,7 @@ struct SearchView: View { } fileprivate var noResults: Bool { - state.store.collection.isEmpty && !state.isLoading && !state.query.isEmpty + items.isEmpty && !state.isLoading && !state.query.isEmpty } var recentQueries: some View { diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index 61fbe40d..73cb287c 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -85,7 +85,7 @@ struct VideoContextMenuView: View { Button { let recent = RecentItem(from: video.channel) recents.add(recent) - navigation.isChannelOpen = true + navigation.presentingChannel = true if navigationStyle == .sidebar { navigation.sidebarSectionChanged.toggle() diff --git a/tvOS/TVNavigationView.swift b/tvOS/TVNavigationView.swift index 10b9c073..3c109536 100644 --- a/tvOS/TVNavigationView.swift +++ b/tvOS/TVNavigationView.swift @@ -53,11 +53,16 @@ struct TVNavigationView: View { .fullScreenCover(isPresented: $player.presentingPlayer) { VideoPlayerView() } - .fullScreenCover(isPresented: $navigation.isChannelOpen) { + .fullScreenCover(isPresented: $navigation.presentingChannel) { if let channel = recents.presentedChannel { ChannelVideosView(channel: channel) } } + .fullScreenCover(isPresented: $navigation.presentingPlaylist) { + if let playlist = recents.presentedPlaylist { + ChannelPlaylistView(playlist: playlist) + } + } .onPlayPauseCommand { navigation.presentingSettings.toggle() } } }