diff --git a/Fixtures/Video+Fixtures.swift b/Fixtures/Video+Fixtures.swift index 1662c0ec..0c124717 100644 --- a/Fixtures/Video+Fixtures.swift +++ b/Fixtures/Video+Fixtures.swift @@ -11,9 +11,9 @@ extension Video { length: 582, published: "7 years ago", views: 21534, - channelID: "AbCdEFgHI", description: "Some relaxing live piano music", genre: "Music", + channel: Channel(id: "AbCdEFgHI", name: "The Channel", subscriptionsCount: "2.3K"), thumbnails: Thumbnail.fixturesForAllQualities(videoId: id), live: false, upcoming: false, diff --git a/Model/Channel.swift b/Model/Channel.swift index 374a762e..62411bfc 100644 --- a/Model/Channel.swift +++ b/Model/Channel.swift @@ -1,12 +1,22 @@ import AVFoundation import Defaults import Foundation +import SwiftyJSON struct Channel: Codable, Defaults.Serializable { var id: String var name: String + var subscriptionsCount: String - static func from(video: Video) -> Channel { - Channel(id: video.channelID, name: video.author) + init(json: JSON) { + id = json["authorId"].stringValue + name = json["author"].stringValue + subscriptionsCount = json["subCountText"].stringValue + } + + init(id: String, name: String, subscriptionsCount: String) { + self.id = id + self.name = name + self.subscriptionsCount = subscriptionsCount } } diff --git a/Model/InvidiousAPI.swift b/Model/InvidiousAPI.swift index 5ef82e5f..d0a5a6e5 100644 --- a/Model/InvidiousAPI.swift +++ b/Model/InvidiousAPI.swift @@ -61,15 +61,19 @@ final class InvidiousAPI: Service { configureTransformer("/auth/feed", requestMethods: [.get]) { (content: Entity) -> [Video] in if let feedVideos = content.json.dictionaryValue["videos"] { - return feedVideos.arrayValue.map { Video($0) } + return feedVideos.arrayValue.map(Video.init) } return [] } + configureTransformer("/auth/subscriptions", requestMethods: [.get]) { (content: Entity) -> [Channel] in + content.json.arrayValue.map(Channel.init) + } + configureTransformer("/channels/*", requestMethods: [.get]) { (content: Entity) -> [Video] in if let channelVideos = content.json.dictionaryValue["latestVideos"] { - return channelVideos.arrayValue.map { Video($0) } + return channelVideos.arrayValue.map(Video.init) } return [] @@ -92,10 +96,18 @@ final class InvidiousAPI: Service { .withParam("region", country.rawValue) } - var subscriptions: Resource { + var feed: Resource { resource("/auth/feed") } + var subscriptions: Resource { + resource("/auth/subscriptions") + } + + func channelSubscription(_ id: String) -> Resource { + resource("/auth/subscriptions").child(id) + } + func channelVideos(_ id: String) -> Resource { resource("/channels/\(id)") } diff --git a/Model/Subscriptions.swift b/Model/Subscriptions.swift new file mode 100644 index 00000000..94e080db --- /dev/null +++ b/Model/Subscriptions.swift @@ -0,0 +1,41 @@ +import Foundation +import Siesta +import SwiftUI + +final class Subscriptions: ObservableObject { + @Published var channels = [Channel]() + + var resource: Resource { + InvidiousAPI.shared.subscriptions + } + + init() { + load() + } + + func subscribe(_ channelID: String) { + performChannelSubscriptionRequest(channelID, method: .post) + } + + func unsubscribe(_ channelID: String) { + performChannelSubscriptionRequest(channelID, method: .delete) + } + + func subscribed(_ channelID: String) -> Bool { + channels.contains { $0.id == channelID } + } + + fileprivate func load() { + resource.load().onSuccess { resource in + if let channels: [Channel] = resource.typedContent() { + self.channels = channels + } + } + } + + fileprivate func performChannelSubscriptionRequest(_ channelID: String, method: RequestMethod) { + InvidiousAPI.shared.channelSubscription(channelID).request(method).onCompletion { _ in + self.load() + } + } +} diff --git a/Model/Video.swift b/Model/Video.swift index 8aaa5b03..e7e4a44d 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -11,7 +11,6 @@ struct Video: Identifiable, Equatable { var length: TimeInterval var published: String var views: Int - var channelID: String var description: String var genre: String @@ -29,6 +28,8 @@ struct Video: Identifiable, Equatable { var dislikes: Int? var keywords = [String]() + var channel: Channel + init( id: String, title: String, @@ -36,9 +37,9 @@ struct Video: Identifiable, Equatable { length: TimeInterval, published: String, views: Int, - channelID: String, description: String, genre: String, + channel: Channel, thumbnails: [Thumbnail] = [], indexID: String? = nil, live: Bool = false, @@ -54,9 +55,9 @@ struct Video: Identifiable, Equatable { self.length = length self.published = published self.views = views - self.channelID = channelID self.description = description self.genre = genre + self.channel = channel self.thumbnails = thumbnails self.indexID = indexID self.live = live @@ -83,7 +84,6 @@ struct Video: Identifiable, Equatable { length = json["lengthSeconds"].doubleValue published = json["publishedText"].stringValue views = json["viewCount"].intValue - channelID = json["authorId"].stringValue description = json["description"].stringValue genre = json["genre"].stringValue @@ -105,6 +105,7 @@ struct Video: Identifiable, Equatable { streams.append(contentsOf: Video.extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)) hlsUrl = json["hlsUrl"].url + channel = Channel(json: json) } var playTime: String? { diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index deac8d04..33e40c6c 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -176,6 +176,9 @@ 37D4B19826717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; }; 37D4B19926717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; }; 37D4B19D2671817900C925CA /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 37D4B19C2671817900C925CA /* SwiftyJSON */; }; + 37E64DD126D597EB00C71877 /* Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* Subscriptions.swift */; }; + 37E64DD226D597EB00C71877 /* Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* Subscriptions.swift */; }; + 37E64DD326D597EB00C71877 /* Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* Subscriptions.swift */; }; 37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */; }; 37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */; }; 37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */; }; @@ -286,6 +289,7 @@ 37D4B18B26717B3800C925CA /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; 37D4B19626717E1500C925CA /* Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video.swift; sourceTree = ""; }; 37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 37E64DD026D597EB00C71877 /* Subscriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscriptions.swift; sourceTree = ""; }; 37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockAPI.swift; sourceTree = ""; }; 37EAD86E267B9ED100D9E01B /* Segment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Segment.swift; sourceTree = ""; }; 37F49BA226CAA59B00304AC0 /* Playlist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playlist+Fixtures.swift"; sourceTree = ""; }; @@ -579,6 +583,7 @@ 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */, 3797758A2689345500DD52A8 /* Store.swift */, 37CEE4C02677B697005A1EFE /* Stream.swift */, + 37E64DD026D597EB00C71877 /* Subscriptions.swift */, 373CFADA269663F1003CB2C6 /* Thumbnail.swift */, 3705B181267B4E4900704544 /* TrendingCategory.swift */, 37D4B19626717E1500C925CA /* Video.swift */, @@ -852,6 +857,7 @@ 377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */, 3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */, 37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */, + 37E64DD126D597EB00C71877 /* Subscriptions.swift in Sources */, 376578892685471400D4EA09 /* Playlist.swift in Sources */, 37B81B0526D2CEDA00675966 /* PlaybackState.swift in Sources */, 373CFADB269663F1003CB2C6 /* Thumbnail.swift in Sources */, @@ -928,6 +934,7 @@ 379775942689365600DD52A8 /* Array+Next.swift in Sources */, 3748186726A7627F0084E870 /* Video+Fixtures.swift in Sources */, 37BE0BDA26A214630092E2DB /* PlayerViewController.swift in Sources */, + 37E64DD226D597EB00C71877 /* Subscriptions.swift in Sources */, 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37C7A1DD267CE9D90010EAD6 /* Profile.swift in Sources */, 37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */, @@ -990,6 +997,7 @@ 37B81B0726D2D6CF00675966 /* PlaybackState.swift in Sources */, 3765788B2685471400D4EA09 /* Playlist.swift in Sources */, 373CFADD269663F1003CB2C6 /* Thumbnail.swift in Sources */, + 37E64DD326D597EB00C71877 /* Subscriptions.swift in Sources */, 37C7A1DE267CE9D90010EAD6 /* Profile.swift in Sources */, 373CFABE26966148003CB2C6 /* CoverSectionView.swift in Sources */, 37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */, diff --git a/Shared/Navigation/AppSidebarNavigation.swift b/Shared/Navigation/AppSidebarNavigation.swift index 4ffca915..831cccd3 100644 --- a/Shared/Navigation/AppSidebarNavigation.swift +++ b/Shared/Navigation/AppSidebarNavigation.swift @@ -49,7 +49,7 @@ struct AppSidebarNavigation: View { SubscriptionsView() } label: { - Label("Subscriptions", systemImage: "play.rectangle.fill") + Label("Subscriptions", systemImage: "star.circle.fill") .accessibility(label: Text("Subscriptions")) } diff --git a/Shared/Navigation/AppTabNavigation.swift b/Shared/Navigation/AppTabNavigation.swift index d16b9095..37f28379 100644 --- a/Shared/Navigation/AppTabNavigation.swift +++ b/Shared/Navigation/AppTabNavigation.swift @@ -10,7 +10,7 @@ struct AppTabNavigation: View { SubscriptionsView() } .tabItem { - Label("Subscriptions", systemImage: "play.rectangle.fill") + Label("Subscriptions", systemImage: "star.circle.fill") .accessibility(label: Text("Subscriptions")) } .tag(TabSelection.subscriptions) diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 2c3cfcee..5211fa8c 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -4,6 +4,7 @@ struct ContentView: View { @StateObject private var navigationState = NavigationState() @StateObject private var playbackState = PlaybackState() @StateObject private var searchState = SearchState() + @StateObject private var subscriptions = Subscriptions() #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -40,6 +41,7 @@ struct ContentView: View { .environmentObject(navigationState) .environmentObject(playbackState) .environmentObject(searchState) + .environmentObject(subscriptions) } } diff --git a/Shared/Player/VideoDetails.swift b/Shared/Player/VideoDetails.swift index 4f2c6c17..d33e5c0c 100644 --- a/Shared/Player/VideoDetails.swift +++ b/Shared/Player/VideoDetails.swift @@ -2,16 +2,76 @@ import Foundation import SwiftUI struct VideoDetails: View { + @EnvironmentObject private var subscriptions + + @State private var subscribed = false + @State private var confirmationShown = false + var video: Video var body: some View { VStack(alignment: .leading) { Text(video.title) .font(.title2.bold()) + .padding(.bottom, 0) - Text(video.author) + Divider() + + HStack(alignment: .center) { + HStack(spacing: 4) { + if subscribed { + Image(systemName: "star.circle.fill") + } + VStack(alignment: .leading) { + Text(video.channel.name) + .font(.system(size: 13)) + .bold() + if !video.channel.subscriptionsCount.isEmpty { + Text("\(video.channel.subscriptionsCount) subscribers") + .font(.caption2) + } + } + } .foregroundColor(.secondary) + Spacer() + + Section { + if subscribed { + Button("Unsubscribe") { + confirmationShown = true + } + #if os(iOS) + .tint(.gray) + #endif + .confirmationDialog("Are you you want to unsubscribe from \(video.channel.name)?", isPresented: $confirmationShown) { + Button("Unsubscribe") { + subscriptions.unsubscribe(video.channel.id) + + withAnimation { + subscribed.toggle() + } + } + } + } else { + Button("Subscribe") { + subscriptions.subscribe(video.channel.id) + + withAnimation { + subscribed.toggle() + } + } + .tint(.blue) + } + } + .font(.system(size: 13)) + .buttonStyle(.borderless) + .buttonBorderShape(.roundedRectangle) + } + .padding(.bottom, -1) + + Divider() + HStack(spacing: 4) { if let published = video.publishedDate { Text(published) @@ -26,25 +86,37 @@ struct VideoDetails: View { Text(publishedAt.formatted(date: .abbreviated, time: .omitted)) } } - .padding(.top, 4) .font(.system(size: 12)) + .padding(.bottom, -1) .foregroundColor(.secondary) + Divider() + HStack { + Spacer() + if let views = video.viewsCount { - VideoDetail(title: "Views", detail: views) + videoDetail(label: "Views", value: views, symbol: "eye.fill") } if let likes = video.likesCount { - VideoDetail(title: "Likes", detail: likes, symbol: "hand.thumbsup.circle.fill", symbolColor: Color("VideoDetailLikesSymbolColor")) + Divider() + + videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup.circle.fill") } if let dislikes = video.dislikesCount { - VideoDetail(title: "Dislikes", detail: dislikes, symbol: "hand.thumbsdown.circle.fill", symbolColor: Color("VideoDetailDislikesSymbolColor")) + Divider() + + videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown.circle.fill") } + + Spacer() } - .padding(.horizontal, 1) - .padding(.vertical, 4) + .frame(maxHeight: 35) + .foregroundColor(.secondary) + + Divider() #if os(macOS) ScrollView(.vertical) { @@ -81,6 +153,25 @@ struct VideoDetails: View { } .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) .padding([.horizontal, .bottom]) + .onAppear { + subscribed = subscriptions.subscribed(video.channel.id) + } + } + + func videoDetail(label: String, value: String, symbol: String) -> some View { + VStack(spacing: 4) { + HStack(spacing: 2) { + Image(systemName: symbol) + + Text(label.uppercased()) + } + .font(.system(size: 9)) + .opacity(0.6) + + Text(value) + } + + .frame(maxWidth: 100) } var showScrollIndicators: Bool { @@ -92,41 +183,9 @@ struct VideoDetails: View { } } -struct VideoDetail: View { - var title: String - var detail: String - var symbol = "eye.fill" - var symbolColor = Color.white - - var body: some View { - VStack { - VStack(spacing: 0) { - HStack(alignment: .center, spacing: 4) { - Image(systemName: symbol) - .foregroundColor(symbolColor) - - Text(title.uppercased()) - - Spacer() - } - .font(.caption2) - .padding([.leading, .top], 4) - .frame(alignment: .leading) - - Divider() - .background(.gray) - .padding(.vertical, 4) - - Text(detail) - .shadow(radius: 1.0) - .font(.title3.bold()) - } - } - .foregroundColor(.white) - .background(Color("VideoDetailBackgroundColor")) - .cornerRadius(6) - .overlay(RoundedRectangle(cornerRadius: 6) - .stroke(Color("VideoDetailBorderColor"), lineWidth: 1)) - .frame(maxWidth: 90) +struct VideoDetails_Previews: PreviewProvider { + static var previews: some View { + VideoDetails(video: Video.fixture) + .environmentObject(Subscriptions()) } } diff --git a/Shared/Views/SubscriptionsView.swift b/Shared/Views/SubscriptionsView.swift index 4b971f45..b0f62abe 100644 --- a/Shared/Views/SubscriptionsView.swift +++ b/Shared/Views/SubscriptionsView.swift @@ -3,7 +3,7 @@ import SwiftUI struct SubscriptionsView: View { @ObservedObject private var store = Store<[Video]>() - var resource = InvidiousAPI.shared.subscriptions + var resource = InvidiousAPI.shared.feed init() { resource.addObserver(store) diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index ea754ba7..c2bb0fe6 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -3,33 +3,49 @@ import SwiftUI struct VideoContextMenuView: View { @EnvironmentObject private var navigationState + @EnvironmentObject private var subscriptions let video: Video @Default(.showingAddToPlaylist) var showingAddToPlaylist @Default(.videoIDToAddToPlaylist) var videoIDToAddToPlaylist + @State private var subscribed = false + var body: some View { - openChannelButton(from: video) + Section { + openChannelButton - openVideoDetailsButton + subscriptionButton + .opacity(subscribed ? 1 : 1) - if navigationState.tabSelection == .playlists { - removeFromPlaylistButton - } else { - addToPlaylistButton + openVideoDetailsButton + + if navigationState.tabSelection == .playlists { + removeFromPlaylistButton + } else { + addToPlaylistButton + } } } - func openChannelButton(from video: Video) -> some View { + var openChannelButton: some View { Button("\(video.author) Channel") { - navigationState.openChannel(Channel.from(video: video)) + navigationState.openChannel(video.channel) } } - func closeChannelButton(from video: Video) -> some View { - Button("Close \(Channel.from(video: video).name) Channel") { - navigationState.closeChannel() + var subscriptionButton: some View { + Group { + if subscriptions.subscribed(video.channel.id) { + Button("Unsubscribe", role: .destructive) { + subscriptions.unsubscribe(video.channel.id) + } + } else { + Button("Subscribe") { + subscriptions.subscribe(video.channel.id) + } + } } } diff --git a/macOS/PlayerViewController.swift b/macOS/PlayerViewController.swift index 85fafc4e..1b461da1 100644 --- a/macOS/PlayerViewController.swift +++ b/macOS/PlayerViewController.swift @@ -20,8 +20,7 @@ final class PlayerViewController: NSViewController { } override func loadView() { - playerState = PlayerState() - playerState.playbackState = playbackState + playerState = PlayerState(playbackState: playbackState) guard playerState.player == nil else { return diff --git a/tvOS/VideoDetailsView.swift b/tvOS/VideoDetailsView.swift index 58c393a1..cd1fbae6 100644 --- a/tvOS/VideoDetailsView.swift +++ b/tvOS/VideoDetailsView.swift @@ -101,7 +101,7 @@ struct VideoDetailsView: View { } var openChannelButton: some View { - let channel = Channel.from(video: store.item!) + let channel = video.channel return Button("Open \(channel.name) channel") { navigationState.openChannel(channel)