diff --git a/Model/Channel.swift b/Model/Channel.swift index 62411bfc..06f3f3c9 100644 --- a/Model/Channel.swift +++ b/Model/Channel.swift @@ -3,7 +3,7 @@ import Defaults import Foundation import SwiftyJSON -struct Channel: Codable, Defaults.Serializable { +struct Channel: Codable, Identifiable, Defaults.Serializable { var id: String var name: String var subscriptionsCount: String diff --git a/Model/InvidiousAPI.swift b/Model/InvidiousAPI.swift index d0a5a6e5..8762a733 100644 --- a/Model/InvidiousAPI.swift +++ b/Model/InvidiousAPI.swift @@ -54,6 +54,10 @@ final class InvidiousAPI: Service { content.json.arrayValue.map(Playlist.init) } + configureTransformer("/auth/playlists/*", requestMethods: [.get]) { (content: Entity) -> Playlist in + Playlist(content.json) + } + configureTransformer("/auth/playlists", requestMethods: [.post, .patch]) { (content: Entity) -> Playlist in // hacky, to verify if possible to get it in easier way Playlist(JSON(parseJSON: String(data: content.content, encoding: .utf8)!)) diff --git a/Model/NavigationState.swift b/Model/NavigationState.swift index c27ca104..4dc42a4c 100644 --- a/Model/NavigationState.swift +++ b/Model/NavigationState.swift @@ -2,6 +2,10 @@ import Foundation import SwiftUI final class NavigationState: ObservableObject { + enum TabSelection: Hashable { + case subscriptions, popular, trending, playlists, channel(String), playlist(String), search + } + @Published var tabSelection: TabSelection = .subscriptions @Published var showingChannel = false @@ -13,6 +17,12 @@ final class NavigationState: ObservableObject { @Published var returnToDetails = false + @Published var presentingPlaylistForm = false + @Published var editedPlaylist: Playlist! + + @Published var presentingUnsubscribeAlert = false + @Published var channelToUnsubscribe: Channel! + func openChannel(_ channel: Channel) { returnToDetails = false self.channel = channel @@ -54,4 +64,21 @@ final class NavigationState: ObservableObject { } ) } + + func presentEditPlaylistForm(_ playlist: Playlist?) { + editedPlaylist = playlist + presentingPlaylistForm = editedPlaylist != nil + } + + func presentNewPlaylistForm() { + editedPlaylist = nil + presentingPlaylistForm = true + } + + func presentUnsubscribeAlert(_ channel: Channel?) { + channelToUnsubscribe = channel + presentingUnsubscribeAlert = channelToUnsubscribe != nil + } } + +typealias TabSelection = NavigationState.TabSelection diff --git a/Model/Playlists.swift b/Model/Playlists.swift new file mode 100644 index 00000000..328564a3 --- /dev/null +++ b/Model/Playlists.swift @@ -0,0 +1,35 @@ +import Foundation +import Siesta +import SwiftUI + +final class Playlists: ObservableObject { + @Published var playlists = [Playlist]() + + var resource: Resource { + InvidiousAPI.shared.playlists + } + + init() { + load() + } + + var all: [Playlist] { + playlists.sorted { $0.title.lowercased() < $1.title.lowercased() } + } + + func find(id: Playlist.ID) -> Playlist? { + all.first { $0.id == id } + } + + func reload() { + load() + } + + fileprivate func load() { + resource.load().onSuccess { resource in + if let playlists: [Playlist] = resource.typedContent() { + self.playlists = playlists + } + } + } +} diff --git a/Model/Subscriptions.swift b/Model/Subscriptions.swift index 94e080db..4a6ad337 100644 --- a/Model/Subscriptions.swift +++ b/Model/Subscriptions.swift @@ -13,6 +13,10 @@ final class Subscriptions: ObservableObject { load() } + var all: [Channel] { + channels.sorted { $0.name.lowercased() < $1.name.lowercased() } + } + func subscribe(_ channelID: String) { performChannelSubscriptionRequest(channelID, method: .post) } diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index 33e40c6c..d703bdda 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -120,6 +120,19 @@ 37B81B0526D2CEDA00675966 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackState.swift */; }; 37B81B0626D2CEDA00675966 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackState.swift */; }; 37B81B0726D2D6CF00675966 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackState.swift */; }; + 37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */; }; + 37BA793C26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */; }; + 37BA793D26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */; }; + 37BA793F26DB8F97002A0235 /* ChannelVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */; }; + 37BA794026DB8F97002A0235 /* ChannelVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */; }; + 37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */; }; + 37BA794326DBA973002A0235 /* Playlists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794226DBA973002A0235 /* Playlists.swift */; }; + 37BA794426DBA973002A0235 /* Playlists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794226DBA973002A0235 /* Playlists.swift */; }; + 37BA794526DBA973002A0235 /* Playlists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794226DBA973002A0235 /* Playlists.swift */; }; + 37BA794726DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794626DC2E56002A0235 /* AppSidebarSubscriptions.swift */; }; + 37BA794826DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794626DC2E56002A0235 /* AppSidebarSubscriptions.swift */; }; + 37BA794B26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794A26DC30EC002A0235 /* AppSidebarPlaylists.swift */; }; + 37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794A26DC30EC002A0235 /* AppSidebarPlaylists.swift */; }; 37BAB54C269B39FD00E75ED1 /* TVNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BAB54B269B39FD00E75ED1 /* TVNavigationView.swift */; }; 37BADCA52699FB72009BE4FB /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 37BADCA42699FB72009BE4FB /* Alamofire */; }; 37BADCA7269A552E009BE4FB /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 37BADCA6269A552E009BE4FB /* Alamofire */; }; @@ -136,7 +149,6 @@ 37BD07C72698B27B003EBB87 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 37BD07C62698B27B003EBB87 /* Introspect */; }; 37BD07C82698B71C003EBB87 /* AppTabNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B0C32671614700C925CA /* AppTabNavigation.swift */; }; 37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BD07B42698AA4D003EBB87 /* ContentView.swift */; }; - 37BD07CA2698FBE5003EBB87 /* AppSidebarNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BD07BA2698AB60003EBB87 /* AppSidebarNavigation.swift */; }; 37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BE0BCE26A0E2D50092E2DB /* VideoPlayerView.swift */; }; 37BE0BD026A0E2D50092E2DB /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BE0BCE26A0E2D50092E2DB /* VideoPlayerView.swift */; }; 37BE0BD126A0E2D50092E2DB /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BE0BCE26A0E2D50092E2DB /* VideoPlayerView.swift */; }; @@ -260,6 +272,11 @@ 37B81AFE26D2CA3700675966 /* VideoDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetails.swift; sourceTree = ""; }; 37B81B0126D2CAE700675966 /* PlaybackBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBar.swift; sourceTree = ""; }; 37B81B0426D2CEDA00675966 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = ""; }; + 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistVideosView.swift; sourceTree = ""; }; + 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelVideosView.swift; sourceTree = ""; }; + 37BA794226DBA973002A0235 /* Playlists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlists.swift; sourceTree = ""; }; + 37BA794626DC2E56002A0235 /* AppSidebarSubscriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarSubscriptions.swift; sourceTree = ""; }; + 37BA794A26DC30EC002A0235 /* AppSidebarPlaylists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarPlaylists.swift; sourceTree = ""; }; 37BAB54B269B39FD00E75ED1 /* TVNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVNavigationView.swift; sourceTree = ""; }; 37BD07B42698AA4D003EBB87 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 37BD07BA2698AB60003EBB87 /* AppSidebarNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarNavigation.swift; sourceTree = ""; }; @@ -362,6 +379,8 @@ isa = PBXGroup; children = ( 37BD07BA2698AB60003EBB87 /* AppSidebarNavigation.swift */, + 37BA794A26DC30EC002A0235 /* AppSidebarPlaylists.swift */, + 37BA794626DC2E56002A0235 /* AppSidebarSubscriptions.swift */, 37D4B0C32671614700C925CA /* AppTabNavigation.swift */, 37BD07B42698AA4D003EBB87 /* ContentView.swift */, ); @@ -417,6 +436,8 @@ 371AAE2826CEC7D900901972 /* Views */ = { isa = PBXGroup; children = ( + 37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */, + 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */, 37AAF27D26737323007FC770 /* PopularView.swift */, 37AAF27F26737550007FC770 /* SearchView.swift */, 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */, @@ -574,6 +595,7 @@ 37B81B0426D2CEDA00675966 /* PlaybackState.swift */, 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */, 376578882685471400D4EA09 /* Playlist.swift */, + 37BA794226DBA973002A0235 /* Playlists.swift */, 37C7A1DB267CE9D90010EAD6 /* Profile.swift */, 373CFACA26966264003CB2C6 /* SearchQuery.swift */, 3711403E26B206A6005B3555 /* SearchState.swift */, @@ -842,10 +864,12 @@ 37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */, 37BD07C82698B71C003EBB87 /* AppTabNavigation.swift in Sources */, 37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, + 37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */, 37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */, 37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */, 37977583268922F600DD52A8 /* InvidiousAPI.swift in Sources */, 37BE0BD626A1D4A90092E2DB /* PlayerViewController.swift in Sources */, + 37BA793F26DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 37754C9D26B7500000DBD602 /* VideosView.swift in Sources */, 3711403F26B206A6005B3555 /* SearchState.swift in Sources */, 37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */, @@ -854,6 +878,7 @@ 37F4AE7226828F0900BD60EA /* VideosCellsView.swift in Sources */, 376578852685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, 3748186626A7627F0084E870 /* Video+Fixtures.swift in Sources */, + 37BA794726DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */, 377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */, 3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */, 37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */, @@ -866,6 +891,7 @@ 3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */, 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */, + 37BA794326DBA973002A0235 /* Playlists.swift in Sources */, 37AAF29026740715007FC770 /* Channel.swift in Sources */, 3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 37B81AFF26D2CA3700675966 /* VideoDetails.swift in Sources */, @@ -878,6 +904,7 @@ 37B81AFC26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */, 37C7A1D5267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37F49BA326CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */, + 37BA794B26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */, 37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */, 373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */, 373CFAC226966159003CB2C6 /* CoverSectionRowView.swift in Sources */, @@ -903,6 +930,7 @@ 37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */, 37CEE4BE2677B670005A1EFE /* SingleAssetStream.swift in Sources */, 373CFABF26966149003CB2C6 /* CoverSectionView.swift in Sources */, + 37BA794826DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */, 37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, 37CEE4C22677B697005A1EFE /* Stream.swift in Sources */, 371F2F1B269B43D300E4A7AB /* NavigationState.swift in Sources */, @@ -912,6 +940,7 @@ 37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */, 37F49BA426CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */, 37EAD870267B9ED100D9E01B /* Segment.swift in Sources */, + 37BA793C26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */, 37141670267A8ACC006CA35D /* TrendingView.swift in Sources */, 377FC7E2267A084A00A6BBAF /* VideoView.swift in Sources */, 37B81B0626D2CEDA00675966 /* PlaybackState.swift in Sources */, @@ -942,9 +971,11 @@ 3797758C2689345500DD52A8 /* Store.swift in Sources */, 37141674267A8E10006CA35D /* Country.swift in Sources */, 37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */, + 37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */, 37D4B19826717E1500C925CA /* Video.swift in Sources */, 37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */, 37BD07C12698AD3B003EBB87 /* TrendingCountry.swift in Sources */, + 37BA794026DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 3711404026B206A6005B3555 /* SearchState.swift in Sources */, 37BE0BD026A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, 373CFAEC26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */, @@ -953,6 +984,7 @@ 3748186B26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 373CFADC269663F1003CB2C6 /* Thumbnail.swift in Sources */, 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, + 37BA794426DBA973002A0235 /* Playlists.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -987,11 +1019,11 @@ 376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, 37D4B1802671650A00C925CA /* PearvidiousApp.swift in Sources */, 3748187026A769D60084E870 /* DetailBadge.swift in Sources */, - 37BD07CA2698FBE5003EBB87 /* AppSidebarNavigation.swift in Sources */, 373CFAC926966188003CB2C6 /* SearchOptionsView.swift in Sources */, 37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */, 37B17DA6268A285E006AEE9B /* VideoDetailsView.swift in Sources */, 37141671267A8ACC006CA35D /* TrendingView.swift in Sources */, + 37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 37AAF29226740715007FC770 /* Channel.swift in Sources */, 37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, 37B81B0726D2D6CF00675966 /* PlaybackState.swift in Sources */, @@ -1012,10 +1044,12 @@ 377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */, 371F2F1C269B43D300E4A7AB /* NavigationState.swift in Sources */, + 37BA794526DBA973002A0235 /* Playlists.swift in Sources */, 37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */, 37BE0BD726A1D4A90092E2DB /* PlayerViewController.swift in Sources */, 37CEE4C32677B697005A1EFE /* Stream.swift in Sources */, 37BE0BE526A336910092E2DB /* OptionsView.swift in Sources */, + 37BA793D26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */, 3711404126B206A6005B3555 /* SearchState.swift in Sources */, 379775952689365600DD52A8 /* Array+Next.swift in Sources */, 37AAF28A2673AB89007FC770 /* ChannelView.swift in Sources */, diff --git a/Shared/Navigation/AppSidebarNavigation.swift b/Shared/Navigation/AppSidebarNavigation.swift index 831cccd3..3424350c 100644 --- a/Shared/Navigation/AppSidebarNavigation.swift +++ b/Shared/Navigation/AppSidebarNavigation.swift @@ -3,17 +3,17 @@ import SwiftUI import Introspect #endif -typealias TabSelection = AppSidebarNavigation.TabSelection - struct AppSidebarNavigation: View { - enum TabSelection: String { - case subscriptions, popular, trending, playlists, channel, search - } - @EnvironmentObject private var navigationState + @EnvironmentObject private var playlists + @EnvironmentObject private var subscriptions @State private var didApplyPrimaryViewWorkAround = false + var selection: Binding { + navigationState.tabSelectionOptionalBinding + } + var body: some View { #if os(iOS) content.introspectViewController { viewController in @@ -38,53 +38,18 @@ struct AppSidebarNavigation: View { NavigationView { sidebar .frame(minWidth: 180) - Text("Select section") } } var sidebar: some View { List { - NavigationLink(tag: TabSelection.subscriptions, selection: navigationState.tabSelectionOptionalBinding) { - SubscriptionsView() - } - label: { - Label("Subscriptions", systemImage: "star.circle.fill") - .accessibility(label: Text("Subscriptions")) - } + mainNavigationLinks - NavigationLink(tag: TabSelection.popular, selection: navigationState.tabSelectionOptionalBinding) { - PopularView() - } - label: { - Label("Popular", systemImage: "chart.bar") - .accessibility(label: Text("Popular")) - } - - NavigationLink(tag: TabSelection.trending, selection: navigationState.tabSelectionOptionalBinding) { - TrendingView() - } - label: { - Label("Trending", systemImage: "chart.line.uptrend.xyaxis") - .accessibility(label: Text("Trending")) - } - - NavigationLink(tag: TabSelection.playlists, selection: navigationState.tabSelectionOptionalBinding) { - PlaylistsView() - } - label: { - Label("Playlists", systemImage: "list.and.film") - .accessibility(label: Text("Playlists")) - } - - NavigationLink(tag: TabSelection.search, selection: navigationState.tabSelectionOptionalBinding) { - SearchView() - } - label: { - Label("Search", systemImage: "magnifyingglass") - .accessibility(label: Text("Search")) - } + AppSidebarSubscriptions(selection: selection) + AppSidebarPlaylists(selection: selection) } + #if os(macOS) .toolbar { Button(action: toggleSidebar) { @@ -94,6 +59,59 @@ struct AppSidebarNavigation: View { #endif } + var mainNavigationLinks: some View { + Group { + NavigationLink(tag: TabSelection.subscriptions, selection: selection) { + SubscriptionsView() + } + label: { + Label("Subscriptions", systemImage: "star.circle.fill") + .accessibility(label: Text("Subscriptions")) + } + + NavigationLink(tag: TabSelection.popular, selection: selection) { + PopularView() + } + label: { + Label("Popular", systemImage: "chart.bar") + .accessibility(label: Text("Popular")) + } + + NavigationLink(tag: TabSelection.trending, selection: selection) { + TrendingView() + } + label: { + Label("Trending", systemImage: "chart.line.uptrend.xyaxis") + .accessibility(label: Text("Trending")) + } + + NavigationLink(tag: TabSelection.playlists, selection: selection) { + PlaylistsView() + } + label: { + Label("Playlists", systemImage: "list.and.film") + .accessibility(label: Text("Playlists")) + } + + NavigationLink(tag: TabSelection.search, selection: selection) { + SearchView() + } + label: { + Label("Search", systemImage: "magnifyingglass") + .accessibility(label: Text("Search")) + } + } + } + + static func symbolSystemImage(_ name: String) -> String { + let firstLetter = name.first?.lowercased() + let regex = #"^[a-z0-9]$"# + + let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark" + + return "\(symbolName).square" + } + #if os(macOS) private func toggleSidebar() { NSApp.keyWindow?.contentViewController?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil) diff --git a/Shared/Navigation/AppSidebarPlaylists.swift b/Shared/Navigation/AppSidebarPlaylists.swift new file mode 100644 index 00000000..ba821cd8 --- /dev/null +++ b/Shared/Navigation/AppSidebarPlaylists.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct AppSidebarPlaylists: View { + @EnvironmentObject private var navigationState + @EnvironmentObject private var playlists + + @Binding var selection: TabSelection? + + var body: some View { + Section(header: Text("Playlists")) { + ForEach(playlists.all) { playlist in + NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $selection) { + PlaylistVideosView(playlist) + } label: { + Label(playlist.title, systemImage: AppSidebarNavigation.symbolSystemImage(playlist.title)) + .badge(Text("\(playlist.videos.count)")) + } + .contextMenu { + Button("Edit") { + navigationState.presentEditPlaylistForm(playlists.find(id: playlist.id)) + } + } + } + + newPlaylistButton + .padding(.top, 8) + } + } + + var newPlaylistButton: some View { + Button(action: { navigationState.presentNewPlaylistForm() }) { + Label("New Playlist", systemImage: "plus.square") + } + .foregroundColor(.secondary) + .buttonStyle(.plain) + } +} diff --git a/Shared/Navigation/AppSidebarSubscriptions.swift b/Shared/Navigation/AppSidebarSubscriptions.swift new file mode 100644 index 00000000..236a83fd --- /dev/null +++ b/Shared/Navigation/AppSidebarSubscriptions.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct AppSidebarSubscriptions: View { + @EnvironmentObject private var navigationState + @EnvironmentObject private var subscriptions + + @Binding var selection: TabSelection? + + var body: some View { + Section(header: Text("Subscriptions")) { + ForEach(subscriptions.all) { channel in + NavigationLink(tag: TabSelection.channel(channel.id), selection: $selection) { + ChannelVideosView(channel) + } label: { + Label(channel.name, systemImage: AppSidebarNavigation.symbolSystemImage(channel.name)) + } + .contextMenu { + Button("Unsubscribe") { + navigationState.presentUnsubscribeAlert(channel) + } + } + .alert(unsubscribeAlertTitle, isPresented: $navigationState.presentingUnsubscribeAlert) { + if let channel = navigationState.channelToUnsubscribe { + Button("Unsubscribe", role: .destructive) { + subscriptions.unsubscribe(channel.id) + } + } + } + } + } + } + + var unsubscribeAlertTitle: String { + if let channel = navigationState.channelToUnsubscribe { + return "Unsubscribe from \(channel.name)" + } + + return "Unknown channel" + } +} diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 5211fa8c..85986846 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -5,6 +5,7 @@ struct ContentView: View { @StateObject private var playbackState = PlaybackState() @StateObject private var searchState = SearchState() @StateObject private var subscriptions = Subscriptions() + @StateObject private var playlists = Playlists() #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -37,11 +38,15 @@ struct ContentView: View { #endif } } + .sheet(isPresented: $navigationState.presentingPlaylistForm) { + PlaylistFormView(playlist: $navigationState.editedPlaylist) + } #endif .environmentObject(navigationState) .environmentObject(playbackState) .environmentObject(searchState) .environmentObject(subscriptions) + .environmentObject(playlists) } } diff --git a/Shared/Playlists/PlaylistFormView.swift b/Shared/Playlists/PlaylistFormView.swift index c3e535b6..d09433b9 100644 --- a/Shared/Playlists/PlaylistFormView.swift +++ b/Shared/Playlists/PlaylistFormView.swift @@ -14,6 +14,8 @@ struct PlaylistFormView: View { @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var playlists + var editing: Bool { playlist != nil } @@ -139,6 +141,8 @@ struct PlaylistFormView: View { playlist = modifiedPlaylist } + playlists.reload() + dismiss() } } diff --git a/Shared/Views/ChannelVideosView.swift b/Shared/Views/ChannelVideosView.swift new file mode 100644 index 00000000..45c558cd --- /dev/null +++ b/Shared/Views/ChannelVideosView.swift @@ -0,0 +1,27 @@ +import Siesta +import SwiftUI + +struct ChannelVideosView: View { + @ObservedObject private var store = Store<[Video]>() + + let channel: Channel + + var resource: Resource { + InvidiousAPI.shared.channelVideos(channel.id) + } + + init(_ channel: Channel) { + self.channel = channel + resource.addObserver(store) + } + + var body: some View { + VideosView(videos: store.collection) + #if !os(tvOS) + .navigationTitle("\(channel.name) Channel") + #endif + .onAppear { + resource.loadIfNeeded() + } + } +} diff --git a/Shared/Views/PlaylistVideosView.swift b/Shared/Views/PlaylistVideosView.swift new file mode 100644 index 00000000..5e71a115 --- /dev/null +++ b/Shared/Views/PlaylistVideosView.swift @@ -0,0 +1,17 @@ +import Siesta +import SwiftUI + +struct PlaylistVideosView: View { + let playlist: Playlist + + init(_ playlist: Playlist) { + self.playlist = playlist + } + + var body: some View { + VideosView(videos: playlist.videos) + #if !os(tvOS) + .navigationTitle("\(playlist.title) Playlist") + #endif + } +}