diff --git a/Model/Cache/SubscribedChannelsModel.swift b/Model/Cache/SubscribedChannelsModel.swift index 1153c5e2..e13b8cf6 100644 --- a/Model/Cache/SubscribedChannelsModel.swift +++ b/Model/Cache/SubscribedChannelsModel.swift @@ -70,6 +70,7 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel { if let channels: [Channel] = resource.typedContent() { self.channels = channels self.storeChannels(account: account, channels: channels) + FeedModel.shared.calculateUnwatchedFeed() onSuccess() } } diff --git a/Model/FeedModel.swift b/Model/FeedModel.swift index 29f99f42..76bd966f 100644 --- a/Model/FeedModel.swift +++ b/Model/FeedModel.swift @@ -11,6 +11,7 @@ final class FeedModel: ObservableObject, CacheModel { @Published var videos = [Video]() @Published private var page = 1 @Published var unwatched = [Account: Int]() + @Published var unwatchedByChannel = [Account: [Channel.ID: Int]]() private var cacheModel = FeedCacheModel.shared private var accounts = AccountsModel.shared @@ -112,43 +113,66 @@ final class FeedModel: ObservableObject, CacheModel { backgroundContext.perform { [weak self] in guard let self else { return } - let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter { $0.finished }.count - let unwatched = feed.count - watched + let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter { $0.finished } + let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } } + let unwatchedCount = feed.count - watched.count DispatchQueue.main.async { [weak self] in guard let self else { return } - if unwatched != self.unwatched[account] { - self.unwatched[account] = unwatched + if unwatchedCount != self.unwatched[account] { + self.unwatched[account] = unwatchedCount } + + let byChannel = Dictionary(grouping: unwatched) { $0.channel.id }.mapValues(\.count) + self.unwatchedByChannel[account] = byChannel } } } func markAllFeedAsWatched() { guard let account = accounts.current else { return } - guard !videos.isEmpty else { return } - backgroundContext.perform { [weak self] in - guard let self else { return } - self.videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, context: self.backgroundContext) } + let mark = { [weak self] in + self?.backgroundContext.perform { [weak self] in + guard let self else { return } + self.videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, context: self.backgroundContext) } - self.calculateUnwatchedFeed() + self.calculateUnwatchedFeed() + } + } + + if videos.isEmpty { + loadCachedFeed { mark() } + } else { + mark() } } + var canMarkAllFeedAsWatched: Bool { + guard let account = accounts.current else { return false } + return (unwatched[account] ?? 0) > 0 + } + func markAllFeedAsUnwatched() { - guard accounts.current != nil, - !videos.isEmpty else { return } + guard accounts.current != nil else { return } - backgroundContext.perform { [weak self] in - guard let self else { return } + let mark = { [weak self] in + self?.backgroundContext.perform { [weak self] in + guard let self else { return } - let watches = self.watchFetchRequestResult(self.videos, context: self.backgroundContext) - watches.forEach { self.backgroundContext.delete($0) } + let watches = self.watchFetchRequestResult(self.videos, context: self.backgroundContext) + watches.forEach { self.backgroundContext.delete($0) } - try? self.backgroundContext.save() + try? self.backgroundContext.save() - self.calculateUnwatchedFeed() + self.calculateUnwatchedFeed() + } + } + + if videos.isEmpty { + loadCachedFeed { mark() } + } else { + mark() } } @@ -183,6 +207,11 @@ final class FeedModel: ObservableObject, CacheModel { PlayerModel.shared.play(unwatched) } + var canPlayUnwatchedFeed: Bool { + guard let account = accounts.current else { return false } + return (unwatched[account] ?? 0) > 0 + } + var feedTime: Date? { if let account = accounts.current { return cacheModel.getFeedTime(account: account) @@ -195,12 +224,13 @@ final class FeedModel: ObservableObject, CacheModel { getFormattedDate(feedTime) } - private func loadCachedFeed() { + private func loadCachedFeed(_ onCompletion: @escaping () -> Void = {}) { guard let account = accounts.current else { return } let cache = cacheModel.retrieveFeed(account: account) if !cache.isEmpty { DispatchQueue.main.async(qos: .userInteractive) { [weak self] in self?.videos = cache + onCompletion() } } } diff --git a/Shared/Navigation/AppSidebarSubscriptions.swift b/Shared/Navigation/AppSidebarSubscriptions.swift index a5dabb91..9de15f35 100644 --- a/Shared/Navigation/AppSidebarSubscriptions.swift +++ b/Shared/Navigation/AppSidebarSubscriptions.swift @@ -3,24 +3,29 @@ import SwiftUI struct AppSidebarSubscriptions: View { @ObservedObject private var navigation = NavigationModel.shared + @ObservedObject private var feed = FeedModel.shared @ObservedObject private var subscriptions = SubscribedChannelsModel.shared + @ObservedObject private var accounts = AccountsModel.shared + var body: some View { Section(header: Text("Subscriptions")) { ForEach(subscriptions.all) { channel in NavigationLink(tag: TabSelection.channel(channel.id), selection: $navigation.tabSelection) { LazyView(ChannelVideosView(channel: channel).modifier(PlayerOverlayModifier())) } label: { - if channel.thumbnailURL != nil { - HStack { + HStack { + if channel.thumbnailURL != nil { ChannelAvatarView(channel: channel, subscribedBadge: false) .frame(width: 20, height: 20) Text(channel.name) + } else { + Label(channel.name, systemImage: RecentsModel.symbolSystemImage(channel.name)) } - } else { - Label(channel.name, systemImage: RecentsModel.symbolSystemImage(channel.name)) } + .backport + .badge(channelBadge(channel)) } .contextMenu { Button("Unsubscribe") { @@ -31,6 +36,14 @@ struct AppSidebarSubscriptions: View { } } } + + func channelBadge(_ channel: Channel) -> Text? { + if let count = feed.unwatchedByChannel[accounts.current]?[channel.id] { + return Text(String(count)) + } + + return nil + } } struct AppSidebarSubscriptions_Previews: PreviewProvider { diff --git a/Shared/Subscriptions/ChannelsView.swift b/Shared/Subscriptions/ChannelsView.swift index 2452a8a6..e3383af2 100644 --- a/Shared/Subscriptions/ChannelsView.swift +++ b/Shared/Subscriptions/ChannelsView.swift @@ -3,6 +3,7 @@ import SDWebImageSwiftUI import SwiftUI struct ChannelsView: View { + @ObservedObject private var feed = FeedModel.shared @ObservedObject private var subscriptions = SubscribedChannelsModel.shared @ObservedObject private var accounts = AccountsModel.shared @@ -23,6 +24,8 @@ struct ChannelsView: View { Label(channel.name, systemImage: RecentsModel.symbolSystemImage(channel.name)) } } + .backport + .badge(channelBadge(channel)) } .contextMenu { Button { @@ -78,6 +81,14 @@ struct ChannelsView: View { #endif } + func channelBadge(_ channel: Channel) -> Text? { + if let count = feed.unwatchedByChannel[accounts.current]?[channel.id] { + return Text(String(count)) + } + + return nil + } + var header: some View { HStack { #if os(tvOS) diff --git a/Shared/Subscriptions/SubscriptionsView.swift b/Shared/Subscriptions/SubscriptionsView.swift index a61f304f..9575cf96 100644 --- a/Shared/Subscriptions/SubscriptionsView.swift +++ b/Shared/Subscriptions/SubscriptionsView.swift @@ -39,6 +39,10 @@ struct SubscriptionsView: View { ToolbarItem { ListingStyleButtons(listingStyle: $subscriptionsListingStyle) } + + ToolbarItem { + toggleWatchedButton + } } #endif } @@ -53,26 +57,12 @@ struct SubscriptionsView: View { if subscriptionsViewPage == .feed { ListingStyleButtons(listingStyle: $subscriptionsListingStyle) - - Button { - feed.playUnwatchedFeed() - } label: { - Label("Play unwatched", systemImage: "play") - } - - Button { - feed.markAllFeedAsWatched() - } label: { - Label("Mark all as watched", systemImage: "checkmark.circle.fill") - } - - Button { - feed.markAllFeedAsUnwatched() - } label: { - Label("Mark all as unwatched", systemImage: "checkmark.circle") - } } + playUnwatchedButton + + toggleWatchedButton + Section { SettingsButtons() } @@ -98,6 +88,40 @@ struct SubscriptionsView: View { } } #endif + + var playUnwatchedButton: some View { + Button { + feed.playUnwatchedFeed() + } label: { + Label("Play all unwatched", systemImage: "play") + } + .disabled(!feed.canPlayUnwatchedFeed) + } + + @ViewBuilder var toggleWatchedButton: some View { + if feed.canMarkAllFeedAsWatched { + markAllFeedAsWatchedButton + } else { + markAllFeedAsUnwatchedButton + } + } + + var markAllFeedAsWatchedButton: some View { + Button { + feed.markAllFeedAsWatched() + } label: { + Label("Mark all as watched", systemImage: "checkmark.circle.fill") + } + .disabled(!feed.canMarkAllFeedAsWatched) + } + + var markAllFeedAsUnwatchedButton: some View { + Button { + feed.markAllFeedAsUnwatched() + } label: { + Label("Mark all as unwatched", systemImage: "checkmark.circle") + } + } } struct SubscriptionsView_Previews: PreviewProvider {