diff --git a/Extensions/Int+Format.swift b/Extensions/Int+Format.swift new file mode 100644 index 00000000..4d3754ec --- /dev/null +++ b/Extensions/Int+Format.swift @@ -0,0 +1,29 @@ +import Foundation + +extension Int { + func formattedAsAbbreviation() -> String { + typealias Abbrevation = (threshold: Double, divisor: Double, suffix: String) + let abbreviations: [Abbrevation] = [ + (0, 1, ""), (1000.0, 1000.0, "K"), + (999_999.0, 1_000_000.0, "M"), (999_999_999.0, 1_000_000_000.0, "B") + ] + + let startValue = Double(abs(self)) + + guard let nextAbbreviationIndex = abbreviations.firstIndex(where: { startValue < $0.threshold }) else { + return String(self) + } + + let abbreviation = abbreviations[abbreviations.index(before: nextAbbreviationIndex)] + let formatter = NumberFormatter() + + formatter.positiveSuffix = abbreviation.suffix + formatter.negativeSuffix = abbreviation.suffix + formatter.allowsFloats = true + formatter.minimumIntegerDigits = 1 + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 1 + + return formatter.string(from: NSNumber(value: Double(self) / abbreviation.divisor))! + } +} diff --git a/Fixtures/Video+Fixtures.swift b/Fixtures/Video+Fixtures.swift index 0c124717..84bbb3a6 100644 --- a/Fixtures/Video+Fixtures.swift +++ b/Fixtures/Video+Fixtures.swift @@ -13,7 +13,7 @@ extension Video { views: 21534, description: "Some relaxing live piano music", genre: "Music", - channel: Channel(id: "AbCdEFgHI", name: "The Channel", subscriptionsCount: "2.3K"), + channel: Channel(id: "AbCdEFgHI", name: "The Channel", subscriptionsCount: 2300, videos: []), thumbnails: Thumbnail.fixturesForAllQualities(videoId: id), live: false, upcoming: false, diff --git a/Model/Channel.swift b/Model/Channel.swift index 06f3f3c9..dd74ecb6 100644 --- a/Model/Channel.swift +++ b/Model/Channel.swift @@ -3,20 +3,41 @@ import Defaults import Foundation import SwiftyJSON -struct Channel: Codable, Identifiable, Defaults.Serializable { +struct Channel: Identifiable, Hashable { var id: String var name: String - var subscriptionsCount: String + var videos = [Video]() + + private var subscriptionsCount: Int? + private var subscriptionsText: String? init(json: JSON) { id = json["authorId"].stringValue name = json["author"].stringValue - subscriptionsCount = json["subCountText"].stringValue + subscriptionsCount = json["subCount"].int + subscriptionsText = json["subCountText"].string + + if let channelVideos = json.dictionaryValue["latestVideos"] { + videos = channelVideos.arrayValue.map(Video.init) + } } - init(id: String, name: String, subscriptionsCount: String) { + init(id: String, name: String, subscriptionsCount: Int? = nil, videos: [Video] = []) { self.id = id self.name = name self.subscriptionsCount = subscriptionsCount + self.videos = videos + } + + var subscriptionsString: String? { + if subscriptionsCount != nil { + return subscriptionsCount!.formattedAsAbbreviation() + } + + return subscriptionsText + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) } } diff --git a/Model/InvidiousAPI.swift b/Model/InvidiousAPI.swift index 8762a733..ea5bdb7c 100644 --- a/Model/InvidiousAPI.swift +++ b/Model/InvidiousAPI.swift @@ -75,12 +75,8 @@ final class InvidiousAPI: Service { 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.init) - } - - return [] + configureTransformer("/channels/*", requestMethods: [.get]) { (content: Entity) -> Channel in + Channel(json: content.json) } configureTransformer("/videos/*", requestMethods: [.get]) { (content: Entity) -> Video in @@ -112,7 +108,7 @@ final class InvidiousAPI: Service { resource("/auth/subscriptions").child(id) } - func channelVideos(_ id: String) -> Resource { + func channel(_ id: String) -> Resource { resource("/channels/\(id)") } diff --git a/Model/NavigationState.swift b/Model/NavigationState.swift index 4dc42a4c..c56ba983 100644 --- a/Model/NavigationState.swift +++ b/Model/NavigationState.swift @@ -8,9 +8,6 @@ final class NavigationState: ObservableObject { @Published var tabSelection: TabSelection = .subscriptions - @Published var showingChannel = false - @Published var channel: Channel? - @Published var showingVideoDetails = false @Published var showingVideo = false @Published var video: Video? @@ -23,15 +20,40 @@ final class NavigationState: ObservableObject { @Published var presentingUnsubscribeAlert = false @Published var channelToUnsubscribe: Channel! + @Published var openChannels = Set() + @Published var isChannelOpen = false + @Published var sidebarSectionChanged = false + func openChannel(_ channel: Channel) { - returnToDetails = false - self.channel = channel - showingChannel = true + openChannels.insert(channel) + + isChannelOpen = true + tabSelection = .channel(channel.id) } - func closeChannel() { - showingChannel = false - channel = nil + func closeChannel(_ channel: Channel) { + guard openChannels.remove(channel) != nil else { + return + } + + isChannelOpen = !openChannels.isEmpty + + if tabSelection == .channel(channel.id) { + tabSelection = .subscriptions + } + } + + func closeAllChannels() { + isChannelOpen = false + openChannels.removeAll() + } + + func showOpenChannel(_ id: Channel.ID) -> Bool { + if case .channel = tabSelection { + return false + } else { + return !openChannels.contains { $0.id == id } + } } func openVideoDetails(_ video: Video) { @@ -59,8 +81,10 @@ final class NavigationState: ObservableObject { get: { self.tabSelection }, - set: { - self.tabSelection = $0 ?? .subscriptions + set: { newValue in + if newValue != nil { + self.tabSelection = newValue! + } } ) } diff --git a/Model/Subscriptions.swift b/Model/Subscriptions.swift index 4a6ad337..80bf4db0 100644 --- a/Model/Subscriptions.swift +++ b/Model/Subscriptions.swift @@ -17,29 +17,30 @@ final class Subscriptions: ObservableObject { channels.sorted { $0.name.lowercased() < $1.name.lowercased() } } - func subscribe(_ channelID: String) { - performChannelSubscriptionRequest(channelID, method: .post) + func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) { + performChannelSubscriptionRequest(channelID, method: .post, onSuccess: onSuccess) } - func unsubscribe(_ channelID: String) { - performChannelSubscriptionRequest(channelID, method: .delete) + func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) { + performChannelSubscriptionRequest(channelID, method: .delete, onSuccess: onSuccess) } - func subscribed(_ channelID: String) -> Bool { + func isSubscribing(_ channelID: String) -> Bool { channels.contains { $0.id == channelID } } - fileprivate func load() { + fileprivate func load(onSuccess: @escaping () -> Void = {}) { resource.load().onSuccess { resource in if let channels: [Channel] = resource.typedContent() { self.channels = channels + onSuccess() } } } - fileprivate func performChannelSubscriptionRequest(_ channelID: String, method: RequestMethod) { + fileprivate func performChannelSubscriptionRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) { InvidiousAPI.shared.channelSubscription(channelID).request(method).onCompletion { _ in - self.load() + self.load(onSuccess: onSuccess) } } } diff --git a/Model/Video.swift b/Model/Video.swift index e7e4a44d..c175bc63 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -127,40 +127,15 @@ struct Video: Identifiable, Equatable { } var viewsCount: String? { - views != 0 ? formattedCount(views) : nil + views != 0 ? views.formattedAsAbbreviation() : nil } var likesCount: String? { - formattedCount(likes) + likes?.formattedAsAbbreviation() } var dislikesCount: String? { - formattedCount(dislikes) - } - - func formattedCount(_ count: Int!) -> String? { - guard count != nil else { - return nil - } - - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - - var number: NSNumber - var unit: String - - if count < 1000 { - return "\(count!)" - } else if count < 1_000_000 { - number = NSNumber(value: Double(count) / 1000.0) - unit = "K" - } else { - number = NSNumber(value: Double(count) / 1_000_000.0) - unit = "M" - } - - return "\(formatter.string(from: number)!)\(unit)" + dislikes?.formattedAsAbbreviation() } var selectableStreams: [Stream] { diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index d703bdda..b149c4ee 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -54,6 +54,8 @@ 3748186E26A769D60084E870 /* DetailBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186D26A769D60084E870 /* DetailBadge.swift */; }; 3748186F26A769D60084E870 /* DetailBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186D26A769D60084E870 /* DetailBadge.swift */; }; 3748187026A769D60084E870 /* DetailBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186D26A769D60084E870 /* DetailBadge.swift */; }; + 3763495126DFF59D00B9A393 /* AppSidebarRecentlyOpened.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecentlyOpened.swift */; }; + 3763495226DFF59D00B9A393 /* AppSidebarRecentlyOpened.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3763495026DFF59D00B9A393 /* AppSidebarRecentlyOpened.swift */; }; 376578852685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; }; 376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; }; 376578872685429C00D4EA09 /* CaseIterable+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376578842685429C00D4EA09 /* CaseIterable+Next.swift */; }; @@ -73,8 +75,6 @@ 377FC7DB267A080300A6BBAF /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 377FC7DA267A080300A6BBAF /* Logging */; }; 377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27D26737323007FC770 /* PopularView.swift */; }; 377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27D26737323007FC770 /* PopularView.swift */; }; - 377FC7E0267A082600A6BBAF /* ChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF2892673AB89007FC770 /* ChannelView.swift */; }; - 377FC7E1267A082600A6BBAF /* ChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF2892673AB89007FC770 /* ChannelView.swift */; }; 377FC7E2267A084A00A6BBAF /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B18B26717B3800C925CA /* VideoView.swift */; }; 377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B18B26717B3800C925CA /* VideoView.swift */; }; 377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.swift */; }; @@ -91,9 +91,14 @@ 379775932689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; }; 379775942689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; }; 379775952689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; }; + 379DDFEE26DEDB0E00EA08E7 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DDFED26DEDB0E00EA08E7 /* EnvironmentValues.swift */; }; + 379DDFEF26DEDB0E00EA08E7 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DDFED26DEDB0E00EA08E7 /* EnvironmentValues.swift */; }; + 379DDFF026DEDB0E00EA08E7 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DDFED26DEDB0E00EA08E7 /* EnvironmentValues.swift */; }; + 379DDFF326DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DDFF226DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift */; }; + 379DDFF426DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DDFF226DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift */; }; + 379DDFF526DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DDFF226DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift */; }; 37AAF27E26737323007FC770 /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27D26737323007FC770 /* PopularView.swift */; }; 37AAF28026737550007FC770 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27F26737550007FC770 /* SearchView.swift */; }; - 37AAF28A2673AB89007FC770 /* ChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF2892673AB89007FC770 /* ChannelView.swift */; }; 37AAF29026740715007FC770 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF28F26740715007FC770 /* Channel.swift */; }; 37AAF29126740715007FC770 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF28F26740715007FC770 /* Channel.swift */; }; 37AAF29226740715007FC770 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF28F26740715007FC770 /* Channel.swift */; }; @@ -133,6 +138,11 @@ 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 */; }; + 37BA794F26DC3E0E002A0235 /* Int+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794E26DC3E0E002A0235 /* Int+Format.swift */; }; + 37BA795026DC3E0E002A0235 /* Int+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794E26DC3E0E002A0235 /* Int+Format.swift */; }; + 37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794E26DC3E0E002A0235 /* Int+Format.swift */; }; + 37BA796F26DC412E002A0235 /* Int+FormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA796D26DC412E002A0235 /* Int+FormatTests.swift */; }; + 37BA797026DC426B002A0235 /* Int+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA794E26DC3E0E002A0235 /* Int+Format.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 */; }; @@ -207,6 +217,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 37BA796726DC40CB002A0235 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 37D4B0BD2671614700C925CA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 37D4B0CE2671614900C925CA; + remoteInfo = "Pearvidious (macOS)"; + }; 37D4B0D52671614900C925CA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 37D4B0BD2671614700C925CA /* Project object */; @@ -248,6 +265,7 @@ 3748186526A7627F0084E870 /* Video+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Video+Fixtures.swift"; sourceTree = ""; }; 3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Thumbnail+Fixtures.swift"; sourceTree = ""; }; 3748186D26A769D60084E870 /* DetailBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailBadge.swift; sourceTree = ""; }; + 3763495026DFF59D00B9A393 /* AppSidebarRecentlyOpened.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarRecentlyOpened.swift; sourceTree = ""; }; 376578842685429C00D4EA09 /* CaseIterable+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaseIterable+Next.swift"; sourceTree = ""; }; 376578882685471400D4EA09 /* Playlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlist.swift; sourceTree = ""; }; 376578902685490700D4EA09 /* PlaylistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsView.swift; sourceTree = ""; }; @@ -257,9 +275,10 @@ 3797758A2689345500DD52A8 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; 379775922689365600DD52A8 /* Array+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Next.swift"; sourceTree = ""; }; 37992DC726CC50BC003D4C27 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 379DDFED26DEDB0E00EA08E7 /* EnvironmentValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = ""; }; + 379DDFF226DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsubscribeAlertModifier.swift; sourceTree = ""; }; 37AAF27D26737323007FC770 /* PopularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularView.swift; sourceTree = ""; }; 37AAF27F26737550007FC770 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; - 37AAF2892673AB89007FC770 /* ChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelView.swift; sourceTree = ""; }; 37AAF28F26740715007FC770 /* Channel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Channel.swift; sourceTree = ""; }; 37AAF29926740A01007FC770 /* VideosListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosListView.swift; sourceTree = ""; }; 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsView.swift; sourceTree = ""; }; @@ -277,6 +296,9 @@ 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 = ""; }; + 37BA794E26DC3E0E002A0235 /* Int+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Format.swift"; sourceTree = ""; }; + 37BA796326DC40CB002A0235 /* Shared Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Shared Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 37BA796D26DC412E002A0235 /* Int+FormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+FormatTests.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 = ""; }; @@ -314,6 +336,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 37BA796026DC40CB002A0235 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37D4B0C62671614900C925CA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -380,6 +409,7 @@ children = ( 37BD07BA2698AB60003EBB87 /* AppSidebarNavigation.swift */, 37BA794A26DC30EC002A0235 /* AppSidebarPlaylists.swift */, + 3763495026DFF59D00B9A393 /* AppSidebarRecentlyOpened.swift */, 37BA794626DC2E56002A0235 /* AppSidebarSubscriptions.swift */, 37D4B0C32671614700C925CA /* AppTabNavigation.swift */, 37BD07B42698AA4D003EBB87 /* ContentView.swift */, @@ -480,6 +510,30 @@ path = iOS; sourceTree = ""; }; + 379DDFF126DEE2A800EA08E7 /* Modifiers */ = { + isa = PBXGroup; + children = ( + 379DDFF226DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift */, + ); + path = Modifiers; + sourceTree = ""; + }; + 37BA796426DC40CB002A0235 /* Shared Tests */ = { + isa = PBXGroup; + children = ( + 37BA796C26DC4105002A0235 /* Extensions */, + ); + path = "Shared Tests"; + sourceTree = ""; + }; + 37BA796C26DC4105002A0235 /* Extensions */ = { + isa = PBXGroup; + children = ( + 37BA796D26DC412E002A0235 /* Int+FormatTests.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 37BE0BD826A214500092E2DB /* macOS */ = { isa = PBXGroup; children = ( @@ -494,6 +548,7 @@ children = ( 379775922689365600DD52A8 /* Array+Next.swift */, 376578842685429C00D4EA09 /* CaseIterable+Next.swift */, + 37BA794E26DC3E0E002A0235 /* Int+Format.swift */, 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */, ); path = Extensions; @@ -509,6 +564,7 @@ 37D4B1B72672CFE300C925CA /* Model */, 37C7A9022679058300E721B4 /* Extensions */, 3748186426A762300084E870 /* Fixtures */, + 37BA796426DC40CB002A0235 /* Shared Tests */, 377FC7D1267A080300A6BBAF /* Frameworks */, 37D4B0CA2671614900C925CA /* Products */, 37D4B174267164B000C925CA /* Tests Apple TV */, @@ -526,7 +582,9 @@ 371AAE2526CEBF0B00901972 /* Trending */, 371AAE2726CEBF4700901972 /* Videos */, 371AAE2826CEC7D900901972 /* Views */, + 379DDFF126DEE2A800EA08E7 /* Modifiers */, 372915E52687E3B900F5A35B /* Defaults.swift */, + 379DDFED26DEDB0E00EA08E7 /* EnvironmentValues.swift */, 37D4B0C22671614700C925CA /* PearvidiousApp.swift */, 37D4B0C42671614800C925CA /* Assets.xcassets */, 37BD07C42698ADEE003EBB87 /* Pearvidious.entitlements */, @@ -543,6 +601,7 @@ 37D4B0DE2671614900C925CA /* Tests macOS.xctest */, 37D4B158267164AE00C925CA /* Pearvidious (Apple TV).app */, 37D4B171267164B000C925CA /* Tests Apple TV.xctest */, + 37BA796326DC40CB002A0235 /* Shared Tests.xctest */, ); name = Products; sourceTree = ""; @@ -568,7 +627,6 @@ children = ( 371AAE2926CF143200901972 /* Options */, 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */, - 37AAF2892673AB89007FC770 /* ChannelView.swift */, 37BAB54B269B39FD00E75ED1 /* TVNavigationView.swift */, 37B17DA3268A285E006AEE9B /* VideoDetailsView.swift */, 37D4B15E267164AF00C925CA /* Assets.xcassets */, @@ -616,6 +674,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 37BA796226DC40CB002A0235 /* Shared Tests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37BA796926DC40CB002A0235 /* Build configuration list for PBXNativeTarget "Shared Tests" */; + buildPhases = ( + 37BA795F26DC40CB002A0235 /* Sources */, + 37BA796026DC40CB002A0235 /* Frameworks */, + 37BA796126DC40CB002A0235 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 37BA796826DC40CB002A0235 /* PBXTargetDependency */, + ); + name = "Shared Tests"; + productName = "Shared Tests"; + productReference = 37BA796326DC40CB002A0235 /* Shared Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 37D4B0C82671614900C925CA /* Pearvidious (iOS) */ = { isa = PBXNativeTarget; buildConfigurationList = 37D4B0EC2671614900C925CA /* Build configuration list for PBXNativeTarget "Pearvidious (iOS)" */; @@ -753,6 +829,10 @@ LastSwiftUpdateCheck = 1300; LastUpgradeCheck = 1300; TargetAttributes = { + 37BA796226DC40CB002A0235 = { + CreatedOnToolsVersion = 13.0; + TestTargetID = 37D4B0CE2671614900C925CA; + }; 37D4B0C82671614900C925CA = { CreatedOnToolsVersion = 13.0; }; @@ -803,11 +883,19 @@ 37D4B0D32671614900C925CA /* Tests iOS */, 37D4B0DD2671614900C925CA /* Tests macOS */, 37D4B170267164B000C925CA /* Tests Apple TV */, + 37BA796226DC40CB002A0235 /* Shared Tests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 37BA796126DC40CB002A0235 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37D4B0C72671614900C925CA /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -857,6 +945,15 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 37BA795F26DC40CB002A0235 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 37BA797026DC426B002A0235 /* Int+Format.swift in Sources */, + 37BA796F26DC412E002A0235 /* Int+FormatTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37D4B0C52671614900C925CA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -864,6 +961,7 @@ 37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */, 37BD07C82698B71C003EBB87 /* AppTabNavigation.swift in Sources */, 37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, + 3763495126DFF59D00B9A393 /* AppSidebarRecentlyOpened.swift in Sources */, 37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */, 37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */, 37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */, @@ -888,6 +986,7 @@ 373CFADB269663F1003CB2C6 /* Thumbnail.swift in Sources */, 37C7A1DC267CE9D90010EAD6 /* Profile.swift in Sources */, 373CFAC026966149003CB2C6 /* CoverSectionView.swift in Sources */, + 379DDFF326DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift in Sources */, 3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */, 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, 377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */, @@ -900,9 +999,9 @@ 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 37B17DA2268A1F8A006AEE9B /* VideoContextMenuView.swift in Sources */, 379775932689365600DD52A8 /* Array+Next.swift in Sources */, - 377FC7E1267A082600A6BBAF /* ChannelView.swift in Sources */, 37B81AFC26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */, 37C7A1D5267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, + 37BA794F26DC3E0E002A0235 /* Int+Format.swift in Sources */, 37F49BA326CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */, 37BA794B26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */, 37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */, @@ -916,6 +1015,7 @@ 372915E62687E3B900F5A35B /* Defaults.swift in Sources */, 37D4B19726717E1500C925CA /* Video.swift in Sources */, 371F2F1A269B43D300E4A7AB /* NavigationState.swift in Sources */, + 379DDFEE26DEDB0E00EA08E7 /* EnvironmentValues.swift in Sources */, 37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, 37BD07BB2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */, 37D4B0E42671614900C925CA /* PearvidiousApp.swift in Sources */, @@ -934,6 +1034,7 @@ 37EAD86C267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, 37CEE4C22677B697005A1EFE /* Stream.swift in Sources */, 371F2F1B269B43D300E4A7AB /* NavigationState.swift in Sources */, + 37BA795026DC3E0E002A0235 /* Int+Format.swift in Sources */, 377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */, 3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */, 37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */, @@ -959,7 +1060,7 @@ 376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */, 37F4AE7326828F0900BD60EA /* VideosCellsView.swift in Sources */, 37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */, - 377FC7E0267A082600A6BBAF /* ChannelView.swift in Sources */, + 379DDFEF26DEDB0E00EA08E7 /* EnvironmentValues.swift in Sources */, 379775942689365600DD52A8 /* Array+Next.swift in Sources */, 3748186726A7627F0084E870 /* Video+Fixtures.swift in Sources */, 37BE0BDA26A214630092E2DB /* PlayerViewController.swift in Sources */, @@ -975,6 +1076,7 @@ 37D4B19826717E1500C925CA /* Video.swift in Sources */, 37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */, 37BD07C12698AD3B003EBB87 /* TrendingCountry.swift in Sources */, + 379DDFF426DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift in Sources */, 37BA794026DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 3711404026B206A6005B3555 /* SearchState.swift in Sources */, 37BE0BD026A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, @@ -984,6 +1086,7 @@ 3748186B26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 373CFADC269663F1003CB2C6 /* Thumbnail.swift in Sources */, 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */, + 3763495226DFF59D00B9A393 /* AppSidebarRecentlyOpened.swift in Sources */, 37BA794426DBA973002A0235 /* Playlists.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1025,6 +1128,7 @@ 37141671267A8ACC006CA35D /* TrendingView.swift in Sources */, 37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 37AAF29226740715007FC770 /* Channel.swift in Sources */, + 379DDFF526DEE2BA00EA08E7 /* UnsubscribeAlertModifier.swift in Sources */, 37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, 37B81B0726D2D6CF00675966 /* PlaybackState.swift in Sources */, 3765788B2685471400D4EA09 /* Playlist.swift in Sources */, @@ -1040,6 +1144,7 @@ 37AAF29A26740A01007FC770 /* VideosListView.swift in Sources */, 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 376578932685490700D4EA09 /* PlaylistsView.swift in Sources */, + 37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */, 3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */, 377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */, @@ -1052,7 +1157,7 @@ 37BA793D26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */, 3711404126B206A6005B3555 /* SearchState.swift in Sources */, 379775952689365600DD52A8 /* Array+Next.swift in Sources */, - 37AAF28A2673AB89007FC770 /* ChannelView.swift in Sources */, + 379DDFF026DEDB0E00EA08E7 /* EnvironmentValues.swift in Sources */, 3705B180267B4DFB00704544 /* TrendingCountry.swift in Sources */, 373CFACD26966264003CB2C6 /* SearchQuery.swift in Sources */, 37141675267A8E10006CA35D /* Country.swift in Sources */, @@ -1079,6 +1184,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 37BA796826DC40CB002A0235 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 37D4B0CE2671614900C925CA /* Pearvidious (macOS) */; + targetProxy = 37BA796726DC40CB002A0235 /* PBXContainerItemProxy */; + }; 37D4B0D62671614900C925CA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 37D4B0C82671614900C925CA /* Pearvidious (iOS) */; @@ -1097,6 +1207,58 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 37BA796A26DC40CB002A0235 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 78Z5H3M6RJ; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "net.arekf.Shared-Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Pearvidious.app/Contents/MacOS/Pearvidious"; + }; + name = Debug; + }; + 37BA796B26DC40CB002A0235 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 78Z5H3M6RJ; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "net.arekf.Shared-Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Pearvidious.app/Contents/MacOS/Pearvidious"; + }; + name = Release; + }; 37D4B0EA2671614900C925CA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1555,6 +1717,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 37BA796926DC40CB002A0235 /* Build configuration list for PBXNativeTarget "Shared Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37BA796A26DC40CB002A0235 /* Debug */, + 37BA796B26DC40CB002A0235 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 37D4B0C02671614700C925CA /* Build configuration list for PBXProject "Pearvidious" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Pearvidious.xcodeproj/xcshareddata/xcschemes/Pearvidious (macOS).xcscheme b/Pearvidious.xcodeproj/xcshareddata/xcschemes/Pearvidious (macOS).xcscheme index 32ad7dd9..fe28b91d 100644 --- a/Pearvidious.xcodeproj/xcshareddata/xcschemes/Pearvidious (macOS).xcscheme +++ b/Pearvidious.xcodeproj/xcshareddata/xcschemes/Pearvidious (macOS).xcscheme @@ -38,6 +38,16 @@ ReferencedContainer = "container:Pearvidious.xcodeproj"> + + + + private var navigationState + @EnvironmentObject private var subscriptions + + func body(content: Content) -> some View { + content + .alert(unsubscribeAlertTitle, isPresented: $navigationState.presentingUnsubscribeAlert) { + if let channel = navigationState.channelToUnsubscribe { + Button("Unsubscribe", role: .destructive) { + subscriptions.unsubscribe(channel.id) { + navigationState.openChannel(channel) + navigationState.sidebarSectionChanged.toggle() + } + } + } + } + } + + var unsubscribeAlertTitle: String { + if let channel = navigationState.channelToUnsubscribe { + return "Unsubscribe from \(channel.name)" + } + + return "Unknown channel" + } +} diff --git a/Shared/Navigation/AppSidebarNavigation.swift b/Shared/Navigation/AppSidebarNavigation.swift index 3424350c..9be450e0 100644 --- a/Shared/Navigation/AppSidebarNavigation.swift +++ b/Shared/Navigation/AppSidebarNavigation.swift @@ -38,16 +38,27 @@ struct AppSidebarNavigation: View { NavigationView { sidebar .frame(minWidth: 180) + Text("Select section") } } var sidebar: some View { - List { - mainNavigationLinks + ScrollViewReader { scrollView in + List { + mainNavigationLinks - AppSidebarSubscriptions(selection: selection) - AppSidebarPlaylists(selection: selection) + Group { + AppSidebarRecentlyOpened(selection: selection) + .id("recentlyOpened") + AppSidebarSubscriptions(selection: selection) + AppSidebarPlaylists(selection: selection) + } + .onChange(of: navigationState.sidebarSectionChanged) { _ in + scrollScrollViewToItem(scrollView: scrollView, for: navigationState.tabSelection) + } + } + .listStyle(.sidebar) } #if os(macOS) @@ -61,48 +72,51 @@ struct AppSidebarNavigation: View { var mainNavigationLinks: some View { Group { - NavigationLink(tag: TabSelection.subscriptions, selection: selection) { - SubscriptionsView() - } - label: { + NavigationLink(destination: SubscriptionsView(), tag: TabSelection.subscriptions, selection: selection) { Label("Subscriptions", systemImage: "star.circle.fill") .accessibility(label: Text("Subscriptions")) } - NavigationLink(tag: TabSelection.popular, selection: selection) { - PopularView() - } - label: { + NavigationLink(destination: PopularView(), tag: TabSelection.popular, selection: selection) { Label("Popular", systemImage: "chart.bar") .accessibility(label: Text("Popular")) } - NavigationLink(tag: TabSelection.trending, selection: selection) { - TrendingView() - } - label: { + NavigationLink(destination: TrendingView(), tag: TabSelection.trending, selection: selection) { Label("Trending", systemImage: "chart.line.uptrend.xyaxis") .accessibility(label: Text("Trending")) } - NavigationLink(tag: TabSelection.playlists, selection: selection) { - PlaylistsView() - } - label: { + NavigationLink(destination: PlaylistsView(), tag: TabSelection.playlists, selection: selection) { Label("Playlists", systemImage: "list.and.film") .accessibility(label: Text("Playlists")) } - NavigationLink(tag: TabSelection.search, selection: selection) { - SearchView() - } - label: { + NavigationLink(destination: SearchView(), tag: TabSelection.search, selection: selection) { Label("Search", systemImage: "magnifyingglass") .accessibility(label: Text("Search")) } } } + func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) { + if case let .channel(id) = selection { + if subscriptions.isSubscribing(id) { + scrollView.scrollTo(id) + } else { + scrollView.scrollTo("recentlyOpened") + } + } else if case let .playlist(id) = selection { + scrollView.scrollTo(id) + } + } + + #if os(macOS) + private func toggleSidebar() { + NSApp.keyWindow?.contentViewController?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil) + } + #endif + static func symbolSystemImage(_ name: String) -> String { let firstLetter = name.first?.lowercased() let regex = #"^[a-z0-9]$"# @@ -111,10 +125,4 @@ struct AppSidebarNavigation: View { return "\(symbolName).square" } - - #if os(macOS) - private func toggleSidebar() { - NSApp.keyWindow?.contentViewController?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil) - } - #endif } diff --git a/Shared/Navigation/AppSidebarPlaylists.swift b/Shared/Navigation/AppSidebarPlaylists.swift index ba821cd8..99248277 100644 --- a/Shared/Navigation/AppSidebarPlaylists.swift +++ b/Shared/Navigation/AppSidebarPlaylists.swift @@ -15,6 +15,7 @@ struct AppSidebarPlaylists: View { Label(playlist.title, systemImage: AppSidebarNavigation.symbolSystemImage(playlist.title)) .badge(Text("\(playlist.videos.count)")) } + .id(playlist.id) .contextMenu { Button("Edit") { navigationState.presentEditPlaylistForm(playlists.find(id: playlist.id)) diff --git a/Shared/Navigation/AppSidebarRecentlyOpened.swift b/Shared/Navigation/AppSidebarRecentlyOpened.swift new file mode 100644 index 00000000..46772f1d --- /dev/null +++ b/Shared/Navigation/AppSidebarRecentlyOpened.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct AppSidebarRecentlyOpened: View { + @Binding var selection: TabSelection? + + @EnvironmentObject private var navigationState + @EnvironmentObject private var subscriptions + + @State private var subscriptionsChanged = false + + var body: some View { + Group { + if !recentlyOpened.isEmpty { + Section(header: Text("Recently Opened")) { + ForEach(recentlyOpened) { channel in + NavigationLink(tag: TabSelection.channel(channel.id), selection: $selection) { + ChannelVideosView(channel) + } label: { + HStack { + Label(channel.name, systemImage: AppSidebarNavigation.symbolSystemImage(channel.name)) + + Spacer() + + Button(action: { navigationState.closeChannel(channel) }) { + Image(systemName: "xmark.circle.fill") + } + .foregroundColor(.secondary) + .buttonStyle(.plain) + } + } + + // force recalculating the view on change of subscriptions + .opacity(subscriptionsChanged ? 1 : 1) + .id(channel.id) + .contextMenu { + Button("Subscribe") { + subscriptions.subscribe(channel.id) { + navigationState.sidebarSectionChanged.toggle() + } + } + } + } + } + .onChange(of: subscriptions.all) { _ in + subscriptionsChanged.toggle() + } + } + } + } + + var recentlyOpened: [Channel] { + navigationState.openChannels.filter { !subscriptions.all.contains($0) } + } +} diff --git a/Shared/Navigation/AppSidebarSubscriptions.swift b/Shared/Navigation/AppSidebarSubscriptions.swift index 236a83fd..39688659 100644 --- a/Shared/Navigation/AppSidebarSubscriptions.swift +++ b/Shared/Navigation/AppSidebarSubscriptions.swift @@ -19,13 +19,7 @@ struct AppSidebarSubscriptions: View { navigationState.presentUnsubscribeAlert(channel) } } - .alert(unsubscribeAlertTitle, isPresented: $navigationState.presentingUnsubscribeAlert) { - if let channel = navigationState.channelToUnsubscribe { - Button("Unsubscribe", role: .destructive) { - subscriptions.unsubscribe(channel.id) - } - } - } + .modifier(UnsubscribeAlertModifier()) } } } diff --git a/Shared/Navigation/AppTabNavigation.swift b/Shared/Navigation/AppTabNavigation.swift index 37f28379..07029285 100644 --- a/Shared/Navigation/AppTabNavigation.swift +++ b/Shared/Navigation/AppTabNavigation.swift @@ -2,10 +2,10 @@ import Defaults import SwiftUI struct AppTabNavigation: View { - @State private var tabSelection: TabSelection = .subscriptions + @EnvironmentObject private var navigationState var body: some View { - TabView(selection: $tabSelection) { + TabView(selection: $navigationState.tabSelection) { NavigationView { SubscriptionsView() } @@ -51,5 +51,19 @@ struct AppTabNavigation: View { } .tag(TabSelection.search) } + .sheet(isPresented: $navigationState.isChannelOpen, onDismiss: { + navigationState.closeChannel(presentedChannel) + }) { + if presentedChannel != nil { + NavigationView { + ChannelVideosView(presentedChannel) + .environment(\.inNavigationView, true) + } + } + } + } + + fileprivate var presentedChannel: Channel! { + navigationState.openChannels.first } } diff --git a/Shared/Player/PlaybackBar.swift b/Shared/Player/PlaybackBar.swift index 164a116e..104efa98 100644 --- a/Shared/Player/PlaybackBar.swift +++ b/Shared/Player/PlaybackBar.swift @@ -59,7 +59,7 @@ struct PlaybackBar: View { Image(systemName: "chevron.down.circle.fill") } .accessibilityLabel(Text("Close")) - .buttonStyle(BorderlessButtonStyle()) + .buttonStyle(.borderless) .foregroundColor(.gray) .keyboardShortcut(.cancelAction) } diff --git a/Shared/Player/VideoDetails.swift b/Shared/Player/VideoDetails.swift index d33e5c0c..ba7b1cbf 100644 --- a/Shared/Player/VideoDetails.swift +++ b/Shared/Player/VideoDetails.swift @@ -26,8 +26,8 @@ struct VideoDetails: View { Text(video.channel.name) .font(.system(size: 13)) .bold() - if !video.channel.subscriptionsCount.isEmpty { - Text("\(video.channel.subscriptionsCount) subscribers") + if let subscribers = video.channel.subscriptionsString { + Text("\(subscribers) subscribers") .font(.caption2) } } @@ -154,7 +154,7 @@ struct VideoDetails: View { .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) .padding([.horizontal, .bottom]) .onAppear { - subscribed = subscriptions.subscribed(video.channel.id) + subscribed = subscriptions.isSubscribing(video.channel.id) } } diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index 15105fcb..1b565bff 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -89,10 +89,9 @@ struct VideoPlayerView: View { navigationState.showingVideoDetails = navigationState.returnToDetails } #if os(macOS) - .navigationTitle(video.title) .frame(maxWidth: 1000, minHeight: 700) #elseif os(iOS) - .navigationBarTitle(video.title, displayMode: .inline) + .navigationBarHidden(true) #endif } diff --git a/Shared/Playlists/PlaylistFormView.swift b/Shared/Playlists/PlaylistFormView.swift index d09433b9..b134b61e 100644 --- a/Shared/Playlists/PlaylistFormView.swift +++ b/Shared/Playlists/PlaylistFormView.swift @@ -44,7 +44,7 @@ struct PlaylistFormView: View { Text(visibility.name) } } - .pickerStyle(SegmentedPickerStyle()) + .pickerStyle(.segmented) } Divider() .padding(.vertical, 4) diff --git a/Shared/Videos/VideoView.swift b/Shared/Videos/VideoView.swift index ca4de91d..2fd674f9 100644 --- a/Shared/Videos/VideoView.swift +++ b/Shared/Videos/VideoView.swift @@ -8,36 +8,50 @@ struct VideoView: View { @Environment(\.verticalSizeClass) private var verticalSizeClass #endif + @Environment(\.inNavigationView) private var inNavigationView + var video: Video var layout: ListingLayout var body: some View { - Button(action: { navigationState.playVideo(video) }) { - VStack { - if layout == .cells { - #if os(iOS) - if verticalSizeClass == .compact { - horizontalRow - .padding(.vertical, 4) - } else { - verticalRow - } - #else - verticalRow - #endif - } else { - horizontalRow + Group { + if inNavigationView { + NavigationLink(destination: VideoPlayerView(video)) { + content + } + } else { + Button(action: { navigationState.playVideo(video) }) { + content } } - #if os(macOS) - .background() - #endif } .modifier(ButtonStyleModifier(layout: layout)) .contentShape(RoundedRectangle(cornerRadius: 12)) .contextMenu { VideoContextMenuView(video: video) } } + var content: some View { + VStack { + if layout == .cells { + #if os(iOS) + if verticalSizeClass == .compact { + horizontalRow + .padding(.vertical, 4) + } else { + verticalRow + } + #else + verticalRow + #endif + } else { + horizontalRow + } + } + #if os(macOS) + .background() + #endif + } + var horizontalRow: some View { HStack(alignment: .top, spacing: 2) { Section { @@ -228,7 +242,7 @@ struct VideoListRowPreview: PreviewProvider { VideoView(video: video, layout: .list) } } - .listStyle(GroupedListStyle()) + .listStyle(.grouped) HStack { ForEach(Video.allFixtures) { video in diff --git a/Shared/Videos/VideosListView.swift b/Shared/Videos/VideosListView.swift index 2ba6421d..4b64021a 100644 --- a/Shared/Videos/VideosListView.swift +++ b/Shared/Videos/VideosListView.swift @@ -24,7 +24,7 @@ struct VideosListView: View { } } } - .listStyle(GroupedListStyle()) + .listStyle(.grouped) } } } diff --git a/Shared/Videos/VideosView.swift b/Shared/Videos/VideosView.swift index 53ea90a0..5718ea7f 100644 --- a/Shared/Videos/VideosView.swift +++ b/Shared/Videos/VideosView.swift @@ -4,8 +4,6 @@ import SwiftUI struct VideosView: View { @EnvironmentObject private var navigationState - @State private var profile = Profile() - #if os(tvOS) @Default(.layout) private var layout #endif diff --git a/Shared/Views/ChannelVideosView.swift b/Shared/Views/ChannelVideosView.swift index 45c558cd..4f83eff5 100644 --- a/Shared/Views/ChannelVideosView.swift +++ b/Shared/Views/ChannelVideosView.swift @@ -2,26 +2,119 @@ import Siesta import SwiftUI struct ChannelVideosView: View { - @ObservedObject private var store = Store<[Video]>() - let channel: Channel - var resource: Resource { - InvidiousAPI.shared.channelVideos(channel.id) - } + @EnvironmentObject private var navigationState + @EnvironmentObject private var subscriptions + + @Environment(\.inNavigationView) private var inNavigationView + + @Environment(\.dismiss) private var dismiss + #if os(iOS) + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + #endif + + @ObservedObject private var store = Store() + + @Namespace private var focusNamespace init(_ channel: Channel) { self.channel = channel + resource.addObserver(store) } var body: some View { - VideosView(videos: store.collection) - #if !os(tvOS) - .navigationTitle("\(channel.name) Channel") + VStack { + #if os(tvOS) + HStack { + Text(navigationTitle) + .font(.title2) + .frame(alignment: .leading) + + Spacer() + + if let subscribers = store.item?.subscriptionsString { + Text("**\(subscribers)** subscribers") + .foregroundColor(.secondary) + } + + subscriptionToggleButton + } + .frame(maxWidth: .infinity) + #endif + + VideosView(videos: store.item?.videos ?? []) + + #if !os(iOS) + .prefersDefaultFocus(in: focusNamespace) + #endif + } + #if !os(iOS) + .focusScope(focusNamespace) #endif - .onAppear { - resource.loadIfNeeded() + #if !os(tvOS) + .toolbar { + ToolbarItem(placement: subscriptionToolbarItemPlacement) { + HStack { + if let channel = store.item, let subscribers = channel.subscriptionsString { + Text("**\(subscribers)** subscribers") + .foregroundColor(.secondary) + } + + subscriptionToggleButton + } + } + + ToolbarItem(placement: .cancellationAction) { + if inNavigationView { + Button("Done") { + dismiss() + } + } + } + } + #endif + .modifier(UnsubscribeAlertModifier()) + .onAppear { + resource.loadIfNeeded() + } + .navigationTitle(navigationTitle) + } + + var resource: Resource { + InvidiousAPI.shared.channel(channel.id) + } + + #if !os(tvOS) + var subscriptionToolbarItemPlacement: ToolbarItemPlacement { + #if os(iOS) + if horizontalSizeClass == .regular { + return .primaryAction + } + #endif + + return .status + } + #endif + + var subscriptionToggleButton: some View { + Group { + if subscriptions.isSubscribing(channel.id) { + Button("Unsubscribe") { + navigationState.presentUnsubscribeAlert(channel) + } + } else { + Button("Subscribe") { + subscriptions.subscribe(channel.id) { + navigationState.sidebarSectionChanged.toggle() + } + } + } } } + + var navigationTitle: String { + store.item?.name ?? channel.name + } } diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index c2bb0fe6..1d885d6e 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -14,7 +14,9 @@ struct VideoContextMenuView: View { var body: some View { Section { - openChannelButton + if navigationState.showOpenChannel(video.channel.id) { + openChannelButton + } subscriptionButton .opacity(subscribed ? 1 : 1) @@ -32,18 +34,25 @@ struct VideoContextMenuView: View { var openChannelButton: some View { Button("\(video.author) Channel") { navigationState.openChannel(video.channel) + navigationState.sidebarSectionChanged.toggle() } } var subscriptionButton: some View { Group { - if subscriptions.subscribed(video.channel.id) { + if subscriptions.isSubscribing(video.channel.id) { Button("Unsubscribe", role: .destructive) { - subscriptions.unsubscribe(video.channel.id) + #if os(tvOS) + subscriptions.unsubscribe(video.channel.id) + #else + navigationState.presentUnsubscribeAlert(video.channel) + #endif } } else { Button("Subscribe") { - subscriptions.subscribe(video.channel.id) + subscriptions.subscribe(video.channel.id) { + navigationState.sidebarSectionChanged.toggle() + } } } } diff --git a/tvOS/ChannelView.swift b/tvOS/ChannelView.swift deleted file mode 100644 index b6c59991..00000000 --- a/tvOS/ChannelView.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Siesta -import SwiftUI - -struct ChannelView: View { - @ObservedObject private var store = Store<[Video]>() - - var id: String - - var resource: Resource { - InvidiousAPI.shared.channelVideos(id) - } - - init(id: String) { - self.id = id - resource.addObserver(store) - } - - var body: some View { - HStack { - Spacer() - - VStack { - Spacer() - VideosView(videos: store.collection) - .onAppear { - resource.loadIfNeeded() - } - Spacer() - } - - Spacer() - } - .edgesIgnoringSafeArea(.all) - .background(.ultraThickMaterial) - } -} diff --git a/tvOS/TVNavigationView.swift b/tvOS/TVNavigationView.swift index 3a65ebf5..23e6081d 100644 --- a/tvOS/TVNavigationView.swift +++ b/tvOS/TVNavigationView.swift @@ -38,21 +38,26 @@ struct TVNavigationView: View { VideoDetailsView(video) } } - .fullScreenCover(isPresented: $navigationState.showingChannel, onDismiss: { - navigationState.showVideoDetailsIfNeeded() - }) { - if let channel = navigationState.channel { - ChannelView(id: channel.id) - } - } .fullScreenCover(isPresented: $navigationState.showingVideo) { if let video = navigationState.video { VideoPlayerView(video) .environmentObject(playbackState) } } + .fullScreenCover(isPresented: $navigationState.isChannelOpen, onDismiss: { + navigationState.closeChannel(presentedChannel) + }) { + if presentedChannel != nil { + ChannelVideosView(presentedChannel) + .background(.thickMaterial) + } + } .onPlayPauseCommand { showingOptions.toggle() } } + + fileprivate var presentedChannel: Channel! { + navigationState.openChannels.first + } } struct TVNavigationView_Previews: PreviewProvider {