diff --git a/Packages/ConfCore/ConfCore/RealmCollection+toArray.swift b/Packages/ConfCore/ConfCore/RealmCollection+toArray.swift new file mode 100644 index 00000000..9e1f45f0 --- /dev/null +++ b/Packages/ConfCore/ConfCore/RealmCollection+toArray.swift @@ -0,0 +1,9 @@ +import RealmSwift + +extension List { + public func toArray() -> [Element] { Array(self) } +} + +extension Results { + public func toArray() -> [Element] { Array(self) } +} diff --git a/Packages/ConfCore/ConfCore/Storage.swift b/Packages/ConfCore/ConfCore/Storage.swift index 08042731..5eb5ca5e 100644 --- a/Packages/ConfCore/ConfCore/Storage.swift +++ b/Packages/ConfCore/ConfCore/Storage.swift @@ -6,11 +6,9 @@ // Copyright © 2017 Guilherme Rambo. All rights reserved. // +import Combine import Foundation import RealmSwift -import RxSwift -import RxRealm -import RxCocoa import OSLog public final class Storage: Logging { @@ -18,9 +16,8 @@ public final class Storage: Logging { public let realmConfig: Realm.Configuration public let realm: Realm - let disposeBag = DisposeBag() + private var disposeBag: Set = [] public static let log = makeLogger() - private let log = Storage.log public init(_ realm: Realm) { self.realmConfig = realm.configuration @@ -28,17 +25,17 @@ public final class Storage: Logging { // This used to be necessary because of CPU usage in the app during script indexing, but it causes a long period of time during indexing where content doesn't reflect what's on the database, // including for user actions such as favoriting, etc. Tested with the current version of Realm in the app and it doesn't seem to be an issue anymore. -// DistributedNotificationCenter.default().rx.notification(.TranscriptIndexingDidStart).subscribe(onNext: { [unowned self] _ in +// DistributedNotificationCenter.default().publisher(for: .TranscriptIndexingDidStart).sink(receiveValue: { [unowned self] _ in // os_log("Locking Realm auto-updates until transcript indexing is finished", log: self.log, type: .info) // // self.realm.autorefresh = false -// }).disposed(by: disposeBag) +// }).store(in: &disposeBag) // -// DistributedNotificationCenter.default().rx.notification(.TranscriptIndexingDidStop).subscribe(onNext: { [unowned self] _ in +// DistributedNotificationCenter.default().publisher(for: .TranscriptIndexingDidStop).sink(receiveValue: { [unowned self] _ in // os_log("Realm auto-updates unlocked", log: self.log, type: .info) // // self.realm.autorefresh = true -// }).disposed(by: disposeBag) +// }).store(in: &disposeBag) deleteOldEventsIfNeeded() } @@ -405,14 +402,14 @@ public final class Storage: Logging { }) } - public lazy var events: Observable> = { + public lazy var events: some Publisher, Error> = { let eventsSortedByDateDescending = self.realm.objects(Event.self).sorted(byKeyPath: "startDate", ascending: false) - return Observable.collection(from: eventsSortedByDateDescending) + return eventsSortedByDateDescending.collectionPublisher }() - public lazy var sessionsObservable: Observable> = { - return Observable.collection(from: self.realm.objects(Session.self)) + public lazy var sessionsObservable: some Publisher, Error> = { + return self.realm.objects(Session.self).collectionPublisher }() public var sessions: Results { @@ -442,45 +439,45 @@ public final class Storage: Logging { }) } - public lazy var eventsObservable: Observable> = { + public lazy var eventsObservable: some Publisher, Error> = { let events = realm.objects(Event.self).sorted(byKeyPath: "startDate", ascending: false) - return Observable.collection(from: events) + return events.collectionPublisher }() - public lazy var focusesObservable: Observable> = { + public lazy var focusesObservable: some Publisher, Error> = { let focuses = realm.objects(Focus.self).sorted(byKeyPath: "name") - return Observable.collection(from: focuses) + return focuses.collectionPublisher }() - public lazy var tracksObservable: Observable> = { + public lazy var tracksObservable: some Publisher, Error> = { let tracks = self.realm.objects(Track.self).sorted(byKeyPath: "order") - return Observable.collection(from: tracks) + return tracks.collectionPublisher }() - public lazy var featuredSectionsObservable: Observable> = { + public lazy var featuredSectionsObservable: some Publisher, Error> = { let predicate = NSPredicate(format: "isPublished = true AND content.@count > 0") let sections = self.realm.objects(FeaturedSection.self).filter(predicate) - return Observable.collection(from: sections) + return sections.collectionPublisher }() - public lazy var scheduleObservable: Observable> = { + public lazy var scheduleObservable: some Publisher, Error> = { let currentEvents = self.realm.objects(Event.self).filter("isCurrent == true") - return Observable.collection(from: currentEvents).map({ $0.first?.identifier }).flatMap { (identifier: String?) -> Observable> in + return currentEvents.collectionPublisher.map({ $0.first?.identifier }).flatMap { (identifier: String?) -> AnyPublisher, Error> in let sections = self.realm.objects(ScheduleSection.self).filter("eventIdentifier == %@", identifier ?? "").sorted(byKeyPath: "representedDate") - return Observable.collection(from: sections) + return sections.collectionPublisher.eraseToAnyPublisher() } }() - public lazy var eventHeroObservable: Observable = { + public lazy var eventHeroObservable: some Publisher = { let hero = self.realm.objects(EventHero.self) - return Observable.collection(from: hero).map { $0.first } + return hero.collectionPublisher.map { $0.first } }() public func asset(with remoteURL: URL) -> SessionAsset? { diff --git a/Packages/ConfCore/ConfCore/SyncEngine.swift b/Packages/ConfCore/ConfCore/SyncEngine.swift index fc8985a1..c7735d1c 100644 --- a/Packages/ConfCore/ConfCore/SyncEngine.swift +++ b/Packages/ConfCore/ConfCore/SyncEngine.swift @@ -7,9 +7,8 @@ // import Foundation -import RxCocoa -import RxSwift import OSLog +import Combine extension Notification.Name { public static let SyncEngineDidSyncSessionsAndSchedule = Notification.Name("SyncEngineDidSyncSessionsAndSchedule") @@ -25,7 +24,7 @@ public final class SyncEngine: Logging { public let userDataSyncEngine: UserDataSyncEngine? - private let disposeBag = DisposeBag() + private var cancellables: Set = [] let transcriptIndexingClient: TranscriptIndexingClient @@ -34,8 +33,8 @@ public final class SyncEngine: Logging { set { transcriptIndexingClient.transcriptLanguage = newValue } } - public var isIndexingTranscripts: BehaviorRelay { transcriptIndexingClient.isIndexing } - public var transcriptIndexingProgress: BehaviorRelay { transcriptIndexingClient.indexingProgress } + public var isIndexingTranscripts: AnyPublisher { transcriptIndexingClient.$isIndexing.eraseToAnyPublisher() } + public var transcriptIndexingProgress: AnyPublisher { transcriptIndexingClient.$indexingProgress.eraseToAnyPublisher() } public init(storage: Storage, client: AppleAPIClient, transcriptLanguage: String) { self.storage = storage @@ -52,11 +51,11 @@ public final class SyncEngine: Logging { self.userDataSyncEngine = nil } - NotificationCenter.default.rx.notification(.SyncEngineDidSyncSessionsAndSchedule).observe(on: MainScheduler.instance).subscribe(onNext: { [unowned self] _ in + NotificationCenter.default.publisher(for: .SyncEngineDidSyncSessionsAndSchedule).receive(on: DispatchQueue.main).sink(receiveValue: { [unowned self] _ in self.transcriptIndexingClient.startIndexing(ignoringCache: false) self.userDataSyncEngine?.start() - }).disposed(by: disposeBag) + }).store(in: &cancellables) } public func syncContent() { diff --git a/Packages/ConfCore/ConfCore/TranscriptIndexingClient.swift b/Packages/ConfCore/ConfCore/TranscriptIndexingClient.swift index f525c995..4a80939c 100644 --- a/Packages/ConfCore/ConfCore/TranscriptIndexingClient.swift +++ b/Packages/ConfCore/ConfCore/TranscriptIndexingClient.swift @@ -7,8 +7,6 @@ // import Foundation -import RxSwift -import RxCocoa final class TranscriptIndexingClient: NSObject, TranscriptIndexingClientProtocol, Logging { @@ -36,8 +34,8 @@ final class TranscriptIndexingClient: NSObject, TranscriptIndexingClientProtocol transcriptIndexingConnection.resume() } - private(set) var isIndexing = BehaviorRelay(value: false) - private(set) var indexingProgress = BehaviorRelay(value: 0) + @Published private(set) var isIndexing = false + @Published private(set) var indexingProgress: Float = 0 private var didRunService = false @@ -128,17 +126,17 @@ final class TranscriptIndexingClient: NSObject, TranscriptIndexingClientProtocol func transcriptIndexingStarted() { log.debug("\(#function, privacy: .public)") - isIndexing.accept(true) + isIndexing = true } func transcriptIndexingProgressDidChange(_ progress: Float) { - indexingProgress.accept(progress) + indexingProgress = progress } func transcriptIndexingStopped() { log.debug("\(#function, privacy: .public)") - isIndexing.accept(false) + isIndexing = false } } diff --git a/Packages/ConfCore/ConfCore/TranscriptLanguagesProvider.swift b/Packages/ConfCore/ConfCore/TranscriptLanguagesProvider.swift index 38b272f2..eab1b7b4 100644 --- a/Packages/ConfCore/ConfCore/TranscriptLanguagesProvider.swift +++ b/Packages/ConfCore/ConfCore/TranscriptLanguagesProvider.swift @@ -7,7 +7,7 @@ // import Foundation -import RxSwift +import Combine import OSLog public final class TranscriptLanguagesProvider: Logging { @@ -20,7 +20,7 @@ public final class TranscriptLanguagesProvider: Logging { self.client = client } - public private(set) var availableLanguageCodes: BehaviorSubject<[TranscriptLanguage]> = BehaviorSubject(value: []) + public private(set) var availableLanguageCodes = CurrentValueSubject<[TranscriptLanguage], Error>([]) public func fetchAvailableLanguages() { log.debug("\(#function, privacy: .public)") @@ -32,9 +32,9 @@ public final class TranscriptLanguagesProvider: Logging { case .success(let config): let languages = config.feeds.keys.compactMap(TranscriptLanguage.init) - self.availableLanguageCodes.on(.next(languages)) + self.availableLanguageCodes.value = languages case .failure(let error): - self.availableLanguageCodes.on(.error(error)) + self.availableLanguageCodes.send(completion: .failure(error)) } } } diff --git a/Packages/ConfCore/ConfCore/UserDataSyncEngine.swift b/Packages/ConfCore/ConfCore/UserDataSyncEngine.swift index cf43f4ec..cdcc40d9 100644 --- a/Packages/ConfCore/ConfCore/UserDataSyncEngine.swift +++ b/Packages/ConfCore/ConfCore/UserDataSyncEngine.swift @@ -10,8 +10,7 @@ import Foundation import CloudKit import CloudKitCodable import RealmSwift -import RxCocoa -import RxSwift +import Combine import struct OSLog.Logger public final class UserDataSyncEngine: Logging { @@ -97,7 +96,7 @@ public final class UserDataSyncEngine: Logging { } } - private let disposeBag = DisposeBag() + private lazy var cancellables: Set = [] private var canStart = false @@ -116,11 +115,11 @@ public final class UserDataSyncEngine: Logging { // Only start the sync engine if there's an iCloud account available, if availability is not // determined yet, start the sync engine after the account availability is known and == available - guard isAccountAvailable.value else { + guard isAccountAvailable else { log.info("iCloud account is not available yet, waiting for availability to start") isWaitingForAccountAvailabilityToStart = true - isAccountAvailable.asObservable().observe(on: MainScheduler.instance).subscribe(onNext: { [unowned self] available in + $isAccountAvailable.receive(on: DispatchQueue.main).sink(receiveValue: { [unowned self] available in guard self.isWaitingForAccountAvailabilityToStart else { return } log.info("iCloud account available = \(String(describing: available), privacy: .public)@") @@ -129,7 +128,7 @@ public final class UserDataSyncEngine: Logging { self.isWaitingForAccountAvailabilityToStart = false self.start() } - }).disposed(by: disposeBag) + }).store(in: &cancellables) return } @@ -146,24 +145,24 @@ public final class UserDataSyncEngine: Logging { } } - public private(set) var isStopping = BehaviorRelay(value: false) + @Published public private(set) var isStopping = false - public private(set) var isPerformingSyncOperation = BehaviorRelay(value: false) + @Published public private(set) var isPerformingSyncOperation = false - public private(set) var isAccountAvailable = BehaviorRelay(value: false) + @Published public private(set) var isAccountAvailable = false public func stop() { - guard isRunning, !isStopping.value else { + guard isRunning, !isStopping else { self.clearSyncMetadata() return } - isStopping.accept(true) + isStopping = true workQueue.async { [unowned self] in defer { DispatchQueue.main.async { - self.isStopping.accept(false) + self.isStopping = false self.isRunning = false } } @@ -185,7 +184,7 @@ public final class UserDataSyncEngine: Logging { private func startObservingSyncOperations() { cloudQueueObservation = cloudOperationQueue.observe(\.operationCount) { [unowned self] queue, _ in - self.isPerformingSyncOperation.accept(queue.operationCount > 0) + self.isPerformingSyncOperation = queue.operationCount > 0 } } @@ -216,9 +215,9 @@ public final class UserDataSyncEngine: Logging { switch status { case .available: - self.isAccountAvailable.accept(true) + self.isAccountAvailable = true default: - self.isAccountAvailable.accept(false) + self.isAccountAvailable = false } } } diff --git a/Packages/ConfCore/Package.swift b/Packages/ConfCore/Package.swift index 04b1154e..ccd7b83a 100644 --- a/Packages/ConfCore/Package.swift +++ b/Packages/ConfCore/Package.swift @@ -16,8 +16,6 @@ let package = Package( dependencies: [ .package(url: "https://github.com/bustoutsolutions/siesta", from: "1.5.2"), .package(url: "https://github.com/realm/realm-swift", from: "10.0.0"), - .package(url: "https://github.com/ReactiveX/RxSwift", from: "6.0.0"), - .package(url: "https://github.com/RxSwiftCommunity/RxRealm", from: "5.0.1"), .package(url: "https://github.com/insidegui/CloudKitCodable", branch: "spm"), .package(path: "../Transcripts") ], @@ -28,9 +26,6 @@ let package = Package( "CloudKitCodable", .product(name: "RealmSwift", package: "realm-swift"), .product(name: "Siesta", package: "siesta"), - "RxSwift", - .product(name: "RxCocoa", package: "RxSwift"), - "RxRealm", "Transcripts" ], path: "ConfCore/") diff --git a/Packages/Transcripts/Transcripts/TranscriptDownloader.swift b/Packages/Transcripts/Transcripts/TranscriptDownloader.swift index 505143b3..2cc8506d 100644 --- a/Packages/Transcripts/Transcripts/TranscriptDownloader.swift +++ b/Packages/Transcripts/Transcripts/TranscriptDownloader.swift @@ -149,7 +149,7 @@ public final class TranscriptDownloader { let mismatched = transcriptsByStatus[.etagMismatch, default: []] let noPreviousEtag = transcriptsByStatus[.noPreviousEtag, default: []] - let cachedEtagMessage = cached.count == 0 ? "none" : noPreviousEtag.map(\.identifier).joined(separator: ", ") + let cachedEtagMessage = cached.count == 0 ? "none" : cached.map(\.identifier).joined(separator: ", ") let mismatchedMessage = mismatched.count == 0 ? "none" : mismatched.map(\.identifier).joined(separator: ", ") let noPreviousEtagMessage = noPreviousEtag.count == 0 ? "none" : noPreviousEtag.map(\.identifier).joined(separator: ", ") diff --git a/WWDC.xcodeproj/project.pbxproj b/WWDC.xcodeproj/project.pbxproj index 4202c2da..e2430e0d 100644 --- a/WWDC.xcodeproj/project.pbxproj +++ b/WWDC.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ 4DBFA4DA20E160CB00BDF34B /* AVAsset+AsyncHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DBFA4D920E160CB00BDF34B /* AVAsset+AsyncHelpers.swift */; }; 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 */; }; + 91EF6A2A2A33FBF8003A71A3 /* Realm+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91EF6A292A33FBF8003A71A3 /* Realm+Combine.swift */; }; DD0159A71ECFE26200F980F1 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0159A61ECFE26200F980F1 /* DeepLink.swift */; }; DD0159A91ED09F5D00F980F1 /* AppCoordinator+Bookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0159A81ED09F5D00F980F1 /* AppCoordinator+Bookmarks.swift */; }; DD0159CF1ED0CD3A00F980F1 /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0159CE1ED0CD3A00F980F1 /* PreferencesWindowController.swift */; }; @@ -70,7 +72,6 @@ DD7F38781EAC0C98002D8C00 /* ShelfViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F38771EAC0C98002D8C00 /* ShelfViewController.swift */; }; DD7F387A1EAC0CE3002D8C00 /* SessionSummaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F38791EAC0CE3002D8C00 /* SessionSummaryViewController.swift */; }; DD7F387D1EAC113A002D8C00 /* WWDCTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F387C1EAC113A002D8C00 /* WWDCTextField.swift */; }; - DD7F38801EAC15B4002D8C00 /* RxNil.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F387F1EAC15B4002D8C00 /* RxNil.swift */; }; DD7F38881EAC2275002D8C00 /* PathUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F38871EAC2275002D8C00 /* PathUtil.swift */; }; DD876D351EC2A7410058EE3B /* ImageDownloadCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD876D331EC2A7410058EE3B /* ImageDownloadCenter.swift */; }; DD90CDC81ED77A3900CADE86 /* SearchFiltersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD90CDC71ED77A3900CADE86 /* SearchFiltersViewController.swift */; }; @@ -141,7 +142,6 @@ DDF32EB71EBE65930028E39D /* AppCoordinator+UserActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF32EB61EBE65930028E39D /* AppCoordinator+UserActivity.swift */; }; DDF32EB91EBE65B50028E39D /* AppCoordinator+Shelf.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF32EB81EBE65B50028E39D /* AppCoordinator+Shelf.swift */; }; DDF32EBB1EBE65DD0028E39D /* AppCoordinator+SessionActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF32EBA1EBE65DD0028E39D /* AppCoordinator+SessionActions.swift */; }; - DDF32EBF1EBE68EE0028E39D /* NSTableView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF32EBE1EBE68EE0028E39D /* NSTableView+Rx.swift */; }; DDF5A5092487066200135E70 /* ClipComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF5A5082487066200135E70 /* ClipComposition.swift */; }; DDF7219A1ECA12780054C503 /* PlayerUI.h in Headers */ = {isa = PBXBuildFile; fileRef = DDF721981ECA12780054C503 /* PlayerUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; DDF7219D1ECA12780054C503 /* PlayerUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDF721961ECA12780054C503 /* PlayerUI.framework */; }; @@ -284,6 +284,8 @@ 4DDF6A772177A00C008E5539 /* DownloadsManagementTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsManagementTableCellView.swift; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; DD0159A81ED09F5D00F980F1 /* AppCoordinator+Bookmarks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppCoordinator+Bookmarks.swift"; sourceTree = ""; }; DD0159CE1ED0CD3A00F980F1 /* PreferencesWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesWindowController.swift; sourceTree = ""; }; @@ -334,7 +336,6 @@ DD7F38771EAC0C98002D8C00 /* ShelfViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShelfViewController.swift; sourceTree = ""; }; DD7F38791EAC0CE3002D8C00 /* SessionSummaryViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionSummaryViewController.swift; sourceTree = ""; }; DD7F387C1EAC113A002D8C00 /* WWDCTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WWDCTextField.swift; sourceTree = ""; }; - DD7F387F1EAC15B4002D8C00 /* RxNil.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RxNil.swift; sourceTree = ""; }; DD7F38871EAC2275002D8C00 /* PathUtil.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PathUtil.swift; sourceTree = ""; }; DD876D331EC2A7410058EE3B /* ImageDownloadCenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageDownloadCenter.swift; sourceTree = ""; }; DD90CDC71ED77A3900CADE86 /* SearchFiltersViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchFiltersViewController.swift; sourceTree = ""; }; @@ -779,15 +780,16 @@ DD7F387E1EAC15A1002D8C00 /* Util */ = { isa = PBXGroup; children = ( + 9104BDFD2A25165A00860C08 /* Combine+UI.swift */, DDB28F841EAD20A10077703F /* UIDebugger.h */, DDB28F851EAD20A10077703F /* UIDebugger.m */, - DD7F387F1EAC15B4002D8C00 /* RxNil.swift */, DD7F38871EAC2275002D8C00 /* PathUtil.swift */, DD876D331EC2A7410058EE3B /* ImageDownloadCenter.swift */, DDDAA40B1EC798B600DF9D02 /* Preferences.swift */, DDC927FD20B7A259004C784D /* NSImage+Compression.swift */, DDA7B7332484416B00F86668 /* CompositionalLayoutBackgroundSwizzler.h */, DDA7B7342484416B00F86668 /* CompositionalLayoutBackgroundSwizzler.m */, + 91EF6A292A33FBF8003A71A3 /* Realm+Combine.swift */, ); name = Util; sourceTree = ""; @@ -1433,7 +1435,7 @@ F4FB06C12A2178C000799F84 /* WWDCWindowContentViewController.swift in Sources */, DDEDFCF11ED927A4002477C8 /* ToggleFilter.swift in Sources */, F4578D592A2659C5005B311A /* LiveStreamOverlay.swift in Sources */, - DD7F38801EAC15B4002D8C00 /* RxNil.swift in Sources */, + 9104BDFE2A25165A00860C08 /* Combine+UI.swift in Sources */, DDFA10BF1EBEAAAD001DCF66 /* DownloadManager.swift in Sources */, DD0159A71ECFE26200F980F1 /* DeepLink.swift in Sources */, DDA60E1720A9083E002EECF5 /* RelatedSessionsViewController.swift in Sources */, @@ -1489,7 +1491,6 @@ F4FB069F2A2148EA00799F84 /* ExploreTabRootView.swift in Sources */, 4DDF6A782177A00C008E5539 /* DownloadsManagementTableCellView.swift in Sources */, F4578D9F2A26A218005B311A /* WWDCAppCommand.swift in Sources */, - DDF32EBF1EBE68EE0028E39D /* NSTableView+Rx.swift in Sources */, F4FB06BF2A216C1F00799F84 /* RemoteGlyph.swift in Sources */, DDDF807E20BA4FFA007284F8 /* WWDCHorizontalScrollView.swift in Sources */, DD7F387D1EAC113A002D8C00 /* WWDCTextField.swift in Sources */, @@ -1504,6 +1505,7 @@ 01B3EB4A1EEDD23100DE1003 /* AppCoordinator+SessionTableViewContextMenuActions.swift in Sources */, DD2E27881EAC2CCB0009D7B6 /* ShelfView.swift in Sources */, DDEDFCF51ED9FF8A002477C8 /* WWDCSegmentedControl.swift in Sources */, + 91EF6A2A2A33FBF8003A71A3 /* Realm+Combine.swift in Sources */, DDF32EB71EBE65930028E39D /* AppCoordinator+UserActivity.swift in Sources */, DD4873D320AE5FF3005033CE /* AppCoordinator+RelatedSessions.swift in Sources */, DDEDFCEF1ED92785002477C8 /* MultipleChoiceFilter.swift in Sources */, diff --git a/WWDC.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WWDC.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 86afb08b..832c2161 100644 --- a/WWDC.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WWDC.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -36,24 +36,6 @@ "version" : "10.40.1" } }, - { - "identity" : "rxrealm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/RxSwiftCommunity/RxRealm", - "state" : { - "revision" : "a7de576348d48286d8b100a501f757a1c531f1dd", - "version" : "5.0.5" - } - }, - { - "identity" : "rxswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ReactiveX/RxSwift", - "state" : { - "revision" : "9dcaa4b333db437b0fbfaf453fad29069044a8b4", - "version" : "6.6.0" - } - }, { "identity" : "siesta", "kind" : "remoteSourceControl", diff --git a/WWDC/AppCoordinator+Shelf.swift b/WWDC/AppCoordinator+Shelf.swift index 9071fffa..7532d269 100644 --- a/WWDC/AppCoordinator+Shelf.swift +++ b/WWDC/AppCoordinator+Shelf.swift @@ -179,7 +179,7 @@ extension AppCoordinator: ShelfViewControllerDelegate { } func publishNowPlayingInfo() { - currentPlayerController?.playerView.nowPlayingInfo = currentPlaybackViewModel?.nowPlayingInfo.value + currentPlayerController?.playerView.nowPlayingInfo = currentPlaybackViewModel?.nowPlayingInfo } } diff --git a/WWDC/AppCoordinator.swift b/WWDC/AppCoordinator.swift index b7cce1bd..1d12413b 100644 --- a/WWDC/AppCoordinator.swift +++ b/WWDC/AppCoordinator.swift @@ -8,17 +8,16 @@ import Cocoa import RealmSwift -import RxSwift +import Combine import ConfCore import PlayerUI -import Combine import OSLog import AVFoundation final class AppCoordinator: Logging { static let log = makeLogger() - private let disposeBag = DisposeBag() + private lazy var cancellables = Set() var liveObserver: LiveObserver @@ -42,10 +41,8 @@ final class AppCoordinator: Logging { var playerOwnerTab: MainWindowTab? /// The session that "owns" the current player (the one that was selected on the active tab when "play" was pressed) - var playerOwnerSessionIdentifier: String? { - didSet { rxPlayerOwnerSessionIdentifier.onNext(playerOwnerSessionIdentifier) } - } - var rxPlayerOwnerSessionIdentifier = BehaviorSubject(value: nil) + @Published + var playerOwnerSessionIdentifier: String? /// Whether we're currently in the middle of a player context transition var isTransitioningPlayerContext = false @@ -61,7 +58,7 @@ final class AppCoordinator: Logging { liveObserver = LiveObserver(dateProvider: today, storage: storage, syncEngine: syncEngine) - // Primary UI Intialization + // Primary UI Initialization tabController = WWDCTabViewController(windowController: windowController) @@ -114,6 +111,8 @@ final class AppCoordinator: Logging { buttonsController.handleSharePlayClicked = { [weak self] in DispatchQueue.main.async { self?.startSharePlay() } } + + startup() } /// The list controller for the active tab @@ -128,34 +127,35 @@ final class AppCoordinator: Logging { } } - var exploreTabLiveSession: Observable { + var exploreTabLiveSession: some Publisher { let liveInstances = storage.realm.objects(SessionInstance.self) .filter("rawSessionType == 'Special Event' AND isCurrentlyLive == true") .sorted(byKeyPath: "startTime", ascending: false) - return Observable.collection(from: liveInstances) + return liveInstances.collectionPublisher .map({ $0.toArray().first?.session }) .map({ SessionViewModel(session: $0, instance: $0?.instances.first, track: nil, style: .schedule) }) + .replaceErrorWithEmpty() } /// The session that is currently selected on the videos tab (observable) - var selectedSession: Observable { - return videosController.listViewController.selectedSession.asObservable() + var selectedSession: some Publisher { + return videosController.listViewController.$selectedSession } /// The session that is currently selected on the schedule tab (observable) - var selectedScheduleItem: Observable { - return scheduleController.splitViewController.listViewController.selectedSession.asObservable() + var selectedScheduleItem: some Publisher { + return scheduleController.splitViewController.listViewController.$selectedSession } /// The session that is currently selected on the videos tab var selectedSessionValue: SessionViewModel? { - return videosController.listViewController.selectedSession.value + return videosController.listViewController.selectedSession } /// The session that is currently selected on the schedule tab var selectedScheduleItemValue: SessionViewModel? { - return scheduleController.splitViewController.listViewController.selectedSession.value + return scheduleController.splitViewController.listViewController.selectedSession } /// The selected session's view model, regardless of which tab it is selected in @@ -169,24 +169,27 @@ final class AppCoordinator: Logging { } private func setupBindings() { - tabController.rxActiveTab.subscribe(onNext: { [weak self] activeTab in - - self?.activeTab = activeTab + tabController + .$activeTabVar + .receive(on: DispatchQueue.main) + .sink { [weak self] activeTab in + self?.activeTab = activeTab - self?.updateSelectedViewModelRegardlessOfTab() - }).disposed(by: disposeBag) + self?.updateSelectedViewModelRegardlessOfTab() + } + .store(in: &cancellables) - func bind(session: Observable, to detailsController: SessionDetailsViewController) { + func bind(session: P, to detailsController: SessionDetailsViewController) where P.Output == SessionViewModel?, P.Failure == Never { - session.subscribe(on: MainScheduler.instance).subscribe(onNext: { [weak self] viewModel in - NSAnimationContext.runAnimationGroup({ context in + session.receive(on: DispatchQueue.main).sink { [weak self] viewModel in + NSAnimationContext.runAnimationGroup { context in context.duration = 0.35 detailsController.viewModel = viewModel self?.updateSelectedViewModelRegardlessOfTab() - }) - - }).disposed(by: disposeBag) + } + } + .store(in: &cancellables) } bind(session: selectedSession, to: videosController.detailViewController) @@ -248,32 +251,49 @@ final class AppCoordinator: Logging { DownloadManager.shared.syncWithFileSystem() } + var hasPerformedInitialListUpdate = false private func doUpdateLists() { // Initial app launch waits for all of these things to be loaded before dismissing the primary loading spinner // It may, however, delay the presentation of content on tabs that already have everything they need - let startupDependencies = Observable.combineLatest(storage.tracksObservable, - storage.eventsObservable, - storage.focusesObservable, - storage.scheduleObservable) + let startupDependencies = Publishers.CombineLatest4( + storage.tracksObservable, + storage.eventsObservable, + storage.focusesObservable, + storage.scheduleObservable + ) startupDependencies + .replaceErrorWithEmpty() .filter { !$0.0.isEmpty && !$0.1.isEmpty && !$0.2.isEmpty } - .take(1) - .subscribe(onNext: { [weak self] tracks, _, _, sections in - guard let self = self else { return } + .prefix(1) + .receive(on: DispatchQueue.main) + .sink { [weak self] tracks, _, _, sections in + guard let self else { return } self.tabController.hideLoading() - self.searchCoordinator.configureFilters() + if !hasPerformedInitialListUpdate { + // Filters only need configured once, the other stuff in + // here might only need to happen once as well + self.searchCoordinator.configureFilters() + } + // These aren't live updating, which is part of the problem. Filter results update live + // but get mixed in with these static lists of live-updating objects. We'll change the architecture + // of the sessions list to get 2 streams and then combine them which will simplify startup self.videosController.listViewController.sessionRowProvider = VideosSessionRowProvider(tracks: tracks) - self.scheduleController.splitViewController.listViewController.sessionRowProvider = ScheduleSessionRowProvider(scheduleSections: sections) - self.scrollToTodayIfWWDC() - }).disposed(by: disposeBag) + + if !hasPerformedInitialListUpdate && liveObserver.isWWDCWeek { + hasPerformedInitialListUpdate = true + + scheduleController.splitViewController.listViewController.scrollToToday() + } + } + .store(in: &cancellables) bindScheduleAvailability() @@ -284,11 +304,14 @@ final class AppCoordinator: Logging { private func bindScheduleAvailability() { storage.eventHeroObservable.map({ $0 != nil }) - .bind(to: scheduleController.showHeroView) - .disposed(by: disposeBag) + .replaceError(with: false) + .receive(on: DispatchQueue.main) + .assign(to: &scheduleController.$showHeroView) - storage.eventHeroObservable.bind(to: scheduleController.heroController.hero) - .disposed(by: disposeBag) + storage.eventHeroObservable + .replaceError(with: nil) + .driveUI(\.heroController.hero, on: scheduleController) + .store(in: &cancellables) } private lazy var searchCoordinator: SearchCoordinator = { @@ -357,14 +380,14 @@ final class AppCoordinator: Logging { // MARK: - Now playing info - private var nowPlayingInfoBag = DisposeBag() + private var nowPlayingInfoBag: Set = [] private func observeNowPlayingInfo() { - nowPlayingInfoBag = DisposeBag() + nowPlayingInfoBag = [] - currentPlaybackViewModel?.nowPlayingInfo.asObservable().subscribe(onNext: { [weak self] _ in + currentPlaybackViewModel?.$nowPlayingInfo.sink(receiveValue: { [weak self] _ in self?.publishNowPlayingInfo() - }).disposed(by: nowPlayingInfoBag) + }).store(in: &nowPlayingInfoBag) } // MARK: - State restoration @@ -390,12 +413,6 @@ final class AppCoordinator: Logging { } } - private func scrollToTodayIfWWDC() { - guard liveObserver.isWWDCWeek else { return } - - scheduleController.splitViewController.listViewController.scrollToToday() - } - // MARK: - Deep linking func handle(link: DeepLink) { @@ -516,8 +533,6 @@ final class AppCoordinator: Logging { // MARK: - SharePlay - private lazy var cancellables = Set() - private var sharePlayConfigured = false func configureSharePlayIfSupported() { diff --git a/WWDC/AppDelegate.swift b/WWDC/AppDelegate.swift index 228381dd..ebe2f124 100644 --- a/WWDC/AppDelegate.swift +++ b/WWDC/AppDelegate.swift @@ -97,8 +97,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { storage: storage, syncEngine: syncEngine ) - coordinator?.windowController.showWindow(self) - coordinator?.startup() } private func handleBootstrapError(_ error: Boot.BootstrapError) { diff --git a/WWDC/BookmarkViewController.swift b/WWDC/BookmarkViewController.swift index 4576660d..3c31cd04 100644 --- a/WWDC/BookmarkViewController.swift +++ b/WWDC/BookmarkViewController.swift @@ -9,31 +9,15 @@ import Cocoa import ConfCore import PlayerUI -import RxSwift -import RxCocoa - -extension Notification.Name { - fileprivate static let WWDCTextViewTextChanged = Notification.Name("WWDCTextViewTextChanged") -} +import Combine private final class WWDCTextView: NSTextView { - - lazy var rxText: Observable = { - return Observable.create { [weak self] observer -> Disposable in - let token = NotificationCenter.default.addObserver(forName: .WWDCTextViewTextChanged, object: self, queue: OperationQueue.main) { _ in - observer.onNext(self?.string ?? "") - } - - return Disposables.create { - NotificationCenter.default.removeObserver(token) - } - } - }() + @Published var stringPublished: String = "" override func didChangeText() { super.didChangeText() - NotificationCenter.default.post(name: .WWDCTextViewTextChanged, object: self) + stringPublished = string } } @@ -113,7 +97,7 @@ final class BookmarkViewController: NSViewController { stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true } - private let disposeBag = DisposeBag() + private lazy var cancellables: Set = [] override func viewDidLoad() { super.viewDidLoad() @@ -121,11 +105,11 @@ final class BookmarkViewController: NSViewController { imageView.image = NSImage(data: bookmark.snapshot) textView.string = bookmark.body - textView.rxText.throttle(.seconds(1), scheduler: MainScheduler.instance).subscribe(onNext: { [weak self] text in + textView.$stringPublished.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true).sink(receiveValue: { [weak self] text in guard let bookmark = self?.bookmark else { return } self?.storage.modify(bookmark) { $0.body = text } - }).disposed(by: disposeBag) + }).store(in: &cancellables) } } diff --git a/WWDC/Combine+UI.swift b/WWDC/Combine+UI.swift new file mode 100644 index 00000000..2fd5b563 --- /dev/null +++ b/WWDC/Combine+UI.swift @@ -0,0 +1,95 @@ +// +// Combine+UI.swift +// WWDC +// +// Created by Allen Humphreys on 5/28/23. +// Copyright © 2023 Guilherme Rambo. All rights reserved. +// + +import Combine +import RealmSwift + +extension Publisher where Failure == Never { + public func driveUI( + _ keyPath: ReferenceWritableKeyPath, + on object: Root + ) -> AnyCancellable { + receive(on: DispatchQueue.main) + .assign(to: keyPath, on: object) + } +} + +extension Publisher where Output: Equatable, Failure == Never { + public func driveUI( + _ keyPath: ReferenceWritableKeyPath, + on object: Root + ) -> AnyCancellable { + removeDuplicates() + .receive(on: DispatchQueue.main) + .assign(to: keyPath, on: object) + } +} + +extension Publisher { + public func compacted() -> some Publisher where Output == Unwrapped? { + compactMap { $0 } + } + + public func replaceNilAndError(with replacement: Unwrapped) -> some Publisher where Output == Unwrapped? { + replaceNil(with: replacement).replaceError(with: replacement) + } +} + +extension Publisher where Output: Equatable, Failure: Error { + public func driveUI(closure: @escaping (Output) -> Void) -> AnyCancellable { + removeDuplicates() + .replaceErrorWithEmpty() + .receive(on: DispatchQueue.main) + .sink(receiveValue: closure) + } +} + +extension Publisher where Output: Equatable { + public func driveUI(`default`: Output, closure: @escaping (Output) -> Void) -> AnyCancellable { + removeDuplicates() + .replaceError(with: `default`) + .receive(on: DispatchQueue.main) + .sink(receiveValue: closure) + } + +} + +public extension RealmCollection where Self: RealmSubscribable { + /// Similar to `changsetPublisher` but only emits a new value when the collection has additions or removals and ignores all upstream + /// values caused by objects being modified + var collectionChangedPublisher: some Publisher { + changesetPublisher + .tryCompactMap { changeset in + switch changeset { + case .initial(let latestValue): + return latestValue + case .update(let latestValue, let deletions, let insertions, _) where !deletions.isEmpty || !insertions.isEmpty: + return latestValue + case .update: + return nil + case .error(let error): + throw error + } + } + } +} + +extension Publisher { + func replaceErrorWithEmpty() -> some Publisher { + self.catch { _ in + // TODO: Errors + Empty() + } + } +} + +extension Publisher where Output == Bool { + func toggled() -> some Publisher { + map { !$0 } + } +} diff --git a/WWDC/DownloadManager.swift b/WWDC/DownloadManager.swift index ecb0735a..036c34b5 100644 --- a/WWDC/DownloadManager.swift +++ b/WWDC/DownloadManager.swift @@ -7,7 +7,7 @@ // import Cocoa -import RxSwift +import Combine import ConfCore import RealmSwift import OSLog @@ -32,13 +32,10 @@ final class DownloadManager: NSObject, Logging { private var backgroundSession: Foundation.URLSession! private var downloadTasks: [String: Download] = [:] { didSet { - downloadTasksSubject.onNext(Array(downloadTasks.values)) + downloads = Array(downloadTasks.values) } } - private let downloadTasksSubject = BehaviorSubject<[Download]>(value: []) - var downloadsObservable: Observable<[Download]> { - return downloadTasksSubject.asObservable() - } + @Published private(set) var downloads: [Download] = [] private let defaults = UserDefaults.standard var storage: Storage! @@ -48,6 +45,8 @@ final class DownloadManager: NSObject, Logging { override init() { super.init() + // TODO: Check a little harder into whether we can keep the delegate methods off the main thread. + // TODO: There are actually UI perf concerns when doing a lot of downloads backgroundSession = URLSession(configuration: configuration, delegate: self, delegateQueue: .main) } @@ -178,109 +177,102 @@ final class DownloadManager: NSObject, Logging { } } - func downloadStatusObservable(for download: Download) -> Observable? { + func downloadStatusObservable(for download: Download) -> AnyPublisher? { guard let remoteURL = URL(string: download.remoteURL) else { return nil } guard let downloadingAsset = storage.asset(with: remoteURL) else { return nil } return downloadStatusObservable(for: downloadingAsset) } - func downloadStatusObservable(for session: Session) -> Observable? { + func downloadStatusObservable(for session: Session) -> AnyPublisher? { guard let asset = session.asset(ofType: DownloadManager.downloadQuality) else { return nil } return downloadStatusObservable(for: asset) } - private func downloadStatusObservable(for asset: SessionAsset) -> Observable? { - - return Observable.create { observer -> Disposable in - let nc = NotificationCenter.default - var latestInfo: DownloadInfo = .unknown - - let checkDownloadedState = { - if let download = self.downloadTasks[asset.remoteURL], - let task = download.task { - - latestInfo = DownloadInfo(task: task) - - switch task.state { - case .running: - observer.onNext(.downloading(latestInfo)) - case .suspended: - observer.onNext(.paused(latestInfo)) - case .canceling: - observer.onNext(.cancelled) - case .completed: - observer.onNext(.finished) - @unknown default: - assertionFailure("An unexpected case was discovered on an non-frozen obj-c enum") - observer.onNext(.downloading(latestInfo)) - } - } else if self.hasDownloadedVideo(remoteURL: asset.remoteURL) { - observer.onNext(.finished) - } else { - observer.onNext(.none) + private func downloadStatusObservable(for asset: SessionAsset) -> AnyPublisher? { + // TODO: This function could probably be improved. Too much duplication, also I don't know that capturing this state locally like this + // TODO: is needed and it feels odd + var latestInfo: DownloadInfo = .unknown + + let currentDownloadState: () -> DownloadStatus = { + if let download = self.downloadTasks[asset.remoteURL], + let task = download.task { + latestInfo = DownloadInfo(task: task) + + switch task.state { + case .running: + return .downloading(latestInfo) + case .suspended: + return .paused(latestInfo) + case .canceling: + return .cancelled + case .completed: + return .finished + @unknown default: + assertionFailure("An unexpected case was discovered on an non-frozen obj-c enum") + return .downloading(latestInfo) } + } else if self.hasDownloadedVideo(remoteURL: asset.remoteURL) { + return .finished + } else { + return .none } + } - checkDownloadedState() - - let fileDeleted = nc.dm_addObserver(forName: .DownloadManagerFileDeletedNotification, filteredBy: asset.relativeLocalURL) { _ in - - observer.onNext(.none) - } - - let fileAdded = nc.dm_addObserver(forName: .DownloadManagerFileAddedNotification, filteredBy: asset.relativeLocalURL) { _ in - - observer.onNext(.finished) - } - - let started = nc.dm_addObserver(forName: .DownloadManagerDownloadStarted, filteredBy: asset.remoteURL) { _ in - - observer.onNext(.downloading(.unknown)) - } - - let cancelled = nc.dm_addObserver(forName: .DownloadManagerDownloadCancelled, filteredBy: asset.remoteURL) { _ in - - observer.onNext(.cancelled) - } - - let paused = nc.dm_addObserver(forName: .DownloadManagerDownloadPaused, filteredBy: asset.remoteURL) { _ in - - observer.onNext(.paused(latestInfo)) - } - - let resumed = nc.dm_addObserver(forName: .DownloadManagerDownloadResumed, filteredBy: asset.remoteURL) { _ in - - observer.onNext(.downloading(latestInfo)) - } - - let failed = nc.dm_addObserver(forName: .DownloadManagerDownloadFailed, filteredBy: asset.remoteURL) { note in + let nc = NotificationCenter.default + let fileDeleted = nc.publisher(for: .DownloadManagerFileDeletedNotification, filteredBy: asset.relativeLocalURL).map { _ in + DownloadStatus.none + } + let fileAdded = nc.publisher(for: .DownloadManagerFileAddedNotification, filteredBy: asset.relativeLocalURL).map { _ in + DownloadStatus.finished + } - let error = note.userInfo?["error"] as? Error - observer.onNext(.failed(error)) + let progress = nc.publisher(for: .DownloadManagerDownloadProgressChanged, filteredBy: asset.remoteURL).map { note in + if let info = note.userInfo?["info"] as? DownloadInfo { + latestInfo = info + if info.taskState == .suspended { + // We can get progress updates that were from while the task was suspending + return DownloadStatus.paused(info) + } else { + return DownloadStatus.downloading(info) + } + } else { + return DownloadStatus.downloading(.unknown) } + } - let finished = nc.dm_addObserver(forName: .DownloadManagerDownloadFinished, filteredBy: asset.remoteURL) { _ in + let paused = nc.publisher(for: .DownloadManagerDownloadPaused, filteredBy: asset.remoteURL).map { _ in + DownloadStatus.paused(latestInfo) + } - observer.onNext(.finished) - } + let resumed = nc.publisher(for: .DownloadManagerDownloadResumed, filteredBy: asset.remoteURL).map { _ in + DownloadStatus.downloading(latestInfo) + } - let progress = nc.dm_addObserver(forName: .DownloadManagerDownloadProgressChanged, filteredBy: asset.remoteURL) { note in + let cancelled = nc.publisher(for: .DownloadManagerDownloadCancelled, filteredBy: asset.remoteURL).map { _ in + DownloadStatus.cancelled + } - if let info = note.userInfo?["info"] as? DownloadInfo { - latestInfo = info - observer.onNext(.downloading(info)) - } else { - observer.onNext(.downloading(.unknown)) - } - } + let finished = nc.publisher(for: .DownloadManagerDownloadFinished, filteredBy: asset.remoteURL).map { _ in + DownloadStatus.finished + } - return Disposables.create { - [fileDeleted, fileAdded, started, cancelled, - paused, resumed, failed, finished, progress].forEach(nc.removeObserver) - } + let failed = nc.publisher(for: .DownloadManagerDownloadFailed, filteredBy: asset.remoteURL).map { notification in + let error = notification.userInfo?["error"] as? Error + return DownloadStatus.failed(error) } + + return Just(currentDownloadState()) + .merge(with: fileDeleted) + .merge(with: fileAdded) + .merge(with: progress) + .merge(with: paused) + .merge(with: resumed) + .merge(with: finished) + .merge(with: cancelled) + .merge(with: failed) + .eraseToAnyPublisher() } // MARK: - URL-based Internal API @@ -530,17 +522,20 @@ extension DownloadManager: URLSessionDownloadDelegate, URLSessionTaskDelegate { let totalBytesWritten: Int64 let totalBytesExpectedToWrite: Int64 let progress: Double + let taskState: URLSessionDownloadTask.State? init(task: URLSessionTask) { totalBytesExpectedToWrite = task.countOfBytesExpectedToReceive totalBytesWritten = task.countOfBytesReceived progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) + taskState = task.state } init(totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64, progress: Double) { self.totalBytesWritten = totalBytesWritten self.totalBytesExpectedToWrite = totalBytesExpectedToWrite self.progress = progress + self.taskState = nil } static let unknown = DownloadInfo(totalBytesWritten: 0, totalBytesExpectedToWrite: 0, progress: -1) @@ -633,13 +628,12 @@ extension DownloadManager { } } -extension NotificationCenter { - - fileprivate func dm_addObserver(forName name: NSNotification.Name, filteredBy object: T, using block: @escaping (Notification) -> Void) -> NSObjectProtocol { - return self.addObserver(forName: name, object: nil, queue: .main) { note in - guard object == note.object as? T else { return } +private extension NotificationCenter { - block(note) - } + func publisher(for name: NSNotification.Name, filteredBy object: T) -> some Combine.Publisher { + publisher(for: name, object: nil) + .filter { notification in + object == notification.object as? T + } } } diff --git a/WWDC/DownloadViewModel.swift b/WWDC/DownloadViewModel.swift index 1bebde60..270c144d 100644 --- a/WWDC/DownloadViewModel.swift +++ b/WWDC/DownloadViewModel.swift @@ -7,14 +7,14 @@ // import ConfCore -import RxSwift +import Combine final class DownloadViewModel { let download: DownloadManager.Download - let status: Observable + let status: AnyPublisher let session: Session - init(download: DownloadManager.Download, status: Observable, session: Session) { + init(download: DownloadManager.Download, status: AnyPublisher, session: Session) { self.download = download self.status = status self.session = session diff --git a/WWDC/DownloadsManagementTableCellView.swift b/WWDC/DownloadsManagementTableCellView.swift index 3368a871..9ffebdc0 100644 --- a/WWDC/DownloadsManagementTableCellView.swift +++ b/WWDC/DownloadsManagementTableCellView.swift @@ -6,7 +6,7 @@ // Copyright © 2018 Guilherme Rambo. All rights reserved. // -import RxSwift +import Combine final class DownloadsManagementTableCellView: NSTableCellView { @@ -35,7 +35,7 @@ final class DownloadsManagementTableCellView: NSTableCellView { return status } - var disposeBag = DisposeBag() + private lazy var cancellables: Set = [] var viewModel: DownloadViewModel? { didSet { @@ -55,7 +55,7 @@ final class DownloadsManagementTableCellView: NSTableCellView { } func bindUI() { - disposeBag = DisposeBag() + cancellables = [] sessionTitleLabel.stringValue = viewModel?.session.title ?? "No ViewModel" @@ -63,10 +63,10 @@ final class DownloadsManagementTableCellView: NSTableCellView { let status = viewModel.status let download = viewModel.download - let throttledStatus = status.throttle(.milliseconds(100), latest: true, scheduler: MainScheduler.instance) + let throttledStatus = status.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true) throttledStatus - .subscribe(onNext: { [weak self] status in + .sink { [weak self] status in guard let self = self else { return } switch status { @@ -81,7 +81,8 @@ final class DownloadsManagementTableCellView: NSTableCellView { self.downloadStatusLabel.stringValue = DownloadsManagementTableCellView.statusString(for: info, download: download) case .finished, .cancelled, .none, .failed: () } - }).disposed(by: disposeBag) + } + .store(in: &cancellables) status .map { status -> NSControl.StateValue in @@ -90,9 +91,10 @@ final class DownloadsManagementTableCellView: NSTableCellView { } return NSControl.StateValue.on } - .distinctUntilChanged() - .bind(to: suspendResumeButton.rx.state) - .disposed(by: disposeBag) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .assign(to: \.state, on: suspendResumeButton) + .store(in: &cancellables) } private lazy var sessionTitleLabel: NSTextField = { @@ -174,6 +176,7 @@ final class DownloadsManagementTableCellView: NSTableCellView { // Horizontal layout let gap: CGFloat = -5 + // fyi, this leading of 20 was chose to make the close button look ok in the detached popover window progressIndicator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20).isActive = true progressIndicator.centerYAnchor.constraint(equalTo: centerYAnchor, constant: 3).isActive = true progressIndicator.trailingAnchor.constraint(equalTo: suspendResumeButton.leadingAnchor, constant: gap - 2).isActive = true diff --git a/WWDC/DownloadsManagementViewController.swift b/WWDC/DownloadsManagementViewController.swift index e4cfc778..b820f710 100644 --- a/WWDC/DownloadsManagementViewController.swift +++ b/WWDC/DownloadsManagementViewController.swift @@ -7,7 +7,7 @@ // import ConfCore -import RxSwift +import Combine class DownloadsManagementViewController: NSViewController { @@ -34,6 +34,7 @@ class DownloadsManagementViewController: NSViewController { v.gridStyleMask = .solidHorizontalGridLineMask v.gridColor = NSColor.gridColor v.selectionHighlightStyle = .none + v.style = .plain let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "download")) v.addTableColumn(column) @@ -69,15 +70,9 @@ class DownloadsManagementViewController: NSViewController { scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -Metrics.topPadding).isActive = true } - override func viewDidAppear() { - super.viewDidAppear() - - view.window?.title = "Downloads" - } - let downloadManager: DownloadManager let storage: Storage - var disposeBag = DisposeBag() + private lazy var cancellables: Set = [] var downloads = [DownloadManager.Download]() { didSet { @@ -85,7 +80,10 @@ class DownloadsManagementViewController: NSViewController { dismiss(nil) } else if downloads != oldValue { tableView.reloadData() - let height = min((Metrics.rowHeight + Metrics.tableGridLineHeight) * CGFloat(downloads.count) + Metrics.topPadding * 2, preferredMaximumSize.height) + let height = min( + (Metrics.rowHeight + Metrics.tableGridLineHeight) * CGFloat(downloads.count) + Metrics.topPadding * 2, + preferredMaximumSize.height + ) self.preferredContentSize = NSSize(width: Metrics.popOverDesiredWidth, height: height) } } @@ -105,12 +103,12 @@ class DownloadsManagementViewController: NSViewController { super.init(nibName: nil, bundle: nil) downloadManager - .downloadsObservable - .throttle(.milliseconds(200), scheduler: MainScheduler.instance) - .subscribe(onNext: { [weak self] in - + .$downloads + .throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] in self?.downloads = $0.sorted(by: DownloadManager.Download.sortingFunction) - }).disposed(by: disposeBag) + } + .store(in: &cancellables) } required init?(coder: NSCoder) { diff --git a/WWDC/EventHeroViewController.swift b/WWDC/EventHeroViewController.swift index 7a7be634..7f0029af 100644 --- a/WWDC/EventHeroViewController.swift +++ b/WWDC/EventHeroViewController.swift @@ -8,12 +8,12 @@ import Cocoa import ConfCore -import RxSwift -import RxCocoa +import Combine public final class EventHeroViewController: NSViewController { - private(set) var hero = BehaviorRelay(value: nil) + @Published + var hero: EventHero? private lazy var backgroundImageView: FullBleedImageView = { let v = FullBleedImageView() @@ -139,12 +139,12 @@ public final class EventHeroViewController: NSViewController { private var imageDownloadOperation: Operation? - private let disposeBag = DisposeBag() + private lazy var cancellables: Set = [] private func bindViews() { - let image = hero.compactMap({ $0?.backgroundImage }).compactMap(URL.init) + let image = $hero.compactMap({ $0?.backgroundImage }).compactMap(URL.init) - image.distinctUntilChanged().subscribe(onNext: { [weak self] imageUrl in + image.driveUI { [weak self] imageUrl in guard let self = self else { return } self.imageDownloadOperation?.cancel() @@ -154,14 +154,14 @@ public final class EventHeroViewController: NSViewController { self.backgroundImageView.image = result.original } - }).disposed(by: disposeBag) + }.store(in: &cancellables) - let heroUnavailable = hero.map({ $0 == nil }) - heroUnavailable.bind(to: backgroundImageView.rx.isHidden).disposed(by: disposeBag) - heroUnavailable.map({ !$0 }).bind(to: placeholderImageView.rx.isHidden).disposed(by: disposeBag) + let heroUnavailable = $hero.map({ $0 == nil }) + heroUnavailable.replaceError(with: true).driveUI(\.isHidden, on: backgroundImageView).store(in: &cancellables) + heroUnavailable.toggled().replaceError(with: false).driveUI(\.isHidden, on: placeholderImageView).store(in: &cancellables) - hero.map({ $0?.title ?? "Schedule not available" }).bind(to: titleLabel.rx.text).disposed(by: disposeBag) - hero.map({ hero in + $hero.map(\.?.title).replaceNil(with: "Schedule not available").replaceError(with: "Schedule not available").driveUI(\.stringValue, on: titleLabel).store(in: &cancellables) + $hero.map({ hero in let unavailable = "The schedule is not currently available. Check back later." guard let hero = hero else { return unavailable } if hero.textComponents.isEmpty { @@ -169,22 +169,22 @@ public final class EventHeroViewController: NSViewController { } else { return hero.textComponents.joined(separator: "\n\n") } - }).bind(to: bodyLabel.rx.text).disposed(by: disposeBag) + }).replaceError(with: "").driveUI(\.stringValue, on: bodyLabel).store(in: &cancellables) - hero.compactMap({ $0?.titleColor }).subscribe(onNext: { [weak self] colorHex in + $hero.compactMap({ $0?.titleColor }).driveUI { [weak self] colorHex in guard let self = self else { return } self.titleLabel.textColor = NSColor.fromHexString(hexString: colorHex) - }).disposed(by: disposeBag) + }.store(in: &cancellables) // Dim background when there's a lot of text to show - hero.compactMap({ $0 }).map({ $0.textComponents.count > 2 }).subscribe(onNext: { [weak self] largeText in + $hero.compactMap({ $0 }).map({ $0.textComponents.count > 2 }).driveUI { [weak self] largeText in self?.backgroundImageView.alphaValue = 0.5 - }).disposed(by: disposeBag) + }.store(in: &cancellables) - hero.compactMap({ $0?.bodyColor }).subscribe(onNext: { [weak self] colorHex in + $hero.compactMap({ $0?.bodyColor }).driveUI { [weak self] colorHex in guard let self = self else { return } self.bodyLabel.textColor = NSColor.fromHexString(hexString: colorHex) - }).disposed(by: disposeBag) + }.store(in: &cancellables) } } diff --git a/WWDC/ExploreTabProvider.swift b/WWDC/ExploreTabProvider.swift index 88bbe622..196d081e 100644 --- a/WWDC/ExploreTabProvider.swift +++ b/WWDC/ExploreTabProvider.swift @@ -2,7 +2,6 @@ import Cocoa import SwiftUI import Combine import ConfCore -import RxSwift import RealmSwift final class ExploreTabProvider: ObservableObject { @@ -15,11 +14,11 @@ final class ExploreTabProvider: ObservableObject { @Published private(set) var content: ExploreTabContent? @Published var scrollOffset = CGPoint.zero - private lazy var featuredSectionsObservable: Observable> = { + private lazy var featuredSectionsObservable: some Publisher, Error> = { storage.featuredSectionsObservable }() - private lazy var continueWatchingSessionsObservable: Observable<[Session]> = { + private lazy var continueWatchingSessionsObservable: some Publisher<[Session], Error> = { let cutoffDate = Calendar.current.date(byAdding: Constants.continueWatchingMaxLastProgressUpdateInterval, to: Date()) ?? Date.distantPast let videoPredicate = Session.videoPredicate @@ -28,7 +27,7 @@ final class ExploreTabProvider: ObservableObject { let sessions = storage.realm.objects(Session.self) .filter(NSCompoundPredicate(andPredicateWithSubpredicates: [videoPredicate, progressPredicate])) - return Observable.collection(from: sessions).map { + return sessions.collectionPublisher.map { Array($0.sorted(by: { guard let p1 = $0.progresses.first else { return false } guard let p2 = $1.progresses.first else { return false } @@ -38,7 +37,7 @@ final class ExploreTabProvider: ObservableObject { } }() - private lazy var recentFavoriteSessionsObservable: Observable<[Session]> = { + private lazy var recentFavoriteSessionsObservable: some Publisher<[Session], Error> = { let cutoffDate = Calendar.current.date(byAdding: Constants.recentFavoritesMaxDateInterval, to: Date()) ?? Date.distantPast let favoritePredicate = NSPredicate(format: "createdAt >= %@ AND isDeleted = false", cutoffDate as NSDate) @@ -47,30 +46,30 @@ final class ExploreTabProvider: ObservableObject { .filter(favoritePredicate) .sorted(byKeyPath: "createdAt", ascending: false) - return Observable.collection(from: favorites).map { + return favorites.collectionPublisher.map { Array($0.compactMap { $0.session.first } .prefix(Constants.maxRecentFavoritesItems)) } }() - private lazy var topicsObservable: Observable<[Track]> = { + private lazy var topicsObservable: some Publisher<[Track], Error> = { let tracks = storage.realm.objects(Track.self) .filter(NSPredicate(format: "sessions.@count >= 1 OR instances.@count >= 1")) .sorted(byKeyPath: "name") - return Observable.collection(from: tracks).map({ $0.toArray() }) + return tracks.collectionPublisher.map({ $0.toArray() }) }() - private lazy var liveEventObservable: Observable = { + private lazy var liveEventObservable: some Publisher = { let liveInstances = storage.realm.objects(SessionInstance.self) .filter("rawSessionType == 'Special Event' AND isCurrentlyLive == true") .sorted(byKeyPath: "startTime", ascending: false) - return Observable.collection(from: liveInstances) + return liveInstances.collectionPublisher .map({ $0.toArray().first?.session }) }() - private var disposeBag = DisposeBag() + private var cancellables: Set = [] fileprivate struct SourceData { var featuredSections: Results @@ -81,24 +80,26 @@ final class ExploreTabProvider: ObservableObject { } func activate() { - Observable.combineLatest( + Publishers.CombineLatest4( featuredSectionsObservable, continueWatchingSessionsObservable, recentFavoriteSessionsObservable, - topicsObservable, - liveEventObservable - ) + topicsObservable + ).combineLatest(liveEventObservable, { first, second in + (first.0, first.1, first.2, first.3, second) + }) + .replaceErrorWithEmpty() .filter { !$0.isEmpty || !$1.isEmpty || !$2.isEmpty || !$3.isEmpty || $4 != nil } - .subscribe(on: MainScheduler.instance) .map(SourceData.init) - .subscribe(onNext: { [weak self] data in + .receive(on: DispatchQueue.main) + .sink { [weak self] data in self?.update(with: data) - }) - .disposed(by: disposeBag) + } + .store(in: &cancellables) } func invalidate() { - disposeBag = DisposeBag() + cancellables = [] } private func update(with data: SourceData) { diff --git a/WWDC/GeneralPreferencesViewController.swift b/WWDC/GeneralPreferencesViewController.swift index 2f61c296..e03aa820 100644 --- a/WWDC/GeneralPreferencesViewController.swift +++ b/WWDC/GeneralPreferencesViewController.swift @@ -7,8 +7,7 @@ // import Cocoa -import RxSwift -import RxCocoa +import Combine import ConfCore extension NSStoryboard.Name { @@ -105,19 +104,17 @@ final class GeneralPreferencesViewController: WWDCWindowContentViewController { languagesProvider.fetchAvailableLanguages() } - private let disposeBag = DisposeBag() - - private var dummyRelay = BehaviorRelay(value: false) + private var cancellables: Set = [] private func bindSyncEngine() { #if ICLOUD guard let engine = userDataSyncEngine, isViewLoaded else { return } // Disable sync switch while there are sync operations running - engine.isPerformingSyncOperation.asDriver() - .map({ !$0 }) - .drive(enableUserDataSyncSwitch.rx.isEnabled) - .disposed(by: disposeBag) + engine.$isPerformingSyncOperation.sink { [weak self] in + self?.enableUserDataSyncSwitch.isEnabled = !$0 + } + .store(in: &cancellables) #else enableUserDataSyncSwitch?.isHidden = true syncDescriptionLabel?.isHidden = true @@ -127,16 +124,14 @@ final class GeneralPreferencesViewController: WWDCWindowContentViewController { private func bindTranscriptIndexingState() { // Disable transcript language pop up while indexing transcripts. - syncEngine.isIndexingTranscripts.asDriver() - .map({ !$0 }) - .drive(transcriptLanguagesPopUp.rx.isEnabled) - .disposed(by: disposeBag) + syncEngine.isIndexingTranscripts.toggled() + .replaceError(with: true) + .driveUI(\.isEnabled, on: transcriptLanguagesPopUp) + .store(in: &cancellables) // Show indexing progress while indexing. - syncEngine.isIndexingTranscripts.asObservable() - .observe(on: MainScheduler.instance) - .bind { [weak self] isIndexing in + syncEngine.isIndexingTranscripts.driveUI { [weak self] isIndexing in guard let self = self else { return } if isIndexing { @@ -148,13 +143,11 @@ final class GeneralPreferencesViewController: WWDCWindowContentViewController { self.indexingProgressIndicator?.isHidden = true self.indexingProgressIndicator?.stopAnimation(nil) } - }.disposed(by: disposeBag) + }.store(in: &cancellables) - syncEngine.transcriptIndexingProgress.asObservable() - .observe(on: MainScheduler.instance) - .bind { [weak self] progress in + syncEngine.transcriptIndexingProgress.driveUI { [weak self] progress in self?.indexingProgressIndicator?.doubleValue = Double(progress) - }.disposed(by: disposeBag) + }.store(in: &cancellables) } @IBAction func searchInTranscriptsSwitchAction(_ sender: Any) { @@ -278,11 +271,10 @@ final class GeneralPreferencesViewController: WWDCWindowContentViewController { showLanguagesLoading() languagesProvider.availableLanguageCodes - .observe(on: MainScheduler.instance) - .bind { [weak self] languages in + .driveUI { [weak self] languages in self?.populateLanguagesPopUp(with: languages) } - .disposed(by: disposeBag) + .store(in: &cancellables) } private func populateLanguagesPopUp(with languages: [TranscriptLanguage]) { diff --git a/WWDC/NSTableView+Rx.swift b/WWDC/NSTableView+Rx.swift deleted file mode 100644 index 88d13a0e..00000000 --- a/WWDC/NSTableView+Rx.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// NSTableView+Rx.swift -// WWDC -// -// Created by Guilherme Rambo on 11/02/17. -// Copyright © 2017 Guilherme Rambo. All rights reserved. -// - -import Cocoa -import RxSwift -import RxCocoa - -final class RxTableViewDelegateProxy: DelegateProxy, NSTableViewDelegate, DelegateProxyType { - - weak private(set) var tableView: NSTableView? - - fileprivate var selectedRowSubject = PublishSubject() - - init(tableView: NSTableView) { - self.tableView = tableView - super.init(parentObject: tableView, delegateProxy: RxTableViewDelegateProxy.self) - } - - static func registerKnownImplementations() { - self.register(make: { RxTableViewDelegateProxy(tableView: $0)}) - } - - func tableViewSelectionDidChange(_ notification: Notification) { - guard let numberOfRows = tableView?.numberOfRows else { return } - guard let selectedRow = tableView?.selectedRow else { return } - - let row: Int? = (0.. NSTableViewDelegate? { - return object.delegate - } - - static func setCurrentDelegate(_ delegate: NSTableViewDelegate?, to object: NSTableView) { - object.delegate = delegate - } -} - -extension Reactive where Base: NSTableView { - - public var delegate: DelegateProxy { - return RxTableViewDelegateProxy.proxy(for: base) - } - - public var selectedRow: ControlProperty { - let delegate = RxTableViewDelegateProxy.proxy(for: base) - - let source = Observable.deferred { [weak tableView = base] () -> Observable in - if let startingRow = tableView?.selectedRow, startingRow >= 0 { - return delegate.selectedRowSubject.startWith(startingRow) - } else { - return delegate.selectedRowSubject.startWith(nil) - } - }.take(until: deallocated) - - let observer = Binder(base) { (control, value: Int?) in - if let row = value { - control.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - } else { - control.deselectAll(nil) - } - } - - return ControlProperty(values: source, valueSink: observer.asObserver()) - } - -} diff --git a/WWDC/PlaybackViewModel.swift b/WWDC/PlaybackViewModel.swift index 714be0b7..1328bde2 100644 --- a/WWDC/PlaybackViewModel.swift +++ b/WWDC/PlaybackViewModel.swift @@ -10,8 +10,7 @@ import Foundation import ConfCore import AVFoundation import PlayerUI -import RxCocoa -import RxSwift +import Combine enum PlaybackError: Error { case sessionNotFound(String) @@ -45,7 +44,7 @@ final class PlaybackViewModel { private var timeObserver: Any? - var nowPlayingInfo: BehaviorRelay = BehaviorRelay(value: nil) + @Published var nowPlayingInfo: PUINowPlayingInfo? init(sessionViewModel: SessionViewModel, storage: Storage) throws { self.storage = storage @@ -107,7 +106,7 @@ final class PlaybackViewModel { #endif player = AVPlayer(url: finalUrl) - nowPlayingInfo.accept(PUINowPlayingInfo(playbackViewModel: self)) + nowPlayingInfo = PUINowPlayingInfo(playbackViewModel: self) initializePlayerTimeSyncIfNeeded(with: session) } @@ -146,9 +145,9 @@ final class PlaybackViewModel { if !d.isZero { DispatchQueue.main.async { - if var nowPlayingInfo = self.nowPlayingInfo.value { + if var nowPlayingInfo = self.nowPlayingInfo { nowPlayingInfo.progress = p / d - self.nowPlayingInfo.accept(nowPlayingInfo) + self.nowPlayingInfo = nowPlayingInfo } } } diff --git a/WWDC/Realm+Combine.swift b/WWDC/Realm+Combine.swift new file mode 100644 index 00000000..09484369 --- /dev/null +++ b/WWDC/Realm+Combine.swift @@ -0,0 +1,28 @@ +// +// Realm+Combine.swift +// WWDC +// +// Created by Allen Humphreys on 6/9/23. +// Copyright © 2023 Guilherme Rambo. All rights reserved. +// + +import Combine +import RealmSwift + +extension RealmSubscribable where Self: Object { + func valuePublisher(share: Bool = true, includeInitialValue: Bool = true) -> some Publisher { + var initialValue: some Publisher { Just(self).setFailureType(to: Error.self) } + var valuePublisher: some Publisher { RealmSwift.valuePublisher(self) } + + switch (share, includeInitialValue) { + case (true, true): + return Publishers.Concatenate(prefix: initialValue, suffix: valuePublisher.share()).eraseToAnyPublisher() + case (false, true): + return Publishers.Concatenate(prefix: initialValue, suffix: valuePublisher).eraseToAnyPublisher() + case (true, false): + return valuePublisher.share().eraseToAnyPublisher() + case (false, false): + return valuePublisher.eraseToAnyPublisher() + } + } +} diff --git a/WWDC/RelatedSessionsViewController.swift b/WWDC/RelatedSessionsViewController.swift index 0f214470..ff21dbf7 100644 --- a/WWDC/RelatedSessionsViewController.swift +++ b/WWDC/RelatedSessionsViewController.swift @@ -113,6 +113,9 @@ final class RelatedSessionsViewController: NSViewController { view.addSubview(titleLabel) titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true titleLabel.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + + // Stay hidden until we get related sessions to show + view.isHidden = true } override func viewDidLoad() { diff --git a/WWDC/RxNil.swift b/WWDC/RxNil.swift deleted file mode 100644 index 5e2255a7..00000000 --- a/WWDC/RxNil.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// RxNil.swift -// WWDC -// -// Created by Guilherme Rambo on 22/04/17. -// Copyright © 2017 Guilherme Rambo. All rights reserved. -// - -import Foundation -import RxSwift - -protocol OptionalType { - associatedtype Wrapped - - var optional: Wrapped? { get } -} - -extension Optional: OptionalType { - var optional: Wrapped? { return self } -} - -extension Observable where Element: OptionalType { - func ignoreNil() -> Observable { - return flatMap { value in - value.optional.map { Observable.just($0) } ?? Observable.empty() - } - } -} diff --git a/WWDC/ScheduleContainerViewController.swift b/WWDC/ScheduleContainerViewController.swift index 4cea9710..12a82bbe 100644 --- a/WWDC/ScheduleContainerViewController.swift +++ b/WWDC/ScheduleContainerViewController.swift @@ -7,8 +7,7 @@ // import Cocoa -import RxSwift -import RxCocoa +import Combine final class ScheduleContainerViewController: WWDCWindowContentViewController { @@ -25,7 +24,8 @@ final class ScheduleContainerViewController: WWDCWindowContentViewController { } /// This should be bound to a state that returns `true` when the schedule is not available. - private(set) var showHeroView = BehaviorRelay(value: false) + @Published + var showHeroView = false private(set) lazy var heroController: EventHeroViewController = { EventHeroViewController() @@ -60,7 +60,7 @@ final class ScheduleContainerViewController: WWDCWindowContentViewController { ]) } - private let disposeBag = DisposeBag() + private lazy var cancellables: Set = [] override func viewDidLoad() { super.viewDidLoad() @@ -69,24 +69,21 @@ final class ScheduleContainerViewController: WWDCWindowContentViewController { } private func bindViews() { - showHeroView.asDriver() - .drive(splitViewController.view.rx.isHidden) - .disposed(by: disposeBag) - - showHeroView.asDriver() - .map({ !$0 }) - .drive(heroController.view.rx.isHidden) - .disposed(by: disposeBag) - - showHeroView.asObservable().subscribe { [weak self] _ in - guard let self = self else { return } - self.view.needsUpdateConstraints = true + $showHeroView.replaceError(with: false).driveUI(\.view.isHidden, on: splitViewController) + .store(in: &cancellables) + + $showHeroView.toggled().replaceError(with: true) + .driveUI(\.view.isHidden, on: heroController) + .store(in: &cancellables) + + $showHeroView.driveUI { [weak self] _ in + self?.view.needsUpdateConstraints = true } - .disposed(by: disposeBag) + .store(in: &cancellables) } override var childForWindowTopSafeAreaConstraint: NSViewController? { - showHeroView.value ? heroController : nil + showHeroView ? heroController : nil } } diff --git a/WWDC/SessionActionsViewController.swift b/WWDC/SessionActionsViewController.swift index 19f8aae5..8b4bea04 100644 --- a/WWDC/SessionActionsViewController.swift +++ b/WWDC/SessionActionsViewController.swift @@ -9,8 +9,7 @@ import Cocoa import PlayerUI import ConfCore -import RxSwift -import RxCocoa +import Combine protocol SessionActionsViewControllerDelegate: AnyObject { @@ -34,7 +33,7 @@ class SessionActionsViewController: NSViewController { fatalError("init(coder:) has not been implemented") } - private var disposeBag = DisposeBag() + private lazy var cancellables: Set = [] var viewModel: SessionViewModel? { didSet { @@ -167,14 +166,14 @@ class SessionActionsViewController: NSViewController { } private func updateBindings() { - disposeBag = DisposeBag() + cancellables = [] guard let viewModel = viewModel else { return } slidesButton.isHidden = (viewModel.session.asset(ofType: .slides) == nil) calendarButton.isHidden = (viewModel.sessionInstance.startTime < today()) - viewModel.rxIsFavorite.subscribe(onNext: { [weak self] isFavorite in + viewModel.rxIsFavorite.replaceError(with: false).sink { [weak self] isFavorite in self?.favoriteButton.state = isFavorite ? .on : .off if isFavorite { @@ -182,44 +181,47 @@ class SessionActionsViewController: NSViewController { } else { self?.favoriteButton.toolTip = "Add to favorites" } - }).disposed(by: disposeBag) - - if let rxDownloadState = DownloadManager.shared.downloadStatusObservable(for: viewModel.session) { - rxDownloadState.throttle(.milliseconds(800), scheduler: MainScheduler.instance).subscribe(onNext: { [weak self] status in - switch status { - case .downloading(let info): - self?.downloadIndicator.isHidden = false - self?.downloadButton.isHidden = true - self?.clipButton.isHidden = true - - if info.progress < 0 { - self?.downloadIndicator.isIndeterminate = true - self?.downloadIndicator.startAnimating() - } else { - self?.downloadIndicator.isIndeterminate = false - self?.downloadIndicator.progress = Float(info.progress) + } + .store(in: &cancellables) + + if let downloadState = DownloadManager.shared.downloadStatusObservable(for: viewModel.session) { + downloadState + .throttle(for: .milliseconds(800), scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] status in + switch status { + case .downloading(let info): + self?.downloadIndicator.isHidden = false + self?.downloadButton.isHidden = true + self?.clipButton.isHidden = true + + if info.progress < 0 { + self?.downloadIndicator.isIndeterminate = true + self?.downloadIndicator.startAnimating() + } else { + self?.downloadIndicator.isIndeterminate = false + self?.downloadIndicator.progress = Float(info.progress) + } + + case .failed: + let alert = WWDCAlert.create() + alert.messageText = "Download Failed!" + alert.informativeText = "An error occurred while attempting to download \"\(viewModel.title)\"." + alert.runModal() + fallthrough + case .paused, .cancelled, .none: + self?.resetDownloadButton() + self?.downloadIndicator.isHidden = true + self?.downloadButton.isHidden = false + self?.clipButton.isHidden = true + case .finished: + self?.downloadButton.toolTip = "Delete downloaded video" + self?.downloadButton.isHidden = false + self?.downloadIndicator.isHidden = true + self?.downloadButton.image = #imageLiteral(resourceName: "trash") + self?.downloadButton.action = #selector(SessionActionsViewController.deleteDownload) + self?.clipButton.isHidden = false } - - case .failed: - let alert = WWDCAlert.create() - alert.messageText = "Download Failed!" - alert.informativeText = "An error occurred while attempting to download \"\(viewModel.title)\"." - alert.runModal() - fallthrough - case .paused, .cancelled, .none: - self?.resetDownloadButton() - self?.downloadIndicator.isHidden = true - self?.downloadButton.isHidden = false - self?.clipButton.isHidden = true - case .finished: - self?.downloadButton.toolTip = "Delete downloaded video" - self?.downloadButton.isHidden = false - self?.downloadIndicator.isHidden = true - self?.downloadButton.image = #imageLiteral(resourceName: "trash") - self?.downloadButton.action = #selector(SessionActionsViewController.deleteDownload) - self?.clipButton.isHidden = false - } - }).disposed(by: disposeBag) + }.store(in: &cancellables) } else { // session can't be downloaded (maybe Lab or download not available yet) downloadIndicator.isHidden = true diff --git a/WWDC/SessionCellView.swift b/WWDC/SessionCellView.swift index a3588055..f19ecc22 100644 --- a/WWDC/SessionCellView.swift +++ b/WWDC/SessionCellView.swift @@ -7,12 +7,11 @@ // import Cocoa -import RxSwift -import RxCocoa +import Combine final class SessionCellView: NSView { - private var disposeBag = DisposeBag() + private var cancellables: Set = [] var viewModel: SessionViewModel? { didSet { @@ -45,20 +44,18 @@ final class SessionCellView: NSView { } private func bindUI() { - disposeBag = DisposeBag() + cancellables = [] guard let viewModel = viewModel else { return } - viewModel.rxTitle.distinctUntilChanged().asDriver(onErrorJustReturn: "").drive(titleLabel.rx.text).disposed(by: disposeBag) - viewModel.rxSubtitle.distinctUntilChanged().asDriver(onErrorJustReturn: "").drive(subtitleLabel.rx.text).disposed(by: disposeBag) - viewModel.rxContext.distinctUntilChanged().asDriver(onErrorJustReturn: "").drive(contextLabel.rx.text).disposed(by: disposeBag) + viewModel.rxTitle.replaceError(with: "").driveUI(\.stringValue, on: titleLabel).store(in: &cancellables) + viewModel.rxSubtitle.replaceError(with: "").driveUI(\.stringValue, on: subtitleLabel).store(in: &cancellables) + viewModel.rxContext.replaceError(with: "").driveUI(\.stringValue, on: contextLabel).store(in: &cancellables) - viewModel.rxIsFavorite.distinctUntilChanged().map({ !$0 }).bind(to: favoritedImageView.rx.isHidden).disposed(by: disposeBag) - viewModel.rxIsDownloaded.distinctUntilChanged().map({ !$0 }).bind(to: downloadedImageView.rx.isHidden).disposed(by: disposeBag) - - viewModel.rxImageUrl.distinctUntilChanged({ $0 != $1 }).subscribe(onNext: { [weak self] imageUrl in - guard let imageUrl = imageUrl else { return } + viewModel.rxIsFavorite.toggled().replaceError(with: true).driveUI(\.isHidden, on: favoritedImageView).store(in: &cancellables) + viewModel.rxIsDownloaded.toggled().replaceError(with: true).driveUI(\.isHidden, on: downloadedImageView).store(in: &cancellables) + viewModel.rxImageUrl.removeDuplicates().replaceErrorWithEmpty().compacted().sink { [weak self] imageUrl in self?.imageDownloadOperation?.cancel() self?.imageDownloadOperation = ImageDownloadCenter.shared.downloadImage(from: imageUrl, thumbnailHeight: Constants.thumbnailHeight, thumbnailOnly: true) { [weak self] url, result in @@ -66,17 +63,18 @@ final class SessionCellView: NSView { self?.thumbnailImageView.image = result.thumbnail } - }).disposed(by: disposeBag) + } + .store(in: &cancellables) - viewModel.rxColor.distinctUntilChanged({ $0 == $1 }).subscribe(onNext: { [weak self] color in + viewModel.rxColor.removeDuplicates().replaceErrorWithEmpty().sink(receiveValue: { [weak self] color in self?.contextColorView.color = color - }).disposed(by: disposeBag) + }).store(in: &cancellables) - viewModel.rxDarkColor.distinctUntilChanged({ $0 == $1 }).subscribe(onNext: { [weak self] color in + viewModel.rxDarkColor.removeDuplicates().replaceErrorWithEmpty().sink(receiveValue: { [weak self] color in self?.snowFlakeView.backgroundColor = color - }).disposed(by: disposeBag) + }).store(in: &cancellables) - viewModel.rxProgresses.subscribe(onNext: { [weak self] progresses in + viewModel.rxProgresses.replaceErrorWithEmpty().sink(receiveValue: { [weak self] progresses in if let progress = progresses.first { self?.contextColorView.hasValidProgress = true self?.contextColorView.progress = progress.relativePosition @@ -84,7 +82,7 @@ final class SessionCellView: NSView { self?.contextColorView.hasValidProgress = false self?.contextColorView.progress = 0 } - }).disposed(by: disposeBag) + }).store(in: &cancellables) } private lazy var titleLabel: NSTextField = { diff --git a/WWDC/SessionRowProvider.swift b/WWDC/SessionRowProvider.swift index e0f3dc13..02aa9df2 100644 --- a/WWDC/SessionRowProvider.swift +++ b/WWDC/SessionRowProvider.swift @@ -10,7 +10,7 @@ import ConfCore import RealmSwift protocol SessionRowProvider { - func sessionRowIdentifierForToday() -> SessionIdentifiable? + func sessionRowIdentifierForToday(onlyIncludingRowsFor included: Results?) -> SessionIdentifiable? func filteredRows(onlyIncludingRowsFor: Results) -> [SessionRow] var allRows: [SessionRow] { get } @@ -56,7 +56,7 @@ struct VideosSessionRowProvider: SessionRowProvider { return rows } - func sessionRowIdentifierForToday() -> SessionIdentifiable? { + func sessionRowIdentifierForToday(onlyIncludingRowsFor included: Results?) -> SessionIdentifiable? { return nil } } @@ -80,14 +80,13 @@ struct ScheduleSessionRowProvider: SessionRowProvider { var shownTimeZone = false let rows: [SessionRow] = scheduleSections.flatMap { section -> [SessionRow] in - var instances: [SessionInstance] + let filteredInstances = filteredInstances(in: section, onlyIncludingRowsFor: included).sorted(by: SessionInstance.standardSort) + guard !filteredInstances.isEmpty else { return []} - if let included = included { - let sessionIdentifiers = Array(included.map { $0.identifier }) - instances = Array(section.instances.filter(NSPredicate(format: "session.identifier IN %@", sessionIdentifiers))) - guard !instances.isEmpty else { return [] } - } else { - instances = Array(section.instances) + let instanceRows: [SessionRow] = filteredInstances.compactMap { instance in + guard let viewModel = SessionViewModel(session: instance.session, instance: instance, track: nil, style: .schedule) else { return nil } + + return SessionRow(viewModel: viewModel) } // Section header @@ -95,24 +94,29 @@ struct ScheduleSessionRowProvider: SessionRowProvider { shownTimeZone = true - let instanceRows: [SessionRow] = instances.sorted(by: SessionInstance.standardSort).compactMap { instance in - guard let viewModel = SessionViewModel(session: instance.session, instance: instance, track: nil, style: .schedule) else { return nil } - - return SessionRow(viewModel: viewModel) - } - return [titleRow] + instanceRows } return rows } - func sessionRowIdentifierForToday() -> SessionIdentifiable? { + func sessionRowIdentifierForToday(onlyIncludingRowsFor included: Results?) -> SessionIdentifiable? { guard let section = scheduleSections.filter("representedDate >= %@", today()).first else { return nil } - guard let identifier = section.instances.first?.session?.identifier else { return nil } + let filteredInstances = filteredInstances(in: section, onlyIncludingRowsFor: included).sorted(by: SessionInstance.standardSort) + + guard let identifier = filteredInstances.first?.session?.identifier else { return nil } return SessionIdentifier(identifier) } + + private func filteredInstances(in section: ScheduleSection, onlyIncludingRowsFor included: Results?) -> [SessionInstance] { + if let included = included { + let sessionIdentifiers = Array(included.map { $0.identifier }) + return Array(section.instances.filter(NSPredicate(format: "session.identifier IN %@", sessionIdentifiers))) + } else { + return Array(section.instances) + } + } } diff --git a/WWDC/SessionSummaryViewController.swift b/WWDC/SessionSummaryViewController.swift index f2b8f2c8..4027620a 100644 --- a/WWDC/SessionSummaryViewController.swift +++ b/WWDC/SessionSummaryViewController.swift @@ -7,13 +7,12 @@ // import Cocoa -import RxSwift -import RxCocoa import ConfCore +import Combine class SessionSummaryViewController: NSViewController { - private var disposeBag = DisposeBag() + private var cancellables: Set = [] var viewModel: SessionViewModel? { didSet { @@ -58,6 +57,7 @@ class SessionSummaryViewController: NSViewController { lazy var relatedSessionsViewController: RelatedSessionsViewController = { let c = RelatedSessionsViewController() + c.view.translatesAutoresizingMaskIntoConstraints = false c.title = "Related Sessions" @@ -67,7 +67,7 @@ class SessionSummaryViewController: NSViewController { private func attributedSummaryString(from string: String) -> NSAttributedString { .create(with: string, font: .systemFont(ofSize: 15), color: .secondaryText, lineHeightMultiple: 1.2) } - + private lazy var summaryTextView: NSTextView = { let v = NSTextView() @@ -171,7 +171,6 @@ class SessionSummaryViewController: NSViewController { summaryScrollView.heightAnchor.constraint(equalToConstant: Metrics.summaryHeight).isActive = true addChild(relatedSessionsViewController) - relatedSessionsViewController.view.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(relatedSessionsViewController.view) relatedSessionsViewController.view.heightAnchor.constraint(equalToConstant: RelatedSessionsViewController.Metrics.height).isActive = true relatedSessionsViewController.view.leadingAnchor.constraint(equalTo: stackView.leadingAnchor).isActive = true @@ -191,28 +190,34 @@ class SessionSummaryViewController: NSViewController { guard let viewModel = viewModel else { return } - disposeBag = DisposeBag() + cancellables = [] - viewModel.rxTitle.map(NSAttributedString.attributedBoldTitle(with:)).subscribe(onNext: { [weak self] title in - self?.titleLabel.attributedStringValue = title - }).disposed(by: disposeBag) - viewModel.rxFooter.bind(to: contextLabel.rx.text).disposed(by: disposeBag) + viewModel + .rxTitle + .replaceError(with: "") + .map(NSAttributedString.attributedBoldTitle(with:)) + .driveUI(\.attributedStringValue, on: titleLabel) + .store(in: &cancellables) + viewModel.rxFooter.replaceError(with: "").driveUI(\.stringValue, on: contextLabel).store(in: &cancellables) - viewModel.rxSummary.subscribe(onNext: { [weak self] summary in + viewModel.rxSummary.driveUI { [weak self] summary in guard let self = self else { return } guard let textStorage = self.summaryTextView.textStorage else { return } let range = NSRange(location: 0, length: textStorage.length) textStorage.replaceCharacters(in: range, with: self.attributedSummaryString(from: summary)) - }).disposed(by: disposeBag) + } + .store(in: &cancellables) - viewModel.rxRelatedSessions.subscribe(onNext: { [weak self] relatedResources in + viewModel.rxRelatedSessions.driveUI { [weak self] relatedResources in let relatedSessions = relatedResources.compactMap({ $0.session }) self?.relatedSessionsViewController.sessions = relatedSessions.compactMap(SessionViewModel.init) - }).disposed(by: disposeBag) + } + .store(in: &cancellables) relatedSessionsViewController.scrollToBeginningOfDocument(nil) - viewModel.rxActionPrompt.bind(to: actionLinkLabel.rx.text).disposed(by: disposeBag) + // TODO: Not even sure what this does + viewModel.rxActionPrompt.replaceNilAndError(with: "").driveUI(\.stringValue, on: actionLinkLabel).store(in: &cancellables) } @objc private func clickedActionLabel() { diff --git a/WWDC/SessionTranscriptViewController.swift b/WWDC/SessionTranscriptViewController.swift index 190801a3..39c4b1a1 100644 --- a/WWDC/SessionTranscriptViewController.swift +++ b/WWDC/SessionTranscriptViewController.swift @@ -9,8 +9,7 @@ import Cocoa import ConfCore import RealmSwift -import RxSwift -import RxCocoa +import Combine extension Notification.Name { static let TranscriptControllerDidSelectAnnotation = Notification.Name("TranscriptControllerDidSelectAnnotation") @@ -127,7 +126,7 @@ final class SessionTranscriptViewController: NSViewController { NotificationCenter.default.addObserver(self, selector: #selector(highlightTranscriptLine), name: .HighlightTranscriptAtCurrentTimecode, object: nil) } - private var disposeBag = DisposeBag() + private lazy var cancellables: Set = [] private lazy var annotations = List() private lazy var filteredAnnotations: [TranscriptAnnotation] = [] @@ -135,21 +134,26 @@ final class SessionTranscriptViewController: NSViewController { private func updateUI() { guard let viewModel = viewModel else { return } - disposeBag = DisposeBag() + cancellables = [] - viewModel.rxTranscriptAnnotations.observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] annotations in - self?.updateAnnotations(with: annotations) - }).disposed(by: disposeBag) + viewModel + .rxTranscriptAnnotations + .driveUI { [weak self] annotations in + self?.updateAnnotations(with: annotations) + } + .store(in: &cancellables) - searchController.searchTerm.subscribe(onNext: { [weak self] term in - self?.updateFilter(with: term) - }).disposed(by: disposeBag) + searchController + .$searchTerm + .driveUI { [weak self] term in + self?.updateFilter(with: term) + } + .store(in: &cancellables) } private func updateAnnotations(with newAnnotations: List) { annotations = newAnnotations - updateFilter(with: searchController.searchTerm.value) + updateFilter(with: searchController.searchTerm) } private func updateFilter(with term: String?) { diff --git a/WWDC/SessionViewModel.swift b/WWDC/SessionViewModel.swift index 30f1f732..f534bede 100644 --- a/WWDC/SessionViewModel.swift +++ b/WWDC/SessionViewModel.swift @@ -8,9 +8,7 @@ import Cocoa import ConfCore -import RxRealm -import RxSwift -import RxCocoa +import Combine import RealmSwift import PlayerUI @@ -26,49 +24,47 @@ final class SessionViewModel { var imageUrl: URL? let trackName: String - private var disposeBag = DisposeBag() - - lazy var rxSession: Observable = { - return Observable.from(object: session) + lazy var rxSession: some Publisher = { + return session.valuePublisher() }() - lazy var rxTranscriptAnnotations: Observable> = { + lazy var rxTranscriptAnnotations: AnyPublisher, Error> = { guard let annotations = session.transcript()?.annotations else { - return Observable.just(List()) + return Just(List()).setFailureType(to: Error.self).eraseToAnyPublisher() } - return Observable.collection(from: annotations) + return annotations.collectionPublisher.eraseToAnyPublisher() }() - lazy var rxSessionInstance: Observable = { - return Observable.from(object: sessionInstance) + lazy var rxSessionInstance: some Publisher = { + return sessionInstance.valuePublisher() }() - lazy var rxTrack: Observable = { - return Observable.from(object: track) + lazy var rxTrack: some Publisher = { + return track.valuePublisher() }() - lazy var rxTitle: Observable = { + lazy var rxTitle: some Publisher = { return rxSession.map { $0.title } }() - lazy var rxSubtitle: Observable = { + lazy var rxSubtitle: some Publisher = { return rxSession.map { SessionViewModel.subtitle(from: $0, at: $0.event.first) } }() - lazy var rxTrackName: Observable = { + lazy var rxTrackName: some Publisher = { return rxTrack.map { $0.name } }() - lazy var rxSummary: Observable = { + lazy var rxSummary: some Publisher = { return rxSession.map { $0.summary } }() - lazy var rxActionPrompt: Observable = { - guard sessionInstance.startTime > today() else { return Observable.just(nil) } - guard actionLinkURL != nil else { return Observable.just(nil) } + lazy var rxActionPrompt: AnyPublisher = { + guard sessionInstance.startTime > today() else { return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() } + guard actionLinkURL != nil else { return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() } - return rxSessionInstance.map { $0.actionLinkPrompt } + return rxSessionInstance.map { $0.actionLinkPrompt }.eraseToAnyPublisher() }() var actionLinkURL: URL? { @@ -77,87 +73,87 @@ final class SessionViewModel { return URL(string: candidateURL) } - lazy var rxContext: Observable = { + lazy var rxContext: AnyPublisher = { if self.style == .schedule { - return Observable.combineLatest(rxSession, rxSessionInstance).map { + return Publishers.CombineLatest(rxSession, rxSessionInstance).map { SessionViewModel.context(for: $0.0, instance: $0.1) - } + }.eraseToAnyPublisher() } else { - return Observable.combineLatest(rxSession, rxTrack).map { + return Publishers.CombineLatest(rxSession, rxTrack).map { SessionViewModel.context(for: $0.0, track: $0.1) - } + }.eraseToAnyPublisher() } }() - lazy var rxFooter: Observable = { + lazy var rxFooter: some Publisher = { return rxSession.map { SessionViewModel.footer(for: $0, at: $0.event.first) } }() - lazy var rxColor: Observable = { - return rxSession.map { SessionViewModel.trackColor(for: $0) }.ignoreNil() + lazy var rxColor: some Publisher = { + return rxSession.compactMap { SessionViewModel.trackColor(for: $0) } }() - lazy var rxDarkColor: Observable = { - return rxSession.map { SessionViewModel.darkTrackColor(for: $0) }.ignoreNil() + lazy var rxDarkColor: some Publisher = { + return rxSession.compactMap { SessionViewModel.darkTrackColor(for: $0) } }() - lazy var rxImageUrl: Observable = { + lazy var rxImageUrl: some Publisher = { return rxSession.map { SessionViewModel.imageUrl(for: $0) } }() - lazy var rxWebUrl: Observable = { + lazy var rxWebUrl: some Publisher = { return rxSession.map { SessionViewModel.webUrl(for: $0) } }() - lazy var rxIsDownloaded: Observable = { + lazy var rxIsDownloaded: some Publisher = { return rxSession.map { $0.isDownloaded } }() - lazy var rxIsFavorite: Observable = { - return Observable.collection(from: self.session.favorites.filter("isDeleted == false")).map { $0.count > 0 } + lazy var rxIsFavorite: some Publisher = { + return self.session.favorites.filter("isDeleted == false").collectionPublisher.map { $0.count > 0 } }() - lazy var rxIsCurrentlyLive: Observable = { + lazy var rxIsCurrentlyLive: some Publisher = { guard self.sessionInstance.realm != nil else { - return Observable.just(false) + return Just(false).setFailureType(to: Error.self).eraseToAnyPublisher() } - return rxSessionInstance.map { $0.isCurrentlyLive } + return rxSessionInstance.map { $0.isCurrentlyLive }.eraseToAnyPublisher() }() - lazy var rxPlayableContent: Observable> = { + lazy var rxPlayableContent: some Publisher, Error> = { let playableAssets = self.session.assets(matching: [.streamingVideo, .liveStreamVideo]) - return Observable.collection(from: playableAssets) + return playableAssets.collectionPublisher }() - lazy var rxCanBePlayed: Observable = { + lazy var rxCanBePlayed: some Publisher = { let validAssets = self.session.assets.filter("(rawAssetType == %@ AND remoteURL != '') OR (rawAssetType == %@ AND SUBQUERY(session.instances, $instance, $instance.isCurrentlyLive == true).@count > 0)", SessionAssetType.streamingVideo.rawValue, SessionAssetType.liveStreamVideo.rawValue) - let validAssetsObservable = Observable.collection(from: validAssets) + let validAssetsObservable = validAssets.collectionPublisher return validAssetsObservable.map { $0.count > 0 } }() - lazy var rxDownloadableContent: Observable> = { + lazy var rxDownloadableContent: some Publisher, Error> = { let downloadableAssets = self.session.assets.filter("(rawAssetType == %@ AND remoteURL != '')", DownloadManager.downloadQuality.rawValue) - return Observable.collection(from: downloadableAssets) + return downloadableAssets.collectionPublisher }() - lazy var rxProgresses: Observable> = { + lazy var rxProgresses: some Publisher, Error> = { let progresses = self.session.progresses.filter(NSPredicate(value: true)) - return Observable.collection(from: progresses) + return progresses.collectionPublisher }() - lazy var rxRelatedSessions: Observable> = { + lazy var rxRelatedSessions: some Publisher, Error> = { // Return sessions with videos, or any session that hasn't yet occurred let predicateFormat = "type == %@ AND (ANY session.assets.rawAssetType == %@ OR ANY session.instances.startTime >= %@)" let relatedPredicate = NSPredicate(format: predicateFormat, RelatedResourceType.session.rawValue, SessionAssetType.streamingVideo.rawValue, today() as NSDate) let validRelatedSessions = self.session.related.filter(relatedPredicate) - return Observable.collection(from: validRelatedSessions) + return validRelatedSessions.collectionPublisher }() convenience init?(session: Session) { diff --git a/WWDC/SessionsTableViewController+SupportingTypesAndExtensions.swift b/WWDC/SessionsTableViewController+SupportingTypesAndExtensions.swift index 84389ab1..aaa4fc60 100644 --- a/WWDC/SessionsTableViewController+SupportingTypesAndExtensions.swift +++ b/WWDC/SessionsTableViewController+SupportingTypesAndExtensions.swift @@ -8,8 +8,7 @@ import ConfCore import RealmSwift -import RxRealm -import RxSwift +import Combine import OSLog /// Conforming to this protocol means the type is capable @@ -97,8 +96,8 @@ final class FilterResults: Logging { private(set) var latestSearchResults: Results? - private var disposeBag = DisposeBag() - private let nowPlayingBag = DisposeBag() + private lazy var cancellables: Set = [] + private var nowPlayingBag: Set = [] private var observerClosure: ((Results?) -> Void)? private var observerToken: NotificationToken? @@ -110,10 +109,11 @@ final class FilterResults: Logging { if let coordinator = (NSApplication.shared.delegate as? AppDelegate)?.coordinator { coordinator - .rxPlayerOwnerSessionIdentifier - .subscribe(onNext: { [weak self] _ in + .$playerOwnerSessionIdentifier + .sink(receiveValue: { [weak self] _ in self?.bindResults() - }).disposed(by: nowPlayingBag) + }) + .store(in: &nowPlayingBag) } } @@ -134,19 +134,26 @@ final class FilterResults: Logging { guard let observerClosure = observerClosure else { return } guard let storage = storage, let query = query?.orCurrentlyPlayingSession() else { return } - disposeBag = DisposeBag() + cancellables = [] do { let realm = try Realm(configuration: storage.realmConfig) let objects = realm.objects(Session.self).filter(query) - Observable - .shallowCollection(from: objects, synchronousStart: true) - .subscribe(onNext: { [weak self] in + // Immediately provide the first value + self.latestSearchResults = objects + observerClosure(objects) + + objects + .collectionChangedPublisher + .dropFirst(1) // first value is provided synchronously to help with timing issues + .replaceErrorWithEmpty() + .sink { [weak self] in self?.latestSearchResults = $0 observerClosure($0) - }).disposed(by: disposeBag) + } + .store(in: &cancellables) } catch { observerClosure(nil) log.error("Failed to initialize Realm for searching: \(String(describing: error), privacy: .public)") @@ -165,52 +172,3 @@ fileprivate extension NSPredicate { return NSCompoundPredicate(orPredicateWithSubpredicates: [self, NSPredicate(format: "identifier == %@", playingSession)]) } } - -public extension ObservableType where Element: NotificationEmitter { - - /** - Returns an `Observable` that emits each time elements are added or removed from the collection. - The observable emits an initial value upon subscription. Similar to `collection(from:synchronousStart)` but - is limited to emitting when elements are added or removed from the collection. Useful for less brute-forcey UI - updates. - - - parameter from: A Realm collection of type `E`: either `Results`, `List`, `LinkingObjects` or `AnyRealmCollection`. - - parameter synchronousStart: whether the resulting `Observable` should emit its first element synchronously (e.g. better for UI bindings) - - - returns: `Observable`, e.g. when called on `Results` it will return `Observable>`, on a `List` it will return `Observable>`, etc. - */ - static func shallowCollection(from collection: Element, synchronousStart: Bool = true) - -> Observable { - - return Observable.create { observer in - if synchronousStart { - observer.onNext(collection) - } - - let token = collection.observe(keyPaths: nil, on: nil) { changeset in - - var value: Element? - - switch changeset { - case .initial(let latestValue): - guard !synchronousStart else { return } - value = latestValue - - case .update(let latestValue, let deletions, let insertions, _) where !deletions.isEmpty || !insertions.isEmpty: - value = latestValue - - case .error(let error): - observer.onError(error) - return - default: () - } - - value.map(observer.onNext) - } - - return Disposables.create { - token.invalidate() - } - } - } -} diff --git a/WWDC/SessionsTableViewController.swift b/WWDC/SessionsTableViewController.swift index bf61a277..d3a75868 100644 --- a/WWDC/SessionsTableViewController.swift +++ b/WWDC/SessionsTableViewController.swift @@ -7,21 +7,23 @@ // import Cocoa -import RxSwift -import RxCocoa +import Combine import RealmSwift import ConfCore import OSLog // MARK: - Sessions Table View Controller -class SessionsTableViewController: NSViewController, NSMenuItemValidation { +class SessionsTableViewController: NSViewController, NSMenuItemValidation, Logging { - private let disposeBag = DisposeBag() + static let log = makeLogger() + + private lazy var cancellables: Set = [] weak var delegate: SessionsTableViewControllerDelegate? - var selectedSession = BehaviorRelay(value: nil) + @Published + var selectedSession: SessionViewModel? let style: SessionsListStyle @@ -84,13 +86,6 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation { tableView.delegate = self setupContextualMenu() - - tableView.rx.selectedRow.map { index -> SessionViewModel? in - guard let index = index else { return nil } - guard case .session(let viewModel) = self.displayedRows[index].kind else { return nil } - - return viewModel - }.bind(to: selectedSession).disposed(by: disposeBag) } override func viewDidAppear() { @@ -138,7 +133,7 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation { func scrollToToday() { - sessionRowProvider?.sessionRowIdentifierForToday().flatMap { select(session: $0) } + sessionRowProvider?.sessionRowIdentifierForToday(onlyIncludingRowsFor: filterResults.latestSearchResults).flatMap { select(session: $0) } } var hasPerformedFirstUpdate = false @@ -196,7 +191,7 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation { !isSessionVisible(for: initialSelection) && canDisplay(session: initialSelection) { searchController.resetFilters() - _filterResults = .empty + filterResults = .empty displayedRows = sessionRowProvider?.allRows ?? [] } @@ -351,28 +346,18 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation { // MARK: - Search + private var filterResults = FilterResults.empty + /// Provide a session identifier if you'd like to override the default selection behavior. Provide /// nil to let the table figure out what selection to apply after the update. func setFilterResults(_ filterResults: FilterResults, animated: Bool, selecting: SessionIdentifiable?) { - _filterResults = filterResults + self.filterResults = filterResults filterResults.observe { [weak self] in + self?.log.debug("Received filter results") self?.updateWith(searchResults: $0, animated: animated, selecting: selecting) } } - var _filterResults = FilterResults.empty - private var filterResults: FilterResults { - get { - return _filterResults - } - set { - _filterResults = newValue - filterResults.observe { [weak self] in - self?.updateWith(searchResults: $0, animated: false, selecting: nil) - } - } - } - // MARK: - UI lazy var searchController = SearchFiltersViewController.loadFromStoryboard() @@ -586,6 +571,19 @@ extension SessionsTableViewController: NSTableViewDataSource, NSTableViewDelegat static let sessionRowHeight: CGFloat = 64 } + func tableViewSelectionDidChange(_ notification: Notification) { + let numberOfRows = tableView.numberOfRows + let selectedRow = tableView.selectedRow + + let row: Int? = (0.. Int { return displayedRows.count } diff --git a/WWDC/ShelfViewController.swift b/WWDC/ShelfViewController.swift index a5af859b..2175be2c 100644 --- a/WWDC/ShelfViewController.swift +++ b/WWDC/ShelfViewController.swift @@ -7,8 +7,7 @@ // import Cocoa -import RxSwift -import RxCocoa +import Combine import CoreMedia protocol ShelfViewControllerDelegate: AnyObject { @@ -22,7 +21,7 @@ class ShelfViewController: NSViewController { weak var delegate: ShelfViewControllerDelegate? - private var disposeBag = DisposeBag() + private lazy var cancellables: Set = [] var viewModel: SessionViewModel? { didSet { @@ -96,16 +95,16 @@ class ShelfViewController: NSViewController { private weak var currentImageDownloadOperation: Operation? private func updateBindings() { - disposeBag = DisposeBag() + cancellables = [] guard let viewModel = viewModel else { shelfView.image = nil return } - viewModel.rxCanBePlayed.map({ !$0 }).bind(to: playButton.rx.isHidden).disposed(by: disposeBag) + viewModel.rxCanBePlayed.toggled().replaceError(with: true).driveUI(\.isHidden, on: playButton).store(in: &cancellables) - viewModel.rxImageUrl.subscribe(onNext: { [weak self] imageUrl in + viewModel.rxImageUrl.replaceErrorWithEmpty().sink { [weak self] imageUrl in self?.currentImageDownloadOperation?.cancel() self?.currentImageDownloadOperation = nil @@ -117,7 +116,8 @@ class ShelfViewController: NSViewController { self?.currentImageDownloadOperation = ImageDownloadCenter.shared.downloadImage(from: imageUrl, thumbnailHeight: Constants.thumbnailHeight) { url, result in self?.shelfView.image = result.original } - }).disposed(by: disposeBag) + } + .store(in: &cancellables) } @objc func play(_ sender: Any?) { diff --git a/WWDC/TitleBarButtonsViewController.swift b/WWDC/TitleBarButtonsViewController.swift index ff15d4ec..db260389 100644 --- a/WWDC/TitleBarButtonsViewController.swift +++ b/WWDC/TitleBarButtonsViewController.swift @@ -8,14 +8,12 @@ import Cocoa import ConfCore -import RxSwift import Combine import SwiftUI final class TitleBarButtonsViewController: NSViewController { private let downloadManager: DownloadManager private let storage: Storage - private let disposeBag = DisposeBag() private weak var managementViewController: DownloadsManagementViewController? var handleSharePlayClicked: () -> Void = { } @@ -31,11 +29,11 @@ final class TitleBarButtonsViewController: NSViewController { super.init(nibName: nil, bundle: nil) downloadManager - .downloadsObservable - .throttle(.milliseconds(200), scheduler: MainScheduler.instance) - .subscribe(onNext: { [weak self] in + .$downloads + .throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] in self?.statusButton.isHidden = $0.isEmpty - }).disposed(by: disposeBag) + }.store(in: &cancellables) bindSharePlayState() } diff --git a/WWDC/TranscriptSearchController.swift b/WWDC/TranscriptSearchController.swift index bd912f71..574774ad 100644 --- a/WWDC/TranscriptSearchController.swift +++ b/WWDC/TranscriptSearchController.swift @@ -8,8 +8,7 @@ import Cocoa import ConfCore -import RxSwift -import RxCocoa +import Combine import PlayerUI final class TranscriptSearchController: NSViewController { @@ -35,7 +34,8 @@ final class TranscriptSearchController: NSViewController { var didSelectOpenInNewWindow: () -> Void = { } var didSelectExportTranscript: () -> Void = { } - private(set) var searchTerm = BehaviorRelay(value: nil) + @Published + private(set) var searchTerm: String = "" private lazy var detachButton: PUIButton = { let b = PUIButton(frame: .zero) @@ -120,21 +120,28 @@ final class TranscriptSearchController: NSViewController { updateStyle() } - private let disposeBag = DisposeBag() + private lazy var cancellables: Set = [] override func viewDidLoad() { super.viewDidLoad() - let throttledSearch = searchField.rx.text.throttle(.milliseconds(500), scheduler: MainScheduler.instance) - - throttledSearch.bind(to: searchTerm) - .disposed(by: disposeBag) - - // The skip(1) prevents us from clearing the search pasteboard on initial binding. - throttledSearch.skip(1).ignoreNil().subscribe(onNext: { term in - NSPasteboard(name: .find).clearContents() - NSPasteboard(name: .find).setString(term, forType: .string) - }).disposed(by: disposeBag) + let throttledSearch = NotificationCenter.default.publisher(for: NSControl.textDidChangeNotification, object: searchField) + .map { + ($0.object as? NSSearchField)?.stringValue ?? "" + } + .throttle(for: .milliseconds(500), scheduler: DispatchQueue.main, latest: true) + .share() + + throttledSearch.assign(to: &$searchTerm) + + // The dropFirst(1) prevents us from clearing the search pasteboard on initial binding. + throttledSearch + .dropFirst(1) + .sink { term in + NSPasteboard(name: .find).clearContents() + NSPasteboard(name: .find).setString(term, forType: .string) + } + .store(in: &cancellables) } @objc private func openInNewWindow() { @@ -150,12 +157,12 @@ final class TranscriptSearchController: NSViewController { super.viewDidAppear() guard let pasteboardTerm = NSPasteboard(name: .find).string(forType: .string), - pasteboardTerm != searchTerm.value else { + pasteboardTerm != searchTerm else { return } searchField.stringValue = pasteboardTerm - searchTerm.accept(pasteboardTerm) + searchTerm = pasteboardTerm } @objc private func exportTranscript() { diff --git a/WWDC/VibrantButton.swift b/WWDC/VibrantButton.swift index 13db8062..efa3e768 100644 --- a/WWDC/VibrantButton.swift +++ b/WWDC/VibrantButton.swift @@ -10,7 +10,7 @@ import Cocoa class VibrantButton: NSView { - var target: Any? + weak var target: AnyObject? var action: Selector? var title: String? { diff --git a/WWDC/VideoPlayerViewController.swift b/WWDC/VideoPlayerViewController.swift index 84e82bb3..c4a048a8 100644 --- a/WWDC/VideoPlayerViewController.swift +++ b/WWDC/VideoPlayerViewController.swift @@ -9,10 +9,8 @@ import Cocoa import AVFoundation import PlayerUI -import RxSwift -import RxCocoa +import Combine import RealmSwift -import RxRealm import ConfCore extension Notification.Name { @@ -29,7 +27,7 @@ protocol VideoPlayerViewControllerDelegate: AnyObject { final class VideoPlayerViewController: NSViewController { - private var disposeBag = DisposeBag() + private lazy var cancellables: Set = [] weak var delegate: VideoPlayerViewControllerDelegate? @@ -37,7 +35,7 @@ final class VideoPlayerViewController: NSViewController { var sessionViewModel: SessionViewModel { didSet { - disposeBag = DisposeBag() + cancellables = [] updateUI() resetAppearanceDelegate() @@ -120,9 +118,9 @@ final class VideoPlayerViewController: NSViewController { NotificationCenter.default.addObserver(self, selector: #selector(annotationSelected(notification:)), name: .TranscriptControllerDidSelectAnnotation, object: nil) - NotificationCenter.default.rx.notification(.SkipBackAndForwardBy30SecondsPreferenceDidChange).observe(on: MainScheduler.instance).subscribe { _ in + NotificationCenter.default.publisher(for: .SkipBackAndForwardBy30SecondsPreferenceDidChange).receive(on: DispatchQueue.main).sink { _ in self.playerView.invalidateAppearance() - }.disposed(by: disposeBag) + }.store(in: &cancellables) } func resetAppearanceDelegate() { @@ -162,10 +160,12 @@ final class VideoPlayerViewController: NSViewController { } func updateUI() { - let bookmarks = sessionViewModel.session.bookmarks.sorted(byKeyPath: "timecode") - Observable.shallowCollection(from: bookmarks).observe(on: MainScheduler.instance).subscribe(onNext: { [weak self] bookmarks in - self?.playerView.annotations = bookmarks.toArray() - }).disposed(by: disposeBag) + let bookmarks = sessionViewModel.session.bookmarks.sorted(byKeyPath: "timecode").collectionChangedPublisher + bookmarks + .map { $0.toArray() } + .replaceError(with: []) + .driveUI(\.annotations, on: playerView) + .store(in: &cancellables) } @objc private func annotationSelected(notification: Notification) { diff --git a/WWDC/WWDCTabViewController.swift b/WWDC/WWDCTabViewController.swift index 2c034850..dff88191 100644 --- a/WWDC/WWDCTabViewController.swift +++ b/WWDC/WWDCTabViewController.swift @@ -7,8 +7,7 @@ // import Cocoa -import RxSwift -import RxCocoa +import Combine protocol WWDCTab: RawRepresentable { var hidesWindowTitleBar: Bool { get } @@ -29,11 +28,8 @@ class WWDCTabViewController: NSTabViewController where Tab.RawValu } } - private var activeTabVar = BehaviorRelay(value: Tab(rawValue: 0)!) - - var rxActiveTab: Observable { - return activeTabVar.asObservable() - } + @Published + private(set) var activeTabVar = Tab(rawValue: 0)! override var selectedTabViewItemIndex: Int { didSet { @@ -56,7 +52,7 @@ class WWDCTabViewController: NSTabViewController where Tab.RawValu return } - activeTabVar.accept(tab) + activeTabVar = tab updateWindowTitleBarVisibility(for: tab) }