diff --git a/Packages/ConfCore/ConfCore/Storage.swift b/Packages/ConfCore/ConfCore/Storage.swift index 34f12087..ea72f3c4 100644 --- a/Packages/ConfCore/ConfCore/Storage.swift +++ b/Packages/ConfCore/ConfCore/Storage.swift @@ -513,12 +513,6 @@ public final class Storage: Logging, Signposting { return tracks }() - public lazy var tracksObservable: some Publisher, Error> = { - let tracks = self.realm.objects(Track.self).sorted(byKeyPath: "order") - - return tracks.collectionPublisher - }() - public lazy var tracksShallowObservable: some Publisher, Error> = { let tracks = self.realm.objects(Track.self).sorted(byKeyPath: "order") @@ -618,10 +612,11 @@ public final class Storage: Logging, Signposting { return realm.objects(Event.self).sorted(byKeyPath: "startDate", ascending: false).toArray() } - public var eventsForFiltering: Results { + public var eventsForFilteringShallowPublisher: some Publisher, Error> { return realm.objects(Event.self) .filter("SUBQUERY(sessions, $session, ANY $session.assets.rawAssetType == %@).@count > %d", SessionAssetType.streamingVideo.rawValue, 0) .sorted(byKeyPath: "startDate", ascending: false) + .collectionChangedPublisher } public var allSessionTypesShallowPublisher: some Publisher<[String], Error> { diff --git a/WWDC.xcodeproj/project.pbxproj b/WWDC.xcodeproj/project.pbxproj index 42a42255..1a042866 100644 --- a/WWDC.xcodeproj/project.pbxproj +++ b/WWDC.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 4DDF6A782177A00C008E5539 /* DownloadsManagementTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDF6A772177A00C008E5539 /* DownloadsManagementTableCellView.swift */; }; 4DF6641620C8A85000FD1684 /* SessionsTableViewController+SupportingTypesAndExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF6641520C8A85000FD1684 /* SessionsTableViewController+SupportingTypesAndExtensions.swift */; }; 9104BDFE2A25165A00860C08 /* Combine+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9104BDFD2A25165A00860C08 /* Combine+UI.swift */; }; + 911C72C92A52169A00CB3757 /* CombineLatestMany.swift in Sources */ = {isa = PBXBuildFile; fileRef = 911C72C82A52169A00CB3757 /* CombineLatestMany.swift */; }; 914367202A4C6B0E004E4392 /* Sequence+GroupedBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9143671F2A4C6B0E004E4392 /* Sequence+GroupedBy.swift */; }; 91C2A0BC2A4DE9B60049B6B7 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 91C2A0BB2A4DE9B60049B6B7 /* OrderedCollections */; }; 91EF6A2A2A33FBF8003A71A3 /* Realm+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91EF6A292A33FBF8003A71A3 /* Realm+Combine.swift */; }; @@ -287,6 +288,7 @@ 4DF6641520C8A85000FD1684 /* SessionsTableViewController+SupportingTypesAndExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionsTableViewController+SupportingTypesAndExtensions.swift"; sourceTree = ""; }; 91037C8C2A32AF62009AF15E /* Transcripts */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Transcripts; path = Packages/Transcripts; sourceTree = ""; }; 9104BDFD2A25165A00860C08 /* Combine+UI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Combine+UI.swift"; sourceTree = ""; }; + 911C72C82A52169A00CB3757 /* CombineLatestMany.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineLatestMany.swift; sourceTree = ""; }; 9143671F2A4C6B0E004E4392 /* Sequence+GroupedBy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+GroupedBy.swift"; sourceTree = ""; }; 91EF6A292A33FBF8003A71A3 /* Realm+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Realm+Combine.swift"; sourceTree = ""; }; DD0159A61ECFE26200F980F1 /* DeepLink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; @@ -794,6 +796,7 @@ DDA7B7342484416B00F86668 /* CompositionalLayoutBackgroundSwizzler.m */, 91EF6A292A33FBF8003A71A3 /* Realm+Combine.swift */, 9143671F2A4C6B0E004E4392 /* Sequence+GroupedBy.swift */, + 911C72C82A52169A00CB3757 /* CombineLatestMany.swift */, ); name = Util; sourceTree = ""; @@ -1422,6 +1425,7 @@ DD0159CF1ED0CD3A00F980F1 /* PreferencesWindowController.swift in Sources */, F44C823C2A22B90600FDE980 /* RemoteImage.swift in Sources */, F44C82332A22879000FDE980 /* TitleBarBlurFadeView.swift in Sources */, + 911C72C92A52169A00CB3757 /* CombineLatestMany.swift in Sources */, F4D0F0362A2012C700C74B50 /* VisualEffectDebugger.m in Sources */, DDC678191EDB2CD300A4E19C /* ActionLabel.swift in Sources */, DD876D351EC2A7410058EE3B /* ImageDownloadCenter.swift in Sources */, diff --git a/WWDC.xcodeproj/xcshareddata/xcschemes/WWDC.xcscheme b/WWDC.xcodeproj/xcshareddata/xcschemes/WWDC.xcscheme index def13371..50c3ab27 100644 --- a/WWDC.xcodeproj/xcshareddata/xcschemes/WWDC.xcscheme +++ b/WWDC.xcodeproj/xcshareddata/xcschemes/WWDC.xcscheme @@ -100,7 +100,7 @@ isEnabled = "NO"> diff --git a/WWDC/AppCoordinator+SessionActions.swift b/WWDC/AppCoordinator+SessionActions.swift index 1687a1d0..86878566 100644 --- a/WWDC/AppCoordinator+SessionActions.swift +++ b/WWDC/AppCoordinator+SessionActions.swift @@ -16,19 +16,19 @@ import OSLog extension AppCoordinator: SessionActionsViewControllerDelegate { func sessionActionsDidSelectCancelDownload(_ sender: NSView?) { - guard let viewModel = selectedViewModelRegardlessOfTab else { return } + guard let viewModel = activeTabSelectedSessionViewModel else { return } DownloadManager.shared.cancelDownloads([viewModel.session]) } func sessionActionsDidSelectFavorite(_ sender: NSView?) { - guard let session = selectedViewModelRegardlessOfTab?.session else { return } + guard let session = activeTabSelectedSessionViewModel?.session else { return } storage.toggleFavorite(on: session) } func sessionActionsDidSelectSlides(_ sender: NSView?) { - guard let viewModel = selectedViewModelRegardlessOfTab else { return } + guard let viewModel = activeTabSelectedSessionViewModel else { return } guard let slidesAsset = viewModel.session.asset(ofType: .slides) else { return } @@ -38,13 +38,13 @@ extension AppCoordinator: SessionActionsViewControllerDelegate { } func sessionActionsDidSelectDownload(_ sender: NSView?) { - guard let viewModel = selectedViewModelRegardlessOfTab else { return } + guard let viewModel = activeTabSelectedSessionViewModel else { return } DownloadManager.shared.download([viewModel.session]) } func sessionActionsDidSelectDeleteDownload(_ sender: NSView?) { - guard let viewModel = selectedViewModelRegardlessOfTab else { return } + guard let viewModel = activeTabSelectedSessionViewModel else { return } let alert = WWDCAlert.create() @@ -71,7 +71,7 @@ extension AppCoordinator: SessionActionsViewControllerDelegate { func sessionActionsDidSelectShare(_ sender: NSView?) { guard let sender = sender else { return } - guard let viewModel = selectedViewModelRegardlessOfTab else { return } + guard let viewModel = activeTabSelectedSessionViewModel else { return } guard let webpageAsset = viewModel.session.asset(ofType: .webpage) else { return } @@ -142,7 +142,7 @@ extension Storage { extension AppCoordinator { @objc func sessionActionsDidSelectCalendar(_ sender: NSView?) { - guard let viewModel = selectedViewModelRegardlessOfTab else { return } + guard let viewModel = activeTabSelectedSessionViewModel else { return } Task { @MainActor in do { diff --git a/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift b/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift index 45769019..1e3de803 100644 --- a/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift +++ b/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift @@ -68,6 +68,12 @@ extension AppCoordinator: SessionsTableViewControllerDelegate { } } + func sessionTableViewContextMenuActionRemoveDownload(viewModels: [SessionViewModel]) { + viewModels.forEach { viewModel in + DownloadManager.shared.deleteDownloadedFile(for: viewModel.session) + } + } + func sessionTableViewContextMenuActionRevealInFinder(viewModels: [SessionViewModel]) { guard let firstSession = viewModels.first?.session else { return } guard let localURL = DownloadManager.shared.downloadedFileURL(for: firstSession) else { return } diff --git a/WWDC/AppCoordinator+Shelf.swift b/WWDC/AppCoordinator+Shelf.swift index 7532d269..e1c98068 100644 --- a/WWDC/AppCoordinator+Shelf.swift +++ b/WWDC/AppCoordinator+Shelf.swift @@ -34,7 +34,7 @@ extension AppCoordinator: ShelfViewControllerDelegate { guard currentPlaybackViewModel != nil else { return } guard let playerController = currentPlayerController else { return } - playerOwnerTab.flatMap(shelf(for:))?.playerContainer.animator().isHidden = playerOwnerSessionIdentifier != selectedViewModelRegardlessOfTab?.identifier + playerOwnerTab.flatMap(shelf(for:))?.playerContainer.animator().isHidden = playerOwnerSessionIdentifier != activeTabSelectedSessionViewModel?.identifier // Everything after this point is for automatically entering PiP @@ -45,7 +45,7 @@ extension AppCoordinator: ShelfViewControllerDelegate { guard !playerController.playerView.isInFullScreenPlayerWindow else { return } // autopip only activates if the user is leaving the currently playing session - guard activeTab != playerOwnerTab || playerOwnerSessionIdentifier != selectedViewModelRegardlessOfTab?.identifier else { return } + guard activeTab != playerOwnerTab || playerOwnerSessionIdentifier != activeTabSelectedSessionViewModel?.identifier else { return } // if the user selected a different session/tab during playback, we move the player to PiP mode and hide the player on the shelf if !playerController.playerView.isInPictureInPictureMode { @@ -65,7 +65,7 @@ extension AppCoordinator: ShelfViewControllerDelegate { tabController.activeTab = playerOwnerTab // Reveal the session - if playerOwnerSessionIdentifier != selectedViewModelRegardlessOfTab?.identifier { + if playerOwnerSessionIdentifier != activeTabSelectedSessionViewModel?.identifier { currentListController?.select(session: SessionIdentifier(identifier)) } @@ -80,7 +80,7 @@ extension AppCoordinator: ShelfViewControllerDelegate { guard let viewModel = shelfController.viewModel else { return } playerOwnerTab = activeTab - playerOwnerSessionIdentifier = selectedViewModelRegardlessOfTab?.identifier + playerOwnerSessionIdentifier = activeTabSelectedSessionViewModel?.identifier do { let playbackViewModel = try PlaybackViewModel(sessionViewModel: viewModel, storage: storage) diff --git a/WWDC/AppCoordinator.swift b/WWDC/AppCoordinator.swift index e9073b79..3f753150 100644 --- a/WWDC/AppCoordinator.swift +++ b/WWDC/AppCoordinator.swift @@ -79,19 +79,15 @@ final class AppCoordinator: Logging, Signposting { } /// The session that is currently selected on the videos tab (observable) - var selectedSession: some Publisher { videosController.listViewController.$selectedSession } + @Published + var videosSelectedSessionViewModel: SessionViewModel? /// The session that is currently selected on the schedule tab (observable) - var selectedScheduleItem: some Publisher { scheduleController.splitViewController.listViewController.$selectedSession } - - /// The session that is currently selected on the videos tab - var selectedSessionValue: SessionViewModel? { videosController.listViewController.selectedSession } - - /// The session that is currently selected on the schedule tab - var selectedScheduleItemValue: SessionViewModel? { scheduleController.splitViewController.listViewController.selectedSession } + @Published + var scheduleSelectedSessionViewModel: SessionViewModel? /// The selected session's view model, regardless of which tab it is selected in - var selectedViewModelRegardlessOfTab: SessionViewModel? + var activeTabSelectedSessionViewModel: SessionViewModel? /// The viewModel for the current playback session var currentPlaybackViewModel: PlaybackViewModel? { @@ -134,13 +130,18 @@ final class AppCoordinator: Logging, Signposting { _playerOwnerSessionIdentifier = .init(initialValue: nil) // Schedule scheduleController = ScheduleContainerViewController( - windowController: windowController, - rowProvider: ScheduleSessionRowProvider( - scheduleSections: storage.scheduleShallowObservable, - filterPredicate: searchCoordinator.$scheduleFilterPredicate, - playingSessionIdentifier: _playerOwnerSessionIdentifier.projectedValue - ), - searchController: scheduleSearchController + splitViewController: SessionsSplitViewController( + windowController: windowController, + listViewController: SessionsTableViewController( + rowProvider: ScheduleSessionRowProvider( + scheduleSections: storage.scheduleShallowObservable, + filterPredicate: searchCoordinator.$scheduleFilterPredicate, + playingSessionIdentifier: _playerOwnerSessionIdentifier.projectedValue + ), + searchController: scheduleSearchController, + initialSelection: Preferences.shared.selectedScheduleItemIdentifier.map(SessionIdentifier.init) + ) + ) ) scheduleController.identifier = NSUserInterfaceItemIdentifier(rawValue: "Schedule") scheduleController.splitViewController.splitView.identifier = NSUserInterfaceItemIdentifier(rawValue: "ScheduleSplitView") @@ -153,12 +154,15 @@ final class AppCoordinator: Logging, Signposting { // Videos videosController = SessionsSplitViewController( windowController: windowController, - rowProvider: VideosSessionRowProvider( - tracks: storage.tracks, - filterPredicate: searchCoordinator.$videosFilterPredicate, - playingSessionIdentifier: _playerOwnerSessionIdentifier.projectedValue - ), - searchController: videosSearchController + listViewController: SessionsTableViewController( + rowProvider: VideosSessionRowProvider( + tracks: storage.tracks, + filterPredicate: searchCoordinator.$videosFilterPredicate, + playingSessionIdentifier: _playerOwnerSessionIdentifier.projectedValue + ), + searchController: videosSearchController, + initialSelection: Preferences.shared.selectedVideoItemIdentifier.map(SessionIdentifier.init) + ) ) videosController.identifier = NSUserInterfaceItemIdentifier(rawValue: "Videos") videosController.splitView.identifier = NSUserInterfaceItemIdentifier(rawValue: "VideosSplitView") @@ -169,8 +173,7 @@ final class AppCoordinator: Logging, Signposting { tabController.addTabViewItem(videosItem) self.windowController = windowController - - restoreApplicationState() + tabController.activeTab = Preferences.shared.activeTab NSApp.isAutomaticCustomizeTouchBarMenuItemEnabled = true @@ -233,49 +236,31 @@ final class AppCoordinator: Logging, Signposting { } private func setupBindings() { - tabController - .$activeTabVar - .receive(on: DispatchQueue.main) - .sink { [weak self] activeTab in - self?.activeTab = activeTab - - self?.updateSelectedViewModelRegardlessOfTab() - } - .store(in: &cancellables) - - // Bind the session tables' selections to their respective detail view controller - // This seems like it should move down a level as those are both children - func bind(session: P, to detailsController: SessionDetailsViewController) where P.Output == SessionViewModel?, P.Failure == Never { - - session.receive(on: DispatchQueue.main).sink { [weak self] viewModel in - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.35 - - // Here we change the controller - detailsController.viewModel = viewModel - self?.updateSelectedViewModelRegardlessOfTab() + videosController.listViewController.$selectedSession.assign(to: &self.$videosSelectedSessionViewModel) + scheduleController.splitViewController.listViewController.$selectedSession.assign(to: &self.$scheduleSelectedSessionViewModel) + + Publishers.CombineLatest3( + tabController.$activeTabVar, + $videosSelectedSessionViewModel, + $scheduleSelectedSessionViewModel + ).receive(on: DispatchQueue.main) + .sink { [weak self] (activeTab, _, _) in + guard let self else { return } + self.activeTab = activeTab + + switch activeTab { + case .schedule: + activeTabSelectedSessionViewModel = scheduleSelectedSessionViewModel + case .videos: + activeTabSelectedSessionViewModel = videosSelectedSessionViewModel + default: + activeTabSelectedSessionViewModel = nil } + + updateShelfBasedOnSelectionChange() + updateCurrentActivity(with: activeTabSelectedSessionViewModel) } .store(in: &cancellables) - } - - bind(session: selectedSession, to: videosController.detailViewController) - - bind(session: selectedScheduleItem, to: scheduleController.splitViewController.detailViewController) - } - - private func updateSelectedViewModelRegardlessOfTab() { - switch activeTab { - case .schedule: - selectedViewModelRegardlessOfTab = selectedScheduleItemValue - case .videos: - selectedViewModelRegardlessOfTab = selectedSessionValue - default: - selectedViewModelRegardlessOfTab = nil - } - - updateShelfBasedOnSelectionChange() - updateCurrentActivity(with: selectedViewModelRegardlessOfTab) } private func setupDelegation() { @@ -331,12 +316,11 @@ final class AppCoordinator: Logging, Signposting { .sink { [weak self] _ in guard let self else { return } - signposter.emitEvent("Hide loading", "Hide loading") - tabController.hideLoading() + self.signposter.emitEvent("Hide loading", "Hide loading") + self.tabController.hideLoading() - // TODO: Think about changing this if liveObserver.isWWDCWeek { - scheduleController.splitViewController.listViewController.scrollToToday() + self.scheduleController.splitViewController.listViewController.scrollToToday() } } .store(in: &cancellables) @@ -397,25 +381,11 @@ final class AppCoordinator: Logging, Signposting { private func saveApplicationState() { Preferences.shared.activeTab = activeTab - Preferences.shared.selectedScheduleItemIdentifier = selectedScheduleItemValue?.identifier - Preferences.shared.selectedVideoItemIdentifier = selectedSessionValue?.identifier + Preferences.shared.selectedScheduleItemIdentifier = scheduleSelectedSessionViewModel?.identifier + Preferences.shared.selectedVideoItemIdentifier = videosSelectedSessionViewModel?.identifier Preferences.shared.filtersState = searchCoordinator.restorationSnapshot() } - private func restoreApplicationState() { - - let activeTab = Preferences.shared.activeTab - tabController.activeTab = activeTab - - if let identifier = Preferences.shared.selectedScheduleItemIdentifier { - scheduleController.splitViewController.listViewController.select(session: SessionIdentifier(identifier)) - } - - if let identifier = Preferences.shared.selectedVideoItemIdentifier { - videosController.listViewController.select(session: SessionIdentifier(identifier)) - } - } - // MARK: - Deep linking func handle(link: DeepLink) { @@ -602,7 +572,7 @@ final class AppCoordinator: Logging, Signposting { return } - guard let viewModel = selectedSessionValue else { + guard let viewModel = videosSelectedSessionViewModel else { let alert = NSAlert() alert.messageText = "Select a Session" alert.informativeText = "Please select the session you'd like to watch together, then start SharePlay." diff --git a/WWDC/CombineLatestMany.swift b/WWDC/CombineLatestMany.swift new file mode 100644 index 00000000..92daef8d --- /dev/null +++ b/WWDC/CombineLatestMany.swift @@ -0,0 +1,109 @@ +// source: https://github.com/CombineCommunity/CombineExt + +// Copyright (c) 2020 Combine Community, and/or Shai Mishali +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension Publisher { + /// Projects `self` and a `Collection` of `Publisher`s onto a type-erased publisher that chains `combineLatest` calls on + /// the inner publishers. This is a variadic overload on Combine’s variants that top out at arity three. + /// + /// - parameter others: A `Collection`-worth of other publishers with matching output and failure types to combine with. + /// + /// - returns: A type-erased publisher with value events from `self` and each of the inner publishers `combineLatest`’d + /// together in an array. + func combineLatest(with others: Others) + -> AnyPublisher<[Output], Failure> + where Others.Element: Publisher, Others.Element.Output == Output, Others.Element.Failure == Failure { + ([self.eraseToAnyPublisher()] + others.map { $0.eraseToAnyPublisher() }).combineLatest() + } + + /// Projects `self` and a `Collection` of `Publisher`s onto a type-erased publisher that chains `combineLatest` calls on + /// the inner publishers. This is a variadic overload on Combine’s variants that top out at arity three. + /// + /// - parameter others: A `Collection`-worth of other publishers with matching output and failure types to combine with. + /// + /// - returns: A type-erased publisher with value events from `self` and each of the inner publishers `combineLatest`’d + /// together in an array. + func combineLatest(with others: Other...) + -> AnyPublisher<[Output], Failure> + where Other.Output == Output, Other.Failure == Failure { + combineLatest(with: others) + } +} + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension Collection where Element: Publisher { + /// Projects a `Collection` of `Publisher`s onto a type-erased publisher that chains `combineLatest` calls on + /// the inner publishers. This is a variadic overload on Combine’s variants that top out at arity three. + /// + /// - returns: A type-erased publisher with value events from each of the inner publishers `combineLatest`’d + /// together in an array. + func combineLatest() -> AnyPublisher<[Element.Output], Element.Failure> { + var wrapped = map { $0.map { [$0] }.eraseToAnyPublisher() } + while wrapped.count > 1 { + wrapped = makeCombinedQuads(input: wrapped) + } + return wrapped.first?.eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() + } +} + +// MARK: - Private helpers +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +/// CombineLatest an array of input publishers in four-somes. +/// +/// - parameter input: An array of publishers +private func makeCombinedQuads( + input: [AnyPublisher<[Output], Failure>] +) -> [AnyPublisher<[Output], Failure>] { + // Iterate over the array of input publishers in steps of four + sequence( + state: input.makeIterator(), + next: { it in it.next().map { ($0, it.next(), it.next(), it.next()) } } + ) + .map { quad in + // Only one publisher + guard let second = quad.1 else { return quad.0 } + + // Two publishers + guard let third = quad.2 else { + return quad.0 + .combineLatest(second) + .map { $0.0 + $0.1 } + .eraseToAnyPublisher() + } + + // Three publishers + guard let fourth = quad.3 else { + return quad.0 + .combineLatest(second, third) + .map { $0.0 + $0.1 + $0.2 } + .eraseToAnyPublisher() + } + + // Four publishers + return quad.0 + .combineLatest(second, third, fourth) + .map { $0.0 + $0.1 + $0.2 + $0.3 } + .eraseToAnyPublisher() + } +} diff --git a/WWDC/FilterType.swift b/WWDC/FilterType.swift index 7527f7aa..cf3c241a 100644 --- a/WWDC/FilterType.swift +++ b/WWDC/FilterType.swift @@ -8,7 +8,7 @@ import Foundation -enum FilterIdentifier: String { +enum FilterIdentifier: String, CaseIterable { case text case event case focus diff --git a/WWDC/ScheduleContainerViewController.swift b/WWDC/ScheduleContainerViewController.swift index 94f2aa73..3ad3cba8 100644 --- a/WWDC/ScheduleContainerViewController.swift +++ b/WWDC/ScheduleContainerViewController.swift @@ -13,12 +13,8 @@ final class ScheduleContainerViewController: WWDCWindowContentViewController { let splitViewController: SessionsSplitViewController - init(windowController: MainWindowController, rowProvider: SessionRowProvider, searchController: SearchFiltersViewController) { - self.splitViewController = SessionsSplitViewController( - windowController: windowController, - rowProvider: rowProvider, - searchController: searchController - ) + init(splitViewController: SessionsSplitViewController) { + self.splitViewController = splitViewController super.init(nibName: nil, bundle: nil) } diff --git a/WWDC/SearchCoordinator.swift b/WWDC/SearchCoordinator.swift index 5e96f899..ec004521 100644 --- a/WWDC/SearchCoordinator.swift +++ b/WWDC/SearchCoordinator.swift @@ -12,46 +12,6 @@ import ConfCore import RealmSwift import OSLog -struct IntermediateFiltersStructure { - var textual: TextualFilter - var event: MultipleChoiceFilter - var platform: MultipleChoiceFilter - var track: MultipleChoiceFilter - var isFavorite: ToggleFilter - var isDownloaded: ToggleFilter - var isUnwatched: ToggleFilter - var hasBookmarks: ToggleFilter - - var all: [FilterType] { - [ - textual, - event, - platform, - track, - isFavorite, - isDownloaded, - isUnwatched, - hasBookmarks - ] - } - - mutating func apply(_ state: WWDCFiltersState.Tab?) { - textual.value = state?.text?.value - event.selectedOptions = state?.event?.selectedOptions ?? [] - platform.selectedOptions = state?.focus?.selectedOptions ?? [] - track.selectedOptions = state?.track?.selectedOptions ?? [] - isFavorite.isOn = state?.isFavorite?.isOn ?? false - isDownloaded.isOn = state?.isDownloaded?.isOn ?? false - isUnwatched.isOn = state?.isUnwatched?.isOn ?? false - hasBookmarks.isOn = state?.hasBookmarks?.isOn ?? false - } -} - -struct FilterPredicate { - var predicate: NSPredicate? - var changeReason: FilterChangeReason -} - final class SearchCoordinator: Logging { private var cancellables: Set = [] @@ -103,15 +63,13 @@ final class SearchCoordinator: Logging { }.store(in: &cancellables) Publishers.CombineLatest4( - // TODO: Make sure these are shallow Realm publishers - storage.eventsForFiltering.collectionChangedPublisher, + storage.eventsForFilteringShallowPublisher, storage.focusesShallowObservable, storage.tracksShallowObservable, storage.allSessionTypesShallowPublisher ) .replaceErrorWithEmpty() .sink { (events, focuses, tracks, sessionTypes) in - // TODO: The first event should restore state, subsequent updates should not self.configureFilters( events: events.toArray(), focuses: focuses.toArray(), @@ -122,34 +80,50 @@ final class SearchCoordinator: Logging { .store(in: &cancellables) } + /// Updates the selected filter options with the ones in the provided state + /// Useful for programmatically changing the selected filters func apply(_ state: WWDCFiltersState) { - restorationFiltersState = state -// configureFilters() + if var videosFilters = IntermediateFiltersStructure.from(existingFilters: videosSearchController.filters) { + videosFilters.apply(state.videosTab) + videosSearchController.filters = videosFilters.all + videosFilterPredicate = .init( + predicate: videosSearchController.currentPredicate, + changeReason: .userInput + ) + } + + if var scheduleFilters = IntermediateFiltersStructure.from(existingFilters: scheduleSearchController.filters) { + scheduleFilters.apply(state.scheduleTab) + scheduleSearchController.filters = scheduleFilters.all + scheduleFilterPredicate = .init( + predicate: scheduleSearchController.currentPredicate, + changeReason: .userInput + ) + } } private func configureFilters(events: [Event], focuses: [Focus], tracks: [Track], sessionTypes: [String]) { // Schedule Filters Configuration var videoFilters = makeVideoFilters(events: events, focuses: focuses, tracks: tracks) - videoFilters.apply(restorationFiltersState?.videosTab) - - // TODO: We should be able to separate state restoration from updating the filtering content from Storage - // TODO: after the API call gets incorporated into Realm. At the moment, call this again in response to - // TODO: content update results in a potentially stale filter state being set back onto the UI :-( - if !videosSearchController.filters.isIdentical(to: videoFilters.all) { - videosSearchController.filters = videoFilters.all + if let currentControllerState = IntermediateFiltersStructure.from(existingFilters: videosSearchController.filters) { + videoFilters.apply(currentControllerState) + } else { + videoFilters.apply(restorationFiltersState?.videosTab) } - + videosSearchController.filters = videoFilters.all videosFilterPredicate = .init(predicate: videosSearchController.currentPredicate, changeReason: .configurationChange) var scheduleFilters = makeScheduleFilters(sessionTypes: sessionTypes, focuses: focuses, tracks: tracks) - scheduleFilters.apply(restorationFiltersState?.scheduleTab) - - if !scheduleSearchController.filters.isIdentical(to: scheduleFilters.all) { - scheduleSearchController.filters = scheduleFilters.all + if let currentControllerState = IntermediateFiltersStructure.from(existingFilters: scheduleSearchController.filters) { + scheduleFilters.apply(currentControllerState) + } else { + scheduleFilters.apply(restorationFiltersState?.scheduleTab) } - + scheduleSearchController.filters = scheduleFilters.all scheduleFilterPredicate = .init(predicate: scheduleSearchController.currentPredicate, changeReason: .configurationChange) + + restorationFiltersState = nil } func makeVideoFilters(events: [Event], focuses: [Focus], tracks: [Track]) -> IntermediateFiltersStructure { @@ -166,8 +140,37 @@ final class SearchCoordinator: Logging { options: eventOptions, emptyTitle: "All Events" ) + let textualFilter = TextualFilter(identifier: .text, value: nil) { value in + let modelKeys: [String] = ["title"] + + guard let value = value else { return nil } + guard value.count >= 2 else { return nil } + + if Int(value) != nil { + return NSPredicate(format: "%K CONTAINS[cd] %@", #keyPath(Session.number), value) + } + + var subpredicates = modelKeys.map { key -> NSPredicate in + return NSPredicate(format: "\(key) CONTAINS[cd] %@", value) + } + + let keywords = NSPredicate(format: "SUBQUERY(instances, $instances, ANY $instances.keywords.name CONTAINS[cd] %@).@count > 0", value) + subpredicates.append(keywords) + + if Preferences.shared.searchInBookmarks { + let bookmarks = NSPredicate(format: "ANY bookmarks.body CONTAINS[cd] %@", value) + subpredicates.append(bookmarks) + } + + if Preferences.shared.searchInTranscripts { + let transcripts = NSPredicate(format: "transcriptText CONTAINS[cd] %@", value) + subpredicates.append(transcripts) + } - return makeFilters(eventFilter: eventFilter, focuses: focuses, tracks: tracks) + return NSCompoundPredicate(orPredicateWithSubpredicates: subpredicates) + } + + return makeFilters(eventFilter: eventFilter, textualFilter: textualFilter, focuses: focuses, tracks: tracks) } func makeScheduleFilters(sessionTypes: [String], focuses: [Focus], tracks: [Track]) -> IntermediateFiltersStructure { @@ -180,13 +183,40 @@ final class SearchCoordinator: Logging { options: eventOptions, emptyTitle: "All Events" ) + let textualFilter = TextualFilter(identifier: .text, value: nil) { value in + let modelKeys: [String] = ["title"] - return makeFilters(keyPathPrefix: "session.", eventFilter: eventFilter, focuses: focuses, tracks: tracks) - } + guard let value = value else { return nil } + guard value.count >= 2 else { return nil } + + if Int(value) != nil { + return NSPredicate(format: "%K CONTAINS[cd] %@", #keyPath(SessionInstance.session.number), value) + } + + var subpredicates = modelKeys.map { key -> NSPredicate in + return NSPredicate(format: "session.\(key) CONTAINS[cd] %@", value) + } + + let keywords = NSPredicate(format: "ANY keywords.name CONTAINS[cd] %@", value) + subpredicates.append(keywords) + + if Preferences.shared.searchInBookmarks { + let bookmarks = NSPredicate(format: "ANY session.bookmarks.body CONTAINS[cd] %@", value) + subpredicates.append(bookmarks) + } - func makeFilters(keyPathPrefix: String = "", eventFilter: MultipleChoiceFilter, focuses: [Focus], tracks: [Track]) -> IntermediateFiltersStructure { - let textualFilter = TextualFilter(identifier: FilterIdentifier.text, value: nil) + if Preferences.shared.searchInTranscripts { + let transcripts = NSPredicate(format: "session.transcriptText CONTAINS[cd] %@", value) + subpredicates.append(transcripts) + } + return NSCompoundPredicate(orPredicateWithSubpredicates: subpredicates) + } + + return makeFilters(keyPathPrefix: "session.", eventFilter: eventFilter, textualFilter: textualFilter, focuses: focuses, tracks: tracks) + } + + func makeFilters(keyPathPrefix: String = "", eventFilter: MultipleChoiceFilter, textualFilter: TextualFilter, focuses: [Focus], tracks: [Track]) -> IntermediateFiltersStructure { let focusOptions = focuses.map { FilterOption(title: $0.name, value: $0.name) } let focusFilter = MultipleChoiceFilter( id: .focus, @@ -267,3 +297,79 @@ extension SearchCoordinator: SearchFiltersViewControllerDelegate { private extension FilterOption { var isWWDCEvent: Bool { title.uppercased().hasPrefix("WWDC") } } + +struct IntermediateFiltersStructure { + var textual: TextualFilter + var event: MultipleChoiceFilter + var platform: MultipleChoiceFilter + var track: MultipleChoiceFilter + var isFavorite: ToggleFilter + var isDownloaded: ToggleFilter + var isUnwatched: ToggleFilter + var hasBookmarks: ToggleFilter + + var all: [FilterType] { + [ + textual, + event, + platform, + track, + isFavorite, + isDownloaded, + isUnwatched, + hasBookmarks + ] + } + + mutating func apply(_ state: IntermediateFiltersStructure) { + textual.value = state.textual.value + event.selectedOptions = state.event.selectedOptions.filter { event.options.contains($0) } + platform.selectedOptions = state.platform.selectedOptions.filter { platform.options.contains($0) } + track.selectedOptions = state.track.selectedOptions.filter { track.options.contains($0) } + isFavorite.isOn = state.isFavorite.isOn + isDownloaded.isOn = state.isDownloaded.isOn + isUnwatched.isOn = state.isUnwatched.isOn + hasBookmarks.isOn = state.hasBookmarks.isOn + } + + mutating func apply(_ state: WWDCFiltersState.Tab?) { + textual.value = state?.text?.value + event.selectedOptions = state?.event?.selectedOptions.filter { event.options.contains($0) } ?? [] + platform.selectedOptions = state?.focus?.selectedOptions.filter { platform.options.contains($0) } ?? [] + track.selectedOptions = state?.track?.selectedOptions.filter { track.options.contains($0) } ?? [] + isFavorite.isOn = state?.isFavorite?.isOn ?? false + isDownloaded.isOn = state?.isDownloaded?.isOn ?? false + isUnwatched.isOn = state?.isUnwatched?.isOn ?? false + hasBookmarks.isOn = state?.hasBookmarks?.isOn ?? false + } + + static func from(existingFilters: [FilterType]) -> IntermediateFiltersStructure? { + let textual: TextualFilter? = existingFilters.findBy(id: .text) + let event: MultipleChoiceFilter? = existingFilters.findBy(id: .event) + let platform: MultipleChoiceFilter? = existingFilters.findBy(id: .focus) + let track: MultipleChoiceFilter? = existingFilters.findBy(id: .track) + let isFavorite: ToggleFilter? = existingFilters.findBy(id: .isFavorite) + let isDownloaded: ToggleFilter? = existingFilters.findBy(id: .isDownloaded) + let isUnwatched: ToggleFilter? = existingFilters.findBy(id: .isUnwatched) + let hasBookmarks: ToggleFilter? = existingFilters.findBy(id: .hasBookmarks) + guard let textual, let event, let platform, let track, let isFavorite, let isDownloaded, let isUnwatched, let hasBookmarks else { + return nil + } + + return IntermediateFiltersStructure( + textual: textual, + event: event, + platform: platform, + track: track, + isFavorite: isFavorite, + isDownloaded: isDownloaded, + isUnwatched: isUnwatched, + hasBookmarks: hasBookmarks + ) + } +} + +struct FilterPredicate: Equatable { + var predicate: NSPredicate? + var changeReason: FilterChangeReason +} diff --git a/WWDC/SearchFiltersViewController.swift b/WWDC/SearchFiltersViewController.swift index 685c9d35..e323544e 100644 --- a/WWDC/SearchFiltersViewController.swift +++ b/WWDC/SearchFiltersViewController.swift @@ -9,11 +9,11 @@ import Cocoa import ConfCore -enum FilterChangeReason { +enum FilterChangeReason: Equatable { case initialValue case configurationChange - case allowSelection(SessionIdentifiable) case userInput + case allowSelection } protocol SearchFiltersViewControllerDelegate: AnyObject { @@ -331,3 +331,11 @@ final class SearchFiltersViewController: NSViewController { } } } + +extension Array where Element == FilterType { + func findBy(id: FilterIdentifier) -> T? { + guard let index = firstIndex(where: { $0.identifier == id }) else { return nil } + + return self[index] as? T + } +} diff --git a/WWDC/SessionRow.swift b/WWDC/SessionRow.swift index 129b2293..6b6946d2 100644 --- a/WWDC/SessionRow.swift +++ b/WWDC/SessionRow.swift @@ -71,6 +71,9 @@ final class SessionRow: CustomDebugStringConvertible { var headerContent: TopicHeaderRowContent? { kind.headerContent } var isSession: Bool { kind.isSession } var sessionViewModel: SessionViewModel? { kind.sessionViewModel } + func represents(session: SessionIdentifiable) -> Bool { + sessionViewModel?.identifier == session.sessionIdentifier + } var debugDescription: String { switch kind { @@ -82,16 +85,6 @@ final class SessionRow: CustomDebugStringConvertible { } } -extension SessionRow { - - func represents(session: SessionIdentifiable) -> Bool { - if case .session(let viewModel) = kind { - return viewModel.identifier == session.sessionIdentifier - } - return false - } -} - extension SessionRow: Hashable { func hash(into hasher: inout Hasher) { diff --git a/WWDC/SessionRowProvider.swift b/WWDC/SessionRowProvider.swift index 8262266f..ee9c8023 100644 --- a/WWDC/SessionRowProvider.swift +++ b/WWDC/SessionRowProvider.swift @@ -6,107 +6,8 @@ // Copyright © 2018 Guilherme Rambo. All rights reserved. // -// -// CombineLatestMany.swift -// CombineExt -// -// Created by Jasdev Singh on 22/03/2020. -// Copyright © 2020 Combine Community. All rights reserved. -// - import OrderedCollections import OSLog - -#if canImport(Combine) -import Combine - -@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) -public extension Publisher { - /// Projects `self` and a `Collection` of `Publisher`s onto a type-erased publisher that chains `combineLatest` calls on - /// the inner publishers. This is a variadic overload on Combine’s variants that top out at arity three. - /// - /// - parameter others: A `Collection`-worth of other publishers with matching output and failure types to combine with. - /// - /// - returns: A type-erased publisher with value events from `self` and each of the inner publishers `combineLatest`’d - /// together in an array. - func combineLatest(with others: Others) - -> AnyPublisher<[Output], Failure> - where Others.Element: Publisher, Others.Element.Output == Output, Others.Element.Failure == Failure { - ([self.eraseToAnyPublisher()] + others.map { $0.eraseToAnyPublisher() }).combineLatest() - } - - /// Projects `self` and a `Collection` of `Publisher`s onto a type-erased publisher that chains `combineLatest` calls on - /// the inner publishers. This is a variadic overload on Combine’s variants that top out at arity three. - /// - /// - parameter others: A `Collection`-worth of other publishers with matching output and failure types to combine with. - /// - /// - returns: A type-erased publisher with value events from `self` and each of the inner publishers `combineLatest`’d - /// together in an array. - func combineLatest(with others: Other...) - -> AnyPublisher<[Output], Failure> - where Other.Output == Output, Other.Failure == Failure { - combineLatest(with: others) - } -} - -@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) -public extension Collection where Element: Publisher { - /// Projects a `Collection` of `Publisher`s onto a type-erased publisher that chains `combineLatest` calls on - /// the inner publishers. This is a variadic overload on Combine’s variants that top out at arity three. - /// - /// - returns: A type-erased publisher with value events from each of the inner publishers `combineLatest`’d - /// together in an array. - func combineLatest() -> AnyPublisher<[Element.Output], Element.Failure> { - var wrapped = map { $0.map { [$0] }.eraseToAnyPublisher() } - while wrapped.count > 1 { - wrapped = makeCombinedQuads(input: wrapped) - } - return wrapped.first?.eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() - } -} - -// MARK: - Private helpers -@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) -/// CombineLatest an array of input publishers in four-somes. -/// -/// - parameter input: An array of publishers -private func makeCombinedQuads( - input: [AnyPublisher<[Output], Failure>] -) -> [AnyPublisher<[Output], Failure>] { - // Iterate over the array of input publishers in steps of four - sequence( - state: input.makeIterator(), - next: { it in it.next().map { ($0, it.next(), it.next(), it.next()) } } - ) - .map { quad in - // Only one publisher - guard let second = quad.1 else { return quad.0 } - - // Two publishers - guard let third = quad.2 else { - return quad.0 - .combineLatest(second) - .map { $0.0 + $0.1 } - .eraseToAnyPublisher() - } - - // Three publishers - guard let fourth = quad.3 else { - return quad.0 - .combineLatest(second, third) - .map { $0.0 + $0.1 + $0.2 } - .eraseToAnyPublisher() - } - - // Four publishers - return quad.0 - .combineLatest(second, third, fourth) - .map { $0.0 + $0.1 + $0.2 + $0.3 } - .eraseToAnyPublisher() - } -} -#endif - import Combine import ConfCore import RealmSwift @@ -114,11 +15,6 @@ import RealmSwift struct SessionRows: Equatable { let all: [SessionRow] let filtered: [SessionRow] - - init(all: [SessionRow] = [], filtered: [SessionRow] = []) { - self.all = all - self.filtered = filtered - } } protocol SessionRowProvider { @@ -143,12 +39,15 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging, Signposting { filterPredicate: P, playingSessionIdentifier: PlayingSession ) where P.Output == FilterPredicate, P.Failure == Never, PlayingSession.Output == String?, PlayingSession.Failure == Never { + // We group by tracks which is important + // We watch for tracks to be added or removed via `collectionChangedPublisher` + // Then within each track, we collect all the sessions sorted accordingly and watch for additions or removals via `collectionChangedPublisher` + // All the tracks' sessions observations are collected via combineLatest() to yield an array of track-to-sorted-sessions + // The output lets us build all possible rows up front, generally this will only emit a single value for 95% of the year + // during WWDC they will change. let tracksAndSessions = tracks.collectionChangedPublisher .replaceErrorWithEmpty() - .map { - Self.log.debug("tracks updated") - return $0 - } + .do { Self.log.debug("tracks updated") } .map { (tracks: Results) in tracks .map { track in @@ -158,9 +57,7 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging, Signposting { .replaceErrorWithEmpty() .map { (track, $0) } }.combineLatest() - .do { - Self.log.debug("Source tracks changed") - } + .do { Self.log.debug("Source tracks changed") } } .switchToLatest() .map { sortedTracks in @@ -169,26 +66,28 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging, Signposting { } } + // This is fairly self explanatory, it emits a value skipping the initial one and filters duplicates let filterPredicate = filterPredicate - .drop(while: { - switch $0.changeReason { - case .initialValue: - return true - default: - return false - } - }).removeDuplicates(by: { previous, current in - previous.predicate == current.predicate - }) // wait for filters to be configured - .do { - Self.log.debug("Filter predicate updated") - } + .drop { $0.changeReason == .initialValue } // wait for filters to be configured + .removeDuplicates() + .do { Self.log.debug("Filter predicate updated") } + publisher = Publishers.CombineLatest3( tracksAndSessions.replaceErrorWithEmpty(), filterPredicate, playingSessionIdentifier ) .map { (allViewModels, predicate, playingSessionIdentifier) in + // Now we have all our sources we combine latest to get a predicate to apply to the filtered sessions + // We mix in the currently playing session to avoid odd UX with an empty list and the details showing + // Much like above, we go through all the tracks, now grouped by SessionRow and apply the predicate to each + // of the track's sorted sessions and observe all of those via combineLatest() + // This yields an array of header row to sorted and filtered sessions to session rows by identifier + // which allows us to quickly produce a new SessionRows model with a simple dictionary lookup + // + // The filtered results are observed so we have live-updating filtering. For example, if you are filtered onto + // favorites, and then unfavorite the session, the list will update and it's powered by this part here. + let effectivePredicate: NSPredicate if let playingSessionIdentifier { effectivePredicate = NSCompoundPredicate( @@ -230,9 +129,9 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging, Signposting { } } - private static func allViewModels(_ tracks: [(Track, Results)]) -> OrderedDictionary, OrderedDictionary)> { + private static func allViewModels(_ trackToSortedSessions: [(Track, Results)]) -> OrderedDictionary, OrderedDictionary)> { OrderedDictionary( - uniqueKeysWithValues: tracks.compactMap { (track, trackSessions) -> (SessionRow, (Results, OrderedDictionary))? in + uniqueKeysWithValues: trackToSortedSessions.compactMap { (track, trackSessions) -> (SessionRow, (Results, OrderedDictionary))? in guard !trackSessions.isEmpty else { return nil } let titleRow = SessionRow(content: .init(title: track.name, symbolName: track.symbolName)) @@ -274,7 +173,6 @@ final class VideosSessionRowProvider: SessionRowProvider, Logging, Signposting { } } -// TODO: Consider using covariant/subclassing to make this simpler compared to protocol final class ScheduleSessionRowProvider: SessionRowProvider, Logging, Signposting { static let log = makeLogger() static let signposter = makeSignposter() @@ -312,17 +210,8 @@ final class ScheduleSessionRowProvider: SessionRowProvider, Logging, Signposting } let filterPredicate = filterPredicate - .drop(while: { - // wait for filters to be configured - switch $0.changeReason { - case .initialValue: - return true - default: - return false - } - }).removeDuplicates(by: { previous, current in - previous.predicate == current.predicate - }) + .drop { $0.changeReason == .initialValue } // wait for filters to be configured + .removeDuplicates() .do { Self.log.debug("Filter predicate updated") } publisher = Publishers.CombineLatest3( @@ -395,13 +284,13 @@ final class ScheduleSessionRowProvider: SessionRowProvider, Logging, Signposting ) } - private static func sessionRows(_ tracks: OrderedDictionary, OrderedDictionary)>) -> SessionRows { - let all = tracks.flatMap { (headerRow, sessions) in + private static func sessionRows(_ sections: OrderedDictionary, OrderedDictionary)>) -> SessionRows { + let all = sections.flatMap { (headerRow, sessions) in let (_, sessionRows) = sessions return [headerRow] + sessionRows.values } - let filtered = tracks.flatMap { (headerRow, sessions) in + let filtered = sections.flatMap { (headerRow, sessions) in let (filteredSessions, sessionRows) = sessions let filteredRows: [SessionRow] = filteredSessions.compactMap { outerSession in sessionRows[outerSession.identifier] @@ -415,13 +304,14 @@ final class ScheduleSessionRowProvider: SessionRowProvider, Logging, Signposting } func sessionRowIdentifierForToday() -> SessionIdentifiable? { - return nil -// guard let scheduleSections else { return nil } -// -// guard let section = scheduleSections.filter("representedDate >= %@", today()).first else { return nil } -// -// guard let identifier = section.instances.first?.session?.identifier else { return nil } -// -// return SessionIdentifier(identifier) + guard let rows else { return nil } + + let sessionViewModelForToday = rows.filtered.lazy.compactMap { $0.sessionViewModel }.first { + return $0.sessionInstance.startTime >= today() + } + + guard let sessionViewModelForToday else { return nil } + + return sessionViewModelForToday } } diff --git a/WWDC/SessionsSplitViewController.swift b/WWDC/SessionsSplitViewController.swift index 2f2cbbb4..cfa7e4a9 100644 --- a/WWDC/SessionsSplitViewController.swift +++ b/WWDC/SessionsSplitViewController.swift @@ -7,6 +7,7 @@ // import Cocoa +import Combine enum SessionsListStyle { case schedule @@ -20,11 +21,21 @@ final class SessionsSplitViewController: NSSplitViewController { var isResizingSplitView = false let windowController: MainWindowController var setupDone = false + private var cancellables: Set = [] - init(windowController: MainWindowController, rowProvider: SessionRowProvider, searchController: SearchFiltersViewController) { + init(windowController: MainWindowController, listViewController: SessionsTableViewController) { self.windowController = windowController - listViewController = SessionsTableViewController(rowProvider: rowProvider, searchController: searchController) - detailViewController = SessionDetailsViewController() + self.listViewController = listViewController + let detailViewController = SessionDetailsViewController() + self.detailViewController = detailViewController + + listViewController.$selectedSession.receive(on: DispatchQueue.main).sink { viewModel in + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.35 + + detailViewController.viewModel = viewModel + } + }.store(in: &cancellables) super.init(nibName: nil, bundle: nil) NotificationCenter.default.addObserver(self, selector: #selector(syncSplitView(notification:)), name: .sideBarSizeSyncNotification, object: nil) diff --git a/WWDC/SessionsTableViewController+SupportingTypesAndExtensions.swift b/WWDC/SessionsTableViewController+SupportingTypesAndExtensions.swift index 922cbdf4..18bbaacd 100644 --- a/WWDC/SessionsTableViewController+SupportingTypesAndExtensions.swift +++ b/WWDC/SessionsTableViewController+SupportingTypesAndExtensions.swift @@ -40,6 +40,7 @@ protocol SessionsTableViewControllerDelegate: AnyObject { func sessionTableViewContextMenuActionFavorite(viewModels: [SessionViewModel]) func sessionTableViewContextMenuActionRemoveFavorite(viewModels: [SessionViewModel]) func sessionTableViewContextMenuActionDownload(viewModels: [SessionViewModel]) + func sessionTableViewContextMenuActionRemoveDownload(viewModels: [SessionViewModel]) func sessionTableViewContextMenuActionCancelDownload(viewModels: [SessionViewModel]) func sessionTableViewContextMenuActionRevealInFinder(viewModels: [SessionViewModel]) } @@ -54,31 +55,3 @@ extension Session { return false } } - -extension Array where Element == SessionRow { - - func index(of session: SessionIdentifiable) -> Int? { - return firstIndex { row in - guard case .session(let viewModel) = row.kind else { return false } - - return viewModel.identifier == session.sessionIdentifier - } - } - - func firstSessionRowIndex() -> Int? { - return firstIndex { row in - if case .session = row.kind { - return true - } - return false - } - } - - func forEachSessionViewModel(_ body: (SessionViewModel) throws -> Void) rethrows { - try forEach { - if case .session(let viewModel) = $0.kind { - try body(viewModel) - } - } - } -} diff --git a/WWDC/SessionsTableViewController.swift b/WWDC/SessionsTableViewController.swift index d063c639..1047a0c7 100644 --- a/WWDC/SessionsTableViewController.swift +++ b/WWDC/SessionsTableViewController.swift @@ -22,15 +22,13 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi weak var delegate: SessionsTableViewControllerDelegate? - @Published - var selectedSession: SessionViewModel? - - init(rowProvider: SessionRowProvider, searchController: SearchFiltersViewController) { + init(rowProvider: SessionRowProvider, searchController: SearchFiltersViewController, initialSelection: SessionIdentifiable?) { var config = Self.defaultLoggerConfig() config.category += ": \(String(reflecting: type(of: rowProvider)))" Self.log = Self.makeLogger(config: config) self.sessionRowProvider = rowProvider self.searchController = searchController + self.stateRestorationSelection = initialSelection super.init(nibName: nil, bundle: nil) @@ -40,7 +38,7 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi .rowsPublisher .receive(on: DispatchQueue.main) .sink { [weak self] in - self?.updateWith(rows: $0, animated: true, selecting: nil) + self?.updateWith(rows: $0, animated: true) } .store(in: &cancellables) } @@ -107,13 +105,17 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi // MARK: - Selection - private var initialSelection: SessionIdentifiable? + @Published + var selectedSession: SessionViewModel? + /// The state restoration selection will be applied on 1st row display and then cleared + private var stateRestorationSelection: SessionIdentifiable? + /// The pending selection will be selected on the next update + private var pendingSelection: SessionIdentifiable? private func selectSessionImmediately(with identifier: SessionIdentifiable) { - guard let index = displayedRows.firstIndex(where: { row in - row.represents(session: identifier) - }) else { + guard let index = displayedRows.firstIndex(where: { $0.represents(session: identifier) }) else { + log.debug("Can't select session \(identifier.sessionIdentifier)") return } @@ -121,30 +123,24 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi tableView.selectRowIndexes(IndexSet([index]), byExtendingSelection: false) } - func select(session: SessionIdentifiable) { - - // If we haven't yet displayed our rows, likely because we haven't come on screen - // yet. We defer scrolling to the requested identifier until that time. - guard hasPerformedInitialRowDisplay else { - initialSelection = session - return - } - - let needsToClearSearchToAllowSelection = !isSessionVisible(for: session) && canDisplay(session: session) + func select(session: SessionIdentifiable, removingFiltersIfNeeded: Bool = true) { + let needsToClearSearchToAllowSelection = removingFiltersIfNeeded && !isSessionVisible(for: session) && canDisplay(session: session) if needsToClearSearchToAllowSelection { - searchController.clearAllFilters(reason: .allowSelection(session)) + pendingSelection = session + searchController.clearAllFilters(reason: .allowSelection) } else { selectSessionImmediately(with: session) } } + /// Select and scroll to the session/get-together/lab that is "upcoming" and in your current filters + /// We do not clear filters, so if your schedule view is just showing videos, it'll scroll to the video that will be released next func scrollToToday() { - - sessionRowProvider.sessionRowIdentifierForToday().flatMap { select(session: $0) } + sessionRowProvider.sessionRowIdentifierForToday().flatMap { select(session: $0, removingFiltersIfNeeded: false) } } - private func updateWith(rows: SessionRows, animated: Bool, selecting session: SessionIdentifiable?) { + private func updateWith(rows: SessionRows, animated: Bool) { let rowsToDisplay: [SessionRow] rowsToDisplay = rows.filtered @@ -153,7 +149,7 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi return } - setDisplayedRows(rowsToDisplay, animated: animated, overridingSelectionWith: session) + setDisplayedRows(rowsToDisplay, animated: animated) } // MARK: - Updating the Displayed Rows @@ -168,54 +164,46 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi private(set) var hasPerformedInitialRowDisplay = false private func performInitialRowDisplayIfNeeded(displaying rows: [SessionRow], allRows: [SessionRow]) -> Bool { - guard !hasPerformedInitialRowDisplay else { return true } displayedRowsLock.suspend() displayedRows = rows - // Clear filters if there is an initial selection that we can display that isn't gonna be visible - if let initialSelection = self.initialSelection, - !isSessionVisible(for: initialSelection) && canDisplay(session: initialSelection) { - - searchController.clearAllFilters(reason: .allowSelection(initialSelection)) - displayedRows = allRows - } - tableView.reloadData() - NSAnimationContext.runAnimationGroup({ context in + NSAnimationContext.runAnimationGroup { context in context.duration = 0 - if let deferredSelection = self.initialSelection { - self.initialSelection = nil + if let deferredSelection = self.stateRestorationSelection { + self.stateRestorationSelection = nil self.selectSessionImmediately(with: deferredSelection) } // Ensure an initial selection if self.tableView.selectedRow == -1, - let defaultIndex = rows.firstSessionRowIndex() { + let defaultIndex = rows.firstIndex(where: { $0.isSession }) { self.tableView.selectRowIndexes(IndexSet(integer: defaultIndex), byExtendingSelection: false) } self.scrollView.alphaValue = 1 self.tableView.allowsEmptySelection = false - }, completionHandler: { + } completionHandler: { self.displayedRowsLock.resume() self.hasPerformedInitialRowDisplay = true - }) + } return false } - private func setDisplayedRows(_ newValue: [SessionRow], animated: Bool, overridingSelectionWith session: SessionIdentifiable?) { + private func setDisplayedRows(_ newValue: [SessionRow], animated: Bool) { // Dismiss the menu when the displayed rows are about to change otherwise it will crash tableView.menu?.cancelTrackingWithoutAnimation() displayedRowsLock.async { - + let sessionToSelect = self.pendingSelection + self.pendingSelection = nil let oldValue = self.displayedRows // Same elements, same order: https://github.com/apple/swift/blob/master/stdlib/public/core/Arrays.swift.gyb#L2203 @@ -253,8 +241,8 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi DispatchQueue.main.sync { var selectedIndexes = IndexSet() - if let session = session, - let overrideIndex = newValue.index(of: session) { + if let sessionToSelect, + let overrideIndex = newValue.firstIndex(where: { $0.sessionViewModel?.identifier == sessionToSelect.sessionIdentifier }) { selectedIndexes.insert(overrideIndex) } else { @@ -284,7 +272,7 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi } } - if selectedIndexes.isEmpty, let defaultIndex = newValue.firstSessionRowIndex() { + if selectedIndexes.isEmpty, let defaultIndex = newValue.firstIndex(where: { $0.isSession }) { selectedIndexes.insert(defaultIndex) } @@ -386,7 +374,8 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi case removeFavorite = 1003 case download = 1004 case cancelDownload = 1005 - case revealInFinder = 1006 + case removeDownload = 1006 + case revealInFinder = 1007 } private func setupContextualMenu() { @@ -416,6 +405,10 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi downloadMenuItem.option = .download contextualMenu.addItem(downloadMenuItem) + let removeDownloadMenuItem = NSMenuItem(title: "Remove Download", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") + contextualMenu.addItem(removeDownloadMenuItem) + removeDownloadMenuItem.option = .removeDownload + let cancelDownloadMenuItem = NSMenuItem(title: "Cancel Download", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") contextualMenu.addItem(cancelDownloadMenuItem) cancelDownloadMenuItem.option = .cancelDownload @@ -462,6 +455,8 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi delegate?.sessionTableViewContextMenuActionDownload(viewModels: viewModels) case .cancelDownload: delegate?.sessionTableViewContextMenuActionCancelDownload(viewModels: viewModels) + case .removeDownload: + delegate?.sessionTableViewContextMenuActionRemoveDownload(viewModels: viewModels) case .revealInFinder: delegate?.sessionTableViewContextMenuActionRevealInFinder(viewModels: viewModels) } @@ -502,6 +497,8 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi return DownloadManager.shared.isDownloadable(viewModel.session) && !DownloadManager.shared.isDownloading(viewModel.session) && !DownloadManager.shared.hasDownloadedVideo(session: viewModel.session) + case .removeDownload: + return viewModel.session.isDownloaded case .cancelDownload: return DownloadManager.shared.isDownloadable(viewModel.session) && DownloadManager.shared.isDownloading(viewModel.session) case .revealInFinder: @@ -552,7 +549,7 @@ extension SessionsTableViewController: NSTableViewDataSource, NSTableViewDelegat let row: Int? = (0.. NSPredicate? var isEmpty: Bool { return predicate == nil } var predicate: NSPredicate? { - guard let value = value else { return nil } - guard value.count >= 2 else { return nil } - - if Int(value) != nil { - return NSPredicate(format: "%K CONTAINS[cd] %@", #keyPath(Session.number), value) - } - - var subpredicates = modelKeys.map { key -> NSPredicate in - return NSPredicate(format: "\(key) CONTAINS[cd] %@", value) - } - - let keywords = NSPredicate(format: "SUBQUERY(instances, $instances, ANY $instances.keywords.name CONTAINS[cd] %@).@count > 0", value) - subpredicates.append(keywords) - - if Preferences.shared.searchInBookmarks { - let bookmarks = NSPredicate(format: "ANY bookmarks.body CONTAINS[cd] %@", value) - subpredicates.append(bookmarks) - } - - if Preferences.shared.searchInTranscripts { - let transcripts = NSPredicate(format: "transcriptText CONTAINS[cd] %@", value) - subpredicates.append(transcripts) - } - - return NSCompoundPredicate(orPredicateWithSubpredicates: subpredicates) + return predicateBuilder(value) } mutating func reset() {