From 8c1d900a63658cd8fe60735ceebd7caf29b91dab Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 13 Dec 2022 00:39:50 +0100 Subject: [PATCH] Unwatched videos in subscriptions --- Backports/Badge+Backport.swift | 9 +- Model/FeedModel.swift | 90 +++++++++++++++++++- Shared/Navigation/AccountsView.swift | 2 +- Shared/Navigation/AppTabNavigation.swift | 21 ++++- Shared/Navigation/Sidebar.swift | 20 +++++ Shared/Player/PlayerQueueRow.swift | 2 +- Shared/Subscriptions/SubscriptionsView.swift | 20 +++++ Shared/Views/VideoContextMenuView.swift | 7 +- 8 files changed, 154 insertions(+), 17 deletions(-) diff --git a/Backports/Badge+Backport.swift b/Backports/Badge+Backport.swift index cd9d9b02..654237ca 100644 --- a/Backports/Badge+Backport.swift +++ b/Backports/Badge+Backport.swift @@ -1,16 +1,11 @@ import SwiftUI extension Backport where Content: View { - @ViewBuilder func badge(_ count: Text) -> some View { + @ViewBuilder func badge(_ count: Text?) -> some View { if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) { content.badge(count) } else { - HStack { - content - Spacer() - Text("\(count)") - .foregroundColor(.secondary) - } + content } } } diff --git a/Model/FeedModel.swift b/Model/FeedModel.swift index ad28fd1e..29f99f42 100644 --- a/Model/FeedModel.swift +++ b/Model/FeedModel.swift @@ -1,4 +1,5 @@ import Cache +import CoreData import Foundation import Siesta import SwiftyJSON @@ -9,11 +10,15 @@ final class FeedModel: ObservableObject, CacheModel { @Published var isLoading = false @Published var videos = [Video]() @Published private var page = 1 + @Published var unwatched = [Account: Int]() + private var cacheModel = FeedCacheModel.shared private var accounts = AccountsModel.shared var storage: Storage? + private var backgroundContext = PersistenceController.shared.container.newBackgroundContext() + var feed: Resource? { accounts.api.feed(page) } @@ -78,7 +83,8 @@ final class FeedModel: ObservableObject, CacheModel { self.videos.append(contentsOf: videos) } else { self.videos = videos - FeedCacheModel.shared.storeFeed(account: account, videos: self.videos) + self.cacheModel.storeFeed(account: account, videos: self.videos) + self.calculateUnwatchedFeed() } } } @@ -99,9 +105,87 @@ final class FeedModel: ObservableObject, CacheModel { loadFeed(force: true, paginating: true) } + func calculateUnwatchedFeed() { + guard let account = accounts.current else { return } + let feed = cacheModel.retrieveFeed(account: account) + guard !feed.isEmpty else { return } + 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 + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if unwatched != self.unwatched[account] { + self.unwatched[account] = unwatched + } + } + } + } + + 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) } + + self.calculateUnwatchedFeed() + } + } + + func markAllFeedAsUnwatched() { + guard accounts.current != nil, + !videos.isEmpty else { return } + + 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) } + + try? self.backgroundContext.save() + + self.calculateUnwatchedFeed() + } + } + + func watchFetchRequestResult(_ videos: [Video], context: NSManagedObjectContext) -> [Watch] { + let watchFetchRequest = Watch.fetchRequest() + watchFetchRequest.predicate = NSPredicate(format: "videoID IN %@", videos.map(\.videoID) as [String]) + return (try? context.fetch(watchFetchRequest)) ?? [] + } + + func playUnwatchedFeed() { + guard let account = accounts.current else { return } + let videos = cacheModel.retrieveFeed(account: account) + guard !videos.isEmpty else { return } + + let watches = watchFetchRequestResult(videos, context: backgroundContext) + let watchesIDs = watches.map(\.videoID) + let unwatched = videos.filter { video in + if !watchesIDs.contains(video.videoID) { + return true + } + + if let watch = watches.first(where: { $0.videoID == video.videoID }), + watch.finished + { + return false + } + + return true + } + + guard !unwatched.isEmpty else { return } + PlayerModel.shared.play(unwatched) + } + var feedTime: Date? { if let account = accounts.current { - return FeedCacheModel.shared.getFeedTime(account: account) + return cacheModel.getFeedTime(account: account) } return nil @@ -113,7 +197,7 @@ final class FeedModel: ObservableObject, CacheModel { private func loadCachedFeed() { guard let account = accounts.current else { return } - let cache = FeedCacheModel.shared.retrieveFeed(account: account) + let cache = cacheModel.retrieveFeed(account: account) if !cache.isEmpty { DispatchQueue.main.async(qos: .userInteractive) { [weak self] in self?.videos = cache diff --git a/Shared/Navigation/AccountsView.swift b/Shared/Navigation/AccountsView.swift index d07c1633..bd15eecb 100644 --- a/Shared/Navigation/AccountsView.swift +++ b/Shared/Navigation/AccountsView.swift @@ -12,7 +12,7 @@ struct AccountsView: View { list } - .frame(minWidth: 500, maxWidth: 800, minHeight: 350, maxHeight: 700) + .frame(minWidth: 500, maxWidth: 800, minHeight: 700, maxHeight: 1200) #else NavigationView { diff --git a/Shared/Navigation/AppTabNavigation.swift b/Shared/Navigation/AppTabNavigation.swift index b6d6b8f9..368016e9 100644 --- a/Shared/Navigation/AppTabNavigation.swift +++ b/Shared/Navigation/AppTabNavigation.swift @@ -5,6 +5,7 @@ struct AppTabNavigation: View { @ObservedObject private var accounts = AccountsModel.shared @ObservedObject private var navigation = NavigationModel.shared private var player = PlayerModel.shared + @ObservedObject private var feed = FeedModel.shared @ObservedObject private var subscriptions = SubscribedChannelsModel.shared @Default(.showHome) private var showHome @@ -47,7 +48,12 @@ struct AppTabNavigation: View { } .overlay(ControlsBar(fullScreen: .constant(false)), alignment: .bottom) } - + .onAppear { + feed.calculateUnwatchedFeed() + } + .onChange(of: accounts.current) { _ in + feed.calculateUnwatchedFeed() + } .id(accounts.current?.id ?? "") .overlay(playlistView) .overlay(channelView) @@ -87,6 +93,19 @@ struct AppTabNavigation: View { .accessibility(label: Text("Subscriptions")) } .tag(TabSelection.subscriptions) + .backport + .badge(subscriptionsBadge) + } + + var subscriptionsBadge: Text? { + guard let account = accounts.current, + let unwatched = feed.unwatched[account], + unwatched > 0 + else { + return nil + } + + return Text("\(String(unwatched))") } private var subscriptionsVisible: Bool { diff --git a/Shared/Navigation/Sidebar.swift b/Shared/Navigation/Sidebar.swift index 6a019802..c040c213 100644 --- a/Shared/Navigation/Sidebar.swift +++ b/Shared/Navigation/Sidebar.swift @@ -4,6 +4,7 @@ import SwiftUI struct Sidebar: View { @ObservedObject private var accounts = AccountsModel.shared @ObservedObject private var navigation = NavigationModel.shared + @ObservedObject private var feed = FeedModel.shared @Default(.showHome) private var showHome @Default(.visibleSections) private var visibleSections @@ -36,6 +37,12 @@ struct Sidebar: View { } .listStyle(.sidebar) } + .onAppear { + feed.calculateUnwatchedFeed() + } + .onChange(of: accounts.current) { _ in + feed.calculateUnwatchedFeed() + } .navigationTitle("Yattee") #if os(iOS) .navigationBarTitleDisplayMode(.inline) @@ -70,6 +77,8 @@ struct Sidebar: View { Label("Subscriptions", systemImage: "star.circle") .accessibility(label: Text("Subscriptions")) } + .backport + .badge(subscriptionsBadge) .id("subscriptions") } @@ -99,6 +108,17 @@ struct Sidebar: View { } } + private var subscriptionsBadge: Text? { + guard let account = accounts.current, + let unwatched = feed.unwatched[account], + unwatched > 0 + else { + return nil + } + + return Text("\(String(unwatched))") + } + private func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) { if case .recentlyOpened = selection { scrollView.scrollTo("recentlyOpened") diff --git a/Shared/Player/PlayerQueueRow.swift b/Shared/Player/PlayerQueueRow.swift index e7c2e6bb..4197f072 100644 --- a/Shared/Player/PlayerQueueRow.swift +++ b/Shared/Player/PlayerQueueRow.swift @@ -90,7 +90,7 @@ struct PlayerQueueRow: View { player.show() } label: { - VideoBanner(video: item.video, playbackTime: watchStoppedAt, videoDuration: watch?.videoDuration) + VideoBanner(video: item.video, playbackTime: watchStoppedAt, videoDuration: watch?.videoDuration, watch: watch) } #if os(tvOS) .buttonStyle(.card) diff --git a/Shared/Subscriptions/SubscriptionsView.swift b/Shared/Subscriptions/SubscriptionsView.swift index 6ad03bf6..a61f304f 100644 --- a/Shared/Subscriptions/SubscriptionsView.swift +++ b/Shared/Subscriptions/SubscriptionsView.swift @@ -10,6 +10,8 @@ struct SubscriptionsView: View { @Default(.subscriptionsViewPage) private var subscriptionsViewPage @Default(.subscriptionsListingStyle) private var subscriptionsListingStyle + @ObservedObject private var feed = FeedModel.shared + var body: some View { SignInRequiredView(title: "Subscriptions".localized()) { switch subscriptionsViewPage { @@ -51,6 +53,24 @@ 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") + } } Section { diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index 56fa7598..eca42c1d 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -149,6 +149,7 @@ struct VideoContextMenuView: View { var markAsWatchedButton: some View { Button { Watch.markAsWatched(videoID: video.videoID, account: accounts.current, duration: video.length, context: backgroundContext) + FeedModel.shared.calculateUnwatchedFeed() } label: { Label("Mark as watched", systemImage: "checkmark.circle.fill") } @@ -156,11 +157,9 @@ struct VideoContextMenuView: View { var removeFromHistoryButton: some View { Button { - guard let watch else { - return - } - + guard let watch else { return } player.removeWatch(watch) + FeedModel.shared.calculateUnwatchedFeed() } label: { Label("Remove from history", systemImage: "delete.left.fill") }