diff --git a/CommunitySupport/CMSCloudKitRepresentable.swift b/CommunitySupport/CMSCloudKitRepresentable.swift index 865e1fd1..b4c97195 100644 --- a/CommunitySupport/CMSCloudKitRepresentable.swift +++ b/CommunitySupport/CMSCloudKitRepresentable.swift @@ -16,14 +16,14 @@ public enum CMSCloudKitError: Error { } public protocol CMSCloudKitRepresentable { - + static var recordType: String { get } - + var originatingRecord: CKRecord? { get set } var identifier: String { get } - + init(record: CKRecord) throws - + func makeRecord() throws -> CKRecord - + } diff --git a/CommunitySupport/CMSCloudKitUtil.swift b/CommunitySupport/CMSCloudKitUtil.swift index 62c23313..89be518a 100644 --- a/CommunitySupport/CMSCloudKitUtil.swift +++ b/CommunitySupport/CMSCloudKitUtil.swift @@ -17,23 +17,23 @@ import CloudKit /// - Returns: If the error can't be retried, returns the error internal func retryCloudKitOperationIfPossible(with error: Error?, block: @escaping () -> ()) -> Error? { guard error != nil else { return nil } - + guard let effectiveError = error as? CKError else { slog("CloudKit puked ¯\\_(ツ)_/¯") return error } - + guard let retryAfter = effectiveError.retryAfterSeconds else { slog("CloudKit error: \(effectiveError)") return effectiveError } - + slog("CloudKit operation error, retrying after \(retryAfter) seconds... \(effectiveError)") - + DispatchQueue.main.asyncAfter(deadline: .now() + retryAfter) { block() } - + return nil } diff --git a/CommunitySupport/CMSCommunityCenter.swift b/CommunitySupport/CMSCommunityCenter.swift index a9de6764..c75fbfff 100644 --- a/CommunitySupport/CMSCommunityCenter.swift +++ b/CommunitySupport/CMSCommunityCenter.swift @@ -28,68 +28,68 @@ extension Notification.Name { } public final class CMSCommunityCenter: NSObject { - + private lazy var container: CKContainer = CKContainer.default() - + private lazy var database: CKDatabase = { return self.container.publicCloudDatabase }() - + public static let shared: CMSCommunityCenter = CMSCommunityCenter() - + public typealias CMSProgressBlock = (_ progress: Double) -> Void public typealias CMSCompletionBlock = (_ error: Error?) -> Void - + public lazy var accountStatus: Observable = { return self.createAccountStatusObservable() }() - + public lazy var userProfile: Observable = { return self.createUserProfileObservable() }() - + public override init() { super.init() - + NotificationCenter.default.addObserver(self, selector: #selector(createSubscriptionsIfNeeded), name: .NSApplicationDidFinishLaunching, object: nil) } - + public func save(model: CMSCloudKitRepresentable, progress: CMSProgressBlock?, completion: @escaping CMSCompletionBlock) { do { let record = try model.makeRecord() - + let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil) operation.savePolicy = .changedKeys - + if let progress = progress { operation.perRecordProgressBlock = { progressRecord, currentProgress in guard progressRecord == record else { return } - + DispatchQueue.main.async { progress(currentProgress) } } } - + operation.modifyRecordsCompletionBlock = { _, _, error in let retryBlock = { self.save(model: model, progress: progress, completion: completion) } - + if let error = retryCloudKitOperationIfPossible(with: error, block: retryBlock) { DispatchQueue.main.async { completion(error) } } else { DispatchQueue.main.async { completion(nil) } } } - + database.add(operation) } catch { completion(error) } } - + public typealias CMSUserCompletionBlock = (_ result: CMSResult) -> Void - + public func fetchCurrentUserProfile(_ completion: @escaping CMSUserCompletionBlock) { let retryBlock = { self.fetchCurrentUserProfile(completion) } - + container.fetchUserRecordID { userRecordID, error in if let error = retryCloudKitOperationIfPossible(with: error, block: retryBlock) { DispatchQueue.main.async { @@ -99,7 +99,7 @@ public final class CMSCommunityCenter: NSObject { } return } - + guard let userRecordID = userRecordID else { DispatchQueue.main.async { NSLog("[CMSCommunityCenter] Nil user record ID") @@ -108,12 +108,12 @@ public final class CMSCommunityCenter: NSObject { } return } - + DispatchQueue.main.async { NSLog("[CMSCommunityCenter] User record ID: \(userRecordID.recordName)") NotificationCenter.default.post(name: .CMSUserIdentifierDidBecomeAvailable, object: userRecordID.recordName) } - + self.database.fetch(withRecordID: userRecordID) { userRecord, error in if let error = retryCloudKitOperationIfPossible(with: error, block: retryBlock) { NSLog("[CMSCommunityCenter] Unable to get the user record from CloudKit: \(error)") @@ -121,17 +121,17 @@ public final class CMSCommunityCenter: NSObject { DispatchQueue.main.async { completion(.error(error)) } return } - + guard let userRecord = userRecord else { NSLog("[CMSCommunityCenter] Nil user record") self.sendErrorNotification(with: nil, info: "Nil user record") DispatchQueue.main.async { completion(.error(CMSCloudKitError.invalidData("No user record"))) } return } - + do { let model = try CMSUserProfile(record: userRecord) - + DispatchQueue.main.async { completion(.success(model)) } } catch { self.sendErrorNotification(with: error, info: "Parsing user profile") @@ -140,7 +140,7 @@ public final class CMSCommunityCenter: NSObject { } } } - + public func promptAndUpdateUserProfileWithDiscoveredInfo(with profile: CMSUserProfile, completion: @escaping (CMSUserProfile?, Error?) -> Void) { container.requestApplicationPermission(.userDiscoverability) { status, error in if let error = error { @@ -150,7 +150,7 @@ public final class CMSCommunityCenter: NSObject { } return } - + switch status { case .granted: self.fetchUserInfo(for: profile, completion: completion) @@ -160,7 +160,7 @@ public final class CMSCommunityCenter: NSObject { } } } - + private func fetchUserInfo(for profile: CMSUserProfile, completion: @escaping (CMSUserProfile?, Error?) -> Void) { guard let record = profile.originatingRecord else { DispatchQueue.main.async { @@ -169,7 +169,7 @@ public final class CMSCommunityCenter: NSObject { } return } - + container.discoverUserIdentity(withUserRecordID: record.recordID) { identity, error in guard let identity = identity, error == nil else { DispatchQueue.main.async { @@ -178,7 +178,7 @@ public final class CMSCommunityCenter: NSObject { } return } - + guard let nameComponents = identity.nameComponents else { DispatchQueue.main.async { self.sendErrorNotification(with: nil, info: "Error getting name components for identity: no name components found") @@ -186,19 +186,19 @@ public final class CMSCommunityCenter: NSObject { } return } - + let formatter = PersonNameComponentsFormatter() let fullName = formatter.string(from: nameComponents) - + self.updateUser(record: record, withName: fullName, completion: completion) } } - + private func updateUser(record: CKRecord, withName fullName: String, completion: @escaping (CMSUserProfile?, Error?) -> Void) { do { var newProfile = try CMSUserProfile(record: record) newProfile.name = fullName - + self.save(model: newProfile, progress: nil) { error in if let error = error { DispatchQueue.main.async { @@ -214,20 +214,20 @@ public final class CMSCommunityCenter: NSObject { DispatchQueue.main.async { completion(nil, CMSCloudKitError.invalidData("Unable to save profile: \(error.localizedDescription)")) } } } - + // MARK: - Subscriptions - + public func processNotification(userInfo: [String : Any]) -> Bool { // TODO: process CloudKit notification return false } - + @objc private func createSubscriptionsIfNeeded() { // TODO: create subscriptions for relevant record types } - + // MARK: - Observable generators - + private func createAccountStatusObservable() -> Observable { return Observable.create { observer -> Disposable in let checkAccountStatus = { @@ -238,28 +238,28 @@ public final class CMSCommunityCenter: NSObject { observer.onNext(.unavailable) return } - + switch status { case .available: observer.onNext(.available) default: observer.onNext(.unavailable) } - + NotificationCenter.default.post(name: .CMSUserProfileDidChange, object: nil) } } - + let cloudKitObserver = NotificationCenter.default.addObserver(forName: .CKAccountChanged, object: nil, queue: nil) { _ in checkAccountStatus() } - + checkAccountStatus() - + return Disposables.create { NotificationCenter.default.removeObserver(cloudKitObserver) } } } - + private func createUserProfileObservable() -> Observable { return Observable.create { observer -> Disposable in let profileNotificationObserver = NotificationCenter.default.addObserver(forName: .CMSUserProfileDidChange, object: nil, queue: nil) { _ in @@ -272,13 +272,13 @@ public final class CMSCommunityCenter: NSObject { } }) } - + return Disposables.create { NotificationCenter.default.removeObserver(profileNotificationObserver) } } } - + private func sendErrorNotification(with error: Error?, info: String) { NotificationCenter.default.post(name: .CMSErrorOccurred, object: error, userInfo: ["info": info]) } - + } diff --git a/CommunitySupport/CMSSubscriptionManager.swift b/CommunitySupport/CMSSubscriptionManager.swift index d7b4aa05..4eb53e36 100644 --- a/CommunitySupport/CMSSubscriptionManager.swift +++ b/CommunitySupport/CMSSubscriptionManager.swift @@ -10,9 +10,9 @@ import Foundation import CloudKit internal final class CMSSubscriptionManager { - + private let defaults = UserDefaults.standard - + var profileSubscriptionCreated: Bool { get { return defaults.bool(forKey: #function) @@ -21,5 +21,5 @@ internal final class CMSSubscriptionManager { defaults.set(newValue, forKey: #function) } } - + } diff --git a/CommunitySupport/CMSUserProfile.swift b/CommunitySupport/CMSUserProfile.swift index 436c2d68..f012a943 100644 --- a/CommunitySupport/CMSUserProfile.swift +++ b/CommunitySupport/CMSUserProfile.swift @@ -10,17 +10,17 @@ import Cocoa import CloudKit public struct CMSUserProfile { - + public static let recordType = "Users" - + public var originatingRecord: CKRecord? public var avatarFileURL: URL? - + public let identifier: String public var name: String public var avatar: NSImage? public let isAdmin: Bool - + public static var empty: CMSUserProfile { return CMSUserProfile(originatingRecord: nil, avatarFileURL: nil, @@ -29,22 +29,22 @@ public struct CMSUserProfile { avatar: nil, isAdmin: false) } - + } extension CMSUserProfile: Equatable { - + var isEmpty: Bool { return identifier.isEmpty } - + public static func ==(lhs: CMSUserProfile, rhs: CMSUserProfile) -> Bool { return lhs.identifier == rhs.identifier && lhs.avatarFileURL == rhs.avatarFileURL && lhs.name == rhs.name && lhs.isAdmin == rhs.isAdmin } - + } enum CMSUserProfileKey: String { @@ -54,7 +54,7 @@ enum CMSUserProfileKey: String { } extension CKRecord { - + subscript(key: CMSUserProfileKey) -> Any? { get { return self[key.rawValue] @@ -63,24 +63,24 @@ extension CKRecord { self[key.rawValue] = newValue as? CKRecordValue } } - + } extension CMSUserProfile: CMSCloudKitRepresentable { - + public init(record: CKRecord) throws { let name = record[.name] as? String ?? "" - + var avatarImage: NSImage? var avatarFileURL: URL? - + if let avatar = record[.avatar] as? CKAsset { avatarFileURL = avatar.fileURL avatarImage = NSImage(contentsOf: avatar.fileURL) } - + let adminFlag = (record[.isAdmin] as? Int == 1) - + self.originatingRecord = record self.identifier = record.recordID.recordName self.name = name @@ -88,22 +88,22 @@ extension CMSUserProfile: CMSCloudKitRepresentable { self.avatarFileURL = avatarFileURL self.avatar = avatarImage } - + public func makeRecord() throws -> CKRecord { guard let record = self.originatingRecord else { throw CMSCloudKitError.invalidData("User record must have a preexisting record") } - + record[.name] = name record[.isAdmin] = isAdmin ? 1 : 0 - + if let avatarUrl = self.avatarFileURL, avatarUrl.isFileURL { record[.avatar] = CKAsset(fileURL: avatarUrl) } else { record[.avatar] = nil } - + return record } - + } diff --git a/ConfCore/Adapter.swift b/ConfCore/Adapter.swift index a711bdb3..1ed662cf 100644 --- a/ConfCore/Adapter.swift +++ b/ConfCore/Adapter.swift @@ -13,7 +13,7 @@ enum AdapterError: Error { case invalidData case unsupported case missingKey(JSONSubscriptType) - + var localizedDescription: String { switch self { case .invalidData: @@ -29,13 +29,13 @@ enum AdapterError: Error { protocol Adapter { associatedtype InputType associatedtype OutputType - + func adapt(_ input: InputType) -> Result func adapt(_ input: [InputType]) -> Result<[OutputType], AdapterError> } extension Adapter { - + func adapt(_ input: [InputType]) -> Result<[OutputType], AdapterError> { let collection = input.flatMap { (item: InputType) -> OutputType? in let itemResult = self.adapt(item) @@ -45,8 +45,8 @@ extension Adapter { default: return nil } } - + return .success(collection) } - + } diff --git a/ConfCore/AppleAPIClient.swift b/ConfCore/AppleAPIClient.swift index c78f4ef5..a832873c 100644 --- a/ConfCore/AppleAPIClient.swift +++ b/ConfCore/AppleAPIClient.swift @@ -13,124 +13,124 @@ import SwiftyJSON // MARK: - Initialization and configuration public final class AppleAPIClient { - + fileprivate var environment: Environment fileprivate var service: Service - + public init(environment: Environment) { self.environment = environment self.service = Service(baseURL: environment.baseURL) - + configureService() - + NotificationCenter.default.addObserver(forName: .WWDCEnvironmentDidChange, object: nil, queue: OperationQueue.main) { _ in self.updateEnvironment() } } - + private let jsonParser = ResponseContentTransformer { JSON($0.content as AnyObject) } - + private func configureService() { service.configure("**") { config in config.pipeline[.parsing].add(self.jsonParser, contentTypes: ["*/json"]) } - + service.configureTransformer(environment.newsPath) { [weak self] (entity: Entity) throws -> [NewsItem]? in let json = entity.content as JSON - + guard let newsItemsJson = json["items"].array else { throw APIError.adapter } - + return try self?.failableAdaptCollection(newsItemsJson, using: NewsItemsJSONAdapter()) } - + service.configureTransformer(environment.sessionsPath) { [weak self] (entity: Entity) throws -> ContentsResponse? in let json = entity.content as JSON - + return try self?.failableAdapt(json, using: ContentsResponseAdapter()) } - + service.configureTransformer(environment.videosPath) { [weak self] (entity: Entity) throws -> SessionsResponse? in let json = entity.content as JSON - + return try self?.failableAdapt(json, using: SessionsResponseAdapter()) } service.configureTransformer(environment.liveVideosPath) { [weak self] (entity: Entity) throws -> [SessionAsset]? in let json = entity.content as JSON - + guard let sessionsDict = json["live_sessions"].dictionary else { throw APIError.adapter } - + let sessionsArray = sessionsDict.flatMap { key, value -> JSON? in guard let id = JSON.init(rawValue: key) else { return nil } - + var v = value - + v["sessionId"] = id - + return v } return try self?.failableAdaptCollection(sessionsArray, using: LiveVideosAdapter()) } } - + fileprivate func updateEnvironment() { currentLiveVideosRequest?.cancel() currentScheduleRequest?.cancel() currentSessionsRequest?.cancel() currentNewsItemsRequest?.cancel() - + self.environment = Environment.current - + self.service = Service(baseURL: environment.baseURL) self.liveVideoAssets = self.makeLiveVideosResource() self.sessions = self.makeSessionsResource() self.schedule = self.makeScheduleResource() self.news = self.makeNewsResource() } - + // MARK: - Resources - + fileprivate lazy var liveVideoAssets: Resource = self.makeLiveVideosResource() - + fileprivate lazy var sessions: Resource = self.makeSessionsResource() - + fileprivate lazy var schedule: Resource = self.makeScheduleResource() - + fileprivate lazy var news: Resource = self.makeNewsResource() - + fileprivate func makeLiveVideosResource() -> Resource { - return self.service.resource(self.environment.liveVideosPath) + return self.service.resource(environment.liveVideosPath) } - + fileprivate func makeSessionsResource() -> Resource { - return self.service.resource(self.environment.videosPath) + return self.service.resource(environment.videosPath) } - + fileprivate func makeScheduleResource() -> Resource { - return self.service.resource(self.environment.sessionsPath) + return self.service.resource(environment.sessionsPath) } - + fileprivate func makeNewsResource() -> Resource { - return self.service.resource(self.environment.newsPath) + return self.service.resource(environment.newsPath) } - + // MARK: - Standard API requests - + private var liveVideoAssetsResource: Resource! private var contentsResource: Resource! private var sessionsResource: Resource! private var newsItemsResource: Resource! - + private var currentLiveVideosRequest: Request? private var currentScheduleRequest: Request? private var currentSessionsRequest: Request? private var currentNewsItemsRequest: Request? - + public func fetchLiveVideoAssets(completion: @escaping (Result<[SessionAsset], APIError>) -> Void) { if liveVideoAssetsResource == nil { liveVideoAssetsResource = liveVideoAssets.addObserver(owner: self) { [weak self] resource, event in @@ -141,60 +141,59 @@ public final class AppleAPIClient { currentLiveVideosRequest?.cancel() currentLiveVideosRequest = liveVideoAssetsResource.loadIfNeeded() } - + public func fetchContent(completion: @escaping (Result) -> Void) { if contentsResource == nil { contentsResource = schedule.addObserver(owner: self) { [weak self] resource, event in self?.process(resource, event: event, with: completion) } } - + currentScheduleRequest?.cancel() currentScheduleRequest = contentsResource.loadIfNeeded() } - + public func fetchNewsItems(completion: @escaping (Result<[NewsItem], APIError>) -> Void) { if newsItemsResource == nil { newsItemsResource = news.addObserver(owner: self) { [weak self] resource, event in self?.process(resource, event: event, with: completion) } } - + currentNewsItemsRequest?.cancel() currentNewsItemsRequest = newsItemsResource.loadIfNeeded() } - } // MARK: - API results processing extension AppleAPIClient { - + /// Convenience method to use a model adapter as a method that returns the model(s) or throws an error fileprivate func failableAdapt(_ input: JSON, using adapter: A) throws -> T where A.InputType == JSON, A.OutputType == T { let result = adapter.adapt(input) - + switch result { - case .error(let error): + case let .error(error): throw error - case .success(let output): + case let .success(output): return output } } - + /// Convenience method to use a model adapter as a method that returns the model(s) or throws an error fileprivate func failableAdaptCollection(_ input: [JSON], using adapter: A) throws -> [T] where A.InputType == JSON, A.OutputType == T { let result = adapter.adapt(input) - + switch result { - case .error(let error): + case let .error(error): throw error - case .success(let output): + case let .success(output): return output } } - + fileprivate func process(_ resource: Resource, event: ResourceEvent, with completion: @escaping (Result) -> ()) { switch event { case .error: diff --git a/ConfCore/Bookmark.swift b/ConfCore/Bookmark.swift index 65519494..952e03fd 100644 --- a/ConfCore/Bookmark.swift +++ b/ConfCore/Bookmark.swift @@ -14,42 +14,42 @@ public class Bookmark: Object { /// Unique identifier public dynamic var identifier = UUID().uuidString - + /// Date/time the bookmark was created public dynamic var createdAt = Date.distantPast - + /// Date/time the bookmark was last modified public dynamic var modifiedAt = Date.distantPast - + /// Whether other users can see this bookmark or only the user who created it can public dynamic var isShared = false - + /// Plain text body public dynamic var body = "" - + /// Formatted text body public dynamic var attributedBody = Data() - + /// Time in the video this bookmark was created public dynamic var timecode = 0.0 - + /// Snapshot from the video at the time the bookmark was created public dynamic var snapshot = Data() - + /// What the presenter was saying close to where the bookmark was created public dynamic var annotation: TranscriptAnnotation? - + /// Soft delete (for syncing) public dynamic var isDeleted: Bool = false - + /// When was this item soft deleted (for syncing) public dynamic var deletedAt: Date = .distantFuture - + /// The session this bookmark is associated with public let session = LinkingObjects(fromType: Session.self, property: "bookmarks") - + public override class func primaryKey() -> String? { return "identifier" } - + } diff --git a/ConfCore/CloudKitHelper.swift b/ConfCore/CloudKitHelper.swift index 4781692c..7edebbb0 100644 --- a/ConfCore/CloudKitHelper.swift +++ b/ConfCore/CloudKitHelper.swift @@ -10,27 +10,27 @@ import Cocoa import CloudKit public final class CloudKitHelper { - + public class func subscriptionExists(with name: String, in database: CKDatabase, completion: @escaping (Bool) -> Void) { let fetchSubscriptionOperation = CKFetchSubscriptionsOperation(subscriptionIDs: [name]) - + fetchSubscriptionOperation.fetchSubscriptionCompletionBlock = { subscriptions, error in if error is CKError { DispatchQueue.main.async { completion(false) } - + return } - + guard let subscriptions = subscriptions else { NSLog("[LiveObserver] No subscriptions returned: nil value") return } - + if !subscriptions.keys.contains(name) { DispatchQueue.main.async { completion(false) } } } - + database.add(fetchSubscriptionOperation) } diff --git a/ConfCore/ContributorsFetcher.swift b/ConfCore/ContributorsFetcher.swift index cd0b5216..82dc4df4 100644 --- a/ConfCore/ContributorsFetcher.swift +++ b/ConfCore/ContributorsFetcher.swift @@ -10,18 +10,18 @@ import Cocoa import SwiftyJSON public final class ContributorsFetcher { - + public static let shared: ContributorsFetcher = ContributorsFetcher() - + fileprivate struct Constants { static let contributorsURL = "https://api.github.com/repos/insidegui/WWDC/contributors" } private let syncQueue = DispatchQueue(label: "io.wwdc.app.contributorsfetcher.sync") private var names = [String]() - + public var infoTextChangedCallback: (_ newText: String) -> () = { _ in } - + public var infoText = "" { didSet { DispatchQueue.main.async { @@ -29,7 +29,7 @@ public final class ContributorsFetcher { } } } - + /// Loads the list of contributors from the GitHub repository and builds the infoText public func load() { guard let url = URL(string: Constants.contributorsURL) else { return } @@ -64,33 +64,33 @@ public final class ContributorsFetcher { } } } - + task.resume() } - + fileprivate func parseResponse(_ data: Data) -> [String] { let jsonData = JSON(data: data) guard let contributors = jsonData.array else { return [String]() } - + var contributorNames = [String]() for contributor in contributors { if let name = contributor["login"].string { contributorNames.append(name) } } - + return contributorNames } - + fileprivate func buildInfoText(_ names: [String]) { var text = "Contributors (GitHub usernames):\n" - + var prefix = "" for name in names { text.append("\(prefix)\(name)") prefix = ", " } - + infoText = text } } diff --git a/ConfCore/DateAdapter.swift b/ConfCore/DateAdapter.swift index 4fa07813..49da1e88 100644 --- a/ConfCore/DateAdapter.swift +++ b/ConfCore/DateAdapter.swift @@ -13,13 +13,13 @@ public let ConfCoreDateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" final class DateAdapter: Adapter { typealias InputType = String typealias OutputType = Date - + func adapt(_ input: String) -> Result { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" formatter.locale = Locale(identifier: "en-US") formatter.timeZone = TimeZone.current - + if let date = formatter.date(from: input) { return .success(date) } else { @@ -31,13 +31,13 @@ final class DateAdapter: Adapter { final class DateTimeAdapter: Adapter { typealias InputType = String typealias OutputType = Date - + func adapt(_ input: String) -> Result { let formatter = DateFormatter() formatter.dateFormat = ConfCoreDateFormat formatter.locale = Locale(identifier: "en-US") formatter.timeZone = TimeZone.current - + if let date = formatter.date(from: input) { return .success(date) } else { diff --git a/ConfCore/Download.swift b/ConfCore/Download.swift index 19fb1d9a..f8902df3 100644 --- a/ConfCore/Download.swift +++ b/ConfCore/Download.swift @@ -20,22 +20,22 @@ enum DownloadStatus: String { @available(*, deprecated: 1.0.0, message: "Provided for legacy support only, do not use this!") public class Download: Object { - + /// Unique identifier public dynamic var identifier = UUID().uuidString - + /// The session this download is associated with public dynamic var sessionIdentifier = "" - + /// When the download was started public dynamic var createdAt = Date() - + /// The current progress of the download (from 0 to 1) public dynamic var progress: Double = 0.0 - + /// The raw status of the download internal dynamic var rawStatus: String = DownloadStatus.none.rawValue - + /// The status of the download var status: DownloadStatus { get { @@ -45,9 +45,9 @@ public class Download: Object { rawStatus = newValue.rawValue } } - + public override class func primaryKey() -> String? { return "identifier" } - + } diff --git a/ConfCore/Environment.swift b/ConfCore/Environment.swift index d924aef2..a7e301d3 100644 --- a/ConfCore/Environment.swift +++ b/ConfCore/Environment.swift @@ -13,32 +13,32 @@ public extension Notification.Name { } public struct Environment { - + public let baseURL: String public let videosPath: String public let sessionsPath: String public let newsPath: String public let liveVideosPath: String - + public static func setCurrent(_ environment: Environment) { objc_sync_enter(self) defer { objc_sync_exit(self) } - + let shouldNotify = (environment != Environment.current) - + _storedEnvironment = environment - + UserDefaults.standard.set(environment.baseURL, forKey: _storedEnvDefaultsKey) - + if shouldNotify { DispatchQueue.main.async { NSLog("ENVIRONMENT CHANGED! New base URL: \(environment.baseURL)") - + NotificationCenter.default.post(name: .WWDCEnvironmentDidChange, object: environment) } } } - + } fileprivate let _storedEnvDefaultsKey = "_confCoreEnvironmentBaseURL" @@ -46,10 +46,10 @@ fileprivate let _storedEnvDefaultsKey = "_confCoreEnvironmentBaseURL" fileprivate var _storedEnvironment: Environment? = Environment.readFromDefaults() extension Environment { - + static func readFromDefaults() -> Environment? { guard let baseURL = UserDefaults.standard.object(forKey: _storedEnvDefaultsKey) as? String else { return nil } - + return Environment( baseURL: baseURL, videosPath: "/videos.json", @@ -58,7 +58,7 @@ extension Environment { liveVideosPath: "/videos_live.json" ) } - + public static var current: Environment { if ProcessInfo.processInfo.arguments.contains("--test") { return .test @@ -70,23 +70,23 @@ extension Environment { } } } - + public static let test = Environment(baseURL: "http://localhost:9042", videosPath: "/videos.json", sessionsPath: "/contents.json", newsPath: "/news.json", liveVideosPath: "/videos_live.json") - + public static let production = Environment(baseURL: "https://api2017.wwdc.io", - videosPath: "/videos.json", - sessionsPath: "/contents.json", - newsPath: "/news.json", - liveVideosPath: "/videos_live.json") - + videosPath: "/videos.json", + sessionsPath: "/contents.json", + newsPath: "/news.json", + liveVideosPath: "/videos_live.json") + } extension Environment: Equatable { - + public static func ==(lhs: Environment, rhs: Environment) -> Bool { return lhs.baseURL == rhs.baseURL && lhs.videosPath == rhs.videosPath diff --git a/ConfCore/Event.swift b/ConfCore/Event.swift index 1b33d405..1d487d64 100644 --- a/ConfCore/Event.swift +++ b/ConfCore/Event.swift @@ -14,48 +14,48 @@ public class Event: Object { /// Unique identifier (ex: wwdc2017) public dynamic var identifier = "" - + /// Event name public dynamic var name = "" - + /// When the event starts public dynamic var startDate = Date.distantPast - + /// When the event ends public dynamic var endDate = Date.distantPast - + /// Is this the current event? public dynamic var isCurrent = false - + /// Sessions held at this event public let sessions = List() - + public dynamic var imagesPath = "" - + /// Session instances for schedule public var sessionInstances = List() - + public override class func primaryKey() -> String? { return "identifier" } - + public static func make(identifier: String, name: String, startDate: Date, endDate: Date, isCurrent: Bool, imagesPath: String) -> Event { let event = Event() - + event.identifier = identifier event.name = name event.startDate = startDate event.endDate = endDate event.isCurrent = isCurrent event.imagesPath = imagesPath - + return event } - + internal static func identifier(from date: Date) -> String { let year = Calendar.current.component(.year, from: date) - + return "wwdc\(year)" } - + } diff --git a/ConfCore/EventsJSONAdapter.swift b/ConfCore/EventsJSONAdapter.swift index 9f8b29a3..15e508f1 100644 --- a/ConfCore/EventsJSONAdapter.swift +++ b/ConfCore/EventsJSONAdapter.swift @@ -15,7 +15,7 @@ private enum EventKeys: String, JSONSubscriptType { case end = "endTime" case identifier = "id" case imagesPath - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } @@ -24,47 +24,47 @@ private enum EventKeys: String, JSONSubscriptType { final class EventsJSONAdapter: Adapter { typealias InputType = JSON typealias OutputType = Event - + func adapt(_ input: JSON) -> Result { guard let identifier = input[EventKeys.identifier].string else { return .error(.missingKey(EventKeys.identifier)) } - + guard let name = input[EventKeys.name].string else { return .error(.missingKey(EventKeys.name)) } - + guard let current = input[EventKeys.current].bool else { return .error(.missingKey(EventKeys.current)) } - + guard let imagesPath = input[EventKeys.imagesPath].string else { return .error(.missingKey(EventKeys.imagesPath)) } - + guard let rawStart = input[EventKeys.start].string else { return .error(.missingKey(EventKeys.start)) } - + guard let rawEnd = input[EventKeys.end].string else { return .error(.missingKey(EventKeys.end)) } - + guard case .success(let startDate) = DateTimeAdapter().adapt(rawStart) else { return .error(.invalidData) } - + guard case .success(let endDate) = DateTimeAdapter().adapt(rawEnd) else { return .error(.invalidData) } - + let event = Event.make(identifier: identifier, name: name, startDate: startDate, endDate: endDate, isCurrent: current, imagesPath: imagesPath) - + return .success(event) } } diff --git a/ConfCore/Favorite.swift b/ConfCore/Favorite.swift index c095d440..728deebc 100644 --- a/ConfCore/Favorite.swift +++ b/ConfCore/Favorite.swift @@ -14,18 +14,18 @@ public class Favorite: Object { /// Unique identifier public dynamic var identifier = UUID().uuidString - + /// When the favorite was created public dynamic var createdAt = Date() - + /// Soft delete (for syncing) public dynamic var isDeleted: Bool = false - + /// The session this favorite is associated with public let session = LinkingObjects(fromType: Session.self, property: "favorites") - + public override class func primaryKey() -> String? { return "identifier" } - + } diff --git a/ConfCore/Focus.swift b/ConfCore/Focus.swift index a442271e..13f66958 100644 --- a/ConfCore/Focus.swift +++ b/ConfCore/Focus.swift @@ -14,12 +14,11 @@ public class Focus: Object { /// The name of the focus area public dynamic var name = "" - + /// Sessions containing this focus public let sessions = LinkingObjects(fromType: Session.self, property: "focuses") - + public override class func primaryKey() -> String? { return "name" } - } diff --git a/ConfCore/FocusesJSONAdapter.swift b/ConfCore/FocusesJSONAdapter.swift index 7e566144..915917d1 100644 --- a/ConfCore/FocusesJSONAdapter.swift +++ b/ConfCore/FocusesJSONAdapter.swift @@ -10,19 +10,18 @@ import Foundation import SwiftyJSON final class FocusesJSONAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = Focus - + func adapt(_ input: JSON) -> Result { guard let name = input.string else { return .error(.invalidData) } - + let focus = Focus() focus.name = name - + return .success(focus) } - } diff --git a/ConfCore/Keyword.swift b/ConfCore/Keyword.swift index bfc12ff0..105736e8 100644 --- a/ConfCore/Keyword.swift +++ b/ConfCore/Keyword.swift @@ -14,12 +14,11 @@ public class Keyword: Object { /// The keyword public dynamic var name = "" - + /// Sessions containing this keyword -// public let sessionInstances = LinkingObjects(fromType: SessionInstance.self, property: "keywords") - + // public let sessionInstances = LinkingObjects(fromType: SessionInstance.self, property: "keywords") + public override class func primaryKey() -> String? { return "name" } - } diff --git a/ConfCore/KeywordsJSONAdapter.swift b/ConfCore/KeywordsJSONAdapter.swift index 70a76443..f676c759 100644 --- a/ConfCore/KeywordsJSONAdapter.swift +++ b/ConfCore/KeywordsJSONAdapter.swift @@ -13,16 +13,16 @@ final class KeywordsJSONAdapter: Adapter { typealias InputType = JSON typealias OutputType = Keyword - + func adapt(_ input: JSON) -> Result { guard let name = input.string else { return .error(.invalidData) } - + let keyword = Keyword() keyword.name = name - + return .success(keyword) } - + } diff --git a/ConfCore/LiveVideosAdapter.swift b/ConfCore/LiveVideosAdapter.swift index 2089d671..efb51301 100644 --- a/ConfCore/LiveVideosAdapter.swift +++ b/ConfCore/LiveVideosAdapter.swift @@ -11,34 +11,33 @@ import SwiftyJSON private enum LiveVideoKeys: String, JSONSubscriptType { case sessionId, tvosUrl - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } } final class LiveVideosAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = SessionAsset - + func adapt(_ input: JSON) -> Result { guard let sessionId = input[LiveVideoKeys.sessionId].string else { return .error(.missingKey(LiveVideoKeys.sessionId)) } - + guard let url = input[LiveVideoKeys.tvosUrl].string else { return .error(.missingKey(LiveVideoKeys.tvosUrl)) } - + let asset = SessionAsset() // Live assets are always for the current year asset.year = Calendar.current.component(.year, from: Date()) asset.sessionId = sessionId asset.rawAssetType = SessionAssetType.liveStreamVideo.rawValue asset.remoteURL = url - + return .success(asset) } - } diff --git a/ConfCore/NewsItem.swift b/ConfCore/NewsItem.swift index 49413349..30d349ac 100644 --- a/ConfCore/NewsItem.swift +++ b/ConfCore/NewsItem.swift @@ -21,27 +21,27 @@ public class NewsItem: Object { /// Unique identifier public dynamic var identifier = "" - + /// The type of news (0 = regular news, 2 = photo gallery) public dynamic var newsType = 0 - + /// The condition that must be true so the user can see this item (used to limit some items to only attendees) public dynamic var visibility = "" - + /// When this news item got published public dynamic var date = Date.distantPast - + /// Title public dynamic var title = "" - + /// Text public dynamic var body = "" - + /// Photos for this news item, only present when `newsType == 2` public let photos = List() - + public override class func primaryKey() -> String? { return "identifier" } - + } diff --git a/ConfCore/NewsItemsJSONAdapter.swift b/ConfCore/NewsItemsJSONAdapter.swift index cb493d3a..1e81a940 100644 --- a/ConfCore/NewsItemsJSONAdapter.swift +++ b/ConfCore/NewsItemsJSONAdapter.swift @@ -11,53 +11,53 @@ import SwiftyJSON private enum NewsItemKeys: String, JSONSubscriptType { case id, title, body, timestamp, visibility, photos, type - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } } final class NewsItemsJSONAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = NewsItem - + func adapt(_ input: JSON) -> Result { if let type = input[NewsItemKeys.type].string { guard type != "pass" else { return .error(.unsupported) } } - + guard let id = input[NewsItemKeys.id].string else { return .error(.missingKey(NewsItemKeys.id)) } - + guard let title = input[NewsItemKeys.title].string else { return .error(.missingKey(NewsItemKeys.title)) } - + guard let timestamp = input[NewsItemKeys.timestamp].double else { return .error(.missingKey(NewsItemKeys.timestamp)) } - + let visibility = input[NewsItemKeys.visibility].stringValue - + let item = NewsItem() - + if let photosJson = input[NewsItemKeys.photos].array { if case .success(let photos) = PhotosJSONAdapter().adapt(photosJson) { item.photos.append(objectsIn: photos) } } - + item.identifier = id item.title = title item.body = input[NewsItemKeys.body].stringValue item.visibility = visibility item.date = Date(timeIntervalSince1970: timestamp) item.newsType = item.photos.count > 0 ? NewsType.gallery.rawValue : NewsType.news.rawValue - + return .success(item) } - + } diff --git a/ConfCore/Photo.swift b/ConfCore/Photo.swift index 452cb5b7..bbbe4e28 100644 --- a/ConfCore/Photo.swift +++ b/ConfCore/Photo.swift @@ -14,18 +14,18 @@ public class Photo: Object { /// Unique identifier public dynamic var identifier = "" - + /// The photo's aspect ratio public dynamic var aspectRatio = 0.0 - + /// The news item this photo is associated with public let newsItem = LinkingObjects(fromType: NewsItem.self, property: "photos") - + /// The representations this photo has public let representations = List() - + public override class func primaryKey() -> String? { return "identifier" } - + } diff --git a/ConfCore/PhotosJSONAdapter.swift b/ConfCore/PhotosJSONAdapter.swift index a93fdbb7..32064ac7 100644 --- a/ConfCore/PhotosJSONAdapter.swift +++ b/ConfCore/PhotosJSONAdapter.swift @@ -11,42 +11,42 @@ import SwiftyJSON private enum PhotoKeys: String, JSONSubscriptType { case id, ratio - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } } final class PhotosJSONAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = Photo - + func adapt(_ input: JSON) -> Result { guard let id = input[PhotoKeys.id].string else { return .error(.missingKey(PhotoKeys.id)) } - + guard let ratio = input[PhotoKeys.ratio].double else { return .error(.missingKey(PhotoKeys.ratio)) } - + let representations = PhotoRepresentationSize.all.map { size -> PhotoRepresentation in let rep = PhotoRepresentation() - + rep.remotePath = "\(id)/\(size.rawValue).jpeg" rep.width = size.rawValue - + return rep } - + let photo = Photo() - + photo.identifier = id photo.aspectRatio = ratio photo.representations.append(objectsIn: representations) - + return .success(photo) } - + } diff --git a/ConfCore/Resource+Error.swift b/ConfCore/Resource+Error.swift index 2ba88482..4be7222b 100644 --- a/ConfCore/Resource+Error.swift +++ b/ConfCore/Resource+Error.swift @@ -13,7 +13,7 @@ public enum APIError: Error { case http(Error) case adapter case unknown - + public var localizedDescription: String { switch self { case .http(let error): @@ -27,7 +27,7 @@ public enum APIError: Error { } extension Resource { - + var error: APIError { if let underlyingError = self.latestError { return .http(underlyingError) @@ -35,5 +35,5 @@ extension Resource { return .unknown } } - + } diff --git a/ConfCore/Room.swift b/ConfCore/Room.swift index cf41fb7a..36125c24 100644 --- a/ConfCore/Room.swift +++ b/ConfCore/Room.swift @@ -11,34 +11,34 @@ import RealmSwift /// Represents a room or venue where sessions are held public class Room: Object { - + public dynamic var identifier = "" - + /// Name of the map file (maps are not present in the macOS app because they are embedded in the iOS app's binary, not given by the API) public dynamic var mapName = "" - + /// Name of the room public dynamic var name = "" - + /// Room floor name public dynamic var floor = "" - + /// Session instances held at this room public let instances = List() - + public override class func primaryKey() -> String? { return "name" } - + public static func make(identifier: String, name: String, mapName: String, floor: String) -> Room { let room = Room() - + room.identifier = identifier room.name = name room.mapName = mapName room.floor = floor - + return room } - + } diff --git a/ConfCore/RoomsJSONAdapter.swift b/ConfCore/RoomsJSONAdapter.swift index c134a679..4b1971f9 100644 --- a/ConfCore/RoomsJSONAdapter.swift +++ b/ConfCore/RoomsJSONAdapter.swift @@ -12,7 +12,7 @@ import SwiftyJSON private enum RoomKeys: String, JSONSubscriptType { case name, mapName, floor case identifier = "id" - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } @@ -21,18 +21,18 @@ private enum RoomKeys: String, JSONSubscriptType { final class RoomsJSONAdapter: Adapter { typealias InputType = JSON typealias OutputType = Room - + func adapt(_ input: JSON) -> Result { guard let identifier = input[RoomKeys.identifier].int else { return .error(.missingKey(RoomKeys.identifier)) } - + guard let name = input[RoomKeys.name].string else { return .error(.missingKey(RoomKeys.name)) } - + let room = Room.make(identifier: "\(identifier)", name: name, mapName: "", floor: "") - + return .success(room) } } diff --git a/ConfCore/ScheduleResponse.swift b/ConfCore/ScheduleResponse.swift index 78fa3760..3bf3e938 100644 --- a/ConfCore/ScheduleResponse.swift +++ b/ConfCore/ScheduleResponse.swift @@ -9,11 +9,11 @@ import Foundation public struct ContentsResponse { - + public let events: [Event] public let rooms: [Room] public let tracks: [Track] public let instances: [SessionInstance] public let sessions: [Session] - + } diff --git a/ConfCore/ScheduleResponseAdapter.swift b/ConfCore/ScheduleResponseAdapter.swift index 7b106315..d69b3290 100644 --- a/ConfCore/ScheduleResponseAdapter.swift +++ b/ConfCore/ScheduleResponseAdapter.swift @@ -11,30 +11,30 @@ import SwiftyJSON private enum ContentKeys: String, JSONSubscriptType { case response, rooms, tracks, sessions, events, contents - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } } final class ContentsResponseAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = ContentsResponse - + func adapt(_ input: JSON) -> Result { guard let eventsJson = input[ContentKeys.events].array else { return .error(.missingKey(ContentKeys.events)) } - + guard case .success(let events) = EventsJSONAdapter().adapt(eventsJson) else { return .error(.invalidData) } - + guard let roomsJson = input[ContentKeys.rooms].array else { return .error(.missingKey(ContentKeys.rooms)) } - + guard case .success(let rooms) = RoomsJSONAdapter().adapt(roomsJson) else { return .error(.invalidData) } @@ -42,33 +42,33 @@ final class ContentsResponseAdapter: Adapter { guard let tracksJson = input[ContentKeys.tracks].array else { return .error(.missingKey(ContentKeys.rooms)) } - + guard case .success(let tracks) = TracksJSONAdapter().adapt(tracksJson) else { return .error(.missingKey(ContentKeys.tracks)) } - + guard let sessionsJson = input[ContentKeys.contents].array else { return .error(.missingKey(ContentKeys.contents)) } - + guard case .success(var sessions) = SessionsJSONAdapter().adapt(sessionsJson) else { return .error(.invalidData) } - + guard case .success(let instances) = SessionInstancesJSONAdapter().adapt(sessionsJson) else { return .error(.invalidData) } - + // remove duplicated sessions instances.forEach { instance in guard let index = sessions.index(where: { $0.identifier == instance.session?.identifier }) else { return } - + sessions.remove(at: index) } - + let response = ContentsResponse(events: events, rooms: rooms, tracks: tracks, instances: instances, sessions: sessions) - + return .success(response) } - + } diff --git a/ConfCore/ScheduleSection.swift b/ConfCore/ScheduleSection.swift index 099356a7..3ab042ef 100644 --- a/ConfCore/ScheduleSection.swift +++ b/ConfCore/ScheduleSection.swift @@ -11,22 +11,22 @@ import RealmSwift /// A section from the schedule, representing a time slot of the conference public final class ScheduleSection: Object { - + public dynamic var identifier: String = "" public dynamic var eventIdentifier: String = "" public dynamic var representedDate: Date = .distantPast public let instances = List() - + public override class func primaryKey() -> String { return "identifier" } - + internal static var identifierFormatter: DateFormatter { let f = DateFormatter() - + f.dateFormat = "MM-dd-yyyy'@'HH:mm" - + return f } - + } diff --git a/ConfCore/Session.swift b/ConfCore/Session.swift index 8c58b1be..44769342 100644 --- a/ConfCore/Session.swift +++ b/ConfCore/Session.swift @@ -14,88 +14,88 @@ public class Session: Object { /// Unique identifier public dynamic var identifier = "" - + /// Session year public dynamic var year = 0 - + /// Session number public dynamic var number = "" - + /// Title public dynamic var title = "" - + public dynamic var staticContentId = "" - + /// Description public dynamic var summary = "" - + /// The event identifier for the event this session belongs to public dynamic var eventIdentifier = "" - + /// Track name public dynamic var trackName = "" - + /// Track identifier public dynamic var trackIdentifier = "" - + /// The session's focuses public let focuses = List() - + /// The session's assets (videos, slides, links) public let assets = List() - + /// Whether this session is downloaded public dynamic var isDownloaded = false - + /// Session favorite public let favorites = List() - + /// Session progress public let progresses = List() - + /// Session bookmarks public let bookmarks = List() - + /// Transcript identifier for the session public dynamic var transcriptIdentifier: String = "" - + /// Shortcut to get the full transcript text (used during search) public dynamic var transcriptText: String = "" - + /// Fetches and returns the transcript object associated with the session public func transcript() -> Transcript? { guard let realm = self.realm else { return nil } guard !transcriptIdentifier.isEmpty else { return nil } - + return realm.objects(Transcript.self).filter("identifier == %@", self.transcriptIdentifier).first } - + /// The session's track public let track = LinkingObjects(fromType: Track.self, property: "sessions") - + /// The event this session belongs to public let event = LinkingObjects(fromType: Event.self, property: "sessions") - + /// Instances of this session public let instances = LinkingObjects(fromType: SessionInstance.self, property: "session") - + public override static func primaryKey() -> String? { return "identifier" } - -// public func transcript() -> Transcript? { -// guard let realm = self.realm else { return nil } -// guard !transcriptIdentifier.isEmpty else { return nil } -// -// return realm.objects(Transcript.self).filter("identifier == %@", self.transcriptIdentifier).first -// } - + + // public func transcript() -> Transcript? { + // guard let realm = self.realm else { return nil } + // guard !transcriptIdentifier.isEmpty else { return nil } + // + // return realm.objects(Transcript.self).filter("identifier == %@", self.transcriptIdentifier).first + // } + public static let videoPredicate: NSPredicate = NSPredicate(format: "ANY assets.rawAssetType == %@", SessionAssetType.streamingVideo.rawValue) - + public static func standardSort(sessionA: Session, sessionB: Session) -> Bool { guard let eventA = sessionA.event.first, let eventB = sessionB.event.first else { return false } guard let trackA = sessionA.track.first, let trackB = sessionB.track.first else { return false } - + if trackA.order == trackB.order { if eventA.startDate == eventB.startDate { return sessionA.title < sessionB.title @@ -106,16 +106,16 @@ public class Session: Object { return trackA.order < trackB.order } } - + public static func standardSortForSchedule(sessionA: Session, sessionB: Session) -> Bool { guard let instanceA = sessionA.instances.first, let instanceB = sessionB.instances.first else { return false } - + return SessionInstance.standardSort(instanceA: instanceA, instanceB: instanceB) } - + func merge(with other: Session, in realm: Realm) { assert(other.identifier == self.identifier, "Can't merge two objects with different identifiers!") - + self.title = other.title self.number = other.number self.summary = other.summary @@ -123,14 +123,14 @@ public class Session: Object { self.trackIdentifier = other.trackIdentifier self.staticContentId = other.staticContentId self.trackName = other.trackName - + // merge assets let assets = other.assets.filter { otherAsset in return !self.assets.contains(where: { $0.identifier == otherAsset.identifier }) } - + self.assets.append(objectsIn: assets) - + let otherFocuses = other.focuses.map { newFocus -> (Focus) in if newFocus.realm == nil, let existingFocus = realm.object(ofType: Focus.self, forPrimaryKey: newFocus.name) @@ -140,7 +140,7 @@ public class Session: Object { return newFocus } } - + self.focuses.removeAll() self.focuses.append(objectsIn: otherFocuses) } diff --git a/ConfCore/SessionAsset.swift b/ConfCore/SessionAsset.swift index a720b98d..ca3b99e0 100644 --- a/ConfCore/SessionAsset.swift +++ b/ConfCore/SessionAsset.swift @@ -22,7 +22,7 @@ public enum SessionAssetType: String { /// Session assets are resources associated with sessions, like videos, PDFs and useful links public class SessionAsset: Object { - + /// The type of asset: /// /// - WWDCSessionAssetTypeHDVideo @@ -36,9 +36,9 @@ public class SessionAsset: Object { self.identifier = generateIdentifier() } } - + public dynamic var identifier = "" - + public var assetType: SessionAssetType { get { return SessionAssetType(rawValue: rawAssetType) ?? .none @@ -47,44 +47,44 @@ public class SessionAsset: Object { rawAssetType = newValue.rawValue } } - + /// The year of the session this asset belongs to public dynamic var year = 0 { didSet { self.identifier = generateIdentifier() } } - + /// The id of the session this asset belongs to public dynamic var sessionId = "" { didSet { self.identifier = generateIdentifier() } } - + /// URL for this asset public dynamic var remoteURL = "" - + /// Relative local URL to save the asset to when downloading public dynamic var relativeLocalURL = "" - + /// The session this asset belongs to public let session = LinkingObjects(fromType: Session.self, property: "assets") - + public override class func primaryKey() -> String? { return "identifier" } - + func merge(with other: SessionAsset, in realm: Realm) { assert(other.remoteURL == self.remoteURL, "Can't merge two objects with different identifiers!") - + self.year = other.year self.sessionId = other.sessionId self.relativeLocalURL = other.relativeLocalURL } - + public func generateIdentifier() -> String { return String(self.year) + "@" + self.sessionId + "~" + self.rawAssetType.replacingOccurrences(of: "WWDCSessionAssetType", with: "") } - + } diff --git a/ConfCore/SessionAssetsAdapter.swift b/ConfCore/SessionAssetsAdapter.swift index defd5059..7cc27953 100644 --- a/ConfCore/SessionAssetsAdapter.swift +++ b/ConfCore/SessionAssetsAdapter.swift @@ -10,12 +10,12 @@ import Foundation import SwiftyJSON final class SessionAssetsJSONAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = [SessionAsset] - + func adapt(_ input: JSON) -> Result<[SessionAsset], AdapterError> { return .error(.invalidData) } - + } diff --git a/ConfCore/SessionInstance.swift b/ConfCore/SessionInstance.swift index 503d6e6b..77cfd171 100644 --- a/ConfCore/SessionInstance.swift +++ b/ConfCore/SessionInstance.swift @@ -10,12 +10,8 @@ import Cocoa import RealmSwift public enum SessionInstanceType: Int { - case session - case lab - case video - case getTogether - case specialEvent - + case session, lab, video, getTogether, specialEvent + init?(rawSessionType: String) { switch rawSessionType { case "Session": @@ -35,31 +31,31 @@ public enum SessionInstanceType: Int { /// A session instance represents a specific occurence of a session with a location and start/end times public class SessionInstance: Object { - + /// Unique identifier public dynamic var identifier = "" - + /// Instance number public dynamic var number = "" - + public var code: Int { guard let n = number.components(separatedBy: "-").last else { return NSNotFound } - + return Int(n) ?? NSNotFound } - + /// The event identifier for the event this instance belongs to public dynamic var eventIdentifier = "" - + /// The session public dynamic var session: Session? = nil - + /// The raw session type as returned by the API public dynamic var rawSessionType = "Session" - + /// Type of session (0 = regular session, 1 = lab, 2 = video-only session) public dynamic var sessionType = 0 - + public var type: SessionInstanceType { get { return SessionInstanceType(rawValue: sessionType) ?? .session @@ -68,50 +64,50 @@ public class SessionInstance: Object { self.sessionType = newValue.rawValue } } - + /// The start time public dynamic var startTime: Date = .distantPast - + /// The end time public dynamic var endTime: Date = .distantPast - + /// Keywords for this session public let keywords = List() - + /// Room name public dynamic var roomName = "" - + /// Room unique identifier public dynamic var roomIdentifier = "" - + // Track name public dynamic var trackName = "" - + public dynamic var trackIdentifier = "" - + /// The track associated with the instance public let track = LinkingObjects(fromType: Track.self, property: "instances") - + /// Whether this is being live streamed at the moment public dynamic var isCurrentlyLive = false - + /// Whether the live flag is being forced by an external source public dynamic var isForcedLive = false - + public override static func primaryKey() -> String? { return "identifier" } - + public override static func ignoredProperties() -> [String] { return ["code"] } - + public static func standardSort(instanceA: SessionInstance, instanceB: SessionInstance) -> Bool { guard let sessionA = instanceA.session, let sessionB = instanceB.session else { return false } - + let nA = instanceA.code let nB = instanceB.code - + if instanceA.sessionType == instanceB.sessionType { if instanceA.sessionType == SessionInstanceType.session.rawValue { if instanceA.startTime == instanceB.startTime { @@ -126,10 +122,10 @@ public class SessionInstance: Object { return instanceA.sessionType < instanceB.sessionType } } - + func merge(with other: SessionInstance, in realm: Realm) { assert(other.identifier == self.identifier, "Can't merge two objects with different identifiers!") - + self.number = other.number self.rawSessionType = other.rawSessionType self.sessionType = other.sessionType @@ -139,11 +135,11 @@ public class SessionInstance: Object { self.trackName = other.trackName self.trackIdentifier = other.trackIdentifier self.eventIdentifier = other.eventIdentifier - + if let otherSession = other.session, let session = self.session { session.merge(with: otherSession, in: realm) } - + let otherKeywords = other.keywords.map { newKeyword -> (Keyword) in if newKeyword.realm == nil, let existingKeyword = realm.object(ofType: Keyword.self, forPrimaryKey: newKeyword.name) diff --git a/ConfCore/SessionInstancesJSONAdapter.swift b/ConfCore/SessionInstancesJSONAdapter.swift index 23bd7e83..26f298ef 100644 --- a/ConfCore/SessionInstancesJSONAdapter.swift +++ b/ConfCore/SessionInstancesJSONAdapter.swift @@ -14,35 +14,35 @@ enum SessionInstanceKeys: String, JSONSubscriptType { case favId = "fav_id" case room = "roomId" case track = "trackId" - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } } final class SessionInstancesJSONAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = SessionInstance - + func adapt(_ input: JSON) -> Result { guard case .success(let session) = SessionsJSONAdapter().adapt(input) else { return .error(.invalidData) } - + // not an instance guard session.year == Calendar.current.component(.year, from: Date()) else { return .error(.invalidData) } - + guard let startGMT = input[SessionInstanceKeys.startTime].string else { return .error(.missingKey(SessionInstanceKeys.startTime)) } - + guard let endGMT = input[SessionInstanceKeys.endTime].string else { return .error(.missingKey(SessionInstanceKeys.startTime)) } - + guard let rawType = input[SessionInstanceKeys.type].string else { return .error(.missingKey(SessionInstanceKeys.type)) } @@ -58,23 +58,23 @@ final class SessionInstancesJSONAdapter: Adapter { guard let trackIdentifier = input[SessionInstanceKeys.track].int else { return .error(.missingKey(SessionInstanceKeys.track)) } - + guard case .success(let startDate) = DateTimeAdapter().adapt(startGMT) else { return .error(.invalidData) } - + guard case .success(let endDate) = DateTimeAdapter().adapt(endGMT) else { return .error(.invalidData) } - + let instance = SessionInstance() - + if let keywordsJson = input[SessionInstanceKeys.keywords].array { if case .success(let keywords) = KeywordsJSONAdapter().adapt(keywordsJson) { instance.keywords.append(objectsIn: keywords) } } - + instance.identifier = session.identifier instance.eventIdentifier = Event.identifier(from: startDate) instance.number = id @@ -85,8 +85,8 @@ final class SessionInstancesJSONAdapter: Adapter { instance.sessionType = SessionInstanceType(rawSessionType: rawType)?.rawValue ?? 0 instance.startTime = startDate instance.endTime = endDate - + return .success(instance) } - + } diff --git a/ConfCore/SessionProgress.swift b/ConfCore/SessionProgress.swift index 8ccab13b..d7ac52f6 100644 --- a/ConfCore/SessionProgress.swift +++ b/ConfCore/SessionProgress.swift @@ -11,62 +11,61 @@ import RealmSwift /// Defines the user action of adding a session as favorite public class SessionProgress: Object { - + /// Unique identifier public dynamic var identifier = UUID().uuidString - + /// When the progress was created public dynamic var createdAt = Date() - + /// When the progress was last update public dynamic var updatedAt = Date() - + /// The current position in the video (in seconds) public dynamic var currentPosition: Double = 0 - + /// The current position in the video, relative to the duration (from 0 to 1) public dynamic var relativePosition: Double = 0 - + /// The session this progress is associated with public let session = LinkingObjects(fromType: Session.self, property: "progresses") - + public override class func primaryKey() -> String? { return "identifier" } - } extension Session { - + public func setCurrentPosition(_ position: Double, _ duration: Double) { guard let realm = self.realm else { return } guard !duration.isNaN, !duration.isZero, !duration.isInfinite else { return } guard !position.isNaN, !position.isZero, !position.isInfinite else { return } - + do { let mustCommit: Bool - + if !realm.isInWriteTransaction { realm.beginWrite() mustCommit = true } else { mustCommit = false } - + var progress: SessionProgress - + if let p = progresses.first { progress = p } else { progress = SessionProgress() progresses.append(progress) } - + progress.currentPosition = position progress.relativePosition = position / duration progress.updatedAt = Date() - + if mustCommit { try realm.commitWrite() } } catch { NSLog("Error updating session progress: \(error)") @@ -92,9 +91,9 @@ extension Session { NSLog("Error updating session progress: \(error)") } } - + public func currentPosition() -> Double { return progresses.first?.currentPosition ?? 0 } - + } diff --git a/ConfCore/SessionsJSONAdapter.swift b/ConfCore/SessionsJSONAdapter.swift index e4f5118f..69443c44 100644 --- a/ConfCore/SessionsJSONAdapter.swift +++ b/ConfCore/SessionsJSONAdapter.swift @@ -11,7 +11,7 @@ import SwiftyJSON enum AssetKeys: String, JSONSubscriptType { case id, year, title, downloadHD, downloadSD, slides, hls, images, shelf - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } @@ -19,30 +19,30 @@ enum AssetKeys: String, JSONSubscriptType { private enum SessionKeys: String, JSONSubscriptType { case id, year, title, platforms, description, startTime, eventContentId, eventId, media, webPermalink, staticContentId - + case track = "trackId" - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } } final class SessionsJSONAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = Session - + func adapt(_ input: JSON) -> Result { guard let id = input[SessionKeys.id].string?.replacingOccurrences(of: "wwdc", with: "") else { return .error(.missingKey(SessionKeys.id)) } - + guard let eventIdentifier = input[SessionKeys.eventId].string else { return .error(.missingKey(SessionKeys.eventId)) } - + let eventYear = eventIdentifier.replacingOccurrences(of: "wwdc", with: "") - + guard let title = input[SessionKeys.title].string else { return .error(.missingKey(SessionKeys.title)) } @@ -50,80 +50,80 @@ final class SessionsJSONAdapter: Adapter { guard let summary = input[SessionKeys.description].string else { return .error(.missingKey(SessionKeys.description)) } - + guard let trackIdentifier = input[SessionKeys.track].int else { return .error(.missingKey(SessionKeys.track)) } - + guard let eventContentId = input[SessionKeys.eventContentId].int else { return .error(.missingKey(SessionKeys.eventContentId)) } - + let session = Session() - + if let focusesJson = input[SessionKeys.platforms].array { if case .success(let focuses) = FocusesJSONAdapter().adapt(focusesJson) { session.focuses.append(objectsIn: focuses) } } - + if let url = input[SessionKeys.media][AssetKeys.hls].string { let streaming = SessionAsset() - + streaming.rawAssetType = SessionAssetType.streamingVideo.rawValue streaming.remoteURL = url streaming.year = Int(eventYear) ?? -1 streaming.sessionId = id - + session.assets.append(streaming) } - + if let hd = input[SessionKeys.media][AssetKeys.downloadHD].string { let hdVideo = SessionAsset() hdVideo.rawAssetType = SessionAssetType.hdVideo.rawValue hdVideo.remoteURL = hd hdVideo.year = Int(eventYear) ?? -1 hdVideo.sessionId = id - + let filename = URL(string: hd)?.lastPathComponent ?? "\(title).mp4" hdVideo.relativeLocalURL = "\(eventYear)/\(filename)" - + session.assets.append(hdVideo) } - + if let sd = input[SessionKeys.media][AssetKeys.downloadSD].string { let sdVideo = SessionAsset() sdVideo.rawAssetType = SessionAssetType.sdVideo.rawValue sdVideo.remoteURL = sd sdVideo.year = Int(eventYear) ?? -1 sdVideo.sessionId = id - + let filename = URL(string: sd)?.lastPathComponent ?? "\(title).mp4" sdVideo.relativeLocalURL = "\(eventYear)/\(filename)" - + session.assets.append(sdVideo) } - + if let slides = input[SessionKeys.media][AssetKeys.slides].string { let slidesAsset = SessionAsset() slidesAsset.rawAssetType = SessionAssetType.slides.rawValue slidesAsset.remoteURL = slides slidesAsset.year = Int(eventYear) ?? -1 slidesAsset.sessionId = id - + session.assets.append(slidesAsset) } - + if let permalink = input[SessionKeys.webPermalink].string { let webPageAsset = SessionAsset() webPageAsset.rawAssetType = SessionAssetType.webpage.rawValue webPageAsset.remoteURL = permalink webPageAsset.year = Int(eventYear) ?? -1 webPageAsset.sessionId = id - + session.assets.append(webPageAsset) } - + session.staticContentId = "\(input[SessionKeys.staticContentId].intValue)" session.identifier = id session.year = Int(eventYear) ?? -1 diff --git a/ConfCore/SessionsResponse.swift b/ConfCore/SessionsResponse.swift index 4e3e0999..cf946c61 100644 --- a/ConfCore/SessionsResponse.swift +++ b/ConfCore/SessionsResponse.swift @@ -9,10 +9,10 @@ import Foundation public struct SessionsResponse { - + public let events: [Event] public let sessions: [Session] public let assets: [SessionAsset] public let tracks: [Track] - + } diff --git a/ConfCore/SessionsResponseAdapter.swift b/ConfCore/SessionsResponseAdapter.swift index c89105ba..07f62b85 100644 --- a/ConfCore/SessionsResponseAdapter.swift +++ b/ConfCore/SessionsResponseAdapter.swift @@ -18,10 +18,10 @@ private enum SessionResponseKeys: String, JSONSubscriptType { } final class SessionsResponseAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = SessionsResponse - + func adapt(_ input: JSON) -> Result { return .error(.invalidData) // guard let eventsJson = input[SessionResponseKeys.events].array else { diff --git a/ConfCore/Storage.swift b/ConfCore/Storage.swift index d2d82d96..269accf6 100644 --- a/ConfCore/Storage.swift +++ b/ConfCore/Storage.swift @@ -12,57 +12,57 @@ import RxSwift import RxRealm public final class Storage { - + public let realmConfig: Realm.Configuration public let realm: Realm - + public init(_ configuration: Realm.Configuration) throws { var config = configuration - + config.migrationBlock = Storage.migrate(migration:oldVersion:) - + self.realmConfig = config - + self.realm = try Realm(configuration: config) - + DistributedNotificationCenter.default().addObserver(forName: .TranscriptIndexingDidStart, object: nil, queue: OperationQueue.main) { [unowned self] _ in #if DEBUG NSLog("[Storage] Locking realm autoupdates until transcript indexing is finished") #endif - + self.realm.autorefresh = false } - + DistributedNotificationCenter.default().addObserver(forName: .TranscriptIndexingDidStop, object: nil, queue: OperationQueue.main) { [unowned self] _ in #if DEBUG NSLog("[Storage] Realm autoupdates unlocked") #endif - + self.realm.autorefresh = true } - + deleteOldEventsIfNeeded() } - + private func makeRealm() throws -> Realm { let c = Realm.Configuration(fileURL: self.realmConfig.fileURL, schemaVersion: self.realmConfig.schemaVersion, migrationBlock: self.realmConfig.migrationBlock) - + return try Realm(configuration: c) } - + public lazy var storageQueue: OperationQueue = { let q = OperationQueue() - + q.name = "WWDC Storage" q.maxConcurrentOperationCount = 1 q.underlyingQueue = DispatchQueue(label: "WWDC Storage", qos: .background) - + return q }() - + private func deleteOldEventsIfNeeded() { guard let wwdc2012 = realm.objects(Event.self).filter("identifier == %@", "wwdc2012").first else { return } - + do { try realm.write { realm.delete(wwdc2012.sessions) @@ -72,7 +72,7 @@ public final class Storage { NSLog("Error deleting old events: \(error)") } } - + internal static func migrate(migration: Migration, oldVersion: UInt64) { if oldVersion < 10 { // alpha cleanup @@ -93,11 +93,11 @@ public final class Storage { if oldVersion < 31 { // remove cached images which might have generic session thumbs instead of the correct ones migration.deleteData(forType: "ImageCacheEntity") - + // delete live stream assets (some of them got duplicated during the week) migration.enumerateObjects(ofType: "SessionAsset") { asset, _ in guard let asset = asset else { return } - + if asset["rawAssetType"] as? String == SessionAssetType.liveStreamVideo.rawValue { migration.delete(asset) } @@ -111,13 +111,13 @@ public final class Storage { if oldVersion < 34 { migration.deleteData(forType: "Transcript") migration.deleteData(forType: "TranscriptAnnotation") - + migration.enumerateObjects(ofType: "Session") { _, session in session?["transcriptIdentifier"] = "" } } } - + func store(contentResult: Result, completion: @escaping (Error?) -> Void) { if case let .error(error) = contentResult { NSLog("Error downloading sessions: \(error)") @@ -128,7 +128,7 @@ public final class Storage { guard case let .success(sessionsResponse) = contentResult else { return } - + performSerializedBackgroundWrite(writeBlock: { backgroundRealm in // Merge existing session data, preserving user-defined data sessionsResponse.sessions.forEach { newSession in @@ -138,7 +138,7 @@ public final class Storage { backgroundRealm.add(newSession, update: true) } } - + // Merge existing instance data, preserving user-defined data sessionsResponse.instances.forEach { newInstance in if let existingInstance = backgroundRealm.object(ofType: SessionInstance.self, forPrimaryKey: newInstance.identifier) { @@ -147,52 +147,52 @@ public final class Storage { backgroundRealm.add(newInstance, update: true) } } - + // Save everything backgroundRealm.add(sessionsResponse.rooms, update: true) backgroundRealm.add(sessionsResponse.tracks, update: true) backgroundRealm.add(sessionsResponse.events, update: true) - + // add instances to rooms backgroundRealm.objects(Room.self).forEach { room in let instances = backgroundRealm.objects(SessionInstance.self).filter("roomIdentifier == %@", room.identifier) - + instances.forEach({ $0.roomName = room.name }) - + room.instances.removeAll() room.instances.append(objectsIn: instances) } - + // add instances and sessions to events backgroundRealm.objects(Event.self).forEach { event in let instances = backgroundRealm.objects(SessionInstance.self).filter("eventIdentifier == %@", event.identifier) let sessions = backgroundRealm.objects(Session.self).filter("eventIdentifier == %@", event.identifier) - + event.sessionInstances.removeAll() event.sessionInstances.append(objectsIn: instances) - + event.sessions.removeAll() event.sessions.append(objectsIn: sessions) } - + // add instances and sessions to tracks backgroundRealm.objects(Track.self).forEach { track in let instances = backgroundRealm.objects(SessionInstance.self).filter("trackIdentifier == %@", track.identifier) let sessions = backgroundRealm.objects(Session.self).filter("trackIdentifier == %@", track.identifier) - + track.instances.removeAll() track.instances.append(objectsIn: instances) - + track.sessions.removeAll() track.sessions.append(objectsIn: sessions) - + sessions.forEach({ $0.trackName = track.name }) instances.forEach { instance in instance.trackName = track.name instance.session?.trackName = track.name } } - + // add live video assets to sessions backgroundRealm.objects(SessionAsset.self).filter("rawAssetType == %@", SessionAssetType.liveStreamVideo.rawValue).forEach { liveAsset in if let session = backgroundRealm.objects(Session.self).filter("year == %d AND number == %@", liveAsset.year, liveAsset.sessionId).first { @@ -201,48 +201,48 @@ public final class Storage { } } } - + // Create schedule view - + backgroundRealm.delete(backgroundRealm.objects(ScheduleSection.self)) - + let instances = backgroundRealm.objects(SessionInstance.self).sorted(by: SessionInstance.standardSort) - + var previousStartTime: Date? = nil for instance in instances { guard instance.startTime != previousStartTime else { continue } - + autoreleasepool { let instancesForSection = instances.filter({ $0.startTime == instance.startTime }) - + let section = ScheduleSection() - + section.representedDate = instance.startTime section.eventIdentifier = instance.eventIdentifier section.instances.removeAll() section.instances.append(objectsIn: instancesForSection) section.identifier = ScheduleSection.identifierFormatter.string(from: instance.startTime) - + backgroundRealm.add(section, update: true) - + previousStartTime = instance.startTime } } }, disableAutorefresh: true, completionBlock: completion) } - + internal func store(liveVideosResult: Result<[SessionAsset], APIError>) { guard case .success(let assets) = liveVideosResult else { return } performSerializedBackgroundWrite(writeBlock: { backgroundRealm in assets.forEach { asset in asset.identifier = asset.generateIdentifier() - + if let existingAsset = backgroundRealm.objects(SessionAsset.self).filter("identifier == %@", asset.identifier).first { existingAsset.remoteURL = asset.remoteURL } else { backgroundRealm.add(asset, update: true) - + if let session = backgroundRealm.objects(Session.self).filter("year == %d AND number == %@", asset.year, asset.sessionId).first { if !session.assets.contains(asset) { session.assets.append(asset) @@ -252,8 +252,8 @@ public final class Storage { } }) } - - + + /// Performs a write transaction in the background /// /// - Parameters: @@ -262,47 +262,47 @@ public final class Storage { /// - completionBlock: A block to be called when the operation is completed (called on the main queue) private func performSerializedBackgroundWrite(writeBlock: @escaping (Realm) throws -> Void, disableAutorefresh: Bool = false, createTransaction: Bool = true, completionBlock: ((Error?) -> Void)? = nil) { if disableAutorefresh { self.realm.autorefresh = false } - + DispatchQueue.global(qos: .userInitiated).async { var storageError: Error? - + self.storageQueue.addOperation { [unowned self] in autoreleasepool { do { let backgroundRealm = try self.makeRealm() - + if createTransaction { backgroundRealm.beginWrite() } - + try writeBlock(backgroundRealm) - + if createTransaction { try backgroundRealm.commitWrite() } - + backgroundRealm.invalidate() } catch { storageError = error } } } - + self.storageQueue.waitUntilAllOperationsAreFinished() - + DispatchQueue.main.async { if disableAutorefresh { self.realm.autorefresh = true self.realm.refresh() } - + completionBlock?(storageError) } } } - + public func backgroundUpdate(with block: @escaping (Realm) throws -> Void) { performSerializedBackgroundWrite(writeBlock: { backgroundRealm in try block(backgroundRealm) }) } - + /// Gives you an opportunity to update `object` on a background queue /// /// - Parameters: @@ -315,16 +315,16 @@ public final class Storage { /// Your write block is not called if the method fails to transfer your object between threads. public func modify(_ object: T, with writeBlock: @escaping (T) -> Void) where T : ThreadConfined { let safeObject = ThreadSafeReference(to: object) - + performSerializedBackgroundWrite(writeBlock: { backgroundRealm in guard let resolvedObject = backgroundRealm.resolve(safeObject) else { return } - + try backgroundRealm.write { writeBlock(resolvedObject) } }, createTransaction: false) } - + /// Gives you an opportunity to update `objects` on a background queue /// /// - Parameters: @@ -337,121 +337,121 @@ public final class Storage { /// Your write block is not called if any of the objects can't be transfered between threads. public func modify(_ objects: [T], with writeBlock: @escaping ([T]) -> Void) where T : ThreadConfined { let safeObjects = objects.map { ThreadSafeReference(to: $0) } - + performSerializedBackgroundWrite(writeBlock: { backgroundRealm in let resolvedObjects = safeObjects.flatMap { backgroundRealm.resolve($0) } - + guard resolvedObjects.count == safeObjects.count else { NSLog("Failed to perform modify in the background. Some objects couldn't be resolved.") return } - + try backgroundRealm.write { writeBlock(resolvedObjects) } }, createTransaction: false) } - + public lazy var events: Observable> = { let eventsSortedByDateDescending = self.realm.objects(Event.self).sorted(byKeyPath: "startDate", ascending: false) - + return Observable.collection(from: eventsSortedByDateDescending) }() - + public lazy var sessionsObservable: Observable> = { return Observable.collection(from: self.realm.objects(Session.self)) }() - + public var sessions: Results { return self.realm.objects(Session.self).filter("assets.@count > 0") } - + public func session(with identifier: String) -> Session? { return realm.object(ofType: Session.self, forPrimaryKey: identifier) } - + public func createFavorite(for session: Session) { modify(session) { bgSession in bgSession.favorites.append(Favorite()) } } - + public var isEmpty: Bool { - return realm.objects(Event.self).count <= 0 + return realm.objects(Event.self).isEmpty } - + public func removeFavorite(for session: Session) { guard let favorite = session.favorites.first else { return } - + modify(favorite) { bgFavorite in bgFavorite.realm?.delete(bgFavorite) } } - + public lazy var tracksObservable: Observable> = { let tracks = self.realm.objects(Track.self).sorted(byKeyPath: "order") - + return Observable.collection(from: tracks) }() - + public lazy var scheduleObservable: Observable> = { let currentEvents = self.realm.objects(Event.self).filter("isCurrent == true") - + return Observable.collection(from: currentEvents).map({ $0.first?.identifier }).flatMap { (identifier: String?) -> Observable> in let sections = self.realm.objects(ScheduleSection.self).filter("eventIdentifier == %@", identifier ?? "").sorted(byKeyPath: "representedDate") - + return Observable.collection(from: sections) } }() - + public func asset(with remoteURL: URL) -> SessionAsset? { return realm.objects(SessionAsset.self).filter("remoteURL == %@", remoteURL.absoluteString).first } - + public func bookmark(with identifier: String, in inputRealm: Realm? = nil) -> Bookmark? { let effectiveRealm = inputRealm ?? self.realm - + return effectiveRealm.object(ofType: Bookmark.self, forPrimaryKey: identifier) } - + public func deleteBookmark(with identifier: String) { guard let bookmark = self.bookmark(with: identifier) else { NSLog("DELETE ERROR: Unable to find bookmark with identifier \(identifier)") return } - + modify(bookmark) { bgBookmark in bgBookmark.realm?.delete(bgBookmark) } } - + public func softDeleteBookmark(with identifier: String) { guard let bookmark = self.bookmark(with: identifier) else { NSLog("SOFT DELETE ERROR: Unable to find bookmark with identifier \(identifier)") return } - + modify(bookmark) { bgBookmark in bgBookmark.isDeleted = true bgBookmark.deletedAt = Date() } } - + public func moveBookmark(with identifier: String, to timecode: Double) { guard let bookmark = self.bookmark(with: identifier) else { NSLog("MOVE ERROR: Unable to find bookmark with identifier \(identifier)") return } - + modify(bookmark) { bgBookmark in bgBookmark.timecode = timecode } } - + public func updateDownloadedFlag(_ isDownloaded: Bool, forAssetsAtPaths filePaths: [String]) { DispatchQueue.main.async { let assets = filePaths.flatMap { self.realm.objects(SessionAsset.self).filter("relativeLocalURL == %@", $0).first } - + self.modify(assets) { bgAssets in bgAssets.forEach { bgAsset in bgAsset.session.first?.isDownloaded = isDownloaded @@ -459,15 +459,15 @@ public final class Storage { } } } - + public var allEvents: [Event] { return realm.objects(Event.self).sorted(byKeyPath: "startDate", ascending: false).toArray() } - + public var allFocuses: [Focus] { return realm.objects(Focus.self).sorted(byKeyPath: "name").toArray() } - + public var allTracks: [Track] { return realm.objects(Track.self).sorted(byKeyPath: "order").toArray() } diff --git a/ConfCore/SyncEngine.swift b/ConfCore/SyncEngine.swift index ec1451d9..2e30fcc7 100644 --- a/ConfCore/SyncEngine.swift +++ b/ConfCore/SyncEngine.swift @@ -14,33 +14,33 @@ extension Notification.Name { } public final class SyncEngine { - + public let storage: Storage public let client: AppleAPIClient - + private var didRunIndexingService = false - + private lazy var transcriptIndexingConnection: NSXPCConnection = { let c = NSXPCConnection(serviceName: "io.wwdc.app.TranscriptIndexingService") - + c.remoteObjectInterface = NSXPCInterface(with: TranscriptIndexingServiceProtocol.self) - + return c }() - + private var transcriptIndexingService: TranscriptIndexingServiceProtocol? { return transcriptIndexingConnection.remoteObjectProxy as? TranscriptIndexingServiceProtocol } - + public init(storage: Storage, client: AppleAPIClient) { self.storage = storage self.client = client - + NotificationCenter.default.addObserver(forName: .SyncEngineDidSyncSessionsAndSchedule, object: nil, queue: OperationQueue.main) { [unowned self] _ in self.startTranscriptIndexingIfNeeded() } } - + public func syncContent() { client.fetchContent { [unowned self] scheduleResult in DispatchQueue.main.async { @@ -50,7 +50,7 @@ public final class SyncEngine { } } } - + public func syncLiveVideos() { client.fetchLiveVideoAssets { [weak self] result in DispatchQueue.main.async { @@ -58,20 +58,20 @@ public final class SyncEngine { } } } - + private func startTranscriptIndexingIfNeeded() { guard !ProcessInfo.processInfo.arguments.contains("--disable-transcripts") else { return } - + guard TranscriptIndexer.needsUpdate(in: storage) else { return } - + guard let url = storage.realmConfig.fileURL else { return } - + guard !didRunIndexingService else { return } didRunIndexingService = true - + transcriptIndexingConnection.resume() - + transcriptIndexingService?.indexTranscriptsIfNeeded(storageURL: url, schemaVersion: storage.realmConfig.schemaVersion) } - + } diff --git a/ConfCore/Track.swift b/ConfCore/Track.swift index 1a2d629c..1e19e91a 100644 --- a/ConfCore/Track.swift +++ b/ConfCore/Track.swift @@ -11,38 +11,38 @@ import RealmSwift /// Tracks represent a specific are of interest (ex: "System Frameworks", "Graphics and Games") public class Track: Object { - + /// Unique identifier public dynamic var identifier = "" - + /// The name of the track public dynamic var name = "" - + /// The order in which the track should be listed public dynamic var order = 0 - + /// Dark theme color public dynamic var darkColor = "" - + /// Color for light backgrounds public dynamic var lightBackgroundColor = "" - + /// Color for light contexts public dynamic var lightColor = "" - + /// Theme title color public dynamic var titleColor = "" - + /// Sessions related to this track public let sessions = List() - + /// Instances related to this track public let instances = List() - + public override class func primaryKey() -> String? { return "name" } - + public static func make(identifier: String, name: String, darkColor: String, @@ -50,15 +50,15 @@ public class Track: Object { lightColor: String, titleColor: String) -> Track { let track = Track() - + track.identifier = identifier track.name = name track.darkColor = darkColor track.lightBackgroundColor = lightBackgroundColor track.lightColor = lightColor track.titleColor = titleColor - + return track } - + } diff --git a/ConfCore/TracksJSONAdapter.swift b/ConfCore/TracksJSONAdapter.swift index 262391a5..b848a1d9 100644 --- a/ConfCore/TracksJSONAdapter.swift +++ b/ConfCore/TracksJSONAdapter.swift @@ -12,52 +12,52 @@ import SwiftyJSON private enum TrackKeys: String, JSONSubscriptType { case name, color, darkColor, titleColor, lightBGColor, ordinal case identifier = "id" - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } } final class TracksJSONAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = Track - + func adapt(_ input: JSON) -> Result { guard let identifier = input[TrackKeys.identifier].int else { return .error(.missingKey(TrackKeys.identifier)) } - + guard let name = input[TrackKeys.name].string else { return .error(.missingKey(TrackKeys.name)) } - + guard let color = input[TrackKeys.color].string else { return .error(.missingKey(TrackKeys.color)) } - + guard let darkColor = input[TrackKeys.darkColor].string else { return .error(.missingKey(TrackKeys.darkColor)) } - + guard let titleColor = input[TrackKeys.titleColor].string else { return .error(.missingKey(TrackKeys.titleColor)) } - + guard let lightBGColor = input[TrackKeys.lightBGColor].string else { return .error(.missingKey(TrackKeys.lightBGColor)) } - + let track = Track.make(identifier: "\(identifier)", - name: name, - darkColor: darkColor, - lightBackgroundColor: lightBGColor, - lightColor: color, - titleColor: titleColor) - + name: name, + darkColor: darkColor, + lightBackgroundColor: lightBGColor, + lightColor: color, + titleColor: titleColor) + track.order = input[TrackKeys.ordinal].intValue - + return .success(track) } - + } diff --git a/ConfCore/Transcript.swift b/ConfCore/Transcript.swift index 4bbef851..3a1e34de 100644 --- a/ConfCore/Transcript.swift +++ b/ConfCore/Transcript.swift @@ -14,15 +14,15 @@ public class Transcript: Object { /// Unique identifier public dynamic var identifier = "" - + /// The annotations the transcript contains public let annotations = List() - + /// The text of the transcript public dynamic var fullText = "" - + public override class func primaryKey() -> String? { return "identifier" } - + } diff --git a/ConfCore/TranscriptAnnotation.swift b/ConfCore/TranscriptAnnotation.swift index 6d8210bd..594c28b1 100644 --- a/ConfCore/TranscriptAnnotation.swift +++ b/ConfCore/TranscriptAnnotation.swift @@ -14,8 +14,8 @@ public class TranscriptAnnotation: Object { /// The time this annotation occurs within the video public dynamic var timecode = 0.0 - + /// The annotation's text public dynamic var body = "" - + } diff --git a/ConfCore/TranscriptIndexer.swift b/ConfCore/TranscriptIndexer.swift index f524c2d4..fb4ea09a 100644 --- a/ConfCore/TranscriptIndexer.swift +++ b/ConfCore/TranscriptIndexer.swift @@ -16,68 +16,68 @@ extension Notification.Name { } public final class TranscriptIndexer: NSObject { - + private let storage: Storage - + public init(_ storage: Storage) { self.storage = storage - + super.init() } - + /// The progress when the transcripts are being downloaded/indexed public var transcriptIndexingProgress: Progress? - + private let asciiWWDCURL = "http://asciiwwdc.com/" - + fileprivate let bgThread = DispatchQueue.global(qos: .utility) - + fileprivate lazy var backgroundOperationQueue: OperationQueue = { let q = OperationQueue() - + q.underlyingQueue = self.bgThread q.name = "Transcript Indexing" - + return q }() - + public static let minTranscriptableSessionLimit: Int = 20 // TODO: increase 2017 to 2018 when transcripts for 2017 become available public static let transcriptableSessionsPredicate: NSPredicate = NSPredicate(format: "year > 2012 AND year < 2017 AND transcriptIdentifier == '' AND SUBQUERY(assets, $asset, $asset.rawAssetType == %@).@count > 0", SessionAssetType.streamingVideo.rawValue) - + public static func needsUpdate(in storage: Storage) -> Bool { let transcriptedSessions = storage.realm.objects(Session.self).filter(TranscriptIndexer.transcriptableSessionsPredicate) return transcriptedSessions.count > minTranscriptableSessionLimit } - + /// Try to download transcripts for sessions that don't have transcripts yet public func downloadTranscriptsIfNeeded() { let transcriptedSessions = storage.realm.objects(Session.self).filter(TranscriptIndexer.transcriptableSessionsPredicate) - + let sessionKeys: [String] = transcriptedSessions.map({ $0.identifier }) - + self.indexTranscriptsForSessionsWithKeys(sessionKeys) } - + func indexTranscriptsForSessionsWithKeys(_ sessionKeys: [String]) { // ignore very low session counts guard sessionKeys.count > TranscriptIndexer.minTranscriptableSessionLimit else { self.waitAndExit() return } - + transcriptIndexingProgress = Progress(totalUnitCount: Int64(sessionKeys.count)) - + for key in sessionKeys { guard let session = storage.realm.object(ofType: Session.self, forPrimaryKey: key) else { return } - + guard session.transcriptIdentifier.isEmpty else { continue } - + indexTranscript(for: session.number, in: session.year, primaryKey: key) } } - + fileprivate var batch: [Transcript] = [] { didSet { if batch.count >= 20 { @@ -87,7 +87,7 @@ public final class TranscriptIndexer: NSObject { } } } - + fileprivate func store(_ transcripts: [Transcript]) { storage.backgroundUpdate { backgroundRealm in transcripts.forEach { transcript in @@ -95,49 +95,49 @@ public final class TranscriptIndexer: NSObject { NSLog("Session not found for \(transcript.identifier)") return } - + session.transcriptIdentifier = transcript.identifier session.transcriptText = transcript.fullText - + backgroundRealm.add(transcript, update: true) } } } - + fileprivate func indexTranscript(for sessionNumber: String, in year: Int, primaryKey: String) { guard let url = URL(string: "\(asciiWWDCURL)\(year)//sessions/\(sessionNumber)") else { return } - + var request = URLRequest(url: url) request.setValue("application/json", forHTTPHeaderField: "Accept") - + let task = URLSession.shared.dataTask(with: request) { [unowned self] data, response, error in defer { self.transcriptIndexingProgress?.completedUnitCount += 1 - + self.checkForCompletion() } - + guard let jsonData = data else { NSLog("No data returned from ASCIIWWDC for \(primaryKey)") - + return } - + let result = TranscriptsJSONAdapter().adapt(JSON(data: jsonData)) - + guard case .success(let transcript) = result else { NSLog("Error parsing transcript for \(primaryKey)") return } - + self.storage.storageQueue.waitUntilAllOperationsAreFinished() - + self.batch.append(transcript) } - + task.resume() } - + public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == #keyPath(OperationQueue.operationCount) { NSLog("operationCount = \(backgroundOperationQueue.operationCount)") @@ -145,26 +145,26 @@ public final class TranscriptIndexer: NSObject { super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) } } - + private func checkForCompletion() { guard let progress = self.transcriptIndexingProgress else { return } - + #if DEBUG NSLog("Completed: \(progress.completedUnitCount) Total: \(progress.totalUnitCount)") #endif - + if progress.completedUnitCount >= progress.totalUnitCount { DispatchQueue.main.async { #if DEBUG NSLog("Transcript indexing finished") #endif - + self.storage.storageQueue.waitUntilAllOperationsAreFinished() self.waitAndExit() } } } - + fileprivate func waitAndExit() { DispatchQueue.main.asyncAfter(deadline: .now() + 5) { exit(0) diff --git a/ConfCore/TranscriptsJSONAdapter.swift b/ConfCore/TranscriptsJSONAdapter.swift index 47f747bf..391ea7a7 100644 --- a/ConfCore/TranscriptsJSONAdapter.swift +++ b/ConfCore/TranscriptsJSONAdapter.swift @@ -11,28 +11,28 @@ import SwiftyJSON private enum TranscriptKeys: String, JSONSubscriptType { case year, number, timecodes, annotations - + case fullText = "transcript" - + var jsonKey: JSONKey { return JSONKey.key(rawValue) } } final class TranscriptsJSONAdapter: Adapter { - + typealias InputType = JSON typealias OutputType = Transcript - + func adapt(_ input: JSON) -> Result { guard let year = input[TranscriptKeys.year].int else { return .error(.missingKey(TranscriptKeys.year)) } - + guard let number = input[TranscriptKeys.number].int else { return .error(.missingKey(TranscriptKeys.number)) } - + guard let timecodes = input[TranscriptKeys.timecodes].array else { return .error(.missingKey(TranscriptKeys.timecodes)) } @@ -40,24 +40,24 @@ final class TranscriptsJSONAdapter: Adapter { guard let annotationsJson = input[TranscriptKeys.annotations].array else { return .error(.missingKey(TranscriptKeys.annotations)) } - + let annotations: [TranscriptAnnotation] = zip(timecodes, annotationsJson).flatMap { time, body in guard let time = time.double, let body = body.string else { return nil } - + let annotation = TranscriptAnnotation() - + annotation.timecode = time annotation.body = body - + return annotation } - + let transcript = Transcript() - + transcript.identifier = "\(year)-\(number)" transcript.fullText = input[TranscriptKeys.fullText].string ?? "" transcript.annotations.append(objectsIn: annotations) - + return .success(transcript) } diff --git a/ConfCoreTests/AdapterTests.swift b/ConfCoreTests/AdapterTests.swift index 9f842786..99b651a8 100644 --- a/ConfCoreTests/AdapterTests.swift +++ b/ConfCoreTests/AdapterTests.swift @@ -17,89 +17,89 @@ class AdapterTests: XCTestCase { XCTFail("Unable to find URL for fixture named \(filename)") fatalError() } - + guard let data = try? Data(contentsOf: fileURL) else { XCTFail("Unable to load fixture named \(filename)") fatalError() } - + return JSON(data: data) } - + func testEventsAdapter() { let json = getJson(from: "sessions") - + guard let eventsArray = json["response"]["events"].array else { XCTFail("Sessions.json fixture doesn't have an \"events\" array") fatalError() } - + let result = EventsJSONAdapter().adapt(eventsArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) case .success(let events): XCTAssertEqual(events.count, 5) - + XCTAssertEqual(events[0].name, "WWDC 2016") XCTAssertEqual(events[0].identifier, "wwdc2016") XCTAssertEqual(events[0].isCurrent, true) - + XCTAssertEqual(events[4].name, "WWDC 2012") XCTAssertEqual(events[4].identifier, "wwdc2012") XCTAssertEqual(events[4].isCurrent, false) } } - + func testRoomsAdapter() { let json = getJson(from: "sessions") - + guard let roomsArray = json["response"]["rooms"].array else { XCTFail("Sessions.json fixture doesn't have a \"rooms\" array") fatalError() } - + let result = RoomsJSONAdapter().adapt(roomsArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) case .success(let rooms): XCTAssertEqual(rooms.count, 29) - + XCTAssertEqual(rooms[0].name, "Bill Graham Civic Auditorium") XCTAssertEqual(rooms[0].mapName, "BGCA") XCTAssertEqual(rooms[0].floor, "billGraham") - + XCTAssertEqual(rooms[28].name, "Recharge Lounge") XCTAssertEqual(rooms[28].mapName, "Lounge-Recharge") XCTAssertEqual(rooms[28].floor, "floor3") } } - + func testTracksAdapter() { let json = getJson(from: "sessions") - + guard let tracksArray = json["response"]["tracks"].array else { XCTFail("Sessions.json fixture doesn't have a \"tracks\" array") fatalError() } - + let result = TracksJSONAdapter().adapt(tracksArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) case .success(let tracks): XCTAssertEqual(tracks.count, 8) - + XCTAssertEqual(tracks[0].name, "Featured") XCTAssertEqual(tracks[0].lightColor, "#9E9E9E") XCTAssertEqual(tracks[0].lightBackgroundColor, "#32353D") XCTAssertEqual(tracks[0].darkColor, "#32353D") XCTAssertEqual(tracks[0].titleColor, "#D9D9DD") - + XCTAssertEqual(tracks[7].name, "Distribution") XCTAssertEqual(tracks[7].lightColor, "#B0619E") XCTAssertEqual(tracks[7].lightBackgroundColor, "#373049") @@ -110,14 +110,14 @@ class AdapterTests: XCTestCase { func testKeywordsAdapter() { let json = getJson(from: "sessions") - + guard let keywordsArray = json["response"]["sessions"][0]["keywords"].array else { XCTFail("Couldn't find a session in sessions.json with an array of keywords") fatalError() } - + let result = KeywordsJSONAdapter().adapt(keywordsArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) @@ -140,14 +140,14 @@ class AdapterTests: XCTestCase { func testFocusesAdapter() { let json = getJson(from: "sessions") - + guard let focusesArray = json["response"]["sessions"][0]["focus"].array else { XCTFail("Couldn't find a session in sessions.json with an array of focuses") fatalError() } - + let result = FocusesJSONAdapter().adapt(focusesArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) @@ -160,74 +160,74 @@ class AdapterTests: XCTestCase { ]); } } - + func testAssetsAdapter() { let json = getJson(from: "videos") - + guard let sessionsArray = json["sessions"].array else { XCTFail("Couldn't find an array of sessions in videos.json") fatalError() } - + let result = SessionAssetsJSONAdapter().adapt(sessionsArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) case .success(let assets): let flattenedAssets = assets.flatMap({ $0 }) XCTAssertEqual(flattenedAssets.count, 2947) - + XCTAssertEqual(flattenedAssets[0].assetType, SessionAssetType.streamingVideo.rawValue) XCTAssertEqual(flattenedAssets[0].remoteURL, "http://devstreaming.apple.com/videos/wwdc/2016/210e4481b1cnwor4n1q/210/hls_vod_mvp.m3u8") XCTAssertEqual(flattenedAssets[0].year, 2016) XCTAssertEqual(flattenedAssets[0].sessionId, "210") - + XCTAssertEqual(flattenedAssets[1].assetType, SessionAssetType.hdVideo.rawValue) XCTAssertEqual(flattenedAssets[1].remoteURL, "http://devstreaming.apple.com/videos/wwdc/2016/210e4481b1cnwor4n1q/210/210_hd_mastering_uikit_on_tvos.mp4") XCTAssertEqual(flattenedAssets[1].relativeLocalURL, "2016/210_hd_mastering_uikit_on_tvos.mp4") XCTAssertEqual(flattenedAssets[1].year, 2016) XCTAssertEqual(flattenedAssets[1].sessionId, "210") - + XCTAssertEqual(flattenedAssets[2].assetType, SessionAssetType.sdVideo.rawValue) XCTAssertEqual(flattenedAssets[2].remoteURL, "http://devstreaming.apple.com/videos/wwdc/2016/210e4481b1cnwor4n1q/210/210_sd_mastering_uikit_on_tvos.mp4") XCTAssertEqual(flattenedAssets[2].relativeLocalURL, "2016/210_sd_mastering_uikit_on_tvos.mp4") XCTAssertEqual(flattenedAssets[2].year, 2016) XCTAssertEqual(flattenedAssets[2].sessionId, "210") - + XCTAssertEqual(flattenedAssets[3].assetType, SessionAssetType.slides.rawValue) XCTAssertEqual(flattenedAssets[3].remoteURL, "http://devstreaming.apple.com/videos/wwdc/2016/210e4481b1cnwor4n1q/210/210_mastering_uikit_on_tvos.pdf") XCTAssertEqual(flattenedAssets[3].year, 2016) XCTAssertEqual(flattenedAssets[3].sessionId, "210") - + XCTAssertEqual(flattenedAssets[4].assetType, SessionAssetType.webpage.rawValue) XCTAssertEqual(flattenedAssets[4].remoteURL, "https://developer.apple.com/wwdc16/210") XCTAssertEqual(flattenedAssets[4].year, 2016) XCTAssertEqual(flattenedAssets[4].sessionId, "210") - + XCTAssertEqual(flattenedAssets[5].assetType, SessionAssetType.image.rawValue) XCTAssertEqual(flattenedAssets[5].remoteURL, "http://devstreaming.apple.com/videos/wwdc/2016/210e4481b1cnwor4n1q/210/images/210_734x413.jpg") XCTAssertEqual(flattenedAssets[5].year, 2016) XCTAssertEqual(flattenedAssets[5].sessionId, "210") } } - + func testLiveAssetsAdapter() { let json = getJson(from: "videos_live") - + guard let sessionsDict = json["live_sessions"].dictionary else { XCTFail("Couldn't find a dictionary of live sessions in videos_live.json") fatalError() } - + let sessionsArray = sessionsDict.map { key, value -> JSON in var v = value v["sessionId"] = JSON.init(rawValue: key)! return v } - + let result = LiveVideosAdapter().adapt(sessionsArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) @@ -239,17 +239,17 @@ class AdapterTests: XCTestCase { XCTAssertEqual(sortedAssets[0].remoteURL, "http://devstreaming.apple.com/videos/wwdc/2016/live/mission_ghub2yon5yewl2i/atv_mvp.m3u8") } } - + func testSessionsAdapter() { let json = getJson(from: "videos") - + guard let sessionsArray = json["sessions"].array else { XCTFail("Couldn't find an array of sessions in videos.json") fatalError() } - + let result = SessionsJSONAdapter().adapt(sessionsArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) @@ -262,23 +262,23 @@ class AdapterTests: XCTestCase { XCTAssertEqual(sessions[0].focuses[0].name, "tvOS") } } - + func testSessionInstancesAdapter() { let json = getJson(from: "sessions") - + guard let instancesArray = json["response"]["sessions"].array else { XCTFail("Couldn't find an array of sessions in sessions.json") fatalError() } - + let result = SessionInstancesJSONAdapter().adapt(instancesArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) case .success(let instances): XCTAssertEqual(instances.count, 316) - + // Lab XCTAssertNotNil(instances[0].session) XCTAssertEqual(instances[0].session?.number, "5080") @@ -297,7 +297,7 @@ class AdapterTests: XCTestCase { XCTAssertEqual(instances[0].keywords.last?.name, "video") XCTAssertEqual(instances[0].startTime, Date(timeIntervalSince1970: 1466020800)) XCTAssertEqual(instances[0].endTime, Date(timeIntervalSince1970: 1466031600)) - + // Session XCTAssertNotNil(instances[2].session) XCTAssertEqual(instances[2].session?.number, "301") @@ -318,29 +318,29 @@ class AdapterTests: XCTestCase { XCTAssertEqual(instances[2].endTime, Date(timeIntervalSince1970: 1465947600)) } } - + func testNewsAndPhotoAdapters() { let json = getJson(from: "news") - + guard let newsArray = json["items"].array else { XCTFail("Couldn't find an array of items in news.json") fatalError() } - + let result = NewsItemsJSONAdapter().adapt(newsArray) - + switch result { case .error(let error): XCTFail(error.localizedDescription) case .success(let items): XCTAssertEqual(items.count, 16) - + // Regular news XCTAssertEqual(items[0].identifier, "991DF480-4435-4930-B0BC-8F75EFB85002") XCTAssertEqual(items[0].title, "Welcome") XCTAssertEqual(items[0].body, "We’re looking forward to an exciting week at the Apple Worldwide Developers Conference. Use this app to stream session videos, browse the conference schedule, mark schedule items as favorites, keep up with the latest conference news, and view maps of Moscone West.") XCTAssertEqual(items[0].date, Date(timeIntervalSince1970: 1464973210)) - + // Photo gallery XCTAssertEqual(items[5].identifier, "047E4F0B-2B8C-499A-BB23-F2CFDB3EB730") XCTAssertEqual(items[5].title, "Scholarship Winners Get the Excitement Started") @@ -351,12 +351,12 @@ class AdapterTests: XCTestCase { XCTAssertEqual(items[5].photos[0].representations[0].remotePath, "6F3D98B4-71A9-4321-9D4E-974346E784FD/256.jpeg") } } - + func testTranscriptsAdapter() { let json = getJson(from: "transcript") - + let result = TranscriptsJSONAdapter().adapt(json) - + switch result { case .error(let error): XCTFail(error.localizedDescription) diff --git a/ConfCoreTests/DatabaseTests.swift b/ConfCoreTests/DatabaseTests.swift index b41113c9..63355754 100644 --- a/ConfCoreTests/DatabaseTests.swift +++ b/ConfCoreTests/DatabaseTests.swift @@ -11,11 +11,11 @@ import RealmSwift @testable import ConfCore class DatabaseTests: XCTestCase { - + private lazy var realmConfiguration: Realm.Configuration? = { guard let desktop = NSSearchPathForDirectoriesInDomains(.desktopDirectory, .userDomainMask, true).first else { return nil } let dirPath = desktop + "/ConfCoreStorage/\(Date().timeIntervalSinceReferenceDate)/" - + if !FileManager.default.fileExists(atPath: dirPath) { do { try FileManager.default.createDirectory(atPath: dirPath, withIntermediateDirectories: true, attributes: nil) @@ -23,51 +23,51 @@ class DatabaseTests: XCTestCase { return nil } } - + return Realm.Configuration(fileURL: URL(fileURLWithPath: dirPath + "tests.realm")) }() - + /// Make sure all objects and relationships are correct func testRealmObjectsAndRelationships() { guard let config = self.realmConfiguration else { XCTFail("Unable to create realm for tests") return } - + let realm = try! Realm(configuration: config) - + try! realm.write { let event = Event() event.identifier = "wwdc2014" event.name = "WWDC 2014" event.startDate = Date.distantPast event.endDate = Date.distantFuture - + let track = Track() track.name = "Developer Tools" track.darkColor = "#43342E" track.lightColor = "#E59053" track.titleColor = "#FFD78D" track.lightBackgroundColor = "#43342E" - + let focus = Focus() focus.name = "macOS" - + let keyword = Keyword() keyword.name = "objective c" - + let room = Room() room.mapName = "Session-NobHill" room.name = "Nob Hill" room.floor = "floor2" - + let session = Session() session.identifier = "wwdc2014-206" session.number = "206" session.summary = "The modern WebKit framework enables developers to integrate web content into their native app experience with more features and fewer lines of code. Dive into the latest WebKit enhancements including modern Objective-C features such as blocks and explicit object types, advanced bridging between JavaScript and Objective-C, increased JavaScript performance via WebKit's super-fast JIT, and more—all delivered in an API unified for both iOS and OS X." session.title = "Introducing the Modern WebKit API" session.focuses.append(focus) - + let asset = SessionAsset() asset.assetType = "WWDCSessionAssetTypeHDVideo" asset.isDownloaded = true @@ -77,7 +77,7 @@ class DatabaseTests: XCTestCase { let asset2 = SessionAsset() asset2.assetType = "WWDCSessionAssetTypeStreamingVideo" asset2.remoteURL = "http://devstreaming.apple.com/videos/wwdc/2014/208xx42tf0hw3vv/208/ref.mov" - + let photoRep = PhotoRepresentation() photoRep.remotePath = "4FF1EAAF-7D24-4F20-A182-0AA1FBB4D8DE/512.jpeg" photoRep.width = 512 @@ -85,45 +85,45 @@ class DatabaseTests: XCTestCase { let photoRep2 = PhotoRepresentation() photoRep2.remotePath = "CC9D2377-90B0-4750-8C97-549DEC08C028/1024.jpeg" photoRep2.width = 1024 - + let instance = SessionInstance() instance.session = session instance.startTime = Date.distantPast instance.endTime = Date.distantFuture - + let newsItem = NewsItem() newsItem.newsType = 0 newsItem.identifier = "74C86280-622B-4BC0-B56A-5C270C5B18D1" newsItem.title = "Check-In" newsItem.body = "Badges will be available to pick up starting Sunday, June 12.\n\nBill Graham Civic Auditorium:\n\nSunday (9 AM – 7 PM)\n\nMonday (7 AM – 7 PM)\n\nMoscone West:\nTuesday – Friday (8 AM – 6 PM)" newsItem.visibility = "user.accessLevel == 6" - + let gallery = NewsItem() gallery.identifier = "E55831D4-8CC0-4073-8D22-6F8F9DDE8DF4" gallery.newsType = 2 gallery.title = "Connecting at the Get Togethers" gallery.body = "13 photos" - + let photo = Photo() photo.identifier = "C720F641-6928-4023-9EA4-B7AF5C3206D0" photo.aspectRatio = 1.5060 - + photo.representations.append(photoRep) photo.representations.append(photoRep2) gallery.photos.append(photo) - + let annotation = TranscriptAnnotation() annotation.timecode = 32.466 annotation.body = "So the first thing, if you'd help me out" - + let transcript = Transcript() transcript.identifier = "2014-206" transcript.annotations.append(annotation) - + let favorite = Favorite() favorite.createdAt = Date() favorite.identifier = UUID().uuidString - + let bookmark = Bookmark() bookmark.identifier = UUID().uuidString bookmark.createdAt = Date() @@ -133,17 +133,17 @@ class DatabaseTests: XCTestCase { bookmark.attributedBody = NSKeyedArchiver.archivedData(withRootObject: str) bookmark.timecode = annotation.timecode bookmark.annotation = annotation - + session.bookmarks.append(bookmark) session.favorites.append(favorite) session.transcript = transcript - + room.instances.append(instance) session.assets.append(asset) session.assets.append(asset2) track.sessions.append(session) event.sessions.append(session) - + realm.add(favorite) realm.add(bookmark) realm.add(annotation) @@ -163,29 +163,29 @@ class DatabaseTests: XCTestCase { realm.add(room) realm.add(session) } - + let event = realm.objects(Event.self).first XCTAssertNotNil(event) XCTAssertEqual(event?.sessions.count, 1) XCTAssertEqual(event?.sessions.first?.event.first?.name, event?.name) - + let track = realm.objects(Track.self).first XCTAssertNotNil(track) XCTAssertEqual(track?.sessions.count, 1) XCTAssertEqual(track?.sessions.first?.track.first?.name, track?.name) - + let focus = realm.objects(Focus.self).first XCTAssertNotNil(focus) XCTAssertEqual(focus?.sessions.count, 1) XCTAssertEqual(focus?.sessions.first?.focuses.first?.name, focus?.name) - + let keyword = realm.objects(Keyword.self).first XCTAssertNotNil(keyword) let room = realm.objects(Room.self).first XCTAssertNotNil(room) XCTAssertEqual(room?.instances.count, 1) XCTAssertEqual(room?.instances.first?.room.first?.name, room?.name) - + let session = realm.objects(Session.self).first XCTAssertNotNil(session) XCTAssertEqual(session?.assets.count, 2) @@ -195,7 +195,7 @@ class DatabaseTests: XCTestCase { XCTAssertEqual(session?.track.count, 1) XCTAssertEqual(session?.event.count, 1) XCTAssertNotNil(session?.transcript) - + let gallery = realm.object(ofType: NewsItem.self, forPrimaryKey: "E55831D4-8CC0-4073-8D22-6F8F9DDE8DF4") XCTAssertNotNil(gallery) diff --git a/ConfCoreTests/NetworkingTests.swift b/ConfCoreTests/NetworkingTests.swift index 83489089..1c26d2d4 100644 --- a/ConfCoreTests/NetworkingTests.swift +++ b/ConfCoreTests/NetworkingTests.swift @@ -10,12 +10,12 @@ import XCTest @testable import ConfCore class NetworkingTests: XCTestCase { - + let client = AppleAPIClient(environment: .test) - + func testNewsItemEndpoint() { let exp = expectation(description: "News items response") - + client.fetchNewsItems { result in switch result { case .error(let error): @@ -23,16 +23,16 @@ class NetworkingTests: XCTestCase { case .success(let items): XCTAssertEqual(items.count, 16) } - + exp.fulfill() } - + waitForExpectations(timeout: 10) } - + func testScheduleEndpoint() { let exp = expectation(description: "Schedule response") - + client.fetchSchedule { result in switch result { case .error(let error): @@ -42,16 +42,16 @@ class NetworkingTests: XCTestCase { XCTAssertEqual(response.rooms.count, 29) XCTAssertEqual(response.instances.count, 316) } - + exp.fulfill() } - + waitForExpectations(timeout: 10) } - + func testSessionsAndAssetsEndpoint() { let exp = expectation(description: "Sessions and assets response") - + client.fetchSessions { result in switch result { case .error(let error): @@ -61,16 +61,16 @@ class NetworkingTests: XCTestCase { XCTAssertEqual(response.sessions.count, 550) XCTAssertEqual(response.assets.count, 2947) } - + exp.fulfill() } - + waitForExpectations(timeout: 10) } - + func testLiveVideosEndpoint() { let exp = expectation(description: "Live videos response") - + client.fetchLiveVideoAssets { result in switch result { case .error(let error): @@ -78,11 +78,11 @@ class NetworkingTests: XCTestCase { case .success(let assets): XCTAssertEqual(assets.count, 111) } - + exp.fulfill() } - + waitForExpectations(timeout: 10) } - + } diff --git a/PlayerUI/Controllers/PUIAnnotationWindowController.swift b/PlayerUI/Controllers/PUIAnnotationWindowController.swift index 195a8b3c..2b6a876e 100644 --- a/PlayerUI/Controllers/PUIAnnotationWindowController.swift +++ b/PlayerUI/Controllers/PUIAnnotationWindowController.swift @@ -14,54 +14,54 @@ class PUIAnnotationWindowController: NSWindowController { static let defaultRect: NSRect = NSRect(x: 0, y: 0, width: 280, height: 56) static let cornerRadius: CGFloat = 4.0 } - + init() { let window = PUIAnnotationWindow(contentRect: Metrics.defaultRect, styleMask: [.borderless], backing: .buffered, defer: false) - + super.init(window: window) - + window.contentView = vfxView } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private lazy var vfxView: NSVisualEffectView = { let v = NSVisualEffectView(frame: .zero) - + v.state = .active v.blendingMode = .behindWindow v.material = .dark v.appearance = NSAppearance(named: NSAppearanceNameVibrantDark) v.maskImage = self.maskImage(with: Metrics.cornerRadius) - + return v }() - + private func maskImage(with cornerRadius: CGFloat) -> NSImage { let edgeLength = 2.0 * cornerRadius + 1.0 let size = NSSize(width: edgeLength, height: edgeLength) - + let image = NSImage(size: size, flipped: false) { rect in NSColor.black.set() - + let bezierPath = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius) - + bezierPath.fill() - + return true } - + image.capInsets = EdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius) image.resizingMode = .stretch - + return image } - + } diff --git a/PlayerUI/Controllers/PUIExternalPlaybackStatusViewController.swift b/PlayerUI/Controllers/PUIExternalPlaybackStatusViewController.swift index d8b97b9c..405fc23e 100644 --- a/PlayerUI/Controllers/PUIExternalPlaybackStatusViewController.swift +++ b/PlayerUI/Controllers/PUIExternalPlaybackStatusViewController.swift @@ -30,82 +30,82 @@ class PUIExternalPlaybackStatusViewController: NSViewController { self.descriptionLabel.stringValue = providerDescription } } - + init() { super.init(nibName: nil, bundle: nil)! } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private lazy var iconImageView: NSImageView = { let v = NSImageView() - + v.widthAnchor.constraint(equalToConstant: 74).isActive = true v.heightAnchor.constraint(equalToConstant: 74).isActive = true - + return v }() - + private lazy var titleLabel: NSTextField = { let f = NSTextField(labelWithString: "") - - f.font = NSFont.systemFont(ofSize: 20, weight: NSFontWeightMedium) + + f.font = .systemFont(ofSize: 20, weight: NSFontWeightMedium) f.textColor = .externalPlaybackText f.alignment = .center - + return f }() - + private lazy var descriptionLabel: NSTextField = { let f = NSTextField(labelWithString: "") - - f.font = NSFont.systemFont(ofSize: 16) + + f.font = .systemFont(ofSize: 16) f.textColor = .timeLabel f.alignment = .center - + return f }() - + private lazy var stackView: NSStackView = { let v = NSStackView(views: [self.iconImageView, self.titleLabel, self.descriptionLabel]) - + v.translatesAutoresizingMaskIntoConstraints = false v.orientation = .vertical v.spacing = 6 - + return v }() - + private lazy var snapshotLayer: PUIBoringLayer = { let l = PUIBoringLayer() - + l.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] l.backgroundColor = NSColor.black.cgColor - + return l }() - + override func loadView() { view = NSView() view.wantsLayer = true view.layer = PUIBoringLayer() view.layer?.masksToBounds = true view.layer?.backgroundColor = NSColor.black.cgColor - + view.layerUsesCoreImageFilters = true - + snapshotLayer.frame = view.bounds view.layer?.addSublayer(snapshotLayer) - + if let blur = CIFilter(name: "CIGaussianBlur"), let sat = CIFilter(name: "CIColorControls") { sat.setDefaults() sat.setValue(2, forKey: "inputSaturation") blur.setValue(100, forKey: "inputRadius") snapshotLayer.filters = [sat, blur] } - + view.addSubview(stackView) stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true diff --git a/PlayerUI/Controllers/PUIPictureContainerViewController.swift b/PlayerUI/Controllers/PUIPictureContainerViewController.swift index bd90fb0b..d37d0ceb 100644 --- a/PlayerUI/Controllers/PUIPictureContainerViewController.swift +++ b/PlayerUI/Controllers/PUIPictureContainerViewController.swift @@ -10,56 +10,57 @@ import Cocoa import AVFoundation protocol PUIPictureContainerViewControllerDelegate: class { - + func pictureContainerViewSuperviewDidChange(to superview: NSView?) - + } final class PUIPictureContainerViewController: NSViewController { - + weak var delegate: PUIPictureContainerViewControllerDelegate? - + let playerLayer: AVPlayerLayer - + init(playerLayer: AVPlayerLayer) { self.playerLayer = playerLayer - + super.init(nibName: nil, bundle: nil)! } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func loadView() { view = NSView() view.wantsLayer = true view.layer = PUIBoringLayer() view.layer?.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] view.layer?.backgroundColor = NSColor.black.cgColor - + playerLayer.frame = view.bounds playerLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] - + view.layer?.addSublayer(playerLayer) - + view.addObserver(self, forKeyPath: #keyPath(NSView.superview), options: [.new], context: nil) } - + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - if keyPath == #keyPath(NSView.superview) { + guard let path = keyPath else { return } + switch path { + case #keyPath(NSView.superview): viewDidMoveToSuperview() - } else { + default: super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) } } - + func viewDidMoveToSuperview() { delegate?.pictureContainerViewSuperviewDidChange(to: view.superview) } - + deinit { view.removeObserver(self, forKeyPath: #keyPath(NSView.superview)) } - } diff --git a/PlayerUI/Definitions/Colors.swift b/PlayerUI/Definitions/Colors.swift index fbf15b5a..53f4c0fc 100644 --- a/PlayerUI/Definitions/Colors.swift +++ b/PlayerUI/Definitions/Colors.swift @@ -9,37 +9,37 @@ import Cocoa extension NSColor { - + static var timeLabel: NSColor { return NSColor(calibratedRed:0.90, green:0.90, blue:0.90, alpha:1.00) } - + static var playerBorder: NSColor { return NSColor(calibratedRed:0.40, green:0.40, blue:0.40, alpha:1.00) } - + static var highlightedPlayerBorder: NSColor { return NSColor(calibratedRed:0.56, green:0.55, blue:0.55, alpha:1.00) } - + static var playerBuffer: NSColor { return NSColor(calibratedRed:0.54, green:0.55, blue:0.57, alpha:1.00) } - + static var playerProgress: NSColor { return NSColor(calibratedRed:0.90, green:0.90, blue:0.90, alpha:1.00) } - + static var playerHighlight: NSColor { return NSColor(calibratedRed:0.00, green:0.80, blue:0.92, alpha:1.00) } - + static var buttonColor: NSColor { return NSColor(calibratedRed:0.75, green:0.75, blue:0.75, alpha:1.00) } - + static var externalPlaybackText: NSColor { return #colorLiteral(red: 0.8980392156862745, green: 0.8980392156862745, blue: 0.8980392156862745, alpha: 1) } - + } diff --git a/PlayerUI/Definitions/Images.swift b/PlayerUI/Definitions/Images.swift index 930eb88c..aae323b6 100644 --- a/PlayerUI/Definitions/Images.swift +++ b/PlayerUI/Definitions/Images.swift @@ -9,79 +9,79 @@ import Cocoa extension NSImage { - + private static var playerBundle: Bundle { return Bundle(for: PUIButton.self) } - + static var PUIPlay: NSImage { return playerBundle.image(forResource: "play")! } - + static var PUIPause: NSImage { return playerBundle.image(forResource: "pause")! } - + static var PUIAirplay: NSImage { return playerBundle.image(forResource: "airplay")! } - + static var PUIBack15s: NSImage { return playerBundle.image(forResource: "back15s")! } - + static var PUIBack30s: NSImage { return playerBundle.image(forResource: "back30s")! } - + static var PUIBookmark: NSImage { return playerBundle.image(forResource: "bookmark")! } - + static var PUIForward15s: NSImage { return playerBundle.image(forResource: "forward15s")! } - + static var PUIForward30s: NSImage { return playerBundle.image(forResource: "forward30s")! } - + static var PUIFullScreen: NSImage { return playerBundle.image(forResource: "fullscreen")! } - + static var PUINextBookmark: NSImage { return playerBundle.image(forResource: "nextbookmark")! } - + static var PUIPreviousBookmark: NSImage { return playerBundle.image(forResource: "prevbookmark")! } - + static var PUIPictureInPicture: NSImage { return playerBundle.image(forResource: "pip")! } - + static var PUIPictureInPictureLarge: NSImage { return playerBundle.image(forResource: "pip-big")! } - + static var PUISubtitles: NSImage { return playerBundle.image(forResource: "subtitles")! } - + static var PUIVolume: NSImage { return playerBundle.image(forResource: "volume")! } - + static var PUIVolumeMuted: NSImage { return playerBundle.image(forResource: "nosound")! } - + static var PUISpeedOne: NSImage { return playerBundle.image(forResource: "speed-1")! } - + static var PUISpeedHalf: NSImage { return playerBundle.image(forResource: "speed-h")! } @@ -89,13 +89,13 @@ extension NSImage { static var PUISpeedOneAndFourth: NSImage { return playerBundle.image(forResource: "speed-125")! } - + static var PUISpeedOneAndHalf: NSImage { return playerBundle.image(forResource: "speed-15")! } - + static var PUISpeedTwo: NSImage { return playerBundle.image(forResource: "speed-2")! } - + } diff --git a/PlayerUI/Definitions/Speeds.swift b/PlayerUI/Definitions/Speeds.swift index 247a3d87..fd6ff8a8 100755 --- a/PlayerUI/Definitions/Speeds.swift +++ b/PlayerUI/Definitions/Speeds.swift @@ -14,11 +14,11 @@ public enum PUIPlaybackSpeed: Float { case midFast = 1.25 case fast = 1.5 case fastest = 2 - + public static var all: [PUIPlaybackSpeed] { return [.slow, .normal, .midFast, .fast, .fastest] } - + var icon: NSImage { switch self { case .slow: @@ -38,9 +38,9 @@ public enum PUIPlaybackSpeed: Float { guard let idx = PUIPlaybackSpeed.all.index(of: self) else { fatalError("Tried to get next speed from nonsensical playback speed \(self). Probably missing in collection.") } - + let nextIdx = idx + 1 < PUIPlaybackSpeed.all.count ? idx + 1 : 0 - + return PUIPlaybackSpeed.all[nextIdx] } } diff --git a/PlayerUI/Protocols/PUIExternalPlaybackConsumer.swift b/PlayerUI/Protocols/PUIExternalPlaybackConsumer.swift index 736fc534..528b72f3 100644 --- a/PlayerUI/Protocols/PUIExternalPlaybackConsumer.swift +++ b/PlayerUI/Protocols/PUIExternalPlaybackConsumer.swift @@ -10,47 +10,47 @@ import Foundation import AVFoundation public protocol PUIExternalPlaybackConsumer: class { - + /// Tells the consumer that this provider's availability status has changed /// /// - Parameter provider: The provider that called the method func externalPlaybackProviderDidChangeAvailabilityStatus(_ provider: PUIExternalPlaybackProvider) - + /// Tells the consumer that the provider's media status has changed /// /// - Parameter provider: The provider that called the method func externalPlaybackProviderDidChangeMediaStatus(_ provider: PUIExternalPlaybackProvider) - + /// Tells the consumer that this provider's device selection menu has changed /// /// - Parameters: /// - provider: The provider that called the method /// - menu: The updated menu to be showed when the provider's icon is clicked func externalPlaybackProvider(_ provider: PUIExternalPlaybackProvider, deviceSelectionMenuDidChangeWith menu: NSMenu) - + /// Tells the consumer that the media is now playing on one of the devices offered by the provider /// /// - Parameter provider: The provider that called the method func externalPlaybackProviderDidBecomeCurrent(_ provider: PUIExternalPlaybackProvider) - + /// Tells the consumer that the current playback session for the provider is no longer valid /// /// - Parameter provider: The provider that called the method func externalPlaybackProviderDidInvalidatePlaybackSession(_ provider: PUIExternalPlaybackProvider) - + /// The media for the remote URL to be played by the provider var remoteMediaUrl: URL? { get } - + /// The URL for a poster image representing the current media var mediaPosterUrl: URL? { get } - + /// The title for the program being played var mediaTitle: String? { get } - + /// Whether the current media is a live stream var mediaIsLiveStream: Bool { get } - + /// The `AVPlayer` instance the consumer is using to play its media var player: AVPlayer? { get } - + } diff --git a/PlayerUI/Protocols/PUIExternalPlaybackProvider.swift b/PlayerUI/Protocols/PUIExternalPlaybackProvider.swift index 708ad8a7..53ab1724 100644 --- a/PlayerUI/Protocols/PUIExternalPlaybackProvider.swift +++ b/PlayerUI/Protocols/PUIExternalPlaybackProvider.swift @@ -9,61 +9,61 @@ import Cocoa public struct PUIExternalPlaybackMediaStatus { - + /// The rate at which the media is playing (1 = playing. 0 = paused) public var rate: Float - + /// The current timestamp the media is playing at (in seconds) public var currentTime: Double - + /// The volume on the external device (between 0 and 1) public var volume: Float - + public init(rate: Float = 0, volume: Float = 1, currentTime: Double = 0) { self.rate = rate self.volume = volume self.currentTime = currentTime } - + } public protocol PUIExternalPlaybackProvider: class { - + /// Initializes the external playback provider to start playing the media at the specified URL /// /// - Parameter consumer: The consumer that's going to be using this provider init(consumer: PUIExternalPlaybackConsumer) - + /// Whether this provider only works with a remote URL or can be used with only the `AVPlayer` instance var requiresRemoteMediaUrl: Bool { get } - + /// The name of the external playback system (ex: "AirPlay") static var name: String { get } - + /// An image to be used as the icon in the UI var icon: NSImage { get } - + /// A larger image to be used when the provider is current var image: NSImage { get } - + /// The current media status var status: PUIExternalPlaybackMediaStatus { get } - + /// Extra information to be displayed on-screen when this playback provider is current var info: String { get } - + /// Return whether this playback system is available var isAvailable: Bool { get } - + /// Tells the external playback provider to play func play() - + /// Tells the external playback provider to pause func pause() - + /// Tells the external playback provider to seek to the specified time (in seconds) func seek(to timestamp: Double) - + /// Tells the external playback provider to change the volume on the device /// /// - Parameter volume: The volume (value between 0 and 1) diff --git a/PlayerUI/Protocols/PUIPlayerViewDelegates.swift b/PlayerUI/Protocols/PUIPlayerViewDelegates.swift index cf566fd9..8ea5580b 100644 --- a/PlayerUI/Protocols/PUIPlayerViewDelegates.swift +++ b/PlayerUI/Protocols/PUIPlayerViewDelegates.swift @@ -9,16 +9,16 @@ import Cocoa public protocol PUIPlayerViewDelegate: class { - + func playerViewWillEnterPictureInPictureMode(_ playerView: PUIPlayerView) func playerViewWillExitPictureInPictureMode(_ playerView: PUIPlayerView, isReturningFromPiP: Bool) func playerViewDidSelectAddAnnotation(_ playerView: PUIPlayerView, at timestamp: Double) func playerViewDidSelectToggleFullScreen(_ playerView: PUIPlayerView) - + } public protocol PUIPlayerViewAppearanceDelegate: class { - + func playerViewShouldShowTimelineView(_ playerView: PUIPlayerView) -> Bool func playerViewShouldShowSubtitlesControl(_ playerView: PUIPlayerView) -> Bool func playerViewShouldShowPictureInPictureControl(_ playerView: PUIPlayerView) -> Bool @@ -29,5 +29,5 @@ public protocol PUIPlayerViewAppearanceDelegate: class { func playerViewShouldShowExternalPlaybackControls(_ playerView: PUIPlayerView) -> Bool func playerViewShouldShowFullScreenButton(_ playerView: PUIPlayerView) -> Bool func PlayerViewShouldShowBackAndForward30SecondsButtons(_ playerView: PUIPlayerView) -> Bool - + } diff --git a/PlayerUI/Protocols/PUITimelineDelegate.swift b/PlayerUI/Protocols/PUITimelineDelegate.swift index 30eec97d..bde868ff 100644 --- a/PlayerUI/Protocols/PUITimelineDelegate.swift +++ b/PlayerUI/Protocols/PUITimelineDelegate.swift @@ -9,15 +9,15 @@ import Cocoa public protocol PUITimelineDelegate: class { - + func viewControllerForTimelineAnnotation(_ annotation: PUITimelineAnnotation) -> NSViewController? func timelineDidHighlightAnnotation(_ annotation: PUITimelineAnnotation?) func timelineDidSelectAnnotation(_ annotation: PUITimelineAnnotation?) - + func timelineCanDeleteAnnotation(_ annotation: PUITimelineAnnotation) -> Bool func timelineCanMoveAnnotation(_ annotation: PUITimelineAnnotation) -> Bool - + func timelineDidMoveAnnotation(_ annotation: PUITimelineAnnotation, to timestamp: Double) func timelineDidDeleteAnnotation(_ annotation: PUITimelineAnnotation) - + } diff --git a/PlayerUI/Util/AVPlayer+Validation.swift b/PlayerUI/Util/AVPlayer+Validation.swift index 883b47e8..cd055727 100644 --- a/PlayerUI/Util/AVPlayer+Validation.swift +++ b/PlayerUI/Util/AVPlayer+Validation.swift @@ -10,19 +10,19 @@ import Foundation import AVFoundation extension AVPlayer { - + static func validateMediaDurationWithSeconds(_ duration: Double) -> Bool { return !duration.isNaN && !duration.isInfinite && !duration.isZero } - + static func validateMediaDuration(_ duration: CMTime) -> Bool { return validateMediaDurationWithSeconds(Double(CMTimeGetSeconds(duration))) } - + var hasValidMediaDuration: Bool { guard let item = currentItem else { return false } - + return AVPlayer.validateMediaDuration(item.duration) } - + } diff --git a/PlayerUI/Util/NSEvent+ForceTouch.swift b/PlayerUI/Util/NSEvent+ForceTouch.swift index 501287e7..99df0f7e 100644 --- a/PlayerUI/Util/NSEvent+ForceTouch.swift +++ b/PlayerUI/Util/NSEvent+ForceTouch.swift @@ -17,11 +17,11 @@ enum PUITouchForce: Int { } extension NSEvent { - + var touchForce: PUITouchForce { return PUITouchForce(rawValue: synthesizedStage)! } - + private var synthesizedStage: Int { switch type { case .tabletPoint: @@ -36,5 +36,5 @@ extension NSEvent { return 1 } } - + } diff --git a/PlayerUI/Util/NSWindow+Snapshot.swift b/PlayerUI/Util/NSWindow+Snapshot.swift index 68a054fb..62619251 100644 --- a/PlayerUI/Util/NSWindow+Snapshot.swift +++ b/PlayerUI/Util/NSWindow+Snapshot.swift @@ -9,31 +9,31 @@ import Cocoa extension NSWindow { - + var snapshot: NSImage? { guard windowNumber != -1 else { return nil } - + guard let cgImage = CGWindowListCreateImage(.null, .optionIncludingWindow, CGWindowID(windowNumber), .boundsIgnoreFraming) else { return nil } - + let image = NSImage(cgImage: cgImage, size: frame.size) - + image.cacheMode = .never - + return image } - + func snapshot(in rect: NSRect) -> NSImage? { guard let fullImage = self.snapshot else { return nil } - + let croppedImage = NSImage(size: rect.size) croppedImage.cacheMode = .never - + croppedImage.lockFocus() fullImage.draw(in: NSRect(x: 0, y: 0, width: rect.size.width, height: rect.size.height), from: rect, operation: .sourceOver, fraction: 1) croppedImage.unlockFocus() - + return croppedImage } } diff --git a/PlayerUI/Util/String+CMTime.swift b/PlayerUI/Util/String+CMTime.swift index ba83a223..29826a36 100644 --- a/PlayerUI/Util/String+CMTime.swift +++ b/PlayerUI/Util/String+CMTime.swift @@ -10,34 +10,34 @@ import Foundation import AVFoundation extension String { - + public init?(timestamp: Double) { let prefix = timestamp < 0 ? "-" : "" - + let date = Date(timeInterval: TimeInterval(timestamp), since: Date()) let components = Calendar.current.dateComponents([.hour, .minute, .second], from: Date(), to: date) - + guard let hour = components.hour, let minute = components.minute, let second = components.second else { return nil } - + let hourString = String(format: "%02d", abs(hour)) let minuteString = String(format: "%02d", abs(minute)) let secondString = String(format: "%02d", abs(second)) - + var bits = [minuteString, secondString] - + if hour > 0 { bits.insert(hourString, at: 0) } - + self.init(prefix + bits.joined(separator: ":")) } - + public init?(time: CMTime) { let secondCount = time.value / Int64(time.timescale) - + self.init(timestamp: Double(secondCount)) } - + } diff --git a/PlayerUI/Views/PUIAnnotationLayer.swift b/PlayerUI/Views/PUIAnnotationLayer.swift index fa904c48..5b34a215 100644 --- a/PlayerUI/Views/PUIAnnotationLayer.swift +++ b/PlayerUI/Views/PUIAnnotationLayer.swift @@ -12,48 +12,48 @@ class PUIAnnotationLayer: PUIBoringLayer { private var attachmentSpacing: CGFloat = 0 private var attachmentAttribute: NSLayoutAttribute = .notAnAttribute - + private(set) var attachedLayer: PUIBoringTextLayer = PUIBoringTextLayer() - + func attach(layer: PUIBoringTextLayer, attribute: NSLayoutAttribute, spacing: CGFloat) { guard attribute == .top else { fatalError("Only .top is implemented for now") } - + attachmentSpacing = spacing attachmentAttribute = attribute - + addSublayer(layer) - + attachedLayer = layer - + layoutAttached(layer: layer) } - + private func layoutAttached(layer: PUIBoringTextLayer) { layer.layoutIfNeeded() - + var f = layer.frame - + if let textLayerContents = layer.string as? NSAttributedString { let s = textLayerContents.size() f.size.width = ceil(s.width) f.size.height = ceil(s.height) } - + let y: CGFloat = -f.height - attachmentSpacing let x: CGFloat = -f.width / 2 + self.bounds.width / 2 - + f.origin.x = x f.origin.y = y - + layer.frame = f } - + override func layoutSublayers() { super.layoutSublayers() - + layoutAttached(layer: attachedLayer) } - + } diff --git a/PlayerUI/Views/PUIAnnotationWindow.swift b/PlayerUI/Views/PUIAnnotationWindow.swift index 81e263dc..394b7f29 100644 --- a/PlayerUI/Views/PUIAnnotationWindow.swift +++ b/PlayerUI/Views/PUIAnnotationWindow.swift @@ -13,5 +13,5 @@ class PUIAnnotationWindow: NSWindow { override var canBecomeKey: Bool { return true } - + } diff --git a/PlayerUI/Views/PUIBoringLayer.swift b/PlayerUI/Views/PUIBoringLayer.swift index 473c704e..902d4b44 100644 --- a/PlayerUI/Views/PUIBoringLayer.swift +++ b/PlayerUI/Views/PUIBoringLayer.swift @@ -11,9 +11,9 @@ import QuartzCore import AVFoundation class PUIBoringLayer: CALayer { - + private var shouldAnimate: Bool = false - + override func action(forKey event: String) -> CAAction? { if shouldAnimate { return super.action(forKey: event) @@ -21,19 +21,19 @@ class PUIBoringLayer: CALayer { return nil } } - + func animate(with block: () -> Void) { shouldAnimate = true block() shouldAnimate = false } - + } final class PUIBoringPlayerLayer: AVPlayerLayer { - + private var shouldAnimate: Bool = false - + override func action(forKey event: String) -> CAAction? { if shouldAnimate { return super.action(forKey: event) @@ -41,20 +41,20 @@ final class PUIBoringPlayerLayer: AVPlayerLayer { return nil } } - + func animate(with block: () -> Void) { shouldAnimate = true block() shouldAnimate = false } - + } final class PUIBoringTextLayer: CATextLayer { - + private var shouldAnimate: Bool = false - + override func action(forKey event: String) -> CAAction? { if shouldAnimate { return super.action(forKey: event) @@ -62,19 +62,19 @@ final class PUIBoringTextLayer: CATextLayer { return nil } } - + func animate(with block: () -> Void) { shouldAnimate = true block() shouldAnimate = false } - + } class PUIBoringGradientLayer: CAGradientLayer { - + private var shouldAnimate: Bool = false - + override func action(forKey event: String) -> CAAction? { if shouldAnimate { return super.action(forKey: event) @@ -82,11 +82,11 @@ class PUIBoringGradientLayer: CAGradientLayer { return nil } } - + func animate(with block: () -> Void) { shouldAnimate = true block() shouldAnimate = false } - + } diff --git a/PlayerUI/Views/PUIBufferLayer.swift b/PlayerUI/Views/PUIBufferLayer.swift index 25295297..25d9f204 100644 --- a/PlayerUI/Views/PUIBufferLayer.swift +++ b/PlayerUI/Views/PUIBufferLayer.swift @@ -11,51 +11,51 @@ import Cocoa struct PUIBufferSegment: Hashable { let start: Double let duration: Double - + var hashValue: Int { return "\(start)->\(duration)".hashValue } - + static func ==(lhs: PUIBufferSegment, rhs: PUIBufferSegment) -> Bool { return lhs.start == rhs.start && lhs.duration == rhs.duration } } final class PUIBufferLayer: CALayer { - + var segments = Set() { didSet { needsDisplayOnBoundsChange = true - + setNeedsDisplay() } } - + override func draw(in ctx: CGContext) { ctx.setFillColor(NSColor.playerBuffer.cgColor) - + let cr = PUITimelineView.Metrics.cornerRadius - + segments.forEach { segment in let rect = self.rect(for: segment) - + guard rect.width > 0 && rect.height > 0 else { return } guard rect.width >= cr * 2 else { return } - + let path = CGPath(roundedRect: rect, cornerWidth: cr, cornerHeight: cr, transform: nil) ctx.addPath(path) - + ctx.fillPath() } } - + private func rect(for segment: PUIBufferSegment) -> CGRect { let cr = PUITimelineView.Metrics.cornerRadius - + let x: CGFloat = round(bounds.width * CGFloat(segment.start) - cr) let w: CGFloat = round(bounds.width * CGFloat(segment.duration) + cr) - + return CGRect(x: x, y: 0, width: w, height: bounds.height) } - + } diff --git a/PlayerUI/Views/PUIButton.swift b/PlayerUI/Views/PUIButton.swift index d8e7ac0a..9bda8adf 100755 --- a/PlayerUI/Views/PUIButton.swift +++ b/PlayerUI/Views/PUIButton.swift @@ -9,71 +9,71 @@ import Cocoa public final class PUIButton: NSControl { - + public var isToggle = false - + public var activeTintColor: NSColor = .playerHighlight { didSet { setNeedsDisplay() } } - + public var tintColor: NSColor = .buttonColor { didSet { setNeedsDisplay() } } - + public var state: Int = NSOffState { didSet { setNeedsDisplay() } } - + public var showsMenuOnLeftClick = false public var showsMenuOnRightClick = false public var sendsActionOnMouseDown = false - + public var image: NSImage? { didSet { guard let image = image else { return } - + if image.isTemplate { self.maskImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) } else { self.maskImage = nil } - + invalidateIntrinsicContentSize() } } - + public var alternateImage: NSImage? { didSet { guard let alternateImage = alternateImage else { return } - + if alternateImage.isTemplate { self.alternateMaskImage = alternateImage.cgImage(forProposedRect: nil, context: nil, hints: nil) } else { self.alternateMaskImage = nil } - + invalidateIntrinsicContentSize() } } - + private var maskImage: CGImage? { didSet { setNeedsDisplay() } } - + private var alternateMaskImage: CGImage? { didSet { setNeedsDisplay() } } - + public override func draw(_ dirtyRect: NSRect) { if let maskImage = maskImage { if let alternateMaskImage = alternateMaskImage, state == NSOnState { @@ -89,12 +89,12 @@ public final class PUIButton: NSControl { } } } - + private func drawMask(_ maskImage: CGImage) { guard let ctx = NSGraphicsContext.current()?.cgContext else { return } - + ctx.clip(to: bounds, mask: maskImage) - + if shouldDrawHighlighted || state == NSOnState || shouldAlwaysDrawHighlighted { ctx.setFillColor(activeTintColor.cgColor) } else if !isEnabled { @@ -103,16 +103,14 @@ public final class PUIButton: NSControl { } else { ctx.setFillColor(tintColor.cgColor) } - + ctx.fill(bounds) } - + private func drawImage(_ image: NSImage?) { - guard let image = image else { return } - - image.draw(in: bounds) + image?.draw(in: bounds) } - + public override var intrinsicContentSize: NSSize { if let image = image { return image.size @@ -120,35 +118,35 @@ public final class PUIButton: NSControl { return NSSize(width: -1, height: -1) } } - + private var shouldDrawHighlighted: Bool = false { didSet { setNeedsDisplay() } } - + public var shouldAlwaysDrawHighlighted: Bool = false { didSet { setNeedsDisplay() } } - + public override var isEnabled: Bool { didSet { setNeedsDisplay() } } - + public override func mouseDown(with event: NSEvent) { guard isEnabled else { return } - + guard !showsMenuOnLeftClick else { showMenu(with: event) return } - + shouldDrawHighlighted = true - + if !sendsActionOnMouseDown { window?.trackEvents(matching: [.leftMouseUp, .leftMouseDragged], timeout: NSEventDurationForever, mode: .eventTrackingRunLoopMode) { e, stop in if e.type == .leftMouseUp { @@ -157,7 +155,7 @@ public final class PUIButton: NSControl { } } } - + if let action = action, let target = target { if isToggle { self.state = (self.state == NSOnState) ? NSOffState : NSOnState @@ -165,20 +163,20 @@ public final class PUIButton: NSControl { NSApp.sendAction(action, to: target, from: self) } } - + public override func rightMouseDown(with event: NSEvent) { guard showsMenuOnRightClick else { - return + return } showMenu(with: event) } private func showMenu(with event: NSEvent) { guard let menu = self.menu else { return } - + menu.popUp(positioning: nil, at: .zero, in: self) } - + public override var effectiveAppearance: NSAppearance { return NSAppearance(named: NSAppearanceNameVibrantDark)! } diff --git a/PlayerUI/Views/PUIPlayerView.swift b/PlayerUI/Views/PUIPlayerView.swift index 10d2be18..37b0325c 100755 --- a/PlayerUI/Views/PUIPlayerView.swift +++ b/PlayerUI/Views/PUIPlayerView.swift @@ -10,17 +10,17 @@ import Cocoa import AVFoundation public final class PUIPlayerView: NSView { - + // MARK: - Public API - + public weak var delegate: PUIPlayerViewDelegate? - + public internal(set) var isInPictureInPictureMode: Bool = false { didSet { guard isInPictureInPictureMode != oldValue else { return } - + self.pipButton.state = isInPictureInPictureMode ? NSOnState : NSOffState - + if isInPictureInPictureMode { self.externalStatusController.providerIcon = .PUIPictureInPictureLarge self.externalStatusController.providerName = "Picture in Picture" @@ -31,75 +31,75 @@ public final class PUIPlayerView: NSView { } } } - + public weak var appearanceDelegate: PUIPlayerViewAppearanceDelegate? { didSet { invalidateAppearance() } } - + public var timelineDelegate: PUITimelineDelegate? { get { return timelineView.delegate } - set { + set { timelineView.delegate = newValue } } - + public var annotations: [PUITimelineAnnotation] { get { return sortedAnnotations } set { self.sortedAnnotations = newValue.filter({ $0.isValid }).sorted(by: { $0.timestamp < $1.timestamp }) - + timelineView.annotations = sortedAnnotations } } - + public weak var player: AVPlayer? { didSet { guard oldValue != player else { return } - + if let oldPlayer = oldValue { teardown(player: oldPlayer) } - + guard player != nil else { return } - + setupPlayer() } } - + public var isInFullScreenPlayerWindow: Bool { return window is PUIPlayerWindow } - + public var remoteMediaUrl: URL? public var mediaPosterUrl: URL? public var mediaTitle: String? public var mediaIsLiveStream: Bool = false - + var pictureContainer: PUIPictureContainerViewController! - + public init(player: AVPlayer) { self.player = player - + super.init(frame: .zero) - + self.wantsLayer = true self.layer = PUIBoringLayer() self.layer?.backgroundColor = NSColor.black.cgColor - + setupPlayer() setupControls() } - + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + public var isPlaying: Bool { if let externalProvider = currentExternalPlaybackProvider { return !externalProvider.status.rate.isZero @@ -107,55 +107,55 @@ public final class PUIPlayerView: NSView { return isInternalPlayerPlaying } } - + public var isInternalPlayerPlaying: Bool { guard let player = player else { return false } - + return !player.rate.isZero } - + public var currentTimestamp: Double { guard let player = player else { return 0 } - + if let externalProvider = currentExternalPlaybackProvider { return Double(externalProvider.status.currentTime) } else { return Double(CMTimeGetSeconds(player.currentTime())) } } - + public var firstAnnotationBeforeCurrentTime: PUITimelineAnnotation? { return annotations.filter({ annotation in guard annotation.isValid else { return false } - + return annotation.timestamp + 1 < currentTimestamp }).last } - + public var firstAnnotationAfterCurrentTime: PUITimelineAnnotation? { return annotations.filter({ annotation in guard annotation.isValid else { return false } - + return annotation.timestamp > currentTimestamp + 1 }).first } - + public func seek(to annotation: PUITimelineAnnotation) { guard let player = player else { return } - + let time = CMTimeMakeWithSeconds(Float64(annotation.timestamp), 9000) - + if isPlayingExternally { currentExternalPlaybackProvider?.seek(to: annotation.timestamp) } else { player.seek(to: time) } } - + public var playbackSpeed: PUIPlaybackSpeed = .normal { didSet { guard let player = player else { return } - + if isPlaying && !isPlayingExternally { player.rate = playbackSpeed.rawValue player.seek(to: player.currentTime()) // Helps the AV sync when speeds change with the TimeDomain algorithm enabled @@ -165,77 +165,77 @@ public final class PUIPlayerView: NSView { updateSelectedMenuItem(forPlaybackSpeed: playbackSpeed) } } - + public var isPlayingExternally: Bool { return currentExternalPlaybackProvider != nil } - + public var hideAllControls: Bool = false { didSet { controlsContainerView.isHidden = hideAllControls extrasMenuContainerView.isHidden = hideAllControls } } - + // MARK: External playback - + fileprivate(set) var externalPlaybackProviders: [PUIExternalPlaybackProviderRegistration] = [] { didSet { updateExternalPlaybackMenus() } } - + public func registerExternalPlaybackProvider(_ provider: PUIExternalPlaybackProvider.Type) { // prevent registering the same provider multiple times guard !externalPlaybackProviders.contains(where: { type(of: $0.provider).name == provider.name }) else { NSLog("PUIPlayerView WARNING: tried to register provider \(provider.name) which was already registered") return } - + let instance = provider.init(consumer: self) let button = self.button(for: instance) let registration = PUIExternalPlaybackProviderRegistration(provider: instance, button: button, menu: NSMenu()) - + externalPlaybackProviders.append(registration) } - + public func invalidateAppearance() { configureWithAppearanceFromDelegate() } - + // MARK: - Private API - + fileprivate var lastKnownWindow: NSWindow? = nil - + private var sortedAnnotations: [PUITimelineAnnotation] = [] { didSet { updateAnnotationsState() } } - + private var playerTimeObserver: Any? - + fileprivate var asset: AVAsset? { return player?.currentItem?.asset } - + private var playerLayer = PUIBoringPlayerLayer() - + private func setupPlayer() { guard let player = player else { return } - + playerLayer.player = player playerLayer.videoGravity = AVLayerVideoGravityResizeAspect - + if pictureContainer == nil { pictureContainer = PUIPictureContainerViewController(playerLayer: playerLayer) pictureContainer.delegate = self pictureContainer.view.frame = bounds pictureContainer.view.autoresizingMask = [.viewWidthSizable, .viewHeightSizable] - + addSubview(pictureContainer.view) } - + player.addObserver(self, forKeyPath: #keyPath(AVPlayer.status), options: [.initial, .new], context: nil) player.addObserver(self, forKeyPath: #keyPath(AVPlayer.volume), options: [.initial, .new], context: nil) player.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: [.initial, .new], context: nil) @@ -243,23 +243,23 @@ public final class PUIPlayerView: NSView { player.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.loadedTimeRanges), options: [.initial, .new], context: nil) player.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.duration), options: [.initial, .new], context: nil) player.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.currentMediaSelection), options: [.initial, .new], context: nil) - + asset?.loadValuesAsynchronously(forKeys: ["tracks"], completionHandler: metadataBecameAvailable) - + playerTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(0.5, 9000), queue: DispatchQueue.main) { [weak self] currentTime in self?.playerTimeDidChange(time: currentTime) } } - + private func teardown(player oldValue: AVPlayer) { oldValue.pause() oldValue.cancelPendingPrerolls() - + if let observer = playerTimeObserver { oldValue.removeTimeObserver(observer) playerTimeObserver = nil } - + oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.status)) oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate)) oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.volume)) @@ -268,35 +268,38 @@ public final class PUIPlayerView: NSView { oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.currentMediaSelection)) oldValue.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem)) } - + public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { DispatchQueue.main.async { - if keyPath == #keyPath(AVPlayer.status) { + guard let keyPath = keyPath else { return } + + switch keyPath { + case #keyPath(AVPlayer.status): self.playerStatusChanged() - } else if keyPath == #keyPath(AVPlayer.currentItem.loadedTimeRanges) { + case #keyPath(AVPlayer.currentItem.loadedTimeRanges): self.updateBufferedSegments() - } else if keyPath == #keyPath(AVPlayer.volume) { + case #keyPath(AVPlayer.volume): self.playerVolumeChanged() - } else if keyPath == #keyPath(AVPlayer.rate) { + case #keyPath(AVPlayer.rate): self.updatePlayingState() self.updatePowerAssertion() - } else if keyPath == #keyPath(AVPlayer.currentItem.duration) { + case #keyPath(AVPlayer.currentItem.duration): self.metadataBecameAvailable() - } else if keyPath == #keyPath(AVPlayer.currentItem.currentMediaSelection) { + case #keyPath(AVPlayer.currentItem.currentMediaSelection): self.updateMediaSelection() - } else if keyPath == #keyPath(AVPlayer.currentItem) { + case #keyPath(AVPlayer.currentItem): if let playerItem = self.player?.currentItem { playerItem.audioTimePitchAlgorithm = AVAudioTimePitchAlgorithmTimeDomain } - } else { + default: super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) } } } - + private func playerVolumeChanged() { guard let player = player else { return } - + if player.volume.isZero { self.volumeButton.image = .PUIVolumeMuted self.volumeSlider.doubleValue = 0 @@ -305,46 +308,46 @@ public final class PUIPlayerView: NSView { self.volumeSlider.doubleValue = Double(player.volume) } } - + private func updateBufferedSegments() { guard let player = player else { return } - + guard let loadedRanges = player.currentItem?.loadedTimeRanges else { return } guard let durationTime = player.currentItem?.duration else { return } - + let duration = Double(CMTimeGetSeconds(durationTime)) - + let segments = loadedRanges.map { value -> PUIBufferSegment in let range = value.timeRangeValue let startTime = Double(CMTimeGetSeconds(range.start)) let segmentDuration = Double(CMTimeGetSeconds(range.duration)) - + return PUIBufferSegment(start: startTime / duration, duration: segmentDuration / duration) } - + timelineView.loadedSegments = Set(segments) } - + private func updateAnnotationsState() { let canGoBack = firstAnnotationBeforeCurrentTime != nil let canGoForward = firstAnnotationAfterCurrentTime != nil - + previousAnnotationButton.isEnabled = canGoBack nextAnnotationButton.isEnabled = canGoForward } - + fileprivate func updatePlayingState() { pipController?.setPlaying(isPlaying) - + if isPlaying { playButton.image = .PUIPause } else { playButton.image = .PUIPlay } } - + fileprivate var activity: NSObjectProtocol? - + fileprivate func updatePowerAssertion() { if self.player?.rate == 0 { if let activity = self.activity { @@ -361,244 +364,245 @@ public final class PUIPlayerView: NSView { fileprivate func updatePlaybackSpeedState() { speedButton.image = playbackSpeed.icon } - + fileprivate var currentPresentationSize: NSSize? { guard let size = player?.currentItem?.presentationSize, size != NSZeroSize else { return nil } - + return size } - + private func playerStatusChanged() { guard let player = player else { return } - + switch player.status { case .readyToPlay: self.updateTimeLabels() default: break } } - + private func metadataBecameAvailable() { + guard let duration = self.asset?.duration else { return } + DispatchQueue.main.async { - guard let duration = self.asset?.duration else { return } - + self.timelineView.mediaDuration = Double(CMTimeGetSeconds(duration)) } } - + fileprivate func playerTimeDidChange(time: CMTime) { + guard let player = self.player else { return } + guard player.hasValidMediaDuration else { return } + guard let duration = self.asset?.duration else { return } + DispatchQueue.main.async { - guard let player = self.player else { return } - - guard player.hasValidMediaDuration else { return } - guard let duration = self.asset?.duration else { return } - + let progress = Double(CMTimeGetSeconds(time) / CMTimeGetSeconds(duration)) self.timelineView.playbackProgress = progress - + self.updateAnnotationsState() self.updateTimeLabels() } } - + private func updateTimeLabels() { guard let player = player else { return } - + guard player.hasValidMediaDuration else { return } guard let duration = self.asset?.duration else { return } - + let time = player.currentTime() - + self.elapsedTimeLabel.stringValue = String(time: time) ?? "" - + let remainingTime = CMTimeSubtract(duration, time) self.remainingTimeLabel.stringValue = String(time: remainingTime) ?? "" } - + deinit { if let player = player { teardown(player: player) } } - + // MARK: Controls - + fileprivate var wasPlayingBeforeStartingInteractiveSeek = false - + private var extrasMenuContainerView: NSStackView! - -// fileprivate var controlsVisualEffectView: NSVisualEffectView! + + // fileprivate var controlsVisualEffectView: NSVisualEffectView! fileprivate var scrimContainerView: PUIScrimContainerView! - + private var timeLabelsContainerView: NSStackView! private var controlsContainerView: NSStackView! private var volumeControlsContainerView: NSStackView! private var centerButtonsContainerView: NSStackView! - + fileprivate var timelineView: PUITimelineView! - + private lazy var elapsedTimeLabel: NSTextField = { let l = NSTextField(labelWithString: "00:00:00") - + l.alignment = .left - l.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium) + l.font = .systemFont(ofSize: 14, weight: NSFontWeightMedium) l.textColor = .timeLabel - + return l }() - + private lazy var remainingTimeLabel: NSTextField = { let l = NSTextField(labelWithString: "-00:00:00") - + l.alignment = .right - l.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium) + l.font = .systemFont(ofSize: 14, weight: NSFontWeightMedium) l.textColor = .timeLabel - + return l }() - + private lazy var fullScreenButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUIFullScreen b.target = self - b.action = #selector(toggleFullscreen(_:)) + b.action = #selector(toggleFullscreen) b.toolTip = "Toggle full screen" - + return b }() - + private lazy var volumeButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUIVolume b.target = self - b.action = #selector(toggleMute(_:)) + b.action = #selector(toggleMute) b.widthAnchor.constraint(equalToConstant: 24).isActive = true b.toolTip = "Mute/unmute" - + return b }() - + fileprivate lazy var volumeSlider: PUISlider = { let s = PUISlider(frame: .zero) - + s.widthAnchor.constraint(equalToConstant: 88).isActive = true s.isContinuous = true s.target = self s.minValue = 0 s.maxValue = 1 - s.action = #selector(volumeSliderAction(_:)) - + s.action = #selector(volumeSliderAction) + return s }() - + private lazy var subtitlesButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUISubtitles b.target = self - b.action = #selector(showSubtitlesMenu(_:)) + b.action = #selector(showSubtitlesMenu) b.toolTip = "Subtitles" - + return b }() - + fileprivate lazy var playButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUIPlay b.target = self - b.action = #selector(togglePlaying(_:)) + b.action = #selector(togglePlaying) b.toolTip = "Play/pause" - + return b }() - + private lazy var previousAnnotationButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUIPreviousBookmark b.target = self - b.action = #selector(previousAnnotation(_:)) + b.action = #selector(previousAnnotation) b.toolTip = "Go to previous bookmark" - + return b }() - + private lazy var nextAnnotationButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUINextBookmark b.target = self - b.action = #selector(nextAnnotation(_:)) + b.action = #selector(nextAnnotation) b.toolTip = "Go to next bookmark" - + return b }() - + private lazy var backButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUIBack15s b.target = self - b.action = #selector(goBackInTime15(_:)) + b.action = #selector(goBackInTime15) b.toolTip = "Go back 15s" - + return b }() - + private lazy var forwardButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUIForward15s b.target = self - b.action = #selector(goForwardInTime15(_:)) + b.action = #selector(goForwardInTime15) b.toolTip = "Go forward 15s" - + return b }() - + fileprivate lazy var speedButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUISpeedOne b.target = self - b.action = #selector(toggleSpeed(_:)) + b.action = #selector(toggleSpeed) b.toolTip = "Change playback speed" b.menu = self.speedsMenu b.showsMenuOnRightClick = true - + return b }() - + private lazy var addAnnotationButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = .PUIBookmark b.target = self - b.action = #selector(addAnnotation(_:)) + b.action = #selector(addAnnotation) b.toolTip = "Add bookmark" - + return b }() - + private lazy var pipButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.isToggle = true b.image = .PUIPictureInPicture b.target = self - b.action = #selector(togglePip(_:)) + b.action = #selector(togglePip) b.toolTip = "Toggle picture in picture" - + return b }() - + private var extrasMenuTopConstraint: NSLayoutConstraint! - + private lazy var externalStatusController = PUIExternalPlaybackStatusViewController() - + private func setupControls() { externalStatusController.view.isHidden = true externalStatusController.view.translatesAutoresizingMaskIntoConstraints = false @@ -607,62 +611,62 @@ public final class PUIPlayerView: NSView { externalStatusController.view.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true externalStatusController.view.topAnchor.constraint(equalTo: topAnchor).isActive = true externalStatusController.view.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true - -// // VFX view -// controlsVisualEffectView = NSVisualEffectView(frame: bounds) -// controlsVisualEffectView.translatesAutoresizingMaskIntoConstraints = false -// controlsVisualEffectView.material = .ultraDark -// controlsVisualEffectView.appearance = NSAppearance(named: NSAppearanceNameVibrantDark) -// controlsVisualEffectView.blendingMode = .withinWindow -// controlsVisualEffectView.wantsLayer = true -// controlsVisualEffectView.layer?.masksToBounds = false -// controlsVisualEffectView.state = .active + + // // VFX view + // controlsVisualEffectView = NSVisualEffectView(frame: bounds) + // controlsVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + // controlsVisualEffectView.material = .ultraDark + // controlsVisualEffectView.appearance = NSAppearance(named: NSAppearanceNameVibrantDark) + // controlsVisualEffectView.blendingMode = .withinWindow + // controlsVisualEffectView.wantsLayer = true + // controlsVisualEffectView.layer?.masksToBounds = false + // controlsVisualEffectView.state = .active scrimContainerView = PUIScrimContainerView(frame: bounds) - + // Time labels timeLabelsContainerView = NSStackView(views: [elapsedTimeLabel, remainingTimeLabel]) timeLabelsContainerView.distribution = .fillEqually timeLabelsContainerView.orientation = .horizontal timeLabelsContainerView.alignment = .centerY - + // Timeline view timelineView = PUITimelineView(frame: .zero) timelineView.viewDelegate = self - + // Volume controls volumeControlsContainerView = NSStackView(views: [volumeButton, volumeSlider]) - + volumeControlsContainerView.orientation = .horizontal volumeControlsContainerView.spacing = 6 volumeControlsContainerView.alignment = .centerY - + // Center Buttons centerButtonsContainerView = NSStackView(frame: bounds) - + // Leading controls (volume, subtitles) centerButtonsContainerView.addView(volumeButton, in: .leading) centerButtonsContainerView.addView(volumeSlider, in: .leading) centerButtonsContainerView.addView(subtitlesButton, in: .leading) - + centerButtonsContainerView.setCustomSpacing(6, after: volumeButton) - + // Center controls (play, annotations, forward, backward) centerButtonsContainerView.addView(backButton, in: .center) centerButtonsContainerView.addView(previousAnnotationButton, in: .center) centerButtonsContainerView.addView(playButton, in: .center) centerButtonsContainerView.addView(nextAnnotationButton, in: .center) centerButtonsContainerView.addView(forwardButton, in: .center) - + // Trailing controls (speed, add annotation, pip) centerButtonsContainerView.addView(speedButton, in: .trailing) centerButtonsContainerView.addView(addAnnotationButton, in: .trailing) centerButtonsContainerView.addView(pipButton, in: .trailing) - + centerButtonsContainerView.orientation = .horizontal centerButtonsContainerView.spacing = 24 centerButtonsContainerView.distribution = .gravityAreas centerButtonsContainerView.alignment = .centerY - + // Visibility priorities centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: volumeButton) centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: volumeSlider) @@ -676,14 +680,14 @@ public final class PUIPlayerView: NSView { centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: addAnnotationButton) centerButtonsContainerView.setVisibilityPriority(NSStackViewVisibilityPriorityDetachOnlyIfNecessary, for: pipButton) centerButtonsContainerView.setContentCompressionResistancePriority(NSLayoutPriorityDefaultLow, for: .horizontal) - + // Main stack view controlsContainerView = NSStackView(views: [ timeLabelsContainerView, timelineView, centerButtonsContainerView - ]) - + ]) + controlsContainerView.orientation = .vertical controlsContainerView.spacing = 12 controlsContainerView.distribution = .fill @@ -691,87 +695,87 @@ public final class PUIPlayerView: NSView { controlsContainerView.wantsLayer = true controlsContainerView.layer?.masksToBounds = false controlsContainerView.layer?.zPosition = 10 - + addSubview(scrimContainerView) addSubview(controlsContainerView) - + scrimContainerView.translatesAutoresizingMaskIntoConstraints = false scrimContainerView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true scrimContainerView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true scrimContainerView.topAnchor.constraint(equalTo: topAnchor).isActive = true scrimContainerView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true - + controlsContainerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12).isActive = true controlsContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12).isActive = true controlsContainerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12).isActive = true - + timeLabelsContainerView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor).isActive = true timeLabelsContainerView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor).isActive = true - + centerButtonsContainerView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor).isActive = true centerButtonsContainerView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor).isActive = true - + // Extras menu (external playback, fullscreen button) extrasMenuContainerView = NSStackView(views: [fullScreenButton]) extrasMenuContainerView.orientation = .horizontal extrasMenuContainerView.alignment = .centerY extrasMenuContainerView.distribution = .equalSpacing extrasMenuContainerView.spacing = 30 - + addSubview(extrasMenuContainerView) - + extrasMenuContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12).isActive = true updateExtrasMenuPosition() } - + private func configureWithAppearanceFromDelegate() { guard let d = appearanceDelegate else { return } - + subtitlesButton.isHidden = !d.playerViewShouldShowSubtitlesControl(self) pipButton.isHidden = !d.playerViewShouldShowPictureInPictureControl(self) speedButton.isHidden = !d.playerViewShouldShowSpeedControl(self) - + let disableAnnotationControls = !d.playerViewShouldShowAnnotationControls(self) addAnnotationButton.isHidden = disableAnnotationControls previousAnnotationButton.isHidden = disableAnnotationControls nextAnnotationButton.isHidden = disableAnnotationControls - + let disableBackAndForward = !d.playerViewShouldShowBackAndForwardControls(self) backButton.isHidden = disableBackAndForward forwardButton.isHidden = disableBackAndForward - + let skipBy30 = d.PlayerViewShouldShowBackAndForward30SecondsButtons(self) backButton.image = skipBy30 ? .PUIBack30s : .PUIBack15s - backButton.action = skipBy30 ? #selector(goBackInTime30(_:)) : #selector(goBackInTime15(_:)) + backButton.action = skipBy30 ? #selector(goBackInTime30) : #selector(goBackInTime15) backButton.toolTip = skipBy30 ? "Go back 30s" : "Go back 15s" forwardButton.image = skipBy30 ? .PUIForward30s : .PUIForward15s - forwardButton.action = skipBy30 ? #selector(goForwardInTime30(_:)) : #selector(goForwardInTime15(_:)) + forwardButton.action = skipBy30 ? #selector(goForwardInTime30) : #selector(goForwardInTime15) forwardButton.toolTip = skipBy30 ? "Go forward 30s" : "Go forward 15s" - + updateExternalPlaybackControlsAvailability() - + fullScreenButton.isHidden = !d.playerViewShouldShowFullScreenButton(self) timelineView.isHidden = !d.playerViewShouldShowTimelineView(self) timeLabelsContainerView.isHidden = !d.playerViewShouldShowTimestampLabels(self) } - + fileprivate func updateExternalPlaybackControlsAvailability() { guard let d = appearanceDelegate else { return } - + let disableExternalPlayback = !d.playerViewShouldShowExternalPlaybackControls(self) externalPlaybackProviders.forEach({ $0.button.isHidden = disableExternalPlayback }) } - + private var isDominantViewInWindow: Bool { guard let contentView = window?.contentView else { return false } guard contentView != self else { return true } - + return bounds.height >= contentView.bounds.height } - + private func updateExtrasMenuPosition() { let topConstant: CGFloat = isDominantViewInWindow ? 34 : 12 - + if extrasMenuTopConstraint == nil { extrasMenuTopConstraint = extrasMenuContainerView.topAnchor.constraint(equalTo: topAnchor, constant: topConstant) extrasMenuTopConstraint.isActive = true @@ -779,39 +783,39 @@ public final class PUIPlayerView: NSView { extrasMenuTopConstraint.constant = topConstant } } - + fileprivate func updateExternalPlaybackMenus() { // clean menu extrasMenuContainerView.arrangedSubviews.enumerated().forEach { idx, v in guard idx < extrasMenuContainerView.arrangedSubviews.count - 1 else { return } - + extrasMenuContainerView.removeArrangedSubview(v) } - + // repopulate externalPlaybackProviders.filter({ $0.provider.isAvailable }).forEach { registration in registration.button.menu = registration.menu extrasMenuContainerView.insertArrangedSubview(registration.button, at: 0) } } - + private func button(for provider: PUIExternalPlaybackProvider) -> PUIButton { let b = PUIButton(frame: .zero) - + b.image = provider.icon b.toolTip = type(of: provider).name b.showsMenuOnLeftClick = true - + return b } - + // MARK: - Control actions - + private var playerVolumeBeforeMuting: Float = 1.0 - + @IBAction public func toggleMute(_ sender: Any?) { guard let player = player else { return } - + if player.volume.isZero { player.volume = playerVolumeBeforeMuting } else { @@ -819,19 +823,19 @@ public final class PUIPlayerView: NSView { player.volume = 0 } } - + @IBAction func volumeSliderAction(_ sender: Any?) { guard let player = player else { return } - + let v = Float(volumeSlider.doubleValue) - + if isPlayingExternally { currentExternalPlaybackProvider?.setVolume(v) } else { player.volume = v } } - + @IBAction public func togglePlaying(_ sender: Any?) { if isPlaying { pause(sender) @@ -839,7 +843,7 @@ public final class PUIPlayerView: NSView { play(sender) } } - + @IBAction public func pause(_ sender: Any?) { if isPlayingExternally { currentExternalPlaybackProvider?.pause() @@ -847,7 +851,7 @@ public final class PUIPlayerView: NSView { player?.rate = 0 } } - + @IBAction public func play(_ sender: Any?) { if isPlayingExternally { currentExternalPlaybackProvider?.play() @@ -855,47 +859,47 @@ public final class PUIPlayerView: NSView { player?.rate = playbackSpeed.rawValue } } - + @IBAction public func previousAnnotation(_ sender: Any?) { guard let annotation = firstAnnotationBeforeCurrentTime else { return } - + seek(to: annotation) } - + @IBAction public func nextAnnotation(_ sender: Any?) { guard let annotation = firstAnnotationAfterCurrentTime else { return } - + seek(to: annotation) } - + @IBAction public func goBackInTime15(_ sender: Any?) { modifyCurrentTime(with: 15, using: CMTimeSubtract) } - + @IBAction public func goForwardInTime15(_ sender: Any?) { modifyCurrentTime(with: 15, using: CMTimeAdd) } - + @IBAction public func goBackInTime30(_ sender: Any?) { modifyCurrentTime(with: 30, using: CMTimeSubtract) } - + @IBAction public func goForwardInTime30(_ sender: Any?) { modifyCurrentTime(with: 30, using: CMTimeAdd) } - + @IBAction public func toggleSpeed(_ sender: Any?) { playbackSpeed = playbackSpeed.next } - + @IBAction public func addAnnotation(_ sender: NSView?) { guard let player = player else { return } - + let timestamp = Double(CMTimeGetSeconds(player.currentTime())) - + delegate?.playerViewDidSelectAddAnnotation(self, at: timestamp) } - + @IBAction public func togglePip(_ sender: NSView?) { if isInPictureInPictureMode { exitPictureInPictureMode() @@ -903,86 +907,86 @@ public final class PUIPlayerView: NSView { enterPictureInPictureMode() } } - + @IBAction public func toggleFullscreen(_ sender: Any?) { delegate?.playerViewDidSelectToggleFullScreen(self) } - + private func modifyCurrentTime(with seconds: Double, using function: (CMTime, CMTime) -> CMTime) { guard let player = player else { return } - + guard let durationTime = player.currentItem?.duration else { return } - + let modifier = CMTimeMakeWithSeconds(seconds, durationTime.timescale) let targetTime = function(player.currentTime(), modifier) - + guard targetTime.isValid && targetTime.isNumeric else { return } - + if isPlayingExternally { currentExternalPlaybackProvider?.seek(to: seconds) } else { player.seek(to: targetTime) } } - + // MARK: - Subtitles - + private var subtitlesMenu: NSMenu? private var subtitlesGroup: AVMediaSelectionGroup? - + private func updateMediaSelection() { guard let playerItem = player?.currentItem else { return } - + guard let subtitlesGroup = playerItem.asset.mediaSelectionGroup(forMediaCharacteristic: AVMediaCharacteristicLegible) else { subtitlesButton.isHidden = true return } - + self.subtitlesGroup = subtitlesGroup - + subtitlesButton.isHidden = false - + let menu = NSMenu() - + subtitlesGroup.options.forEach { option in - let item = NSMenuItem(title: option.displayName, action: #selector(didSelectSubtitleOption(_:)), keyEquivalent: "") + let item = NSMenuItem(title: option.displayName, action: #selector(didSelectSubtitleOption), keyEquivalent: "") item.representedObject = option item.target = self - + menu.addItem(item) } - + self.subtitlesMenu = menu } - + @objc fileprivate func didSelectSubtitleOption(_ sender: NSMenuItem) { guard let subtitlesGroup = self.subtitlesGroup else { return } guard let option = sender.representedObject as? AVMediaSelectionOption else { return } - + // reset all item's states sender.menu?.items.forEach({ $0.state = NSOffState }) - + if option.extendedLanguageTag == player?.currentItem?.selectedMediaOption(in: subtitlesGroup)?.extendedLanguageTag { player?.currentItem?.select(nil, in: subtitlesGroup) sender.state = NSOffState return } - + player?.currentItem?.select(option, in: subtitlesGroup) - + sender.state = NSOnState } - + @IBAction func showSubtitlesMenu(_ sender: PUIButton) { subtitlesMenu?.popUp(positioning: nil, at: .zero, in: sender) } - + // MARK: - Playback speeds - + fileprivate lazy var speedsMenu: NSMenu = { let m = NSMenu() for speed in PUIPlaybackSpeed.all { - let item = NSMenuItem(title: "\(String(format: "%g", speed.rawValue))x", action: #selector(didSelectSpeed(_:)), keyEquivalent: "") + let item = NSMenuItem(title: "\(String(format: "%g", speed.rawValue))x", action: #selector(didSelectSpeed), keyEquivalent: "") item.target = self item.representedObject = speed item.state = speed == self.playbackSpeed ? NSOnState : NSOffState @@ -990,42 +994,42 @@ public final class PUIPlayerView: NSView { } return m }() - + fileprivate func updateSelectedMenuItem(forPlaybackSpeed speed: PUIPlaybackSpeed) { for item in speedsMenu.items { item.state = (item.representedObject as? PUIPlaybackSpeed) == speed ? NSOnState : NSOffState } } - + @objc private func didSelectSpeed(_ sender: NSMenuItem) { guard let speed = sender.representedObject as? PUIPlaybackSpeed else { return } playbackSpeed = speed } - + // MARK: - Key commands - + private var keyDownEventMonitor: Any? - + private enum KeyCommands: UInt16 { case spaceBar = 49 } - + private func startMonitoringKeyEvents() { if keyDownEventMonitor != nil { stopMonitoringKeyEvents() } - + keyDownEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [unowned self] event in guard let command = KeyCommands(rawValue: event.keyCode) else { return event } - + // ignore keystrokes when editing text guard !(self.window?.firstResponder is NSTextView) else { return event } guard !self.timelineView.isEditingAnnotation else { return event } - + switch command { case .spaceBar: self.togglePlaying(nil) @@ -1033,19 +1037,19 @@ public final class PUIPlayerView: NSView { } } } - + private func stopMonitoringKeyEvents() { if let keyDownEventMonitor = keyDownEventMonitor { NSEvent.removeMonitor(keyDownEventMonitor) } - + keyDownEventMonitor = nil } - - - + + + // MARK: - PiP Support - + public func snapshotPlayer(completion: @escaping (NSImage?) -> Void) { guard let currentTime = player?.currentTime() else { completion(nil) @@ -1055,101 +1059,101 @@ public final class PUIPlayerView: NSView { completion(nil) return } - + let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true - + let time = NSValue(time: currentTime) generator.generateCGImagesAsynchronously(forTimes: [time]) { _, rawImage, _, result, error in guard let rawImage = rawImage, error == nil else { DispatchQueue.main.async { completion(nil) } return } - + let image = NSImage(cgImage: rawImage, size: NSSize(width: rawImage.width, height: rawImage.height)) DispatchQueue.main.async { completion(image) } } } - + fileprivate var pipController: PIPViewController? - + fileprivate func enterPictureInPictureMode() { delegate?.playerViewWillEnterPictureInPictureMode(self) - + snapshotPlayer { [weak self] image in self?.externalStatusController.snapshot = image } - + pipController = PIPViewController() pipController?.delegate = self pipController?.setPlaying(self.isPlaying) pipController?.aspectRatio = self.currentPresentationSize ?? NSSize(width: 640, height: 360) pipController?.view.layer?.backgroundColor = NSColor.black.cgColor - + pipController?.presentAsPicture(inPicture: self.pictureContainer) - + self.isInPictureInPictureMode = true } - + fileprivate func exitPictureInPictureMode() { - if self.pictureContainer.presenting == pipController { - pipController?.dismissViewController(pictureContainer) - } + if self.pictureContainer.presenting == pipController { + pipController?.dismissViewController(pictureContainer) + } } - + // MARK: - Visibility management - + fileprivate var canHideControls: Bool { guard let player = player else { return false } - + guard isPlaying else { return false } guard player.status == .readyToPlay else { return false } guard let window = window else { return false } - + guard !timelineView.isEditingAnnotation else { return false } - + let windowMouseRect = window.convertFromScreen(NSRect(origin: NSEvent.mouseLocation(), size: CGSize(width: 1, height: 1))) let viewMouseRect = convert(windowMouseRect, from: nil) - + // don't hide the controls when the mouse is over them return !viewMouseRect.intersects(controlsContainerView.frame) } - + fileprivate var mouseIdleTimer: Timer! - + fileprivate func resetMouseIdleTimer(start: Bool = true) { if mouseIdleTimer != nil { mouseIdleTimer.invalidate() mouseIdleTimer = nil } - + if start { - mouseIdleTimer = Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(mouseIdleTimerAction(_:)), userInfo: nil, repeats: false) + mouseIdleTimer = Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(mouseIdleTimerAction), userInfo: nil, repeats: false) } } - + @objc fileprivate func mouseIdleTimerAction(_ sender: Timer) { guard canHideControls else { return } - + if !isInPictureInPictureMode { NSCursor.hide() } - + hideControls(animated: true) } - + private func hideControls(animated: Bool) { guard canHideControls else { return } - + setControls(opacity: 0, animated: animated) } - + private func showControls(animated: Bool) { NSCursor.unhide() - + setControls(opacity: 1, animated: animated) } - + private func setControls(opacity: CGFloat, animated: Bool) { NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = animated ? 0.4 : 0.0 @@ -1158,90 +1162,90 @@ public final class PUIPlayerView: NSView { self.extrasMenuContainerView.animator().alphaValue = opacity }, completionHandler: nil) } - + public override func viewWillMove(toWindow newWindow: NSWindow?) { NotificationCenter.default.removeObserver(self, name: .NSWindowWillEnterFullScreen, object: self.window) NotificationCenter.default.removeObserver(self, name: .NSWindowWillExitFullScreen, object: self.window) - + NotificationCenter.default.addObserver(self, selector: #selector(windowWillEnterFullScreen), name: .NSWindowWillEnterFullScreen, object: newWindow) NotificationCenter.default.addObserver(self, selector: #selector(windowWillExitFullScreen), name: .NSWindowWillExitFullScreen, object: newWindow) } - + public override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - + resetMouseIdleTimer() updateExtrasMenuPosition() - + if window != nil { self.lastKnownWindow = window startMonitoringKeyEvents() } } - + private var windowIsInFullScreen: Bool { guard let window = window else { return false } - + return window.styleMask.contains(.fullScreen) } - + @objc private func windowWillEnterFullScreen() { fullScreenButton.isHidden = true updateExtrasMenuPosition() } - + @objc private func windowWillExitFullScreen() { fullScreenButton.isHidden = false updateExtrasMenuPosition() } - + // MARK: - Events - + private var mouseTrackingArea: NSTrackingArea! - + public override func updateTrackingAreas() { super.updateTrackingAreas() - + if mouseTrackingArea != nil { removeTrackingArea(mouseTrackingArea) } - + let options: NSTrackingAreaOptions = [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect] mouseTrackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) - + addTrackingArea(mouseTrackingArea) } - + public override func mouseEntered(with event: NSEvent) { showControls(animated: true) resetMouseIdleTimer() - + super.mouseExited(with: event) } - + public override func mouseMoved(with event: NSEvent) { showControls(animated: true) resetMouseIdleTimer() - + super.mouseMoved(with: event) } - + public override func mouseExited(with event: NSEvent) { resetMouseIdleTimer(start: false) - + hideControls(animated: true) - + super.mouseExited(with: event) } - + public override var acceptsFirstResponder: Bool { return true } - + public override func acceptsFirstMouse(for event: NSEvent?) -> Bool { return true } - + public override func mouseDown(with event: NSEvent) { if event.type == .leftMouseDown && event.clickCount == 2 { toggleFullscreen(self) @@ -1249,15 +1253,15 @@ public final class PUIPlayerView: NSView { super.mouseDown(with: event) } } - + // MARK: - External playback state management - + private func unhighlightExternalPlaybackButtons() { externalPlaybackProviders.forEach { registration in registration.button.tintColor = .buttonColor } } - + fileprivate var currentExternalPlaybackProvider: PUIExternalPlaybackProvider? { didSet { if currentExternalPlaybackProvider != nil { @@ -1267,210 +1271,210 @@ public final class PUIPlayerView: NSView { } } } - + @objc private func transitionToExternalPlayback() { guard let current = self.currentExternalPlaybackProvider else { transitionToInternalPlayback() return } - + let currentProviderName = type(of: current).name - + unhighlightExternalPlaybackButtons() - + guard let registration = externalPlaybackProviders.filter({ type(of: $0.provider).name == currentProviderName }).first else { return } - + registration.button.tintColor = .playerHighlight - + snapshotPlayer { [weak self] image in self?.externalStatusController.snapshot = image } - + externalStatusController.providerIcon = current.image externalStatusController.providerName = currentProviderName externalStatusController.providerDescription = "Playing in \(currentProviderName)" + "\n" + current.info externalStatusController.view.isHidden = false - + pipButton.isEnabled = false subtitlesButton.isEnabled = false speedButton.isEnabled = false forwardButton.isEnabled = false backButton.isEnabled = false - + controlsContainerView.alphaValue = 0.5 } - + @objc private func transitionToInternalPlayback() { unhighlightExternalPlaybackButtons() - + pipButton.isEnabled = true subtitlesButton.isEnabled = true speedButton.isEnabled = true forwardButton.isEnabled = true backButton.isEnabled = true - + controlsContainerView.alphaValue = 1 - + externalStatusController.view.isHidden = true } - + } // MARK: - PUITimelineViewDelegate extension PUIPlayerView: PUITimelineViewDelegate { - + func timelineDidReceiveForceTouch(at timestamp: Double) { guard let player = player else { return } - + let timestamp = Double(CMTimeGetSeconds(player.currentTime())) - + delegate?.playerViewDidSelectAddAnnotation(self, at: timestamp) } - + func timelineViewWillBeginInteractiveSeek() { wasPlayingBeforeStartingInteractiveSeek = isPlaying - + self.pause(nil) } - + func timelineViewDidSeek(to progress: Double) { guard let duration = asset?.duration else { return } - + let targetTime = progress * Double(CMTimeGetSeconds(duration)) let time = CMTimeMakeWithSeconds(targetTime, duration.timescale) - + if isPlayingExternally { currentExternalPlaybackProvider?.seek(to: targetTime) } else { player?.seek(to: time) } } - + func timelineViewDidFinishInteractiveSeek() { if wasPlayingBeforeStartingInteractiveSeek { self.play(nil) } } - + } // MARK: - External playback support extension PUIPlayerView: PUIExternalPlaybackConsumer { - + private func isCurrentProvider(_ provider: PUIExternalPlaybackProvider) -> Bool { guard let currentProvider = currentExternalPlaybackProvider else { return false } - + return type(of: provider).name == type(of: currentProvider).name } - + public func externalPlaybackProviderDidChangeMediaStatus(_ provider: PUIExternalPlaybackProvider) { volumeSlider.doubleValue = Double(provider.status.volume) - + if let speed = PUIPlaybackSpeed(rawValue: provider.status.rate) { self.playbackSpeed = speed } - + let time = CMTimeMakeWithSeconds(Float64(provider.status.currentTime), 9000) playerTimeDidChange(time: time) - + updatePlayingState() } - + public func externalPlaybackProviderDidChangeAvailabilityStatus(_ provider: PUIExternalPlaybackProvider) { updateExternalPlaybackMenus() updateExternalPlaybackControlsAvailability() - + if !provider.isAvailable && isCurrentProvider(provider) { // current provider got invalidated, go back to internal playback self.currentExternalPlaybackProvider = nil } } - + public func externalPlaybackProviderDidInvalidatePlaybackSession(_ provider: PUIExternalPlaybackProvider) { if isCurrentProvider(provider) { let wasPlaying = !provider.status.rate.isZero - + // provider session invalidated, go back to internal playback self.currentExternalPlaybackProvider = nil - + if wasPlaying { self.player?.play() self.updatePlayingState() } } } - + public func externalPlaybackProvider(_ provider: PUIExternalPlaybackProvider, deviceSelectionMenuDidChangeWith menu: NSMenu) { guard let registrationIndex = externalPlaybackProviders.index(where: { type(of: $0.provider).name == type(of: provider).name }) else { return } - + externalPlaybackProviders[registrationIndex].menu = menu } - + public func externalPlaybackProviderDidBecomeCurrent(_ provider: PUIExternalPlaybackProvider) { if isInternalPlayerPlaying { player?.rate = 0 } - + self.currentExternalPlaybackProvider = provider } - + } // MARK: - PiP delegate extension PUIPlayerView: PIPViewControllerDelegate, PUIPictureContainerViewControllerDelegate { - + public func pipActionStop(_ pip: PIPViewController) { self.pause(pip) delegate?.playerViewWillExitPictureInPictureMode(self, isReturningFromPiP: false) } - + public func pipActionReturn(_ pip: PIPViewController) { delegate?.playerViewWillExitPictureInPictureMode(self, isReturningFromPiP: true) - + if !NSApp.isActive { NSApp.activate(ignoringOtherApps: true) } - + if let window = self.lastKnownWindow { window.makeKeyAndOrderFront(pip) - + if window.isMiniaturized { window.deminiaturize(nil) } } } - + public func pipActionPause(_ pip: PIPViewController) { self.pause(pip) } - + public func pipActionPlay(_ pip: PIPViewController) { self.play(pip) } - + public func pipDidClose(_ pip: PIPViewController) { pictureContainer.view.frame = self.bounds - + self.addSubview(pictureContainer.view, positioned: .below, relativeTo: self.scrimContainerView) - + self.isInPictureInPictureMode = false self.pipController = nil } - + public func pipWillClose(_ pip: PIPViewController) { pip.replacementRect = self.frame pip.replacementView = self pip.replacementWindow = self.lastKnownWindow } - + func pictureContainerViewSuperviewDidChange(to superview: NSView?) { guard let superview = superview else { return } - + pictureContainer.view.frame = superview.bounds - + if superview == self, pipController != nil { if self.pictureContainer.presenting == pipController { pipController?.dismissViewController(self.pictureContainer) diff --git a/PlayerUI/Views/PUIPlayerWindow.swift b/PlayerUI/Views/PUIPlayerWindow.swift index e49bfbb2..9fc86bd4 100644 --- a/PlayerUI/Views/PUIPlayerWindow.swift +++ b/PlayerUI/Views/PUIPlayerWindow.swift @@ -10,82 +10,82 @@ import Cocoa import AVFoundation open class PUIPlayerWindow: NSWindow { - + @IBInspectable @objc open var hidesTitlebar = true - + // MARK: - Initialization - + public override init(contentRect: NSRect, styleMask style: NSWindowStyleMask, backing bufferingType: NSBackingStoreType, defer flag: Bool) { var effectiveStyle = style effectiveStyle.insert(.fullSizeContentView) - + super.init(contentRect: contentRect, styleMask: effectiveStyle, backing: bufferingType, defer: flag) - + applyCustomizations() } - + open override func awakeFromNib() { super.awakeFromNib() - + applyCustomizations() } - + // MARK: - Custom appearance - + open override var effectiveAppearance: NSAppearance { return NSAppearance(named: NSAppearanceNameVibrantDark)! } - + fileprivate var titlebarWidgets = Set() - + fileprivate func appearanceForWidgets() -> NSAppearance? { return NSAppearance(named: NSAppearanceNameAqua) } - + fileprivate func applyAppearanceToWidgets() { let appearance = appearanceForWidgets() titlebarWidgets.forEach { $0.appearance = appearance } } - + fileprivate var _storedTitlebarView: NSVisualEffectView? open var titlebarView: NSVisualEffectView? { guard _storedTitlebarView == nil else { return _storedTitlebarView } guard let containerClass = NSClassFromString("NSTitlebarContainerView") else { return nil } - + guard let containerView = contentView?.superview?.subviews.filter({ $0.isKind(of: containerClass) }).last else { return nil } - + guard let titlebar = containerView.subviews.filter({ $0.isKind(of: NSVisualEffectView.self) }).last as? NSVisualEffectView else { return nil } - + _storedTitlebarView = titlebar - + return _storedTitlebarView } - + fileprivate var titleTextField: NSTextField? fileprivate var titlebarSeparatorLayer: CALayer? fileprivate var titlebarGradientLayer: CAGradientLayer? - + fileprivate var fullscreenObserver: NSObjectProtocol? - + fileprivate func applyCustomizations(_ note: Notification? = nil) { titleVisibility = .hidden isMovableByWindowBackground = true - + titlebarView?.material = .ultraDark titlebarView?.state = .active - + installTitlebarGradientIfNeeded() installTitlebarSeparatorIfNeeded() installTitleTextFieldIfNeeded() - + installFullscreenObserverIfNeeded() - + applyAppearanceToWidgets() } - + fileprivate func installTitleTextFieldIfNeeded() { guard titleTextField == nil && titlebarView != nil else { return } - + titleTextField = NSTextField(frame: titlebarView!.bounds) titleTextField!.isEditable = false titleTextField!.isSelectable = false @@ -93,25 +93,25 @@ open class PUIPlayerWindow: NSWindow { titleTextField!.isBezeled = false titleTextField!.isBordered = false titleTextField!.stringValue = title - titleTextField!.font = NSFont.titleBarFont(ofSize: 13.0) + titleTextField!.font = .titleBarFont(ofSize: 13.0) titleTextField!.textColor = NSColor(calibratedWhite: 0.9, alpha: 0.8) titleTextField!.alignment = .center titleTextField!.translatesAutoresizingMaskIntoConstraints = false titleTextField!.lineBreakMode = .byTruncatingMiddle titleTextField!.sizeToFit() - + titlebarView!.addSubview(titleTextField!) titleTextField!.centerYAnchor.constraint(equalTo: titlebarView!.centerYAnchor).isActive = true titleTextField!.centerXAnchor.constraint(equalTo: titlebarView!.centerXAnchor).isActive = true titleTextField!.leadingAnchor.constraint(greaterThanOrEqualTo: titlebarView!.leadingAnchor, constant: 67.0).isActive = true titleTextField!.setContentCompressionResistancePriority(0.1, for: .horizontal) - + titleTextField!.layer?.compositingFilter = "lightenBlendMode" } - + fileprivate func installTitlebarGradientIfNeeded() { guard titlebarGradientLayer == nil && titlebarView != nil else { return } - + titlebarGradientLayer = CAGradientLayer() titlebarGradientLayer!.colors = [NSColor(calibratedWhite: 0.0, alpha: 0.4).cgColor, NSColor.clear.cgColor] titlebarGradientLayer!.frame = titlebarView!.bounds @@ -119,86 +119,86 @@ open class PUIPlayerWindow: NSWindow { titlebarGradientLayer!.compositingFilter = "overlayBlendMode" titlebarView?.layer?.insertSublayer(titlebarGradientLayer!, at: 0) } - + fileprivate func installTitlebarSeparatorIfNeeded() { guard titlebarSeparatorLayer == nil && titlebarView != nil else { return } - + titlebarSeparatorLayer = CALayer() titlebarSeparatorLayer!.backgroundColor = NSColor(calibratedWhite: 0.0, alpha: 0.9).cgColor titlebarSeparatorLayer!.frame = CGRect(x: 0.0, y: 0.0, width: titlebarView!.bounds.width, height: 1.0) titlebarSeparatorLayer!.autoresizingMask = [.layerWidthSizable, .layerMinYMargin] titlebarView?.layer?.addSublayer(titlebarSeparatorLayer!) } - + fileprivate func installFullscreenObserverIfNeeded() { guard fullscreenObserver == nil else { return } - + let nc = NotificationCenter.default - + // the customizations (especially the title text field ones) have to be reapplied when entering and exiting fullscreen nc.addObserver(forName: NSNotification.Name.NSWindowDidEnterFullScreen, object: self, queue: nil, using: applyCustomizations) nc.addObserver(forName: NSNotification.Name.NSWindowDidExitFullScreen, object: self, queue: nil, using: applyCustomizations) } - + open override func makeKeyAndOrderFront(_ sender: Any?) { super.makeKeyAndOrderFront(sender) - + applyCustomizations() } - + // MARK: - Titlebar management - + public private(set) var titlebarCompanionViews = [NSView]() - + public func addTitlebarCompanion(view: NSView) { guard titlebarCompanionViews.index(of: view) == nil else { return } - + titlebarCompanionViews.append(view) } - + public func removeTitlebarCompanion(view: NSView) { guard let index = titlebarCompanionViews.index(of: view) else { return } - + titlebarCompanionViews.remove(at: index) } - + func hideTitlebar(_ animated: Bool = true) { setTitlebarOpacity(0.0, animated: animated) } - + func showTitlebar(_ animated: Bool = true) { setTitlebarOpacity(1.0, animated: animated) } - + fileprivate func setTitlebarOpacity(_ opacity: CGFloat, animated: Bool) { guard hidesTitlebar else { return } - + // when the window is in full screen, the titlebar view is in another window (the "toolbar window") guard titlebarView?.window == self else { return } - + NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = animated ? 0.4 : 0.0 self.titlebarView?.animator().alphaValue = opacity self.titlebarCompanionViews.forEach({ $0.animator().alphaValue = opacity }) - }, completionHandler: nil) + }, completionHandler: nil) } - + open override func standardWindowButton(_ b: NSWindowButton) -> NSButton? { guard let button = super.standardWindowButton(b) else { return nil } - + titlebarWidgets.insert(button) - + return button } - + // MARK: - Content management - + open override var title: String { didSet { titleTextField?.stringValue = title } } - + open override var contentView: NSView? { set { let darkContentView = PUIPlayerWindowContentView(frame: newValue?.frame ?? NSZeroRect) @@ -212,19 +212,19 @@ open class PUIPlayerWindow: NSWindow { return super.contentView } } - + } private class PUIPlayerWindowContentView: NSView { - + fileprivate var overlayView: PUIPlayerWindowOverlayView? - + fileprivate func installOverlayView() { overlayView = PUIPlayerWindowOverlayView(frame: bounds) overlayView!.autoresizingMask = [.viewWidthSizable, .viewHeightSizable] addSubview(overlayView!, positioned: .above, relativeTo: subviews.last) } - + fileprivate func moveOverlayViewToTop() { if overlayView == nil { installOverlayView() @@ -233,89 +233,89 @@ private class PUIPlayerWindowContentView: NSView { addSubview(overlayView!, positioned: .above, relativeTo: subviews.last) } } - + fileprivate override func draw(_ dirtyRect: NSRect) { NSColor.black.setFill() NSRectFill(dirtyRect) } - + fileprivate override func addSubview(_ aView: NSView) { super.addSubview(aView) - + if aView != overlayView { moveOverlayViewToTop() } } - + } private class PUIPlayerWindowOverlayView: NSView { - + fileprivate var PUIPlayerWindow: PUIPlayerWindow? { return window as? PUIPlayerWindow } - + fileprivate var mouseTrackingArea: NSTrackingArea! - + fileprivate override func updateTrackingAreas() { super.updateTrackingAreas() - + if mouseTrackingArea != nil { removeTrackingArea(mouseTrackingArea) } - + mouseTrackingArea = NSTrackingArea(rect: bounds, options: [.inVisibleRect, .mouseEnteredAndExited, .mouseMoved, .activeAlways], owner: self, userInfo: nil) addTrackingArea(mouseTrackingArea) } - + fileprivate var mouseIdleTimer: Timer! - + fileprivate func resetMouseIdleTimer() { if mouseIdleTimer != nil { mouseIdleTimer.invalidate() mouseIdleTimer = nil } - - mouseIdleTimer = Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(mouseIdleTimerAction(_:)), userInfo: nil, repeats: false) + + mouseIdleTimer = Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(mouseIdleTimerAction), userInfo: nil, repeats: false) } - + @objc fileprivate func mouseIdleTimerAction(_ sender: Timer) { PUIPlayerWindow?.hideTitlebar() } - + fileprivate override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - + NotificationCenter.default.addObserver(self, selector: #selector(windowWillExitFullscreen), name: NSNotification.Name.NSWindowWillExitFullScreen, object: window) resetMouseIdleTimer() } - + @objc fileprivate func windowWillExitFullscreen() { resetMouseIdleTimer() } - + fileprivate override func mouseEntered(with theEvent: NSEvent) { resetMouseIdleTimer() PUIPlayerWindow?.showTitlebar() } - + fileprivate override func mouseExited(with theEvent: NSEvent) { PUIPlayerWindow?.hideTitlebar() } - + fileprivate override func mouseMoved(with theEvent: NSEvent) { resetMouseIdleTimer() PUIPlayerWindow?.showTitlebar() } - + fileprivate override func draw(_ dirtyRect: NSRect) { return } - + deinit { NotificationCenter.default.removeObserver(self) mouseIdleTimer.invalidate() mouseIdleTimer = nil } - + } diff --git a/PlayerUI/Views/PUIScrimContainerView.swift b/PlayerUI/Views/PUIScrimContainerView.swift index 95230040..8e29398c 100644 --- a/PlayerUI/Views/PUIScrimContainerView.swift +++ b/PlayerUI/Views/PUIScrimContainerView.swift @@ -9,25 +9,25 @@ import Cocoa final class PUIScrimContainerView: NSView { - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + wantsLayer = true layer?.backgroundColor = NSColor(calibratedWhite: 0, alpha: 0.2).cgColor - + layer?.addSublayer(bottomScrimLayer) layer?.addSublayer(topScrimLayer) } - + required init?(coder: NSCoder) { fatalError() } - + private let scrimRatio: CGFloat = 0.138 - + private let scrimOpacity: Float = 0.4 - + private let scrimColors: [CGColor] = [ NSColor(calibratedWhite: 0, alpha: 1).cgColor, NSColor(calibratedWhite: 0, alpha: 0.88).cgColor, @@ -37,7 +37,7 @@ final class PUIScrimContainerView: NSView { NSColor(calibratedWhite: 0, alpha: 0.12).cgColor, NSColor(calibratedWhite: 0, alpha: 0).cgColor ] - + private let scrimLocations: [NSNumber] = [ NSNumber(value: 0), NSNumber(value: 0.037), @@ -47,40 +47,40 @@ final class PUIScrimContainerView: NSView { NSNumber(value: 0.7), NSNumber(value: 1) ] - + private lazy var bottomScrimLayer: PUIBoringGradientLayer = { let l = PUIBoringGradientLayer() - + l.colors = self.scrimColors l.locations = self.scrimLocations l.startPoint = CGPoint(x: 0, y: 0) l.endPoint = CGPoint(x: 0, y: 1) l.opacity = self.scrimOpacity - + return l }() - + private lazy var topScrimLayer: PUIBoringGradientLayer = { let l = PUIBoringGradientLayer() - + l.colors = self.scrimColors l.locations = self.scrimLocations l.startPoint = CGPoint(x: 0, y: 1) l.endPoint = CGPoint(x: 0, y: 0) l.opacity = self.scrimOpacity - + return l }() - + override func layout() { var topRect = bounds topRect.size.height = bounds.height * scrimRatio bottomScrimLayer.frame = topRect - + var bottomRect = bounds bottomRect.size.height = bounds.height * scrimRatio bottomRect.origin.y = bounds.height - bottomRect.height topScrimLayer.frame = bottomRect } - + } diff --git a/PlayerUI/Views/PUISlider.swift b/PlayerUI/Views/PUISlider.swift index 9ef6b552..6763fb80 100644 --- a/PlayerUI/Views/PUISlider.swift +++ b/PlayerUI/Views/PUISlider.swift @@ -13,38 +13,38 @@ class PUISlider: NSSlider { override class func cellClass() -> AnyClass? { return PUISliderCell.self } - + } class PUISliderCell: NSSliderCell { - + override func drawKnob(_ knobRect: NSRect) { if isHighlighted { NSColor.playerProgress.setFill() } else { NSColor.highlightedPlayerBorder.setFill() } - + let path = NSBezierPath(ovalIn: knobRect.insetBy(dx: 4, dy: 4)) - + NSColor(calibratedWhite: 0, alpha: 0.4).setStroke() - + path.stroke() path.fill() } - + override func drawBar(inside rect: NSRect, flipped: Bool) { let finalRect = rect.insetBy(dx: 2, dy: 0.5) - + NSColor.playerBuffer.withAlphaComponent(0.8).setFill() - + let barPath = NSBezierPath(roundedRect: finalRect, xRadius: 2, yRadius: 2) barPath.fill() - + let progressRect = NSRect(x: finalRect.origin.x, y: finalRect.origin.y, width: finalRect.width * CGFloat(doubleValue / maxValue), height: finalRect.height) let progressPath = NSBezierPath(roundedRect: progressRect, xRadius: 2, yRadius: 2) NSColor(calibratedWhite: 1, alpha: 0.7).setFill() progressPath.fill() } - + } diff --git a/PlayerUI/Views/PUITimelineView.swift b/PlayerUI/Views/PUITimelineView.swift index c4169848..8fefdf8e 100644 --- a/PlayerUI/Views/PUITimelineView.swift +++ b/PlayerUI/Views/PUITimelineView.swift @@ -10,76 +10,76 @@ import Cocoa import AVFoundation protocol PUITimelineViewDelegate: class { - + func timelineViewWillBeginInteractiveSeek() func timelineViewDidSeek(to progress: Double) func timelineViewDidFinishInteractiveSeek() func timelineDidReceiveForceTouch(at timestamp: Double) - + } public final class PUITimelineView: NSView { - + // MARK: - Public API - + public override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + buildUI() } - + public required init?(coder: NSCoder) { super.init(coder: coder) - + buildUI() } - + public override var intrinsicContentSize: NSSize { return NSSize(width: -1, height: 8) } - + public weak var delegate: PUITimelineDelegate? - + public var bufferingProgress: Double = 0 { didSet { layoutBufferingLayer() } } - + public var playbackProgress: Double = 0 { didSet { layoutPlaybackLayer() } } - + public var annotations: [PUITimelineAnnotation] = [] { didSet { layoutAnnotations() } } - + public var mediaDuration: Double = 0 { didSet { needsLayout = true } } - + public var hasValidMediaDuration: Bool { return AVPlayer.validateMediaDurationWithSeconds(mediaDuration) } - + // MARK: - Private API - + private var annotationWindowController: PUIAnnotationWindowController? - + weak var viewDelegate: PUITimelineViewDelegate? - + var loadedSegments = Set() { didSet { bufferingProgressLayer.segments = loadedSegments } } - + struct Metrics { static let cornerRadius: CGFloat = 4 static let annotationBubbleDiameter: CGFloat = 12 @@ -87,158 +87,158 @@ public final class PUITimelineView: NSView { static let annotationDragThresholdVertical: CGFloat = 15 static let annotationDragThresholdHorizontal: CGFloat = 6 } - + private var borderLayer: PUIBoringLayer! private var bufferingProgressLayer: PUIBufferLayer! private var playbackProgressLayer: PUIBoringLayer! private var ghostProgressLayer: PUIBoringLayer! - + private func buildUI() { wantsLayer = true layer = PUIBoringLayer() - + // Main border - + borderLayer = PUIBoringLayer() borderLayer.borderColor = NSColor.playerBorder.cgColor borderLayer.borderWidth = 1.0 borderLayer.frame = bounds borderLayer.cornerRadius = Metrics.cornerRadius - + layer?.addSublayer(borderLayer) - + // Buffering bar - + bufferingProgressLayer = PUIBufferLayer() bufferingProgressLayer.frame = bounds bufferingProgressLayer.cornerRadius = Metrics.cornerRadius bufferingProgressLayer.masksToBounds = true - + layer?.addSublayer(bufferingProgressLayer) - + // Playback bar - + playbackProgressLayer = PUIBoringLayer() playbackProgressLayer.backgroundColor = NSColor.playerProgress.cgColor playbackProgressLayer.frame = bounds playbackProgressLayer.cornerRadius = Metrics.cornerRadius playbackProgressLayer.masksToBounds = true - + layer?.addSublayer(playbackProgressLayer) - + // Ghost bar - + ghostProgressLayer = PUIBoringLayer() ghostProgressLayer.backgroundColor = NSColor.playerProgress.withAlphaComponent(0.5).cgColor ghostProgressLayer.frame = bounds ghostProgressLayer.cornerRadius = Metrics.cornerRadius ghostProgressLayer.masksToBounds = true - + layer?.addSublayer(ghostProgressLayer) } - + public override func layout() { super.layout() - + borderLayer.frame = bounds - + layoutBufferingLayer() layoutPlaybackLayer() layoutAnnotations(distributeOnly: true) } - + private func layoutBufferingLayer() { bufferingProgressLayer.frame = bounds } - + private func layoutPlaybackLayer() { guard hasValidMediaDuration else { return } - + let playbackWidth = bounds.width * CGFloat(playbackProgress) var playbackRect = bounds playbackRect.size.width = playbackWidth playbackProgressLayer.frame = playbackRect } - + private var hasMouseInside: Bool = false { didSet { reactToMouse() } } - + private var mouseTrackingArea: NSTrackingArea! - + public override func updateTrackingAreas() { super.updateTrackingAreas() - + if mouseTrackingArea != nil { removeTrackingArea(mouseTrackingArea) } - + let options: NSTrackingAreaOptions = [.mouseEnteredAndExited, .mouseMoved, .activeInActiveApp] let trackingBounds = bounds.insetBy(dx: -2.5, dy: -7) mouseTrackingArea = NSTrackingArea(rect: trackingBounds, options: options, owner: self, userInfo: nil) - + addTrackingArea(mouseTrackingArea) } - + public override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) - + hasMouseInside = true } - + public override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) - + hasMouseInside = false - + unhighlightCurrentHoveredAnnotationIfNeeded() } - + public override func mouseMoved(with event: NSEvent) { super.mouseMoved(with: event) - + guard hasMouseInside else { return } - + updateGhostProgress(with: event) trackMouseAgainstAnnotations(with: event) } - + private func updateGhostProgress(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) - + guard point.x > 0 && point.x < bounds.width else { return } - + let ghostWidth = point.x var ghostRect = bounds ghostRect.size.width = ghostWidth ghostProgressLayer.frame = ghostRect } - + public override var mouseDownCanMoveWindow: Bool { set { } get { return false } } - + public override func mouseDown(with event: NSEvent) { if let targetAnnotation = hoveredAnnotation { mouseDown(targetAnnotation.0, layer: targetAnnotation.1, originalEvent: event) return } - + selectedAnnotation = nil delegate?.timelineDidSelectAnnotation(nil) unhighlightCurrentHoveredAnnotationIfNeeded() - + var startedInteractiveSeek = false - + window?.trackEvents(matching: [.pressure, .leftMouseUp, .leftMouseDragged, .tabletPoint], timeout: NSEventDurationForever, mode: .eventTrackingRunLoopMode) { e, stop in let point = self.convert(e.locationInWindow, from: nil) let progress = Double(point.x / self.bounds.width) - + switch e.type { case .leftMouseUp: if startedInteractiveSeek { @@ -247,7 +247,7 @@ public final class PUITimelineView: NSView { // single click seek self.viewDelegate?.timelineViewDidSeek(to: progress) } - + stop.pointee = true case .pressure, .tabletPoint: switch e.touchForce { @@ -256,13 +256,13 @@ public final class PUITimelineView: NSView { stop.pointee = true return } - + let timestamp = self.mediaDuration * progress - + DebugLog("Force touch at \(timestamp)s") - + self.viewDelegate?.timelineDidReceiveForceTouch(at: timestamp) - + stop.pointee = true default: break } @@ -271,23 +271,23 @@ public final class PUITimelineView: NSView { startedInteractiveSeek = true self.viewDelegate?.timelineViewWillBeginInteractiveSeek() } - + self.viewDelegate?.timelineViewDidSeek(to: progress) - + self.ghostProgressLayer.opacity = 0 default: break } } - + NSApp.discardEvents(matching: .leftMouseDown, before: nil) } - + private func reactToMouse() { if hasMouseInside { borderLayer.animate { self.borderLayer.borderColor = NSColor.highlightedPlayerBorder.cgColor } - + ghostProgressLayer.animate { self.ghostProgressLayer.opacity = 1 } @@ -295,86 +295,86 @@ public final class PUITimelineView: NSView { borderLayer.animate { self.borderLayer.borderColor = NSColor.playerBorder.cgColor } - + ghostProgressLayer.animate { self.ghostProgressLayer.opacity = 0 } } } - + public override var allowsVibrancy: Bool { return true } - + public override var isFlipped: Bool { return true } - + // MARK: - Annotation management - + private var annotationLayers: [PUIAnnotationLayer] = [] - + private func layoutAnnotations(distributeOnly: Bool = false) { guard hasValidMediaDuration else { return } - + annotationLayers.forEach({ $0.removeFromSuperlayer() }) annotationLayers.removeAll() - + let sf = self.window?.screen?.backingScaleFactor ?? 1 - + annotationLayers = annotations.map { annotation in let l = PUIAnnotationLayer() - + l.backgroundColor = NSColor.playerHighlight.cgColor l.name = annotation.identifier l.zPosition = 999 l.cornerRadius = Metrics.annotationBubbleDiameter / 2 l.borderColor = NSColor.white.cgColor l.borderWidth = 0 - + let textLayer = PUIBoringTextLayer() - + textLayer.string = self.attributedString(for: annotation.timestamp) textLayer.contentsScale = sf textLayer.opacity = 0 - + l.attach(layer: textLayer, attribute: .top, spacing: 8) - + return l } - + annotations.forEach { annotation in guard let l = annotationLayers.first(where: { $0.name == annotation.identifier }) else { return } - + self.layoutAnnotationLayer(l, for: annotation, with: Metrics.annotationBubbleDiameter) } - + annotationLayers.forEach({ self.layer?.addSublayer($0) }) } - + private func attributedString(for timestamp: Double) -> NSAttributedString { let pStyle = NSMutableParagraphStyle() pStyle.alignment = .center - + let timeTextAttributes: [String: Any] = [ NSFontAttributeName: NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium), NSForegroundColorAttributeName: NSColor.playerHighlight, NSParagraphStyleAttributeName: pStyle ] - + let timeStr = String(timestamp: timestamp) ?? "" - + return NSAttributedString(string: timeStr, attributes: timeTextAttributes) } - + private func layoutAnnotationLayer(_ layer: PUIBoringLayer, for annotation: PUITimelineAnnotation, with diameter: CGFloat, animated: Bool = false) { guard hasValidMediaDuration else { return } - + let x: CGFloat = (CGFloat(annotation.timestamp / self.mediaDuration) * self.bounds.width) - (diameter / 2) let y: CGFloat = self.bounds.height / 2 - diameter / 2 - + let f = CGRect(x: x, y: y, width: diameter, height: diameter) - + if animated { layer.animate { layer.frame = f @@ -384,19 +384,19 @@ public final class PUITimelineView: NSView { layer.frame = f layer.cornerRadius = f.width / 2 } - + if let (sa, _) = selectedAnnotation { if sa.identifier == annotation.identifier { configureAnnotationLayerAsHighlighted(layer: layer) } } } - + private var hoveredAnnotation: (PUITimelineAnnotation, PUIAnnotationLayer)? private var selectedAnnotation: (PUITimelineAnnotation, PUIAnnotationLayer)? { didSet { unhighlight(annotationTuple: oldValue) - + if selectedAnnotation != nil { showAnnotationWindow() hoveredAnnotation = nil @@ -406,103 +406,103 @@ public final class PUITimelineView: NSView { } } } - + private func annotationUnderMouse(with event: NSEvent, diameter: CGFloat = Metrics.annotationBubbleDiameter) -> (annotation: PUITimelineAnnotation, layer: PUIAnnotationLayer)? { var point = convert(event.locationInWindow, from: nil) point.x -= diameter / 2 - + let s = CGSize(width: diameter, height: diameter) let testRect = CGRect(origin: point, size: s) - + guard let annotationLayer = annotationLayers.first(where: { $0.frame.intersects(testRect) }) else { return nil } - + guard let name = annotationLayer.name else { return nil } - + guard let annotation = annotations.first(where: { $0.identifier == name }) else { return nil } - + return (annotation: annotation, layer: annotationLayer) } - + private func trackMouseAgainstAnnotations(with event: NSEvent) { guard let (annotation, annotationLayer) = annotationUnderMouse(with: event) else { unhighlightCurrentHoveredAnnotationIfNeeded() - + return } - + hoveredAnnotation = (annotation, annotationLayer) - + mouseOver(annotation, layer: annotationLayer) } - + private func unhighlightCurrentHoveredAnnotationIfNeeded() { guard let (ha, hal) = self.hoveredAnnotation else { return } - + if let (sa, _) = self.selectedAnnotation { guard sa.identifier != ha.identifier else { return } } - + mouseOut(ha, layer: hal) - + self.hoveredAnnotation = nil } - + private func unhighlight(annotationTuple: (PUITimelineAnnotation, PUIAnnotationLayer)?) { guard let (sa, sal) = annotationTuple else { return } - + mouseOut(sa, layer: sal) } - + private func mouseOver(_ annotation: PUITimelineAnnotation, layer: PUIAnnotationLayer) { CATransaction.begin() defer { CATransaction.commit() } - + layer.animate { configureAnnotationLayerAsHighlighted(layer: layer) } - + delegate?.timelineDidHighlightAnnotation(annotation) } - + private func configureAnnotationLayerAsHighlighted(layer: PUIBoringLayer) { guard let layer = layer as? PUIAnnotationLayer else { return } - + let s = Metrics.annotationBubbleDiameterHoverScale layer.transform = CATransform3DMakeScale(s, s, s) layer.borderWidth = 1 layer.attachedLayer.animate { layer.attachedLayer.opacity = 1 } } - + private func mouseOut(_ annotation: PUITimelineAnnotation, layer: PUIAnnotationLayer) { CATransaction.begin() defer { CATransaction.commit() } - + layer.animate { layer.transform = CATransform3DIdentity layer.borderWidth = 0 layer.attachedLayer.animate { layer.attachedLayer.opacity = 0 } } - + delegate?.timelineDidHighlightAnnotation(nil) } - + private enum AnnotationDragMode { case none case delete case move } - + private func mouseDown(_ annotation: PUITimelineAnnotation, layer: PUIAnnotationLayer, originalEvent: NSEvent) { let startingPoint = self.convert(originalEvent.locationInWindow, from: nil) let originalPosition = layer.position - + let originalTimestampString = layer.attachedLayer.string - + var cancelled = true - + let canDelete = delegate?.timelineCanDeleteAnnotation(annotation) ?? false let canMove = delegate?.timelineCanMoveAnnotation(annotation) ?? false - + var mode: AnnotationDragMode = .none { didSet { if oldValue != .delete && mode == .delete { @@ -517,43 +517,43 @@ public final class PUITimelineView: NSView { } } } - + func makeTimestamp(for point: CGPoint) -> Double { var timestamp = Double(point.x / self.bounds.width) * self.mediaDuration - + if timestamp < 0 { timestamp = 0 } else if timestamp > self.mediaDuration { timestamp = self.mediaDuration } - + return timestamp } - + func updateAnnotationTextLayer(at point: CGPoint) { let timestamp = makeTimestamp(for: point) - + layer.attachedLayer.string = self.attributedString(for: timestamp) } - + func updateAnnotationTextLayer(with string: Any?) { guard let str = string else { return } - + layer.attachedLayer.string = str } - + window?.trackEvents(matching: [.leftMouseUp, .leftMouseDragged, .keyUp], timeout: NSEventDurationForever, mode: .eventTrackingRunLoopMode) { event, stop in let point = self.convert(event.locationInWindow, from: nil) - + switch event.type { case .leftMouseUp: switch mode { case .delete: cancelled = false - + // poof NSShowAnimationEffect(.poof, NSEvent.mouseLocation(), .zero, nil, nil, nil) - + CATransaction.begin() CATransaction.setCompletionBlock { layer.removeFromSuperlayer() } layer.animate { @@ -561,37 +561,37 @@ public final class PUITimelineView: NSView { layer.opacity = 0 } CATransaction.commit() - + self.delegate?.timelineDidDeleteAnnotation(annotation) case .move: cancelled = false - + let timestamp = makeTimestamp(for: point) - + self.delegate?.timelineDidMoveAnnotation(annotation, to: timestamp) case .none: self.selectedAnnotation = (annotation, layer) self.delegate?.timelineDidSelectAnnotation(annotation) } - + mode = .none self.hoveredAnnotation = nil updateAnnotationTextLayer(at: point) - + stop.pointee = true case .leftMouseDragged: self.selectedAnnotation = nil - + if mode != .delete { guard point.x - layer.bounds.width / 2 > 0 else { return } guard point.x < self.borderLayer.frame.width - layer.bounds.width / 2 else { return } } - + let verticalDiff = startingPoint.y - point.y let horizontalDiff = startingPoint.x - point.x - + var newPosition = layer.position - + if abs(verticalDiff) > Metrics.annotationDragThresholdVertical && canDelete { newPosition = point mode = .delete @@ -599,13 +599,13 @@ public final class PUITimelineView: NSView { newPosition.y = originalPosition.y newPosition.x = point.x mode = .move - + updateAnnotationTextLayer(at: point) } else { layer.position = originalPosition mode = .none } - + if mode != .none { layer.position = newPosition } @@ -613,69 +613,69 @@ public final class PUITimelineView: NSView { // cancel with ESC if event.keyCode == 53 { mode = .none - + layer.animate { layer.position = originalPosition } - + stop.pointee = true } default: break } } } - + // MARK: - Annotation editing management - + private var annotationCommandsMonitor: Any? private var currentAnnotationEditor: NSViewController? - + private func showAnnotationWindow() { guard let (annotation, annotationLayer) = self.selectedAnnotation else { return } guard let controller = delegate?.viewControllerForTimelineAnnotation(annotation) else { return } - + self.currentAnnotationEditor = controller - + if annotationWindowController == nil { annotationWindowController = PUIAnnotationWindowController() } - + guard let windowController = annotationWindowController else { return } guard let annotationWindow = windowController.window else { return } guard let contentView = annotationWindowController?.window?.contentView else { return } - + controller.view.frame = contentView.bounds controller.view.autoresizingMask = [.viewWidthSizable, .viewHeightSizable] contentView.addSubview(controller.view) - + let layerRect = convertFromLayer(annotationLayer.frame) let windowRect = convert(layerRect, to: nil) guard var screenRect = window?.convertToScreen(windowRect) else { return } - + screenRect.origin.y += 56 screenRect.origin.x -= round((contentView.bounds.width / 2) - annotationLayer.bounds.width) - + annotationWindow.alphaValue = 0 - + window?.addChildWindow(annotationWindow, ordered: .above) annotationWindow.setFrameOrigin(screenRect.origin) - + if annotation.isEmpty { annotationWindow.makeKeyAndOrderFront(nil) } else { annotationWindow.orderFront(nil) } - + annotationWindow.animator().alphaValue = 1 - + enum AnnotationKeyCommand: UInt16 { case escape = 53 case enter = 36 } - + annotationCommandsMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyUp]) { event in guard let command = AnnotationKeyCommand(rawValue: event.keyCode) else { return event } - + switch command { case .enter: if event.modifierFlags.contains(.command) { @@ -685,17 +685,17 @@ public final class PUITimelineView: NSView { self.selectedAnnotation = nil self.unhighlightCurrentHoveredAnnotationIfNeeded() } - + return event } } - + private func hideAnnotationWindow() { if let monitor = annotationCommandsMonitor { NSEvent.removeMonitor(monitor) annotationCommandsMonitor = nil } - + NSAnimationContext.beginGrouping() NSAnimationContext.current().completionHandler = { self.annotationWindowController?.close() @@ -706,9 +706,9 @@ public final class PUITimelineView: NSView { self.annotationWindowController?.window?.animator().alphaValue = 0 NSAnimationContext.endGrouping() } - + var isEditingAnnotation: Bool { return annotationWindowController != nil } - + } diff --git a/ThrowBack/TBPreferences.swift b/ThrowBack/TBPreferences.swift index 392e252e..e806f49e 100644 --- a/ThrowBack/TBPreferences.swift +++ b/ThrowBack/TBPreferences.swift @@ -9,9 +9,9 @@ import Foundation public final class TBPreferences { - + public static let shared: TBPreferences = TBPreferences() - + var presentedVersionFiveMigrationPrompt: Bool { get { return UserDefaults.standard.bool(forKey: #function) @@ -20,12 +20,12 @@ public final class TBPreferences { UserDefaults.standard.set(newValue, forKey: #function) } } - + // MARK: - Legacy Preferences - + fileprivate let defaults = UserDefaults.standard fileprivate let nc = NotificationCenter.default - + // keys for NSUserDefault's dictionary fileprivate struct Keys { static let mainWindowFrame = "mainWindowFrame" @@ -38,20 +38,20 @@ public final class TBPreferences { static let automaticRefreshEnabled = "automaticRefreshEnabled" static let floatOnTopEnabled = "floatOnTopEnabled" static let automaticRefreshSuggestionPresentedAt = "automaticRefreshSuggestionPresentedAt" - + struct transcript { static let font = "transcript.font" static let textColor = "transcript.textColor" static let bgColor = "transcript.bgColor" } - + struct VideosController { static let selectedItem = "VideosController.selectedItem" static let searchTerm = "VideosController.searchTerm" static let dividerPosition = "VideosController.dividerPosition" } } - + // default values if preferences were not set fileprivate struct DefaultValues { static let localVideoStoragePath = NSString.path(withComponents: [NSHomeDirectory(), "Library", "Application Support", "WWDC"]) @@ -65,20 +65,20 @@ public final class TBPreferences { static let automaticRefreshIntervalRegular = 3600.0 static let floatOnTopEnabled = false static let automaticRefreshSuggestionPresentedAt = Date.distantPast - + struct transcript { static let font = NSFont(name: "Avenir Next", size: 16.0)! static let textColor = NSColor.black static let bgColor = NSColor.white } - + struct VideosController { static let selectedItem = -1 static let searchTerm = "" static let dividerPosition = 260.0 } } - + // the main window's frame var mainWindowFrame: NSRect { set { @@ -92,7 +92,7 @@ public final class TBPreferences { } } } - + // the selected session on the list var selectedSession: Int { set { @@ -106,7 +106,7 @@ public final class TBPreferences { } } } - + // the splitView's divider position var dividerPosition: CGFloat { set { @@ -120,7 +120,7 @@ public final class TBPreferences { } } } - + // the search term var searchTerm: String { set { @@ -134,7 +134,7 @@ public final class TBPreferences { } } } - + // where to save downloaded videos public var localVideoStoragePath: String { set { @@ -148,7 +148,7 @@ public final class TBPreferences { } } } - + // the transcript font var transcriptFont: NSFont { set { @@ -168,7 +168,7 @@ public final class TBPreferences { } } } - + // the transcript's text color var transcriptTextColor: NSColor { // NSColor can't be put into NSUserDefaults directly, so we archive It and store as a NSData blob @@ -188,7 +188,7 @@ public final class TBPreferences { } } } - + // the transcript's background color var transcriptBgColor: NSColor { // NSColor can't be put into NSUserDefaults directly, so we archive It and store as a NSData blob @@ -208,7 +208,7 @@ public final class TBPreferences { } } } - + // the last scale selected for the video window var lastVideoWindowScale: CGFloat { get { @@ -222,7 +222,7 @@ public final class TBPreferences { defaults.set(NSNumber(value: Double(newValue) as Double), forKey: Keys.lastVideoWindowScale) } } - + // play live events automatically or not var autoplayLiveEvents: Bool { get { @@ -236,7 +236,7 @@ public final class TBPreferences { defaults.set(NSNumber(value: newValue as Bool), forKey: Keys.autoplayLiveEvents) } } - + // how often to check for live events (in seconds) var liveEventCheckInterval: Double { get { @@ -250,7 +250,7 @@ public final class TBPreferences { defaults.set(NSNumber(value: newValue as Double), forKey: Keys.liveEventCheckInterval) } } - + // user was informed about the possibility to watch the live keynote here :) var userKnowsLiveEventThing: Bool { get { @@ -264,7 +264,7 @@ public final class TBPreferences { defaults.set(NSNumber(value: newValue as Bool), forKey: Keys.userKnowsLiveEventThing) } } - + // user was informed about the possibility to watch the 2016 Apple TV Tech Talks var tvTechTalksAlerted: Bool { get { @@ -278,7 +278,7 @@ public final class TBPreferences { defaults.set(NSNumber(value: newValue as Bool), forKey: Keys.tvTechTalksAlerted) } } - + // periodically refresh the list of sessions var automaticRefreshEnabled: Bool { get { @@ -292,7 +292,7 @@ public final class TBPreferences { defaults.set(NSNumber(value: newValue as Bool), forKey: Keys.automaticRefreshEnabled) } } - + var floatOnTopEnabled: Bool { get { if let object = defaults.object(forKey: Keys.floatOnTopEnabled) as? NSNumber { @@ -305,7 +305,7 @@ public final class TBPreferences { defaults.set(NSNumber(value: newValue as Bool), forKey: Keys.floatOnTopEnabled) } } - + var automaticRefreshSuggestionPresentedAt: Date? { get { if let object = defaults.object(forKey: Keys.automaticRefreshSuggestionPresentedAt) as? Date { diff --git a/ThrowBack/TBSession.swift b/ThrowBack/TBSession.swift index 6cd28ba2..75bbf4c4 100644 --- a/ThrowBack/TBSession.swift +++ b/ThrowBack/TBSession.swift @@ -10,32 +10,32 @@ import Foundation import RealmSwift internal struct TBSession { - + let identifier: String let isFavorite: Bool let isDownloaded: Bool let position: Double let relativePosition: Double - + init?(_ migrationObject: MigrationObject?) { guard let obj = migrationObject else { return nil } - + guard var identifier = obj["uniqueId"] as? String else { return nil } - + identifier = identifier.replacingOccurrences(of: "#", with: "") - + guard let isFavorite = obj["favorite"] as? Bool else { return nil } guard let isDownloaded = obj["downloaded"] as? Bool else { return nil } guard let position = obj["currentPosition"] as? Double else { return nil } guard let relativePosition = obj["progress"] as? Double else { return nil } - + self.identifier = identifier self.isFavorite = isFavorite self.position = position self.relativePosition = relativePosition self.isDownloaded = isDownloaded } - + } diff --git a/ThrowBack/TBUserDataMigrator.swift b/ThrowBack/TBUserDataMigrator.swift index 50bf3c1d..c3acf2d4 100644 --- a/ThrowBack/TBUserDataMigrator.swift +++ b/ThrowBack/TBUserDataMigrator.swift @@ -17,7 +17,7 @@ private struct TBConstants { public enum TBMigrationError: Error { case realm(Error) - + public var localizedDescription: String { switch self { case .realm(let underlyingError): @@ -32,94 +32,94 @@ public enum TBMigrationResult { } public final class TBUserDataMigrator { - + private let fileURL: URL private var realm: Realm! private weak var newRealm: Realm! - + public var isPerformingMigration = false - + public static var presentedMigrationPrompt: Bool { get { if ProcessInfo.processInfo.arguments.contains("--force-migration") { return false } - + return TBPreferences.shared.presentedVersionFiveMigrationPrompt } set { TBPreferences.shared.presentedVersionFiveMigrationPrompt = newValue } } - + public init(legacyDatabaseFileURL: URL, newRealm: Realm) { self.fileURL = legacyDatabaseFileURL self.newRealm = newRealm } - + public var needsMigration: Bool { // the old database file is renamed after migration, if the file exists, migration is needed return FileManager.default.fileExists(atPath: fileURL.path) } - + public func performMigration(completion: @escaping (TBMigrationResult) -> Void) { guard !isPerformingMigration else { return } - + isPerformingMigration = true - + defer { isPerformingMigration = false } - + var legacyConfig = Realm.Configuration(fileURL: fileURL, schemaVersion: TBConstants.legacySchemaVersion, objectTypes: []) - + legacyConfig.schemaVersion = TBConstants.migratedSchemaVersion legacyConfig.migrationBlock = self.migrationBlock - + do { self.realm = try Realm(configuration: legacyConfig) - + completion(.success) } catch { completion(.failed(.realm(error))) } } - + private func migrationBlock(migration: Migration, version: UInt64) { self.newRealm.beginWrite() - + migration.enumerateObjects(ofType: "Session") { legacySession, _ in guard let migrationSession = TBSession(legacySession) else { return } - + guard let newSession = self.newRealm.object(ofType: Session.self, forPrimaryKey: migrationSession.identifier) else { return } - + if migrationSession.isFavorite { newSession.favorites.append(Favorite()) } - + if !migrationSession.position.isZero || !migrationSession.relativePosition.isZero { let progress = SessionProgress() - + progress.currentPosition = migrationSession.position progress.relativePosition = migrationSession.relativePosition - + newSession.progresses.append(progress) } - + if let asset = newSession.assets.filter("rawAssetType == %@", SessionAssetType.hdVideo.rawValue).first { let basePath = TBPreferences.shared.localVideoStoragePath let newLocalFileURL = URL(fileURLWithPath: basePath + "/" + asset.relativeLocalURL) let localDirectoryURL = newLocalFileURL.deletingLastPathComponent() let originalLocalFileURL = URL(fileURLWithPath: basePath + "/" + newLocalFileURL.lastPathComponent) - + if FileManager.default.fileExists(atPath: originalLocalFileURL.path) { do { if !FileManager.default.fileExists(atPath: localDirectoryURL.path) { try FileManager.default.createDirectory(at: localDirectoryURL, withIntermediateDirectories: true, attributes: nil) } - + try FileManager.default.moveItem(at: originalLocalFileURL, to: newLocalFileURL) - + newSession.isDownloaded = true } catch { NSLog("Error moving downloaded file from \(originalLocalFileURL) to \(newLocalFileURL): \(error)") @@ -127,20 +127,20 @@ public final class TBUserDataMigrator { } } } - + do { try self.newRealm.commitWrite() } catch { NSLog("Error saving migrated realm data: \(error)") } - + let backupFileURL = self.fileURL.deletingPathExtension().appendingPathExtension("backup") - + do { try FileManager.default.moveItem(at: self.fileURL, to: backupFileURL) } catch { NSLog("Error moving backup file to \(backupFileURL)") } } - + } diff --git a/TranscriptIndexingService/TranscriptIndexingService.swift b/TranscriptIndexingService/TranscriptIndexingService.swift index 50998e60..89480c55 100644 --- a/TranscriptIndexingService/TranscriptIndexingService.swift +++ b/TranscriptIndexingService/TranscriptIndexingService.swift @@ -11,9 +11,9 @@ import ConfCore import RealmSwift final class TranscriptIndexingService: NSObject, TranscriptIndexingServiceProtocol { - + private var transcriptIndexer: TranscriptIndexer! - + func indexTranscriptsIfNeeded(storageURL: URL, schemaVersion: UInt64) { if transcriptIndexer == nil { do { @@ -25,8 +25,8 @@ final class TranscriptIndexingService: NSObject, TranscriptIndexingServiceProtoc return } } - + transcriptIndexer.downloadTranscriptsIfNeeded() } - + } diff --git a/TranscriptIndexingService/TranscriptIndexingServiceProtocol.swift b/TranscriptIndexingService/TranscriptIndexingServiceProtocol.swift index 6ce5b43d..8cc5447d 100644 --- a/TranscriptIndexingService/TranscriptIndexingServiceProtocol.swift +++ b/TranscriptIndexingService/TranscriptIndexingServiceProtocol.swift @@ -9,7 +9,7 @@ import Foundation @objc protocol TranscriptIndexingServiceProtocol: NSObjectProtocol { - + func indexTranscriptsIfNeeded(storageURL: URL, schemaVersion: UInt64) - + } diff --git a/WWDC.xcodeproj/project.pbxproj b/WWDC.xcodeproj/project.pbxproj index cc24b94d..af05c1bd 100644 --- a/WWDC.xcodeproj/project.pbxproj +++ b/WWDC.xcodeproj/project.pbxproj @@ -729,6 +729,7 @@ DDAE7C531E5BC6CD00CEA205 /* Frameworks */, ); sourceTree = ""; + wrapsLines = 0; }; DD36A4AD1E478C6A00B2EA88 /* Products */ = { isa = PBXGroup; diff --git a/WWDC/AboutWindowController.swift b/WWDC/AboutWindowController.swift index 1de7a010..96f77bce 100644 --- a/WWDC/AboutWindowController.swift +++ b/WWDC/AboutWindowController.swift @@ -89,7 +89,7 @@ final class AboutWindowController: NSWindowController { iconCreatorLabel.alphaValue = 0.8 iconCreatorLabel.textColor = .primary iconCreatorLabel.target = self - iconCreatorLabel.action = #selector(openIconCreatorWebsite(_:)) + iconCreatorLabel.action = #selector(openIconCreatorWebsite) } if let website = info["GRBundleUserInterfaceCreatorWebsite"] as? String { @@ -98,7 +98,7 @@ final class AboutWindowController: NSWindowController { uiCreatorLabel.alphaValue = 0.8 uiCreatorLabel.textColor = .primary uiCreatorLabel.target = self - uiCreatorLabel.action = #selector(openUserInterfaceCreatorWebsite(_:)) + uiCreatorLabel.action = #selector(openUserInterfaceCreatorWebsite) } if let website = info["GRBundleDesignContributorWebsite"] as? String { @@ -107,7 +107,7 @@ final class AboutWindowController: NSWindowController { designContributorLabel.alphaValue = 0.8 designContributorLabel.textColor = .primary designContributorLabel.target = self - designContributorLabel.action = #selector(openDesignContributorWebsite(_:)) + designContributorLabel.action = #selector(openDesignContributorWebsite) } if let website = info["GRBundleMainDeveloperWebsite"] as? String { @@ -116,7 +116,7 @@ final class AboutWindowController: NSWindowController { creatorLabel.alphaValue = 0.8 creatorLabel.textColor = .primary creatorLabel.target = self - creatorLabel.action = #selector(openMainDeveloperWebsite(_:)) + creatorLabel.action = #selector(openMainDeveloperWebsite) } } diff --git a/WWDC/AccountPreferencesViewController.swift b/WWDC/AccountPreferencesViewController.swift index 16dc8dbe..d664ee41 100644 --- a/WWDC/AccountPreferencesViewController.swift +++ b/WWDC/AccountPreferencesViewController.swift @@ -14,99 +14,99 @@ import CommunitySupport class AccountPreferencesViewController: NSViewController, WWDCImageViewDelegate { private let disposeBag = DisposeBag() - + var profile: CMSUserProfile? { didSet { updateUI() } } - + var cloudAccountIsAvailable = false { didSet { updateUI() } } - + init() { super.init(nibName: nil, bundle: nil)! } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func loadView() { self.view = NSView() self.view.wantsLayer = true } - + override func viewDidLoad() { super.viewDidLoad() - + buildUI() } - + private lazy var avatarImageView: WWDCImageView = { let v = WWDCImageView() - + v.widthAnchor.constraint(equalToConstant: 98).isActive = true v.heightAnchor.constraint(equalToConstant: 98).isActive = true v.translatesAutoresizingMaskIntoConstraints = false v.isEditable = true v.delegate = self - + return v }() - + private lazy var nameLabel: NSTextField = { let f = NSTextField(labelWithString: "") - - f.font = NSFont.systemFont(ofSize: 18, weight: NSFontWeightMedium) + + f.font = .systemFont(ofSize: 18, weight: NSFontWeightMedium) f.textColor = .prefsPrimaryText f.translatesAutoresizingMaskIntoConstraints = false f.alignment = .center - + return f }() - + private lazy var infoLabel: WWDCTextField = { let help = "Your community account is used to sync your data like bookmarks,\nvideo progress and favorites. It can also be used to recommend\nsessions to other viewers and share bookmarks." - + let f = WWDCTextField(wrappingLabelWithString: help) - - f.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightRegular) + + f.font = .systemFont(ofSize: 14, weight: NSFontWeightRegular) f.textColor = .prefsSecondaryText f.cell?.backgroundStyle = .dark f.maximumNumberOfLines = 5 f.translatesAutoresizingMaskIntoConstraints = false f.alignment = .center f.isSelectable = false - + return f }() - + private lazy var permissionLabel: WWDCTextField = { let help = "Your profile is currently incomplete.\nDo you want to complete it with your full name?" - + let f = WWDCTextField(wrappingLabelWithString: help) - + f.isSelectable = false - f.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium) + f.font = .systemFont(ofSize: 14, weight: NSFontWeightMedium) f.textColor = .prefsPrimaryText f.cell?.backgroundStyle = .dark f.maximumNumberOfLines = 5 f.translatesAutoresizingMaskIntoConstraints = false f.alignment = .center - + return f }() - + private lazy var errorLabel: NSTextField = { let help = "This feature requires your macOS account to have an iCloud account.\nPlease go to System Preferences and log in to your iCloud account." - + let f = NSTextField(wrappingLabelWithString: help) - - f.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightRegular) + + f.font = .systemFont(ofSize: 14, weight: NSFontWeightRegular) f.textColor = .errorText f.cell?.backgroundStyle = .dark f.maximumNumberOfLines = 5 @@ -114,19 +114,19 @@ class AccountPreferencesViewController: NSViewController, WWDCImageViewDelegate f.alignment = .center f.isHidden = true f.isSelectable = false - + return f }() - + private lazy var completeButton: NSButton = { - let b = NSButton(title: "Complete my profile", target: self, action: #selector(completeProfile(_:))) - + let b = NSButton(title: "Complete my profile", target: self, action: #selector(completeProfile)) + b.keyEquivalent = "\r" b.translatesAutoresizingMaskIntoConstraints = false - + return b }() - + private func buildUI() { view.addSubview(nameLabel) view.addSubview(avatarImageView) @@ -134,28 +134,28 @@ class AccountPreferencesViewController: NSViewController, WWDCImageViewDelegate view.addSubview(errorLabel) view.addSubview(permissionLabel) view.addSubview(completeButton) - + avatarImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true avatarImageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 22).isActive = true - + nameLabel.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor).isActive = true nameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 12).isActive = true - + infoLabel.centerXAnchor.constraint(equalTo: nameLabel.centerXAnchor).isActive = true infoLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 40).isActive = true - + errorLabel.centerXAnchor.constraint(equalTo: nameLabel.centerXAnchor).isActive = true errorLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 40).isActive = true - + permissionLabel.centerXAnchor.constraint(equalTo: infoLabel.centerXAnchor).isActive = true permissionLabel.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 12).isActive = true - + completeButton.centerXAnchor.constraint(equalTo: permissionLabel.centerXAnchor).isActive = true completeButton.topAnchor.constraint(equalTo: permissionLabel.bottomAnchor, constant: 12).isActive = true } - + private var loadingView: ModalLoadingView? - + private func updateUI() { guard cloudAccountIsAvailable else { errorLabel.isHidden = false @@ -164,37 +164,37 @@ class AccountPreferencesViewController: NSViewController, WWDCImageViewDelegate completeButton.isHidden = true return } - + guard let profile = profile else { loadingView = ModalLoadingView.show(attachedTo: view) - + return } - + errorLabel.isHidden = true infoLabel.isHidden = false permissionLabel.isHidden = false completeButton.isHidden = false - + loadingView?.hide() - + avatarImageView.image = profile.avatar ?? #imageLiteral(resourceName: "avatar") avatarImageView.isHidden = false avatarImageView.isRounded = true - + nameLabel.stringValue = profile.name permissionLabel.isHidden = !profile.name.isEmpty completeButton.isHidden = !profile.name.isEmpty } - + @objc private func completeProfile(_ sender: Any?) { guard let profile = profile else { return } - + loadingView?.show(in: view) - + CMSCommunityCenter.shared.promptAndUpdateUserProfileWithDiscoveredInfo(with: profile) { [weak self] newProfile, error in self?.loadingView?.hide() - + if let error = error { let alert = WWDCAlert.create() alert.messageText = "Error updating profile" @@ -205,28 +205,28 @@ class AccountPreferencesViewController: NSViewController, WWDCImageViewDelegate } } } - + func wwdcImageView(_ imageView: WWDCImageView, didReceiveNewImageWithFileURL url: URL) { guard var updatedProfile = profile else { return } - + loadingView?.show(in: view) - + updatedProfile.avatarFileURL = url updatedProfile.avatar = imageView.image - + CMSCommunityCenter.shared.save(model: updatedProfile, progress: nil) { [unowned self] error in self.loadingView?.hide() - + if let error = error { WWDCAlert.show(with: error) } } } - + override func viewDidAppear() { super.viewDidAppear() - + updateUI() } - + } diff --git a/WWDC/ActionLabel.swift b/WWDC/ActionLabel.swift index 6bea8e4e..c9245771 100644 --- a/WWDC/ActionLabel.swift +++ b/WWDC/ActionLabel.swift @@ -9,23 +9,23 @@ import Cocoa final class ActionLabel: NSTextField { - + private var cursorTrackingArea: NSTrackingArea! - + override func updateTrackingAreas() { super.updateTrackingAreas() - + if cursorTrackingArea != nil { removeTrackingArea(cursorTrackingArea) } - + cursorTrackingArea = NSTrackingArea(rect: bounds, options: [.cursorUpdate, .inVisibleRect, .activeInActiveApp], owner: self, userInfo: nil) addTrackingArea(cursorTrackingArea) } - + override func cursorUpdate(with event: NSEvent) { if event.trackingArea == cursorTrackingArea { NSCursor.pointingHand().push() @@ -33,11 +33,11 @@ final class ActionLabel: NSTextField { super.cursorUpdate(with: event) } } - + override func mouseDown(with event: NSEvent) { if let action = self.action { NSApp.sendAction(action, to: target, from: self) } } - + } diff --git a/WWDC/AppCoordinator+Bookmarks.swift b/WWDC/AppCoordinator+Bookmarks.swift index a5d233ba..bb389502 100644 --- a/WWDC/AppCoordinator+Bookmarks.swift +++ b/WWDC/AppCoordinator+Bookmarks.swift @@ -16,10 +16,10 @@ import RxRealm import ConfCore extension AppCoordinator: PUITimelineDelegate, VideoPlayerViewControllerDelegate { - + func createBookmark(at timecode: Double, with snapshot: NSImage?) { guard let session = currentPlayerController?.sessionViewModel.session else { return } - + storage.modify(session) { bgSession in let bookmark = Bookmark() bookmark.timecode = timecode @@ -27,56 +27,56 @@ extension AppCoordinator: PUITimelineDelegate, VideoPlayerViewControllerDelegate bgSession.bookmarks.append(bookmark) } - + // TODO: begin editing new bookmark } - + func viewControllerForTimelineAnnotation(_ annotation: PUITimelineAnnotation) -> NSViewController? { guard let bookmark = annotation as? Bookmark else { return nil } - + return BookmarkViewController(bookmark: bookmark, storage: storage) } - + func timelineDidHighlightAnnotation(_ annotation: PUITimelineAnnotation?) { - + } - + func timelineDidSelectAnnotation(_ annotation: PUITimelineAnnotation?) { guard let annotation = annotation else { return } - + currentPlayerController?.playerView.seek(to: annotation) } - + func timelineCanDeleteAnnotation(_ annotation: PUITimelineAnnotation) -> Bool { return true } - + func timelineCanMoveAnnotation(_ annotation: PUITimelineAnnotation) -> Bool { return true } - + func timelineDidMoveAnnotation(_ annotation: PUITimelineAnnotation, to timestamp: Double) { storage.moveBookmark(with: annotation.identifier, to: timestamp) } - + func timelineDidDeleteAnnotation(_ annotation: PUITimelineAnnotation) { storage.softDeleteBookmark(with: annotation.identifier) } - + } extension Bookmark: PUITimelineAnnotation { - + public var isEmpty: Bool { return body.isEmpty } - + public var timestamp: Double { return timecode } - + public var isValid: Bool { return !isInvalidated && !isDeleted } - + } diff --git a/WWDC/AppCoordinator+SessionActions.swift b/WWDC/AppCoordinator+SessionActions.swift index 07e006dd..b5d91070 100644 --- a/WWDC/AppCoordinator+SessionActions.swift +++ b/WWDC/AppCoordinator+SessionActions.swift @@ -13,63 +13,63 @@ import ConfCore import PlayerUI extension AppCoordinator: SessionActionsViewControllerDelegate { - + func sessionActionsDidSelectCancelDownload(_ sender: NSView?) { guard let viewModel = selectedViewModelRegardlessOfTab else { return } - + guard let url = viewModel.session.assets.filter({ $0.assetType == .hdVideo }).first?.remoteURL else { return } - + _ = DownloadManager.shared.cancelDownload(url) } - + func sessionActionsDidSelectFavorite(_ sender: NSView?) { guard let viewModel = selectedViewModelRegardlessOfTab else { return } - + if viewModel.isFavorite { storage.removeFavorite(for: viewModel.session) } else { storage.createFavorite(for: viewModel.session) } } - + func sessionActionsDidSelectSlides(_ sender: NSView?) { guard let viewModel = selectedViewModelRegardlessOfTab else { return } - + guard let slidesAsset = viewModel.session.asset(of: .slides) else { return } - + guard let url = URL(string: slidesAsset.remoteURL) else { return } - + NSWorkspace.shared().open(url) } - + func sessionActionsDidSelectDownload(_ sender: NSView?) { guard let viewModel = selectedViewModelRegardlessOfTab else { return } - + guard let videoAsset = viewModel.session.assets.filter({ $0.assetType == .hdVideo }).first else { return } - + DownloadManager.shared.download(videoAsset) } - - func sessionActionsDidSelectDeleteDownload(_ sender: NSView?) { + + func sessionActionsDidSelectDeleteDownload(_ sender: NSView?) { guard let viewModel = selectedViewModelRegardlessOfTab else { return } - + guard let videoAsset = viewModel.session.assets.filter({ $0.assetType == .hdVideo }).first else { return } - + let alert = WWDCAlert.create() - + alert.messageText = "Remove downloaded video" alert.informativeText = "Are you sure you want to delete the downloaded video? This action can't be undone." - + alert.addButton(withTitle: "No") alert.addButton(withTitle: "Yes") - + enum Choice: Int { case yes = 1001 case no = 1000 } - + guard let choice = Choice(rawValue: alert.runModal()) else { return } - + switch choice { case .yes: DownloadManager.shared.deleteDownload(for: videoAsset) @@ -77,17 +77,16 @@ extension AppCoordinator: SessionActionsViewControllerDelegate { break } } - + func sessionActionsDidSelectShare(_ sender: NSView?) { guard let sender = sender else { return } guard let viewModel = selectedViewModelRegardlessOfTab else { return } - + guard let webpageAsset = viewModel.session.assets.filter({ $0.assetType == .webpage }).first else { return } - + guard let url = URL(string: webpageAsset.remoteURL) else { return } - + let picker = NSSharingServicePicker(items: [url]) picker.show(relativeTo: .zero, of: sender, preferredEdge: .minY) } - } diff --git a/WWDC/AppCoordinator+Shelf.swift b/WWDC/AppCoordinator+Shelf.swift index 1adc960f..f9afd8c3 100644 --- a/WWDC/AppCoordinator+Shelf.swift +++ b/WWDC/AppCoordinator+Shelf.swift @@ -13,55 +13,55 @@ import ConfCore import PlayerUI extension AppCoordinator: ShelfViewControllerDelegate { - + func updateShelfBasedOnSelectionChange() { guard !isTransitioningPlayerContext else { return } - + guard currentPlaybackViewModel != nil else { return } guard let playerController = currentPlayerController else { return } - + guard playerOwnerSessionIdentifier != selectedViewModelRegardlessOfTab?.identifier else { playerController.view.isHidden = false return } - + playerController.view.isHidden = true - + // ignore when not playing or when playing externally guard playerController.playerView.isInternalPlayerPlaying else { return } - + // ignore when playing in fullscreen guard !playerController.playerView.isInFullScreenPlayerWindow else { return } - + guard !canRestorePlaybackContext else { return } - + // if the user selected a different session/tab during playback, we move the player to PiP mode and hide the player on the shelf - + if !playerController.playerView.isInPictureInPictureMode { playerController.playerView.togglePip(nil) } - + canRestorePlaybackContext = true } - + func goBackToContextBeforePiP(_ isReturningFromPip: Bool) { isTransitioningPlayerContext = true defer { isTransitioningPlayerContext = false } - + guard canRestorePlaybackContext else { return } guard playerOwnerSessionIdentifier != selectedViewModelRegardlessOfTab?.identifier else { return } guard let identifier = playerOwnerSessionIdentifier else { return } guard let tab = playerOwnerTab else { return } - + if isReturningFromPip { tabController.activeTab = tab currentListController.selectSession(with: identifier) currentPlayerController?.view.isHidden = false } - + canRestorePlaybackContext = false } - + func shelfViewControllerDidSelectPlay(_ shelfController: ShelfViewController) { if let playerController = currentPlayerController { if playerController.playerView.isInFullScreenPlayerWindow { @@ -69,60 +69,60 @@ extension AppCoordinator: ShelfViewControllerDelegate { playerController.detachedWindowController.close() } } - + self.currentPlaybackViewModel = nil - + guard let viewModel = shelfController.viewModel else { return } - + self.playerOwnerTab = activeTab self.playerOwnerSessionIdentifier = selectedViewModelRegardlessOfTab?.identifier - + do { let playbackViewModel = try PlaybackViewModel(sessionViewModel: viewModel, storage: storage) - + canRestorePlaybackContext = false isTransitioningPlayerContext = false - + self.currentPlaybackViewModel = playbackViewModel - + if currentPlayerController == nil { currentPlayerController = VideoPlayerViewController(player: playbackViewModel.player, session: viewModel) currentPlayerController?.playerWillExitPictureInPicture = { [weak self] isReturningFromPip in self?.goBackToContextBeforePiP(isReturningFromPip) } - + currentPlayerController?.delegate = self currentPlayerController?.playerView.timelineDelegate = self } else { currentPlayerController?.player = playbackViewModel.player currentPlayerController?.sessionViewModel = viewModel } - + currentPlayerController?.playbackViewModel = playbackViewModel - + attachPlayerToShelf(shelfController) } catch { WWDCAlert.show(with: error) } } - + private func attachPlayerToShelf(_ shelf: ShelfViewController) { guard let playerController = currentPlayerController else { return } - + shelf.playButton.isHidden = true - + playerController.view.frame = shelf.view.bounds playerController.view.alphaValue = 0 playerController.view.isHidden = false - + playerController.view.translatesAutoresizingMaskIntoConstraints = false - + shelf.view.addSubview(playerController.view) shelf.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "|-(0)-[playerView]-(0)-|", options: [], metrics: nil, views: ["playerView": playerController.view])) - + shelf.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-(0)-[playerView]-(0)-|", options: [], metrics: nil, views: ["playerView": playerController.view])) - + playerController.view.alphaValue = 1 } - + } diff --git a/WWDC/AppCoordinator+UserActivity.swift b/WWDC/AppCoordinator+UserActivity.swift index 11e4d233..64f35485 100644 --- a/WWDC/AppCoordinator+UserActivity.swift +++ b/WWDC/AppCoordinator+UserActivity.swift @@ -13,22 +13,22 @@ import ConfCore import PlayerUI extension AppCoordinator { - + func updateCurrentActivity(with item: UserActivityRepresentable?) { guard let item = item else { currentActivity?.invalidate() currentActivity = nil return } - + let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb) - + activity.title = item.title activity.webpageURL = item.webUrl - + activity.becomeCurrent() - + currentActivity = activity } - + } diff --git a/WWDC/AppCoordinator.swift b/WWDC/AppCoordinator.swift index 6a0adfc8..b9676d8e 100644 --- a/WWDC/AppCoordinator.swift +++ b/WWDC/AppCoordinator.swift @@ -14,58 +14,58 @@ import PlayerUI import ThrowBack final class AppCoordinator { - + private let disposeBag = DisposeBag() - + var liveObserver: LiveObserver - + var storage: Storage var syncEngine: SyncEngine - + var windowController: MainWindowController var tabController: WWDCTabViewController - + var scheduleController: SessionsSplitViewController var videosController: SessionsSplitViewController - + var currentPlayerController: VideoPlayerViewController? - + var currentActivity: NSUserActivity? - + var activeTab: MainWindowTab = .schedule - + /// The tab that "owns" the current player (the one that was active when the "play" button was pressed) var playerOwnerTab: MainWindowTab? = nil - + /// The session that "owns" the current player (the one that was selected on the active tab when "play" was pressed) var playerOwnerSessionIdentifier: String? = nil - + /// Whether playback can be restored to the previous context when exiting PiP mode (go back to tab/session) var canRestorePlaybackContext = false - + /// Whether we're currently in the middle of a player context transition var isTransitioningPlayerContext = false - + init(windowController: MainWindowController) { let filePath = PathUtil.appSupportPath + "/ConfCore.realm" - + var realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: filePath)) realmConfig.schemaVersion = Constants.coreSchemaVersion - + let client = AppleAPIClient(environment: .current) - + do { self.storage = try Storage(realmConfig) - + self.syncEngine = SyncEngine(storage: storage, client: client) } catch { fatalError("Realm initialization error: \(error)") } - + DownloadManager.shared.start(with: storage) - + self.tabController = WWDCTabViewController(windowController: windowController) - + // Schedule self.scheduleController = SessionsSplitViewController(windowController: windowController, listStyle: .schedule) self.scheduleController.identifier = "Schedule" @@ -74,7 +74,7 @@ final class AppCoordinator { let scheduleItem = NSTabViewItem(viewController: scheduleController) scheduleItem.label = "Schedule" self.tabController.addTabViewItem(scheduleItem) - + // Videos self.videosController = SessionsSplitViewController(windowController: windowController, listStyle: .videos) self.videosController.identifier = "Videos" @@ -83,19 +83,19 @@ final class AppCoordinator { let videosItem = NSTabViewItem(viewController: videosController) videosItem.label = "Videos" self.tabController.addTabViewItem(videosItem) - + self.windowController = windowController - + self.liveObserver = LiveObserver(dateProvider: Today, storage: storage) - + setupBindings() setupDelegation() - + NotificationCenter.default.addObserver(forName: .WWDCTabViewControllerDidFinishLoading, object: nil, queue: nil) { _ in self.restoreApplicationState() } NotificationCenter.default.addObserver(forName: .NSApplicationDidFinishLaunching, object: nil, queue: nil) { _ in self.startup() } NotificationCenter.default.addObserver(forName: .NSApplicationWillTerminate, object: nil, queue: nil) { _ in self.saveApplicationState() } } - + /// The list controller for the active tab var currentListController: SessionsTableViewController { switch activeTab { @@ -105,51 +105,51 @@ final class AppCoordinator { return videosController.listViewController } } - + /// The session that is currently selected on the videos tab (observable) var selectedSession: Observable { return videosController.listViewController.selectedSession.asObservable() } - + /// The session that is currently selected on the schedule tab (observable) var selectedScheduleItem: Observable { return scheduleController.listViewController.selectedSession.asObservable() } - + /// The session that is currently selected on the videos tab var selectedSessionValue: SessionViewModel? { return videosController.listViewController.selectedSession.value } - + /// The session that is currently selected on the schedule tab var selectedScheduleItemValue: SessionViewModel? { return scheduleController.listViewController.selectedSession.value } - + /// The selected session's view model, regardless of which tab it is selected in var selectedViewModelRegardlessOfTab: SessionViewModel? - + /// The viewModel for the current playback session var currentPlaybackViewModel: PlaybackViewModel? - + private func setupBindings() { tabController.rxActiveTab.subscribe(onNext: { [weak self] activeTab in self?.activeTab = activeTab - + self?.updateSelectedViewModelRegardlessOfTab() }).addDisposableTo(self.disposeBag) - + selectedSession.subscribeOn(MainScheduler.instance).subscribe(onNext: { [weak self] viewModel in self?.videosController.detailViewController.viewModel = viewModel self?.updateSelectedViewModelRegardlessOfTab() }).addDisposableTo(self.disposeBag) - + selectedScheduleItem.subscribeOn(MainScheduler.instance).subscribe(onNext: { [weak self] viewModel in self?.scheduleController.detailViewController.viewModel = viewModel self?.updateSelectedViewModelRegardlessOfTab() }).addDisposableTo(self.disposeBag) } - + private func updateSelectedViewModelRegardlessOfTab() { switch activeTab { case .schedule: @@ -157,26 +157,26 @@ final class AppCoordinator { case .videos: self.selectedViewModelRegardlessOfTab = selectedSessionValue } - + self.updateShelfBasedOnSelectionChange() self.updateCurrentActivity(with: selectedViewModelRegardlessOfTab) } - + private func setupDelegation() { let videoDetail = videosController.detailViewController - + videoDetail.shelfController.delegate = self videoDetail.summaryController.actionsViewController.delegate = self - + let scheduleDetail = scheduleController.detailViewController - + scheduleDetail.shelfController.delegate = self scheduleDetail.summaryController.actionsViewController.delegate = self videosController.listViewController.delegate = self scheduleController.listViewController.delegate = self } - + private func updateListsAfterSync(migrate: Bool = false) { if migrate { presentMigrationInterfaceIfNeeded { [weak self] in @@ -186,61 +186,61 @@ final class AppCoordinator { } else { doUpdateLists() } - + DownloadManager.shared.syncWithFileSystem() } - + private func doUpdateLists() { if !storage.isEmpty { tabController.hideLoading() } - + storage.tracksObservable.subscribe(onNext: { [weak self] tracks in self?.videosController.listViewController.tracks = tracks }).dispose() - + storage.scheduleObservable.subscribe(onNext: { [weak self] sections in self?.scheduleController.listViewController.scheduleSections = sections }).dispose() - + liveObserver.start() - + restoreListStatesIfNeeded() - + setupSearch() } - + private lazy var searchCoordinator: SearchCoordinator = { return SearchCoordinator(self.storage, sessionsController: self.scheduleController.listViewController, videosController: self.videosController.listViewController, restorationFiltersState: Preferences.shared.filtersState) }() - + private func setupSearch() { searchCoordinator.configureFilters() } - + @IBAction func refresh(_ sender: Any?) { syncEngine.syncContent() syncEngine.syncLiveVideos() liveObserver.refresh() - + resetAutorefreshTimer() } - + func startup() { RemoteEnvironment.shared.start() - + ContributorsFetcher.shared.load() - + windowController.contentViewController = tabController windowController.showWindow(self) - + if storage.isEmpty { tabController.showLoading() } - + NotificationCenter.default.addObserver(forName: .SyncEngineDidSyncSessionsAndSchedule, object: nil, queue: OperationQueue.main) { note in if let error = note.object as? Error { NSApp.presentError(error) @@ -248,94 +248,89 @@ final class AppCoordinator { self.updateListsAfterSync(migrate: true) } } - + NotificationCenter.default.addObserver(forName: .WWDCEnvironmentDidChange, object: nil, queue: OperationQueue.main) { _ in self.refresh(nil) } - + refresh(nil) updateListsAfterSync() - + if Arguments.showPreferences { showPreferences(nil) } else { showAccountPreferencesIfAppropriate() } } - + private func showAccountPreferencesIfAppropriate() { guard !Preferences.shared.showedAccountPromptAtStartup else { return } - + guard TBUserDataMigrator.presentedMigrationPrompt else { return } - + Preferences.shared.showedAccountPromptAtStartup = true - + showAccountPreferences() } - + func receiveNotification(with userInfo: [String : Any]) -> Bool { - if liveObserver.processSubscriptionNotification(with: userInfo) { - return true - } else if RemoteEnvironment.shared.processSubscriptionNotification(with: userInfo) { - return true - } else { - return false - } + return liveObserver.processSubscriptionNotification(with: userInfo) && + RemoteEnvironment.shared.processSubscriptionNotification(with: userInfo) } - + // MARK: - State restoration - + private var didRestoreLists = false - + private var deferredLink: DeepLink? - + private func saveApplicationState() { Preferences.shared.activeTab = activeTab Preferences.shared.selectedScheduleItemIdentifier = selectedScheduleItemValue?.identifier Preferences.shared.selectedVideoItemIdentifier = selectedSessionValue?.identifier Preferences.shared.filtersState = searchCoordinator.currentFiltersState() } - + private func restoreApplicationState() { self.tabController.activeTab = Preferences.shared.activeTab } - + private func restoreListStatesIfNeeded() { defer { didRestoreLists = true } - + if let link = deferredLink { return handle(link: link) } - + guard !didRestoreLists else { return } - + if !scrollToToday() { if let identifier = Preferences.shared.selectedScheduleItemIdentifier { self.scheduleController.listViewController.selectSession(with: identifier) } } - + if let identifier = Preferences.shared.selectedVideoItemIdentifier { self.videosController.listViewController.selectSession(with: identifier) } } - + private func scrollToToday() -> Bool { guard liveObserver.isWWDCWeek else { return false } - + scheduleController.listViewController.scrollToToday() - + return true } - + // MARK: - Deep linking - + func handle(link: DeepLink) { guard didRestoreLists else { self.deferredLink = link return } - + if link.isForCurrentYear { self.tabController.activeTab = .schedule self.scheduleController.listViewController.selectSession(with: link.sessionIdentifier) @@ -344,93 +339,93 @@ final class AppCoordinator { self.videosController.listViewController.selectSession(with: link.sessionIdentifier) } } - + // MARK: - Preferences - + private lazy var preferencesCoordinator: PreferencesCoordinator = PreferencesCoordinator() - + func showAccountPreferences() { preferencesCoordinator.show(in: .account) } - + func showPreferences(_ sender: Any?) { preferencesCoordinator.show() } - + // MARK: - About window - + fileprivate lazy var aboutWindowController: AboutWindowController = { var aboutWC = AboutWindowController(infoText: ContributorsFetcher.shared.infoText) - + ContributorsFetcher.shared.infoTextChangedCallback = { [unowned self] newText in self.aboutWindowController.infoText = newText } - + return aboutWC }() - + func showAboutWindow() { aboutWindowController.showWindow(nil) } - + // MARK: - Autorefresh - + private var autorefreshTimer: Timer! - + private func resetAutorefreshTimer() { if autorefreshTimer != nil { autorefreshTimer.invalidate() autorefreshTimer = nil } - + guard Preferences.shared.refreshPeriodically else { return } - - autorefreshTimer = Timer.scheduledTimer(timeInterval: Constants.autorefreshInterval, target: self, selector: #selector(refresh(_:)), userInfo: nil, repeats: false) + + autorefreshTimer = Timer.scheduledTimer(timeInterval: Constants.autorefreshInterval, target: self, selector: #selector(refresh), userInfo: nil, repeats: false) autorefreshTimer.tolerance = Constants.autorefreshInterval / 3 } - + // MARK: - Data migration - + private var userIsThinkingVeryHardAboutMigration = false - + private var migrator: TBUserDataMigrator! - + private func presentMigrationInterfaceIfNeeded(completion: @escaping () -> Void) { guard !ProcessInfo.processInfo.arguments.contains("--skip-migration") else { completion() return } - + guard !userIsThinkingVeryHardAboutMigration else { return } - + userIsThinkingVeryHardAboutMigration = true - + if migrator != nil { guard !migrator.isPerformingMigration else { return } } - + let legacyURL = URL(fileURLWithPath: PathUtil.appSupportPath + "/default.realm") self.migrator = TBUserDataMigrator(legacyDatabaseFileURL: legacyURL, newRealm: storage.realm) - + guard self.migrator.needsMigration && !TBUserDataMigrator.presentedMigrationPrompt else { completion() return } - + let alert = WWDCAlert.create() alert.messageText = "Migrate your data" alert.informativeText = "I noticed you have a previous version's data on your Mac. Do you want to migrate your preferences, favorites and other user data to the new version?\n\nNOTICE: if you import your data, old versions of the app will no longer work on this computer." - + alert.addButton(withTitle: "Migrate Data") alert.addButton(withTitle: "Start Fresh") alert.addButton(withTitle: "Quit") - + enum Choice: Int { case migrate = 1000 case startFresh = 1001 case quit = 1002 } - + guard let choice = Choice(rawValue: alert.runModal()) else { return } - + switch choice { case .migrate: migrator.performMigration { [weak self] result in @@ -441,18 +436,18 @@ final class AppCoordinator { case .quit: NSApp.terminate(nil) } - + TBUserDataMigrator.presentedMigrationPrompt = true } - + private func migrationFinished(with result: TBMigrationResult, completion: @escaping () -> Void) { switch result { case .failed(let error): WWDCAlert.show(with: error) default: break } - + completion() } - + } diff --git a/WWDC/AppDelegate.swift b/WWDC/AppDelegate.swift index fcfa49b1..8c0efc07 100644 --- a/WWDC/AppDelegate.swift +++ b/WWDC/AppDelegate.swift @@ -12,19 +12,19 @@ import CommunitySupport class AppDelegate: NSObject, NSApplicationDelegate { let coordinator = AppCoordinator(windowController: MainWindowController()) - + func applicationWillFinishLaunching(_ notification: Notification) { NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(handleURLEvent(_:replyEvent:)), forEventClass: UInt32(kInternetEventClass), andEventID: UInt32(kAEGetURL)) } - + func applicationDidFinishLaunching(_ notification: Notification) { UserDefaults.standard.register(defaults: ["NSApplicationCrashOnExceptions": true]) - + LoggingHelper.install() - + NSApp.registerForRemoteNotifications(matching: []) } - + func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String : Any]) { if CMSCommunityCenter.shared.processNotification(userInfo: userInfo) { // Community center handled this notification @@ -33,34 +33,34 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Coordinator handled this notification return } - + // handle other types of notification } - + func handleURLEvent(_ event: NSAppleEventDescriptor?, replyEvent: NSAppleEventDescriptor?) { guard let event = event else { return } guard let urlString = event.paramDescriptor(forKeyword: UInt32(keyDirectObject))?.stringValue else { return } guard let url = URL(string: urlString) else { return } guard let link = DeepLink(url: url) else { return } - + coordinator.handle(link: link) } @IBAction func showPreferences(_ sender: Any) { coordinator.showPreferences(sender) } - + @IBAction func reload(_ sender: Any) { coordinator.refresh(sender) } - + @IBAction func showAboutWindow(_ sender: Any) { coordinator.showAboutWindow() } - + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { coordinator.windowController.showWindow(sender) - + return true } diff --git a/WWDC/Arguments.swift b/WWDC/Arguments.swift index 90cdf0fb..659ff4f1 100644 --- a/WWDC/Arguments.swift +++ b/WWDC/Arguments.swift @@ -9,33 +9,33 @@ import Foundation struct Arguments { - + private static var args: [String] { return ProcessInfo.processInfo.arguments } - + static var skipMigration: Bool { return args.contains("--skip-migration") } - + static var useTestVideo: Bool { return args.contains("--use-test-video") } - + static var showPreferences: Bool { return args.contains("--prefs") } - + static var disableRemoteEnvironment: Bool { return args.contains("--no-remote") } - + static var deloreanDate: String? { guard let deloreanIndex = args.index(of: "--delorean") else { return nil } - + guard args.count > deloreanIndex + 1 else { return nil } - + return args[deloreanIndex + 1] } - + } diff --git a/WWDC/BookmarkViewController.swift b/WWDC/BookmarkViewController.swift index bb288bf1..33298292 100644 --- a/WWDC/BookmarkViewController.swift +++ b/WWDC/BookmarkViewController.swift @@ -17,13 +17,13 @@ extension Notification.Name { } 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) } @@ -32,101 +32,100 @@ private final class WWDCTextView: NSTextView { override func didChangeText() { super.didChangeText() - + NotificationCenter.default.post(name: .WWDCTextViewTextChanged, object: self) } - } final class BookmarkViewController: NSViewController { let bookmark: Bookmark let storage: Storage - + init(bookmark: Bookmark, storage: Storage) { self.storage = storage self.bookmark = bookmark - + super.init(nibName: nil, bundle: nil)! } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + fileprivate lazy var imageView: WWDCImageView = { let v = WWDCImageView() - + v.widthAnchor.constraint(equalToConstant: 85).isActive = true v.heightAnchor.constraint(equalToConstant: 48).isActive = true v.backgroundColor = .black - + return v }() - + private lazy var textView: WWDCTextView = { let v = WWDCTextView() - + v.drawsBackground = false v.backgroundColor = .clear - v.font = NSFont.systemFont(ofSize: 12) + v.font = .systemFont(ofSize: 12) v.textColor = .secondaryText v.autoresizingMask = [.viewWidthSizable, .viewHeightSizable] - + return v }() - + private lazy var scrollView: NSScrollView = { let v = NSScrollView() - + v.drawsBackground = false v.backgroundColor = .clear v.borderType = .noBorder v.documentView = self.textView - + return v }() - + private lazy var stackView: NSStackView = { let v = NSStackView(views: [self.imageView, self.scrollView]) - + v.orientation = .horizontal v.spacing = 8 v.alignment = .centerY v.translatesAutoresizingMaskIntoConstraints = false v.edgeInsets = EdgeInsets(top: 4, left: 4, bottom: 4, right: 4) - + return v }() - + override func loadView() { view = NSView() view.wantsLayer = true view.appearance = WWDCAppearance.appearance() - + scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false - + view.addSubview(stackView) stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true stackView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true } - + private let disposeBag = DisposeBag() - + override func viewDidLoad() { super.viewDidLoad() - + imageView.image = NSImage(data: bookmark.snapshot) textView.string = bookmark.body - + textView.rxText.throttle(1, scheduler: MainScheduler.instance).subscribe(onNext: { [weak self] text in guard let bookmark = self?.bookmark else { return } self?.storage.modify(bookmark) { $0.body = text } }).addDisposableTo(self.disposeBag) } - + } diff --git a/WWDC/ChromeCastPlaybackProvider.swift b/WWDC/ChromeCastPlaybackProvider.swift index a5667fdc..b860a84b 100644 --- a/WWDC/ChromeCastPlaybackProvider.swift +++ b/WWDC/ChromeCastPlaybackProvider.swift @@ -130,7 +130,7 @@ final class ChromeCastPlaybackProvider: NSObject, PUIExternalPlaybackProvider { let menu = NSMenu() scanner.devices.forEach { device in - let item = NSMenuItem(title: device.name, action: #selector(didSelectDeviceOnMenu(_:)), keyEquivalent: "") + let item = NSMenuItem(title: device.name, action: #selector(didSelectDeviceOnMenu), keyEquivalent: "") item.representedObject = device item.target = self @@ -280,7 +280,7 @@ final class ChromeCastPlaybackProvider: NSObject, PUIExternalPlaybackProvider { } fileprivate func startFetchingMediaStatusPeriodically() { - mediaStatusRefreshTimer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(requestMediaStatus(_:)), userInfo: nil, repeats: true) + mediaStatusRefreshTimer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(requestMediaStatus), userInfo: nil, repeats: true) } @objc private func requestMediaStatus(_ sender: Any?) { diff --git a/WWDC/DeepLink.swift b/WWDC/DeepLink.swift index f6d3d8a6..bccbf145 100644 --- a/WWDC/DeepLink.swift +++ b/WWDC/DeepLink.swift @@ -9,36 +9,36 @@ import Foundation struct DeepLink { - + private struct LinkConstants { static let host = "developer.apple.com" } - + let year: Int let eventIdentifier: String let sessionNumber: Int - + let isForCurrentYear: Bool - + var sessionIdentifier: String { return "\(year)-\(sessionNumber)" } - + init?(url: URL) { guard url.host == LinkConstants.host else { return nil } - + let components = url.pathComponents - + guard components.count >= 3 else { return nil } - + let yearParameter = components[components.count - 2] let sessionIdentifierParameter = components[components.count - 1] - + guard let sessionNumber = Int(sessionIdentifierParameter) else { return nil } - + let year: String let fullYear: String - + if yearParameter.characters.count > 6 { year = yearParameter.substring(from: yearParameter.index(yearParameter.endIndex, offsetBy: -4)) fullYear = year @@ -47,15 +47,15 @@ struct DeepLink { // this will only work for the next 983 years ¯\_(ツ)_/¯ fullYear = "20\(year)" } - + guard let yearNumber = Int(fullYear) else { return nil } let currentYear = "\(Calendar.current.component(.year, from: Today()))" let currentYearDigits = currentYear.substring(from: currentYear.index(currentYear.startIndex, offsetBy: 2)) - + self.year = yearNumber self.eventIdentifier = "wwdc\(year)" self.sessionNumber = sessionNumber self.isForCurrentYear = (year == currentYearDigits) } - + } diff --git a/WWDC/DownloadManager.swift b/WWDC/DownloadManager.swift index 375ba8d4..221db882 100644 --- a/WWDC/DownloadManager.swift +++ b/WWDC/DownloadManager.swift @@ -12,7 +12,7 @@ import ConfCore import RealmSwift extension Notification.Name { - + static let DownloadManagerFileAddedNotification = Notification.Name("DownloadManagerFileAddedNotification") static let DownloadManagerFileDeletedNotification = Notification.Name("DownloadManagerFileDeletedNotification") static let DownloadManagerDownloadStarted = Notification.Name("DownloadManagerDownloadStarted") @@ -22,7 +22,7 @@ extension Notification.Name { static let DownloadManagerDownloadFailed = Notification.Name("DownloadManagerDownloadFailed") static let DownloadManagerDownloadFinished = Notification.Name("DownloadManagerDownloadFinished") static let DownloadManagerDownloadProgressChanged = Notification.Name("DownloadManagerDownloadProgressChanged") - + } enum DownloadStatus { @@ -35,40 +35,40 @@ enum DownloadStatus { } extension URL { - + var isDirectory: Bool { guard isFileURL else { return false } var directory: ObjCBool = false return FileManager.default.fileExists(atPath: path, isDirectory: &directory) ? directory.boolValue : false } - + var subDirectories: [URL] { guard isDirectory else { return [] } return (try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]).filter{ $0.isDirectory }) ?? [] } - + } final class DownloadManager: NSObject { - + fileprivate let configuration = URLSessionConfiguration.background(withIdentifier: "WWDC Video Downloader") fileprivate var backgroundSession: Foundation.URLSession! fileprivate var downloadTasks: [String : URLSessionDownloadTask] = [:] fileprivate let defaults = UserDefaults.standard - + fileprivate var storage: Storage! - + static let shared: DownloadManager = DownloadManager() - + override init() { super.init() - + self.backgroundSession = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) } - + func start(with storage: Storage) { self.storage = storage - + backgroundSession.getTasksWithCompletionHandler { _, _, pendingTasks in for task in pendingTasks { if let key = task.originalRequest?.url!.absoluteString { @@ -76,7 +76,7 @@ final class DownloadManager: NSObject { } } } - + NotificationCenter.default.addObserver(forName: .LocalVideoStoragePathPreferenceDidChange, object: nil, queue: nil) { _ in self.monitorDownloadsFolder() } @@ -84,33 +84,33 @@ final class DownloadManager: NSObject { updateDownloadedFlagsOfPreviouslyDownloaded() monitorDownloadsFolder() } - + fileprivate func localStoragePath(for asset: SessionAsset) -> String { return Preferences.shared.localVideoStorageURL.appendingPathComponent(asset.relativeLocalURL).path } - + func allTasks() -> [URLSessionDownloadTask] { return Array(self.downloadTasks.values) } - + func download(_ asset: SessionAsset) { downloadRemoteUrl(asset.remoteURL) } - + private func downloadRemoteUrl(_ url: String) { if isDownloading(url) || hasVideo(url) { return } - + let task = backgroundSession.downloadTask(with: URL(string: url)!) if let key = task.originalRequest?.url!.absoluteString { self.downloadTasks[key] = task } task.resume() - + NotificationCenter.default.post(name: .DownloadManagerDownloadStarted, object: url) } - + func pauseDownload(_ url: String) -> Bool { if let task = downloadTasks[url] { task.suspend() @@ -120,7 +120,7 @@ final class DownloadManager: NSObject { print("VideoStore was asked to pause downloading URL \(url), but there's no task for that URL") return false } - + func resumeDownload(_ url: String) -> Bool { if let task = downloadTasks[url] { task.resume() @@ -130,7 +130,7 @@ final class DownloadManager: NSObject { print("VideoStore was asked to resume downloading URL \(url), but there's no task for that URL") return false } - + func cancelDownload(_ url: String) -> Bool { if let task = downloadTasks[url] { task.cancel() @@ -141,66 +141,66 @@ final class DownloadManager: NSObject { print("VideoStore was asked to cancel downloading URL \(url), but there's no task for that URL") return false } - + func isDownloading(_ url: String) -> Bool { let downloading = downloadTasks.keys.filter { taskURL in return url == taskURL } - + return (downloading.count > 0) } - + func localVideoPath(_ remoteURL: String) -> String? { guard let url = URL(string: remoteURL) else { return nil } - + guard let asset = storage.asset(with: url) else { return nil } - + let path = localStoragePath(for: asset) - + return path } - + func localFileURL(for session: Session) -> URL? { guard let asset = session.asset(of: .hdVideo) else { return nil } - + let path = self.localStoragePath(for: asset) - + guard FileManager.default.fileExists(atPath: path) else { return nil } - + return URL(fileURLWithPath: path) } - + func localVideoAbsoluteURLString(_ remoteURL: String) -> String? { guard let localPath = localVideoPath(remoteURL) else { return nil } - + return URL(fileURLWithPath: localPath).absoluteString } - + func hasVideo(_ url: String) -> Bool { guard let path = self.localVideoPath(url) else { return false } - + return FileManager.default.fileExists(atPath: path) } - + enum RemoveDownloadError: Error { case notDownloaded case fileSystem(Error) case internalError(String) } - + func removeDownload(_ url: String) throws { if isDownloading(url) { _ = cancelDownload(url) return } - + if hasVideo(url) { guard let path = localVideoPath(url) else { throw RemoveDownloadError.internalError("Unable to generate local video path from remote URL") } - + do { try FileManager.default.removeItem(atPath: path) } catch { @@ -210,7 +210,7 @@ final class DownloadManager: NSObject { throw RemoveDownloadError.notDownloaded } } - + func deleteDownload(for asset: SessionAsset) { do { try removeDownload(asset.remoteURL) @@ -218,26 +218,26 @@ final class DownloadManager: NSObject { WWDCAlert.show(with: error) } } - + func downloadedFileURL(for session: Session) -> URL? { guard let asset = session.assets.filter("rawAssetType == %@", SessionAssetType.hdVideo.rawValue).first else { return nil } - + let path = self.localStoragePath(for: asset) - + guard FileManager.default.fileExists(atPath: path) else { return nil } - + return URL(fileURLWithPath: path) } - + func downloadStatusObservable(for session: Session) -> Observable? { guard let asset = session.asset(of: .hdVideo) else { return nil } - + return Observable.create { observer -> Disposable in let nc = NotificationCenter.default let q = OperationQueue.main - + let checkDownloadedState = { if self.isDownloading(asset.remoteURL) { observer.onNext(.downloading(-1)) @@ -247,68 +247,68 @@ final class DownloadManager: NSObject { observer.onNext(.none) } } - + checkDownloadedState() - + let fileDeleted = nc.addObserver(forName: .DownloadManagerFileDeletedNotification, object: nil, queue: q) { note in guard asset.relativeLocalURL == note.object as? String else { return } - + observer.onNext(.none) } - + let fileAdded = nc.addObserver(forName: .DownloadManagerFileAddedNotification, object: nil, queue: q) { note in guard asset.relativeLocalURL == note.object as? String else { return } - + observer.onNext(.finished) } - + let started = nc.addObserver(forName: .DownloadManagerDownloadStarted, object: nil, queue: q) { note in guard asset.remoteURL == note.object as? String else { return } - + observer.onNext(.downloading(-1)) } - + let cancelled = nc.addObserver(forName: .DownloadManagerDownloadCancelled, object: nil, queue: q) { note in guard asset.remoteURL == note.object as? String else { return } - + observer.onNext(.cancelled) } - + let paused = nc.addObserver(forName: .DownloadManagerDownloadPaused, object: nil, queue: q) { note in guard asset.remoteURL == note.object as? String else { return } - + observer.onNext(.paused) } - + let resumed = nc.addObserver(forName: .DownloadManagerDownloadResumed, object: nil, queue: q) { note in guard asset.remoteURL == note.object as? String else { return } - + observer.onNext(.downloading(-1)) } - + let failed = nc.addObserver(forName: .DownloadManagerDownloadFailed, object: nil, queue: q) { note in guard asset.remoteURL == note.object as? String else { return } - + let error = note.userInfo?["error"] as? Error observer.onNext(.failed(error)) } - + let finished = nc.addObserver(forName: .DownloadManagerDownloadFinished, object: nil, queue: q) { note in guard asset.remoteURL == note.object as? String else { return } - + observer.onNext(.finished) } - + let progress = nc.addObserver(forName: .DownloadManagerDownloadProgressChanged, object: nil, queue: q) { note in guard asset.remoteURL == note.object as? String else { return } - + if let info = note.userInfo?["info"] as? DownloadInfo { observer.onNext(.downloading(info.progress)) } else { observer.onNext(.downloading(-1)) } } - + return Disposables.create { nc.removeObserver(fileDeleted) nc.removeObserver(fileAdded) @@ -322,55 +322,55 @@ final class DownloadManager: NSObject { } } } - + // MARK: File observation - + fileprivate var topFolderMonitor: DTFolderMonitor! fileprivate var subfoldersMonitors: [DTFolderMonitor] = [] fileprivate var existingVideoFiles = [String]() - + func syncWithFileSystem() { let videosPath = Preferences.shared.localVideoStorageURL.path updateDownloadedFlagsByEnumeratingFilesAtPath(videosPath) } - + func monitorDownloadsFolder() { if topFolderMonitor != nil { topFolderMonitor.stopMonitoring() topFolderMonitor = nil } - + subfoldersMonitors.forEach({ $0.stopMonitoring() }) subfoldersMonitors.removeAll() - + let url = Preferences.shared.localVideoStorageURL - + topFolderMonitor = DTFolderMonitor(for: url) { [unowned self] in self.setupSubdirectoryMonitors(on: url) self.updateDownloadedFlagsByEnumeratingFilesAtPath(url.path) } - + setupSubdirectoryMonitors(on: url) - + topFolderMonitor.startMonitoring() } - + private func setupSubdirectoryMonitors(on mainDirURL: URL) { subfoldersMonitors.forEach({ $0.stopMonitoring() }) subfoldersMonitors.removeAll() - + mainDirURL.subDirectories.forEach { subdir in guard let monitor = DTFolderMonitor(for: subdir, block: { [unowned self] in self.updateDownloadedFlagsByEnumeratingFilesAtPath(mainDirURL.path) }) else { return } - + subfoldersMonitors.append(monitor) - + monitor.startMonitoring() } } - + fileprivate func updateDownloadedFlagsOfPreviouslyDownloaded() { let expectedOnDisk = storage.sessions.filter(NSPredicate(format: "isDownloaded == true")) var notPresent = [String]() @@ -386,89 +386,89 @@ final class DownloadManager: NSObject { storage.updateDownloadedFlag(false, forAssetsAtPaths: notPresent) notPresent.forEach { NotificationCenter.default.post(name: .DownloadManagerFileDeletedNotification, object: $0) } } - + /// Updates the downloaded status for the sessions on the database based on the existence of the downloaded video file fileprivate func updateDownloadedFlagsByEnumeratingFilesAtPath(_ path: String) { guard let enumerator = FileManager.default.enumerator(atPath: path) else { return } guard let files = enumerator.allObjects as? [String] else { return } - + self.storage.updateDownloadedFlag(true, forAssetsAtPaths: files) - + files.forEach { NotificationCenter.default.post(name: .DownloadManagerFileAddedNotification, object: $0) } - + if self.existingVideoFiles.count == 0 { self.existingVideoFiles = files return } - + let removedFiles = self.existingVideoFiles.filter { !files.contains($0) } - + self.storage.updateDownloadedFlag(false, forAssetsAtPaths: removedFiles) - + removedFiles.forEach { NotificationCenter.default.post(name: .DownloadManagerFileDeletedNotification, object: $0) } } - + // MARK: Teardown - + deinit { if topFolderMonitor != nil { topFolderMonitor.stopMonitoring() } } - + } extension DownloadManager: URLSessionDownloadDelegate, URLSessionTaskDelegate { - + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { guard let originalURL = downloadTask.originalRequest?.url else { return } - + let originalAbsoluteURLString = originalURL.absoluteString - + guard let localPath = self.localVideoPath(originalAbsoluteURLString) else { return } let destinationUrl = URL(fileURLWithPath: localPath) let destinationDir = destinationUrl.deletingLastPathComponent() - + do { if !FileManager.default.fileExists(atPath: destinationDir.path) { try FileManager.default.createDirectory(at: destinationDir, withIntermediateDirectories: true, attributes: nil) } - + try FileManager.default.moveItem(at: location, to: destinationUrl) - + downloadTasks.removeValue(forKey: originalAbsoluteURLString) - + NotificationCenter.default.post(name: .DownloadManagerDownloadFinished, object: originalAbsoluteURLString) } catch { NotificationCenter.default.post(name: .DownloadManagerDownloadFailed, object: originalAbsoluteURLString, userInfo: ["error": error]) } } - + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let originalURL = task.originalRequest?.url else { return } - + let originalAbsoluteURLString = originalURL.absoluteString - + let info: [String: Error]? - + if let error = error { info = ["error": error] } else { info = nil } - + NotificationCenter.default.post(name: .DownloadManagerDownloadFailed, object: originalAbsoluteURLString, userInfo: info) } - + fileprivate struct DownloadInfo { let totalBytesWritten: Int64 let totalBytesExpectedToWrite: Int64 let progress: Double } - + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { guard let originalURL = downloadTask.originalRequest?.url?.absoluteString else { return } - + let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) let info = DownloadInfo(totalBytesWritten: totalBytesWritten, totalBytesExpectedToWrite: totalBytesExpectedToWrite, progress: progress) NotificationCenter.default.post(name: .DownloadManagerDownloadProgressChanged, object: originalURL, userInfo: ["info": info]) diff --git a/WWDC/FilterType.swift b/WWDC/FilterType.swift index 6ae5ac77..d3cd2619 100644 --- a/WWDC/FilterType.swift +++ b/WWDC/FilterType.swift @@ -12,7 +12,7 @@ typealias WWDCFiltersStateDictionary = [ String : [ FilterIdentifier.RawValue : typealias WWDCFilterTypeDictionary = [ String : Any ] protocol FilterType { - + var identifier: String { get set } var isEmpty: Bool { get } var predicate: NSPredicate? { get } diff --git a/WWDC/ImageDownloadCenter.swift b/WWDC/ImageDownloadCenter.swift index e961b098..58179f78 100644 --- a/WWDC/ImageDownloadCenter.swift +++ b/WWDC/ImageDownloadCenter.swift @@ -12,94 +12,94 @@ import RealmSwift typealias ImageDownloadCompletionBlock = (_ sourceURL: URL, _ original: NSImage?, _ thumbnail: NSImage?) -> Void final class ImageDownloadCenter { - + static let shared: ImageDownloadCenter = ImageDownloadCenter() - + private let cacheProvider = ImageCacheProvider() private let queue = OperationQueue() - + func downloadImage(from url: URL, thumbnailHeight: CGFloat, thumbnailOnly: Bool = false, completion: @escaping ImageDownloadCompletionBlock) -> Operation? { if let cache = cacheProvider.cacheEntity(for: url) { var original: NSImage? - + if !thumbnailOnly { original = NSImage(data: cache.original) } - + let thumb = NSImage(data: cache.thumbnail) - + original?.cacheMode = .never thumb?.cacheMode = .never - + completion(url, original, thumb) - + return nil } - + guard !hasActiveOperation(for: url) else { return nil } - + let operation = ImageDownloadOperation(url: url, cache: cacheProvider, thumbnailHeight: thumbnailHeight) operation.imageCompletionHandler = completion - + queue.addOperation(operation) - + return operation } - + func hasActiveOperation(for url: URL) -> Bool { return queue.operations.filter { op in guard let op = op as? ImageDownloadOperation else { return false } - + return op.url == url && op.isExecuting - }.count > 0 + }.count > 0 } - + } final class ImageCacheEntity: Object { - + dynamic var key: String = "" dynamic var createdAt: Date = Date() dynamic var original: Data = Data() dynamic var thumbnail: Data = Data() - + override class func primaryKey() -> String { return "key" } - + } private final class ImageCacheProvider { - + private var originalCaches: [URL: URL] = [:] private var thumbCaches: [URL: URL] = [:] - + private let upperLimit = 16 * 1024 * 1024 - + private func makeRealm() -> Realm? { let filePath = PathUtil.appSupportPath + "/ImageCache.realm" - + var realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: filePath)) realmConfig.objectTypes = [ImageCacheEntity.self] realmConfig.schemaVersion = 1 realmConfig.migrationBlock = { _, _ in } - + return try? Realm(configuration: realmConfig) } - + private lazy var realm: Realm? = { return self.makeRealm() }() - + func cacheEntity(for url: URL) -> ImageCacheEntity? { return self.realm?.object(ofType: ImageCacheEntity.self, forPrimaryKey: url.absoluteString) } - + func cacheImage(for key: URL, original: Data?, thumbnail: Data?) { guard let original = original, let thumbnail = thumbnail else { return } - + guard original.count < upperLimit, thumbnail.count < upperLimit else { return } - + DispatchQueue.global(qos: .utility).async { autoreleasepool { guard let bgRealm = self.makeRealm() else { return } @@ -122,28 +122,28 @@ private final class ImageCacheProvider { } } } - + } private final class ImageDownloadOperation: Operation { - + var imageCompletionHandler: ImageDownloadCompletionBlock? - + let url: URL let thumbnailHeight: CGFloat - + let cacheProvider: ImageCacheProvider - + init(url: URL, cache: ImageCacheProvider, thumbnailHeight: CGFloat = 1.0) { self.url = url self.cacheProvider = cache self.thumbnailHeight = thumbnailHeight } - + open override var isAsynchronous: Bool { return true } - + internal var _executing = false { willSet { willChangeValue(forKey: "isExecuting") @@ -152,11 +152,11 @@ private final class ImageDownloadOperation: Operation { didChangeValue(forKey: "isExecuting") } } - + open override var isExecuting: Bool { return _executing } - + internal var _finished = false { willSet { willChangeValue(forKey: "isFinished") @@ -165,80 +165,80 @@ private final class ImageDownloadOperation: Operation { didChangeValue(forKey: "isFinished") } } - + open override var isFinished: Bool { return _finished } - + override func start() { _executing = true - + URLSession.shared.dataTask(with: self.url) { [weak self] data, response, error in guard let welf = self else { return } - + guard !welf.isCancelled else { return } - + guard let data = data, let httpResponse = response as? HTTPURLResponse, error == nil else { DispatchQueue.main.async { welf.imageCompletionHandler?(welf.url, nil, nil) } welf._executing = false welf._finished = true return } - + guard httpResponse.statusCode == 200 else { DispatchQueue.main.async { welf.imageCompletionHandler?(welf.url, nil, nil) } welf._executing = false welf._finished = true return } - + guard data.count > 0 else { DispatchQueue.main.async { welf.imageCompletionHandler?(welf.url, nil, nil) } welf._executing = false welf._finished = true return } - + guard let originalImage = NSImage(data: data) else { DispatchQueue.main.async { welf.imageCompletionHandler?(welf.url, nil, nil) } welf._executing = false welf._finished = true return } - + guard !welf.isCancelled else { return } - + let thumbnailImage = originalImage.resized(to: welf.thumbnailHeight) - + originalImage.cacheMode = .never thumbnailImage.cacheMode = .never - + DispatchQueue.main.async { welf.imageCompletionHandler?(welf.url, originalImage, thumbnailImage) } - + welf.cacheProvider.cacheImage(for: welf.url, original: data, thumbnail: thumbnailImage.tiffRepresentation) - + welf._executing = false welf._finished = true - }.resume() + }.resume() } - + } private extension NSImage { - + func resized(to maxHeight: CGFloat) -> NSImage { let scaleFactor = maxHeight / size.height let newWidth = size.width * scaleFactor let newHeight = size.height * scaleFactor - + let resizedImage = NSImage(size: NSSize(width: newWidth, height: newHeight)) - + resizedImage.lockFocus() self.draw(in: NSRect(x: 0, y: 0, width: newWidth, height: newHeight)) resizedImage.unlockFocus() - + return resizedImage } diff --git a/WWDC/LiveObserver.swift b/WWDC/LiveObserver.swift index d8c434f5..3606c400 100644 --- a/WWDC/LiveObserver.swift +++ b/WWDC/LiveObserver.swift @@ -12,76 +12,76 @@ import RealmSwift import CloudKit final class LiveObserver { - + private let dateProvider: DateProvider private let storage: Storage - + private var timer: Timer? - + var isRunning = false - + private let specialEventsObserver: CloudKitLiveObserver - + init(dateProvider: @escaping DateProvider, storage: Storage) { self.dateProvider = dateProvider self.storage = storage self.specialEventsObserver = CloudKitLiveObserver(storage: storage) } - + var isWWDCWeek: Bool { return storage.realm.objects(Event.self).filter("startDate <= %@ AND endDate > %@ ", dateProvider(), dateProvider()).count > 0 } - + func start() { guard !isRunning else { return } - + specialEventsObserver.fetch() - + guard isWWDCWeek else { NSLog("Live event observer not started because we're not on WWDC week") isRunning = false return } - + isRunning = true - + NSLog("Live event observer started") - - self.timer = Timer.scheduledTimer(timeInterval: Constants.liveSessionCheckInterval, target: self, selector: #selector(checkForLiveSessions(_:)), userInfo: nil, repeats: true) - + + self.timer = Timer.scheduledTimer(timeInterval: Constants.liveSessionCheckInterval, target: self, selector: #selector(checkForLiveSessions), userInfo: nil, repeats: true) + // This timer doesn't have to be very precise, giving it a tolerance improves CPU and battery usage ;) self.timer?.tolerance = 10.0 - + checkForLiveSessions(nil) } - + func refresh() { specialEventsObserver.fetch() } - + private var allLiveInstances: Results { return storage.realm.objects(SessionInstance.self).filter("isCurrentlyLive == true") } - + @objc private func checkForLiveSessions(_ sender: Any?) { let startTime = dateProvider() let endTime = dateProvider().addingTimeInterval(Constants.liveSessionEndTimeTolerance) - + let previouslyLiveInstances = allLiveInstances.toArray() var notLiveAnymore: [SessionInstance] = [] - + let liveInstances = storage.realm.objects(SessionInstance.self).filter("startTime <= %@ AND endTime > %@ AND SUBQUERY(session.assets, $asset, $asset.rawAssetType == %@).@count > 0", startTime, endTime, SessionAssetType.liveStreamVideo.rawValue) - + previouslyLiveInstances.forEach { instance in if !liveInstances.contains(instance) { notLiveAnymore.append(instance) } } - + setLiveFlag(false, for: notLiveAnymore) setLiveFlag(true, for: liveInstances.toArray()) } - + private func setLiveFlag(_ value: Bool, for instances: [SessionInstance]) { storage.modify(instances) { bgInstances in bgInstances.forEach { instance in @@ -91,128 +91,128 @@ final class LiveObserver { } } } - + func processSubscriptionNotification(with userInfo: [String : Any]) -> Bool { return self.specialEventsObserver.processSubscriptionNotification(with: userInfo) } - + } private extension SessionAsset { - + static var recordType: String { return "LiveSession" } - + convenience init?(record: CKRecord) { guard let number = record["sessionNumber"] as? Int else { return nil } guard let hls = record["hls"] as? String else { return nil } - + self.init() - + self.assetType = .liveStreamVideo self.year = Calendar.current.component(.year, from: Today()) self.sessionId = "\(number)" self.remoteURL = hls self.identifier = self.generateIdentifier() } - + } private final class CloudKitLiveObserver: NSObject { - + private let storage: Storage - + private lazy var database: CKDatabase = CKContainer.default().publicCloudDatabase - + init(storage: Storage) { self.storage = storage - + super.init() - + createSubscriptionIfNeeded() } - + func fetch() { #if ICLOUD - let query = CKQuery(recordType: SessionAsset.recordType, predicate: NSPredicate(value: true)) - let operation = CKQueryOperation(query: query) - - var fetchedRecords: [CKRecord] = [] - - operation.recordFetchedBlock = { record in - fetchedRecords.append(record) - } - - operation.queryCompletionBlock = { [unowned self] _, error in - if let error = error { - NSLog("Error fetching special live videos: \(error)") - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - self.fetch() - } - } else { - DispatchQueue.main.async { self.store(fetchedRecords) } + let query = CKQuery(recordType: SessionAsset.recordType, predicate: NSPredicate(value: true)) + let operation = CKQueryOperation(query: query) + + var fetchedRecords: [CKRecord] = [] + + operation.recordFetchedBlock = { record in + fetchedRecords.append(record) } - } - - database.add(operation) + + operation.queryCompletionBlock = { [unowned self] _, error in + if let error = error { + NSLog("Error fetching special live videos: \(error)") + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + self.fetch() + } + } else { + DispatchQueue.main.async { self.store(fetchedRecords) } + } + } + + database.add(operation) #endif } - + private func createSubscriptionIfNeeded() { #if ICLOUD - CloudKitHelper.subscriptionExists(with: specialLiveEventsSubscriptionID, in: database) { [unowned self] exists in - if !exists { - self.doCreateSubscription() + CloudKitHelper.subscriptionExists(with: specialLiveEventsSubscriptionID, in: database) { [unowned self] exists in + if !exists { + self.doCreateSubscription() + } } - } #endif } - + private func doCreateSubscription() { #if ICLOUD - let options: CKQuerySubscriptionOptions = [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] - let subscription = CKQuerySubscription(recordType: SessionAsset.recordType, - predicate: NSPredicate(value: true), - subscriptionID: self.specialLiveEventsSubscriptionID, - options: options) - - database.save(subscription) { _, error in - if let error = error { - NSLog("[LiveObserver] Error creating subscriptions: \(error)") - return + let options: CKQuerySubscriptionOptions = [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] + let subscription = CKQuerySubscription(recordType: SessionAsset.recordType, + predicate: NSPredicate(value: true), + subscriptionID: self.specialLiveEventsSubscriptionID, + options: options) + + database.save(subscription) { _, error in + if let error = error { + NSLog("[LiveObserver] Error creating subscriptions: \(error)") + return + } } - } #endif } - + private let specialLiveEventsSubscriptionID: String = "SPECIAL-LIVE-EVENTS" - + func processSubscriptionNotification(with userInfo: [String : Any]) -> Bool { #if ICLOUD - let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) - - // check if the remote notification is for us, if not, tell the caller that we haven't handled it - guard notification.subscriptionID == self.specialLiveEventsSubscriptionID else { return false } - - // notification for special live events, just fetch everything again - fetch() - - return true + let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) + + // check if the remote notification is for us, if not, tell the caller that we haven't handled it + guard notification.subscriptionID == self.specialLiveEventsSubscriptionID else { return false } + + // notification for special live events, just fetch everything again + fetch() + + return true #else return false #endif } - + private func store(_ records: [CKRecord]) { print("storing live records") - + storage.backgroundUpdate { realm in records.forEach { record in guard let asset = SessionAsset(record: record) else { return } guard let session = realm.object(ofType: Session.self, forPrimaryKey: "\(asset.year)-\(asset.sessionId)") else { return } guard let instance = session.instances.first else { return } - + if let existingAsset = realm.object(ofType: SessionAsset.self, forPrimaryKey: asset.identifier) { // update existing asset hls URL if appropriate existingAsset.remoteURL = asset.remoteURL @@ -220,7 +220,7 @@ private final class CloudKitLiveObserver: NSObject { // add new live asset to corresponding session session.assets.append(asset) } - + instance.isForcedLive = (record["isLive"] as? Int == 1) instance.isCurrentlyLive = instance.isForcedLive } diff --git a/WWDC/LoggingHelper.swift b/WWDC/LoggingHelper.swift index 4e91dd67..458259f7 100644 --- a/WWDC/LoggingHelper.swift +++ b/WWDC/LoggingHelper.swift @@ -14,51 +14,51 @@ import Crashlytics import CommunitySupport final class LoggingHelper { - + static func install() { guard !Preferences.shared.userOptedOutOfCrashReporting else { return } - + Fabric.with([Crashlytics.self]) - + observeCommunityCenterNotifications() } - + static func registerCloudKitUserIdentifier(_ identifier: String) { guard !Preferences.shared.userOptedOutOfCrashReporting else { return } - + Crashlytics.sharedInstance().setUserIdentifier(identifier) } - + static func registerError(_ error: Error, info: [String: Any]? = nil) { guard !Preferences.shared.userOptedOutOfCrashReporting else { return } - + Crashlytics.sharedInstance().recordError(error, withAdditionalUserInfo: info) } - + static func registerEvent(with name: String, info: [String: Any] = [:]) { guard !Preferences.shared.userOptedOutOfCrashReporting else { return } - + Answers.logCustomEvent(withName: name, customAttributes: info) } - + static func observeCommunityCenterNotifications() { NotificationCenter.default.addObserver(forName: .CMSErrorOccurred, object: nil, queue: OperationQueue.main) { note in let error: Error - + if let noteError = note.object as? Error { error = noteError } else { error = NSError(domain: "CMSCommunityCenter", code: -1, userInfo: note.userInfo) } - + LoggingHelper.registerError(error, info: note.userInfo as? [String: Any]) } - + NotificationCenter.default.addObserver(forName: .CMSUserIdentifierDidBecomeAvailable, object: nil, queue: OperationQueue.main) { note in guard let identifier = note.object as? String else { return } - + LoggingHelper.registerCloudKitUserIdentifier(identifier) } } - + } diff --git a/WWDC/MainWindowController.swift b/WWDC/MainWindowController.swift index 8d6d4971..603e18ce 100644 --- a/WWDC/MainWindowController.swift +++ b/WWDC/MainWindowController.swift @@ -26,46 +26,43 @@ extension Notification.Name { } final class MainWindowController: NSWindowController { - + static var defaultRect: NSRect { - if let screen = NSScreen.main() { - return screen.visibleFrame.insetBy(dx: 50, dy: 120) - } else { - return NSRect(x: 0, y: 0, width: 1200, height: 600) - } + return NSScreen.main()?.visibleFrame.insetBy(dx: 50, dy: 120) ?? + NSRect(x: 0, y: 0, width: 1200, height: 600) } public var sidebarInitWidth: CGFloat? - - init() { + + init() { let mask: NSWindowStyleMask = [.titled, .resizable, .miniaturizable, .closable] let window = WWDCWindow(contentRect: MainWindowController.defaultRect, styleMask: mask, backing: .buffered, defer: false) - + super.init(window: window) - + window.title = "WWDC" - + window.appearance = WWDCAppearance.appearance() window.center() - + window.titleVisibility = .hidden - + window.toolbar = NSToolbar(identifier: "WWDC") - + window.identifier = "main" window.setFrameAutosaveName("main") window.minSize = NSSize(width: 1060, height: 700) - + windowDidLoad() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func windowDidLoad() { super.windowDidLoad() } - + @IBAction func performFindPanelAction(_ sender: Any) { NotificationCenter.default.post(name: .MainWindowWantsToSelectSearchField, object: nil) } diff --git a/WWDC/ModalLoadingView.swift b/WWDC/ModalLoadingView.swift index 3063eea0..6e42d89c 100644 --- a/WWDC/ModalLoadingView.swift +++ b/WWDC/ModalLoadingView.swift @@ -12,67 +12,67 @@ class ModalLoadingView: NSView { private lazy var backgroundView: NSVisualEffectView = { let v = NSVisualEffectView() - + v.material = .ultraDark v.appearance = WWDCAppearance.appearance() v.blendingMode = .withinWindow v.translatesAutoresizingMaskIntoConstraints = false v.state = .active - + return v }() - + private lazy var spinner: NSProgressIndicator = { let p = NSProgressIndicator() - + p.isIndeterminate = true p.style = .spinningStyle p.translatesAutoresizingMaskIntoConstraints = false - + return p }() - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + wantsLayer = true - + addSubview(backgroundView) backgroundView.addSubview(spinner) - + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true backgroundView.topAnchor.constraint(equalTo: topAnchor).isActive = true backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true - + spinner.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor).isActive = true spinner.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor).isActive = true } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + static func show(attachedTo view: NSView) -> ModalLoadingView { let v = ModalLoadingView(frame: view.bounds) - + v.show(in: view) - + return v } - + func show(in view: NSView) { alphaValue = 0 autoresizingMask = [.viewWidthSizable, .viewHeightSizable] spinner.startAnimation(nil) - + view.addSubview(self) - + NSAnimationContext.runAnimationGroup({ _ in self.alphaValue = 1 }, completionHandler: nil) } - + func hide() { NSAnimationContext.runAnimationGroup({ _ in self.spinner.stopAnimation(nil) @@ -81,5 +81,5 @@ class ModalLoadingView: NSView { self.removeFromSuperview() }) } - + } diff --git a/WWDC/MultipleChoiceFilter.swift b/WWDC/MultipleChoiceFilter.swift index ddb06aff..4a37f1de 100644 --- a/WWDC/MultipleChoiceFilter.swift +++ b/WWDC/MultipleChoiceFilter.swift @@ -12,17 +12,17 @@ struct FilterOption: Equatable { let title: String let value: String let isNegative: Bool - + init(title: String, value: String, isNegative: Bool = false) { self.title = title self.value = value self.isNegative = isNegative } - + func negated(with newTitle: String) -> FilterOption { return FilterOption(title: newTitle, value: self.value, isNegative: true) } - + static func ==(lhs: FilterOption, rhs: FilterOption) -> Bool { return lhs.value == rhs.value } @@ -83,43 +83,43 @@ struct MultipleChoiceFilter: FilterType { _selectedOptions = newValue.filter { options.contains($0) } } } - + var emptyTitle: String - - + + var isEmpty: Bool { return selectedOptions.isEmpty } - + var title: String { if isEmpty || selectedOptions.count == options.count { return emptyTitle } else { let t = selectedOptions.reduce("", { $0 + ", " + $1.title }) - + guard !t.isEmpty else { return t } - + return t.substring(from: t.index(t.startIndex, offsetBy: 2)) } } - + var predicate: NSPredicate? { guard !isEmpty else { return nil } - + let subpredicates = selectedOptions.map { option -> NSPredicate in let format: String - + let op = option.isNegative ? "!=" : "==" - + if isSubquery { format = "SUBQUERY(\(collectionKey), $\(collectionKey), $\(collectionKey).\(modelKey) \(op) %@).@count > 0" } else { format = "\(modelKey) \(op) %@" } - + return NSPredicate(format: format, option.value) } - + return NSCompoundPredicate(orPredicateWithSubpredicates: subpredicates) } diff --git a/WWDC/NSImage+Thumbnail.swift b/WWDC/NSImage+Thumbnail.swift index 660d73db..df10036b 100644 --- a/WWDC/NSImage+Thumbnail.swift +++ b/WWDC/NSImage+Thumbnail.swift @@ -9,23 +9,23 @@ import Cocoa extension NSImage { - + static func thumbnailImage(with url: URL, maxWidth: CGFloat) -> NSImage? { guard let inputImage = NSImage(contentsOf: url) else { return nil } - + let aspectRatio = inputImage.size.width / inputImage.size.height - + let thumbSize = NSSize(width: maxWidth, height: maxWidth * aspectRatio) - + let outputImage = NSImage(size: thumbSize) - + outputImage.lockFocus() - + inputImage.draw(in: NSRect(x: 0, y: 0, width: thumbSize.width, height: thumbSize.height), from: .zero, operation: .sourceOver, fraction: 1) - + outputImage.unlockFocus() - + return outputImage } - + } diff --git a/WWDC/NSTableView+IGListKit.swift b/WWDC/NSTableView+IGListKit.swift index c59134c6..5016175d 100644 --- a/WWDC/NSTableView+IGListKit.swift +++ b/WWDC/NSTableView+IGListKit.swift @@ -10,20 +10,20 @@ import Cocoa import IGListKit extension NSTableView { - + func reload(withOldValue oldValue: [IGListDiffable], newValue: [IGListDiffable]) { guard oldValue.count > 0 else { reloadData() return } - + let diff = IGListDiff(oldValue, newValue, .equality) - + beginUpdates() insertRows(at: diff.inserts, withAnimation: .effectGap) removeRows(at: diff.deletes, withAnimation: .effectGap) reloadData(forRowIndexes: diff.updates, columnIndexes: IndexSet([0])) endUpdates() } - + } diff --git a/WWDC/NSTableView+Rx.swift b/WWDC/NSTableView+Rx.swift index ce4119cf..d819831b 100644 --- a/WWDC/NSTableView+Rx.swift +++ b/WWDC/NSTableView+Rx.swift @@ -11,68 +11,68 @@ import RxSwift import RxCocoa final class RxTableViewDelegateProxy: DelegateProxy, NSTableViewDelegate, DelegateProxyType { - + weak private(set) var tableView: NSTableView? - + fileprivate var selectedRowSubject = PublishSubject() - + required init(parentObject: AnyObject) { self.tableView = parentObject as? NSTableView - + super.init(parentObject: parentObject) } - + public override class func createProxyForObject(_ object: AnyObject) -> AnyObject { let control: NSTableView = object as! NSTableView return control.createRxDelegateProxy() } - + public class func currentDelegateFor(_ object: AnyObject) -> AnyObject? { let tableView: NSTableView = object as! NSTableView return tableView.delegate } - + public class func setCurrentDelegate(_ delegate: AnyObject?, toObject object: AnyObject) { let tableView: NSTableView = object as! NSTableView tableView.delegate = delegate as? NSTableViewDelegate } - + func tableViewSelectionDidChange(_ notification: Notification) { guard let numberOfRows = tableView?.numberOfRows else { return } guard let selectedRow = tableView?.selectedRow else { return } - - let row: Int? = (selectedRow >= 0 && selectedRow < numberOfRows) ? selectedRow : nil - + + let row: Int? = (0.. RxTableViewDelegateProxy { return RxTableViewDelegateProxy(parentObject: self) } - + } extension Reactive where Base: NSTableView { - + public var delegate: DelegateProxy { return RxTableViewDelegateProxy.proxyForObject(base) } - + public var selectedRow: ControlProperty { let delegate = RxTableViewDelegateProxy.proxyForObject(base) - + let source = Observable.deferred { [weak tableView = self.base] () -> Observable in if let startingRow = tableView?.selectedRow, startingRow >= 0 { return delegate.selectedRowSubject.startWith(startingRow) } else { return delegate.selectedRowSubject.startWith(nil) } - }.takeUntil(deallocated) - + }.takeUntil(deallocated) + let observer = UIBindingObserver(UIElement: base) { (control, value: Int?) in if let row = value { control.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) @@ -80,7 +80,7 @@ extension Reactive where Base: NSTableView { control.deselectAll(nil) } } - + return ControlProperty(values: source, valueSink: observer.asObserver()) } diff --git a/WWDC/PathUtil.swift b/WWDC/PathUtil.swift index 0f4f2b25..caf479ea 100644 --- a/WWDC/PathUtil.swift +++ b/WWDC/PathUtil.swift @@ -9,16 +9,16 @@ import Cocoa final class PathUtil { - + static var appSupportPath: String { guard let identifier = Bundle.main.bundleIdentifier else { fatalError("Bundle identifier is nil, this should never happen") } - + let dir = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true).first! let path = dir + "/\(identifier)" - + return path } - + } diff --git a/WWDC/PlaybackViewModel.swift b/WWDC/PlaybackViewModel.swift index 07a67b07..a17c78d7 100644 --- a/WWDC/PlaybackViewModel.swift +++ b/WWDC/PlaybackViewModel.swift @@ -15,7 +15,7 @@ enum PlaybackError: Error { case sessionNotFound(String) case assetNotFound(String) case invalidAsset(String) - + var localizedDescription: String { switch self { case .sessionNotFound(let identifier): @@ -29,7 +29,7 @@ enum PlaybackError: Error { } extension Session { - + func asset(of type: SessionAssetType) -> SessionAsset? { if type == .image { return imageAsset() @@ -37,50 +37,50 @@ extension Session { return assets.filter({ $0.assetType == type }).first } } - + func imageAsset() -> SessionAsset? { guard let path = event.first?.imagesPath else { return nil } guard let baseURL = URL(string: path) else { return nil } - + let filename = "\(staticContentId)_wide_900x506_1x.jpg" - + let url = baseURL.appendingPathComponent("\(staticContentId)/\(filename)") - + let asset = SessionAsset() - + asset.assetType = .image asset.remoteURL = url.absoluteString - + return asset } - + } final class PlaybackViewModel { - + let sessionViewModel: SessionViewModel let player: AVPlayer let isLiveStream: Bool - + var remoteMediaURL: URL? var title: String? var imageURL: URL? - + private var timeObserver: Any? - + init(sessionViewModel: SessionViewModel, storage: Storage) throws { self.title = sessionViewModel.title self.imageURL = sessionViewModel.imageUrl - + self.sessionViewModel = sessionViewModel self.remoteMediaURL = nil - + guard let session = storage.session(with: sessionViewModel.identifier) else { throw PlaybackError.sessionNotFound(sessionViewModel.identifier) } - + var streamUrl: URL? - + // first, check if the session is being live streamed now if session.instances.filter("isCurrentlyLive == true").count > 0 { if let liveAsset = session.assets.filter("rawAssetType == %@", SessionAssetType.liveStreamVideo.rawValue).first, let liveURL = URL(string: liveAsset.remoteURL) { @@ -93,21 +93,21 @@ final class PlaybackViewModel { } else { self.isLiveStream = false } - + // not live if !self.isLiveStream { // must have at least streaming video asset guard let asset = session.asset(of: .streamingVideo) else { throw PlaybackError.assetNotFound(session.identifier) } - + // remote url for the streaming video must be valid guard let remoteUrl = URL(string: asset.remoteURL) else { throw PlaybackError.invalidAsset(asset.remoteURL) } - + self.remoteMediaURL = remoteUrl - + // check if we have a downloaded file and use it instead if let localUrl = DownloadManager.shared.localFileURL(for: session) { streamUrl = localUrl @@ -115,35 +115,35 @@ final class PlaybackViewModel { streamUrl = remoteUrl } } - + guard var finalUrl = streamUrl else { throw PlaybackError.invalidAsset("No valid video URL could be found") } - + #if DEBUG - if Arguments.useTestVideo { - finalUrl = URL(fileURLWithPath: "/Users/inside/Movies/test.m4v") - } + if Arguments.useTestVideo { + finalUrl = URL(fileURLWithPath: "/Users/inside/Movies/test.m4v") + } #endif - + self.player = AVPlayer(url: finalUrl) - + if !self.isLiveStream { let p = session.currentPosition() self.player.seek(to: CMTimeMakeWithSeconds(Float64(p), 9000)) - + self.timeObserver = self.player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(5, 9000), queue: DispatchQueue.main) { [weak self] currentTime in guard let duration = self?.player.currentItem?.asset.duration else { return } guard CMTIME_IS_VALID(duration) else { return } - + let p = Double(CMTimeGetSeconds(currentTime)) let d = Double(CMTimeGetSeconds(duration)) - + self?.sessionViewModel.session.setCurrentPosition(p, d) } } } - + deinit { if let timeObserver = timeObserver { player.removeTimeObserver(timeObserver) diff --git a/WWDC/Preferences.swift b/WWDC/Preferences.swift index 6518690b..994aec02 100644 --- a/WWDC/Preferences.swift +++ b/WWDC/Preferences.swift @@ -11,19 +11,17 @@ import ThrowBack import SwiftyJSON extension Notification.Name { - static let LocalVideoStoragePathPreferenceDidChange = Notification.Name("LocalVideoStoragePathPreferenceDidChange") static let RefreshPeriodicallyPreferenceDidChange = Notification.Name("RefreshPeriodicallyPreferenceDidChange") static let SkipBackAndForwardBy30SecondsPreferenceDidChange = Notification.Name("SkipBackAndForwardBy30SecondsPreferenceDidChange") - } final class Preferences { - + static let shared: Preferences = Preferences() - + private let defaults = UserDefaults.standard - + /// The URL for the folder where downloaded videos will be saved var localVideoStorageURL: URL { get { @@ -31,26 +29,26 @@ final class Preferences { } set { TBPreferences.shared.localVideoStoragePath = newValue.path - + defaults.set(newValue.path, forKey: #function) - + defaults.synchronize() - + NotificationCenter.default.post(name: .LocalVideoStoragePathPreferenceDidChange, object: nil) } } - + var activeTab: MainWindowTab { get { let rawValue = defaults.integer(forKey: #function) - + return MainWindowTab(rawValue: rawValue) ?? .schedule } set { defaults.set(newValue.rawValue, forKey: #function) } } - + var selectedScheduleItemIdentifier: String? { get { return defaults.object(forKey: #function) as? String @@ -59,7 +57,7 @@ final class Preferences { defaults.set(newValue, forKey: #function) } } - + var selectedVideoItemIdentifier: String? { get { return defaults.object(forKey: #function) as? String @@ -83,7 +81,7 @@ final class Preferences { } } } - + var showedAccountPromptAtStartup: Bool { get { return defaults.bool(forKey: #function) @@ -92,7 +90,7 @@ final class Preferences { defaults.set(newValue, forKey: #function) } } - + var userOptedOutOfCrashReporting: Bool { get { return defaults.bool(forKey: #function) @@ -101,7 +99,7 @@ final class Preferences { defaults.set(newValue, forKey: #function) } } - + var searchInTranscripts: Bool { get { return defaults.bool(forKey: #function) @@ -110,7 +108,7 @@ final class Preferences { defaults.set(newValue, forKey: #function) } } - + var searchInBookmarks: Bool { get { return defaults.bool(forKey: #function) @@ -119,18 +117,18 @@ final class Preferences { defaults.set(newValue, forKey: #function) } } - + var refreshPeriodically: Bool { get { return defaults.bool(forKey: #function) } set { defaults.set(newValue, forKey: #function) - + NotificationCenter.default.post(name: .RefreshPeriodicallyPreferenceDidChange, object: nil) } } - + var skipBackAndForwardBy30Seconds: Bool { get { return defaults.bool(forKey: #function) @@ -141,5 +139,5 @@ final class Preferences { NotificationCenter.default.post(name: .SkipBackAndForwardBy30SecondsPreferenceDidChange, object: nil) } } - + } diff --git a/WWDC/PreferencesCoordinator.swift b/WWDC/PreferencesCoordinator.swift index 03bcea28..92366769 100644 --- a/WWDC/PreferencesCoordinator.swift +++ b/WWDC/PreferencesCoordinator.swift @@ -17,56 +17,56 @@ enum PreferencesTab: Int { } final class PreferencesCoordinator { - + private let disposeBag = DisposeBag() - + private let windowController: PreferencesWindowController - + private let tabController: WWDCTabViewController - + private let generalController: GeneralPreferencesViewController private let accountController: AccountPreferencesViewController - + init() { self.windowController = PreferencesWindowController() self.tabController = WWDCTabViewController(windowController: self.windowController) - + // General self.generalController = GeneralPreferencesViewController.loadFromStoryboard() generalController.identifier = "General" let generalItem = NSTabViewItem(viewController: generalController) generalItem.label = "General" self.tabController.addTabViewItem(generalItem) - + // Account self.accountController = AccountPreferencesViewController() accountController.identifier = "Account" let accountItem = NSTabViewItem(viewController: accountController) accountItem.label = "Account" self.tabController.addTabViewItem(accountItem) - + self.windowController.contentViewController = tabController - + setupAccountBindings() } - + func show(in tab: PreferencesTab = .general) { windowController.window?.center() windowController.showWindow(nil) - + tabController.activeTab = tab } - + func setupAccountBindings() { #if ICLOUD CMSCommunityCenter.shared.accountStatus.observeOn(MainScheduler.instance).subscribe(onNext: { [weak self] status in self?.accountController.cloudAccountIsAvailable = (status == .available) }).addDisposableTo(self.disposeBag) - + CMSCommunityCenter.shared.userProfile.observeOn(MainScheduler.instance).subscribe(onNext: { [weak self] profile in self?.accountController.profile = profile }).addDisposableTo(self.disposeBag) #endif } - + } diff --git a/WWDC/PreferencesWindowController.swift b/WWDC/PreferencesWindowController.swift index 43f76c3a..73f15244 100644 --- a/WWDC/PreferencesWindowController.swift +++ b/WWDC/PreferencesWindowController.swift @@ -13,38 +13,38 @@ class PreferencesWindowController: NSWindowController { static var defaultRect: NSRect { return NSRect(x: 0, y: 0, width: 650, height: 500) } - + init() { let mask: NSWindowStyleMask = [.titled, .closable] let window = WWDCWindow(contentRect: PreferencesWindowController.defaultRect, styleMask: mask, backing: .buffered, defer: false) - + super.init(window: window) - + window.title = "Preferences" - + window.appearance = WWDCAppearance.appearance() window.center() - + window.titleVisibility = .hidden - + window.toolbar = NSToolbar(identifier: "WWDCPreferences") - + window.identifier = "preferences" window.minSize = PreferencesWindowController.defaultRect.size - + window.animationBehavior = .alertPanel - + window.backgroundColor = .auxWindowBackground - + windowDidLoad() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func windowDidLoad() { super.windowDidLoad() } - + } diff --git a/WWDC/RemoteEnvironment.swift b/WWDC/RemoteEnvironment.swift index 8ab48ce3..622d62d2 100644 --- a/WWDC/RemoteEnvironment.swift +++ b/WWDC/RemoteEnvironment.swift @@ -11,57 +11,57 @@ import CloudKit import ConfCore final class RemoteEnvironment: NSObject { - + private struct Constants { static let environmentRecordType = "Environment" static let subscriptionDefaultsName = "remoteEnvironmentSubscriptionID" } - + private lazy var container: CKContainer = CKContainer.default() private lazy var database: CKDatabase = { return self.container.publicCloudDatabase }() - + static let shared: RemoteEnvironment = RemoteEnvironment() - + func start() { #if ICLOUD guard !Arguments.disableRemoteEnvironment else { return } - + fetch() createSubscriptionIfNeeded() #endif } - + private func fetch() { #if ICLOUD let query = CKQuery(recordType: Constants.environmentRecordType, predicate: NSPredicate(value: true)) - + let operation = CKQueryOperation(query: query) - + operation.recordFetchedBlock = { record in guard let env = Environment(record) else { NSLog("Error parsing remote environment") return } - + Environment.setCurrent(env) } - + operation.queryCompletionBlock = { [unowned self] _, error in if let error = error { NSLog("Error fetching remote environment: \(error)") - + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { self.fetch() } } } - + database.add(operation) #endif } - + private let environmentSubscriptionID = "REMOTE-ENVIRONMENT" - + private func createSubscriptionIfNeeded() { CloudKitHelper.subscriptionExists(with: environmentSubscriptionID, in: database) { [unowned self] exists in if !exists { @@ -69,45 +69,45 @@ final class RemoteEnvironment: NSObject { } } } - + private func doCreateSubscription() { let options: CKQuerySubscriptionOptions = [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] let subscription = CKQuerySubscription(recordType: Constants.environmentRecordType, predicate: NSPredicate(value: true), subscriptionID: environmentSubscriptionID, options: options) - + database.save(subscription) { _, error in if let error = error { NSLog("[RemoteEnvironment] Error creating subscription: \(error)") } } } - + func processSubscriptionNotification(with userInfo: [String : Any]) -> Bool { let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) - + // check if the remote notification is for us, if not, tell the caller that we haven't handled it guard notification.subscriptionID == self.environmentSubscriptionID else { return false } - + // notification for environment change fetch() - + return true } - + } extension Environment { - + init?(_ record: CKRecord) { guard let baseURLStr = record["baseURL"] as? String, URL(string: baseURLStr) != nil else { return nil } - + self.baseURL = baseURLStr self.videosPath = "/videos.json" self.liveVideosPath = "/videos_live.json" self.newsPath = "/news.json" self.sessionsPath = "/sessions.json" } - + } diff --git a/WWDC/RxNil.swift b/WWDC/RxNil.swift index f0401cad..5e2255a7 100644 --- a/WWDC/RxNil.swift +++ b/WWDC/RxNil.swift @@ -11,7 +11,7 @@ import RxSwift protocol OptionalType { associatedtype Wrapped - + var optional: Wrapped? { get } } diff --git a/WWDC/SearchCoordinator.swift b/WWDC/SearchCoordinator.swift index 3de553fb..2651f67c 100644 --- a/WWDC/SearchCoordinator.swift +++ b/WWDC/SearchCoordinator.swift @@ -22,9 +22,9 @@ enum FilterIdentifier: String { } final class SearchCoordinator { - + let storage: Storage - + let scheduleController: SessionsTableViewController let videosController: SessionsTableViewController @@ -34,11 +34,11 @@ final class SearchCoordinator { fileprivate var scheduleSearchController: SearchFiltersViewController { return scheduleController.searchController } - + fileprivate var videosSearchController: SearchFiltersViewController { return videosController.searchController } - + init(_ storage: Storage, sessionsController: SessionsTableViewController, videosController: SessionsTableViewController, @@ -54,7 +54,7 @@ final class SearchCoordinator { name: .MainWindowWantsToSelectSearchField, object: nil) } - + func configureFilters() { // Schedule Filters Configuration @@ -182,36 +182,36 @@ final class SearchCoordinator { if activeVideosFilters.count > 0 { updateSearchResults(for: videosController, with: activeVideosFilters) } - + // set delegates scheduleSearchController.delegate = self videosSearchController.delegate = self } - + private func isAllEmpty(_ filters: [FilterType]) -> Bool { return filters.reduce(true, { return $0.0 && $0.1.isEmpty }) } - + fileprivate lazy var searchQueue: DispatchQueue = DispatchQueue(label: "Search", qos: .userInteractive) - + fileprivate func updateSearchResults(for controller: SessionsTableViewController, with filters: [FilterType]) { guard !isAllEmpty(filters) else { if controller.searchResults != nil { controller.searchResults = nil } - + return } - + var subpredicates = filters.flatMap { $0.predicate } - + if controller == scheduleController { subpredicates.append(NSPredicate(format: "ANY event.isCurrent == true")) subpredicates.append(NSPredicate(format: "instances.@count > 0")) } else if controller == videosController { subpredicates.append(Session.videoPredicate) } - + var predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates) if let appDelegate = NSApplication.shared().delegate as? AppDelegate, @@ -220,18 +220,18 @@ final class SearchCoordinator { // Keep the currently playing video in the list to ensure PIP can re-select it if needed predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [predicate, NSPredicate(format: "identifier == %@", currentlyPlayingSession.identifier)]) } - + #if DEBUG print(predicate) #endif - + searchQueue.async { [unowned self] in do { let realm = try Realm(configuration: self.storage.realmConfig) - + let results = realm.objects(Session.self).filter(predicate) - let keys: Set = Set(results.map({ $0.identifier })) - + let keys: Set = Set(results.map { $0.identifier }) + DispatchQueue.main.async { let searchResults = self.storage.realm.objects(Session.self).filter("identifier IN %@", keys) controller.searchResults = searchResults @@ -241,12 +241,12 @@ final class SearchCoordinator { } } } - + @objc fileprivate func activateSearchField() { if let window = scheduleSearchController.view.window { window.makeFirstResponder(scheduleSearchController.searchField) } - + if let window = videosSearchController.view.window { window.makeFirstResponder(videosSearchController.searchField) } @@ -267,7 +267,7 @@ final class SearchCoordinator { } extension SearchCoordinator: SearchFiltersViewControllerDelegate { - + func searchFiltersViewController(_ controller: SearchFiltersViewController, didChangeFilters filters: [FilterType]) { if controller == scheduleSearchController { updateSearchResults(for: scheduleController, with: filters) diff --git a/WWDC/SearchFiltersViewController.swift b/WWDC/SearchFiltersViewController.swift index d9a8cdec..47f3f1e8 100644 --- a/WWDC/SearchFiltersViewController.swift +++ b/WWDC/SearchFiltersViewController.swift @@ -9,9 +9,9 @@ import Cocoa protocol SearchFiltersViewControllerDelegate: class { - + func searchFiltersViewController(_ controller: SearchFiltersViewController, didChangeFilters filters: [FilterType]) - + } enum FilterSegment: Int { @@ -36,107 +36,107 @@ enum FilterSegment: Int { } extension NSSegmentedControl { - + func isSelected(for segment: FilterSegment) -> Bool { return isSelected(forSegment: segment.rawValue) } - + } final class SearchFiltersViewController: NSViewController { - + static func loadFromStoryboard() -> SearchFiltersViewController { let storyboard = NSStoryboard(name: "Main", bundle: nil) - + return storyboard.instantiateController(withIdentifier: "SearchFiltersViewController") as! SearchFiltersViewController } - + @IBOutlet weak var eventsPopUp: NSPopUpButton! @IBOutlet weak var focusesPopUp: NSPopUpButton! @IBOutlet weak var tracksPopUp: NSPopUpButton! @IBOutlet weak var bottomSegmentedControl: NSSegmentedControl! @IBOutlet weak var filterButton: NSButton! @IBOutlet weak var searchField: NSSearchField! - + var filters: [FilterType] { set { effectiveFilters = newValue - + updateUI() } get { return effectiveFilters } } - + private var effectiveFilters: [FilterType] = [] - + weak var delegate: SearchFiltersViewControllerDelegate? - + @IBAction func eventsPopUpAction(_ sender: Any) { guard let filterIndex = effectiveFilters.index(where: { $0.identifier == FilterIdentifier.event.rawValue }) else { return } updateMultipleChoiceFilter(at: filterIndex, with: eventsPopUp) } - + @IBAction func focusesPopUpAction(_ sender: Any) { guard let filterIndex = effectiveFilters.index(where: { $0.identifier == FilterIdentifier.focus.rawValue }) else { return } - + updateMultipleChoiceFilter(at: filterIndex, with: focusesPopUp) } - + @IBAction func tracksPopUpAction(_ sender: Any) { guard let filterIndex = effectiveFilters.index(where: { $0.identifier == FilterIdentifier.track.rawValue }) else { return } - + updateMultipleChoiceFilter(at: filterIndex, with: tracksPopUp) } - + private var favoriteSegmentSelected = false private var downloadedSegmentSelected = false private var unwatchedSegmentSelected = false - + @IBAction func bottomSegmentedControlAction(_ sender: Any) { if favoriteSegmentSelected != bottomSegmentedControl.isSelected(for: .favorite) { if let favoriteIndex = effectiveFilters.index(where: { $0.identifier == FilterIdentifier.isFavorite.rawValue }) { updateToggleFilter(at: favoriteIndex, with: bottomSegmentedControl.isSelected(for: .favorite)) } } - + if downloadedSegmentSelected != bottomSegmentedControl.isSelected(for: .downloaded) { if let downloadedIndex = effectiveFilters.index(where: { $0.identifier == FilterIdentifier.isDownloaded.rawValue }) { updateToggleFilter(at: downloadedIndex, with: bottomSegmentedControl.isSelected(for: .downloaded)) } } - + if unwatchedSegmentSelected != bottomSegmentedControl.isSelected(for: .unwatched) { if let unwatchedIndex = effectiveFilters.index(where: { $0.identifier == FilterIdentifier.isUnwatched.rawValue }) { updateToggleFilter(at: unwatchedIndex, with: bottomSegmentedControl.isSelected(for: .unwatched)) } } - + favoriteSegmentSelected = bottomSegmentedControl.isSelected(for: .favorite) downloadedSegmentSelected = bottomSegmentedControl.isSelected(for: .downloaded) unwatchedSegmentSelected = bottomSegmentedControl.isSelected(for: .unwatched) } - + @IBAction func searchFieldAction(_ sender: Any) { guard let textIndex = effectiveFilters.index(where: { $0.identifier == FilterIdentifier.text.rawValue }) else { return } - + updateTextualFilter(at: textIndex, with: searchField.stringValue) } - + @IBAction func filterButtonAction(_ sender: Any) { setFilters(hidden: !eventsPopUp.isHidden) } - + override func viewDidLoad() { super.viewDidLoad() - + setFilters(hidden: true) - + updateUI() } - + func setFilters(hidden: Bool) { filterButton.state = hidden ? 0 : 1 eventsPopUp.isHidden = hidden @@ -144,57 +144,57 @@ final class SearchFiltersViewController: NSViewController { tracksPopUp.isHidden = hidden bottomSegmentedControl.isHidden = hidden } - + private func updateMultipleChoiceFilter(at filterIndex: Int, with popUp: NSPopUpButton) { guard let selectedItem = popUp.selectedItem else { return } guard let menu = popUp.menu else { return } guard var filter = effectiveFilters[filterIndex] as? MultipleChoiceFilter else { return } - + selectedItem.state = (selectedItem.state == NSOffState) ? NSOnState : NSOffState - + let selected = menu.items.filter({ $0.state == NSOnState }).flatMap({ $0.representedObject as? FilterOption }) - + filter.selectedOptions = selected - + var updatedFilters = effectiveFilters updatedFilters[filterIndex] = filter - + delegate?.searchFiltersViewController(self, didChangeFilters: updatedFilters) - + popUp.title = filter.title - + effectiveFilters = updatedFilters } - + private func updateToggleFilter(at filterIndex: Int, with state: Bool) { guard var filter = effectiveFilters[filterIndex] as? ToggleFilter else { return } - + filter.isOn = state - + var updatedFilters = effectiveFilters updatedFilters[filterIndex] = filter - + delegate?.searchFiltersViewController(self, didChangeFilters: updatedFilters) - + effectiveFilters = updatedFilters } - + private func updateTextualFilter(at filterIndex: Int, with text: String) { guard var filter = effectiveFilters[filterIndex] as? TextualFilter else { return } - + filter.value = text - + var updatedFilters = effectiveFilters updatedFilters[filterIndex] = filter - + delegate?.searchFiltersViewController(self, didChangeFilters: updatedFilters) - + effectiveFilters = updatedFilters } - + private func popUpButton(for filter: MultipleChoiceFilter) -> NSPopUpButton? { guard let filterIdentifier = FilterIdentifier(rawValue: filter.identifier) else { return nil } - + switch filterIdentifier { case .event: return eventsPopUp @@ -205,7 +205,7 @@ final class SearchFiltersViewController: NSViewController { default: return nil } } - + private func updateUI() { guard isViewLoaded else { return } diff --git a/WWDC/SessionActionsViewController.swift b/WWDC/SessionActionsViewController.swift index 853026d7..baa86d62 100644 --- a/WWDC/SessionActionsViewController.swift +++ b/WWDC/SessionActionsViewController.swift @@ -13,7 +13,7 @@ import RxSwift import RxCocoa protocol SessionActionsViewControllerDelegate: class { - + func sessionActionsDidSelectSlides(_ sender: NSView?) func sessionActionsDidSelectFavorite(_ sender: NSView?) func sessionActionsDidSelectDownload(_ sender: NSView?) @@ -27,61 +27,61 @@ class SessionActionsViewController: NSViewController { init() { super.init(nibName: nil, bundle: nil)! } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private var disposeBag = DisposeBag() - + var viewModel: SessionViewModel? = nil { didSet { resetDownloadButton() updateBindings() } } - + weak var delegate: SessionActionsViewControllerDelegate? - + private lazy var favoriteButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = #imageLiteral(resourceName: "favorite") b.alternateImage = #imageLiteral(resourceName: "favorite-filled") b.target = self - b.action = #selector(toggleFavorite(_:)) + b.action = #selector(toggleFavorite) b.isToggle = true b.shouldAlwaysDrawHighlighted = true - + return b }() - + private lazy var slidesButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = #imageLiteral(resourceName: "slides") b.target = self - b.action = #selector(showSlides(_:)) + b.action = #selector(showSlides) b.shouldAlwaysDrawHighlighted = true b.toolTip = "Open slides" - + return b }() - + private lazy var downloadButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = #imageLiteral(resourceName: "download") b.target = self - b.action = #selector(download(_:)) + b.action = #selector(download) b.shouldAlwaysDrawHighlighted = true - + return b }() - + private lazy var downloadIndicator: NSProgressIndicator = { let pi = NSProgressIndicator(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - + pi.style = .spinningStyle pi.isIndeterminate = false pi.minValue = 0 @@ -91,25 +91,25 @@ class SessionActionsViewController: NSViewController { pi.widthAnchor.constraint(equalToConstant: 24).isActive = true pi.heightAnchor.constraint(equalToConstant: 24).isActive = true pi.isHidden = true - pi.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(cancelDownload(_:)))) + pi.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(cancelDownload))) pi.toolTip = "Click to cancel" - + return pi }() - + private lazy var shareButton: PUIButton = { let b = PUIButton(frame: .zero) - + b.image = #imageLiteral(resourceName: "share") b.target = self - b.action = #selector(share(_:)) + b.action = #selector(share) b.shouldAlwaysDrawHighlighted = true b.sendsActionOnMouseDown = true b.toolTip = "Share session" - + return b }() - + private lazy var stackView: NSStackView = { let v = NSStackView(views: [ self.slidesButton, @@ -118,53 +118,53 @@ class SessionActionsViewController: NSViewController { self.downloadIndicator, self.shareButton ]) - + v.orientation = .horizontal v.spacing = 22 v.alignment = .centerY v.distribution = .equalSpacing - + return v }() - + override func loadView() { view = stackView view.wantsLayer = true view.translatesAutoresizingMaskIntoConstraints = false - + view.setContentHuggingPriority(NSLayoutPriorityDefaultHigh, for: .horizontal) } - + override func viewDidLoad() { super.viewDidLoad() - + updateBindings() } - + private func updateBindings() { self.disposeBag = DisposeBag() - + guard let viewModel = viewModel else { return } - + slidesButton.isHidden = (viewModel.session.asset(of: .slides) == nil) - + viewModel.rxIsFavorite.subscribe(onNext: { [weak self] isFavorite in self?.favoriteButton.state = isFavorite ? NSOnState : NSOffState - + if isFavorite { self?.favoriteButton.toolTip = "Remove from favorites" } else { self?.favoriteButton.toolTip = "Add to favorites" } }).addDisposableTo(self.disposeBag) - + if let rxDownloadState = DownloadManager.shared.downloadStatusObservable(for: viewModel.session) { rxDownloadState.throttle(0.8, scheduler: MainScheduler.instance).subscribe(onNext: { [weak self] status in switch status { case .downloading(let progress): self?.downloadIndicator.isHidden = false self?.downloadButton.isHidden = true - + if progress < 0 { self?.downloadIndicator.isIndeterminate = true self?.downloadIndicator.startAnimation(nil) @@ -181,7 +181,7 @@ class SessionActionsViewController: NSViewController { self?.downloadButton.isHidden = false self?.downloadIndicator.isHidden = true self?.downloadButton.image = #imageLiteral(resourceName: "trash") - self?.downloadButton.action = #selector(SessionActionsViewController.deleteDownload(_:)) + self?.downloadButton.action = #selector(SessionActionsViewController.deleteDownload) } }).addDisposableTo(self.disposeBag) } else { @@ -191,13 +191,13 @@ class SessionActionsViewController: NSViewController { resetDownloadButton() } } - + private func resetDownloadButton() { downloadButton.toolTip = "Download video for offline watching" downloadButton.image = #imageLiteral(resourceName: "download") - downloadButton.action = #selector(SessionActionsViewController.download(_:)) + downloadButton.action = #selector(SessionActionsViewController.download) } - + @IBAction func toggleFavorite(_ sender: NSView?) { delegate?.sessionActionsDidSelectFavorite(sender) } @@ -205,24 +205,24 @@ class SessionActionsViewController: NSViewController { @IBAction func showSlides(_ sender: NSView?) { delegate?.sessionActionsDidSelectSlides(sender) } - + @IBAction func download(_ sender: NSView?) { self.downloadButton.isHidden = true self.downloadIndicator.isIndeterminate = true self.downloadIndicator.startAnimation(nil) self.downloadIndicator.isHidden = false - + delegate?.sessionActionsDidSelectDownload(sender) } - + @IBAction func deleteDownload(_ sender: NSView?) { delegate?.sessionActionsDidSelectDeleteDownload(sender) } - + @IBAction func share(_ sender: NSView?) { delegate?.sessionActionsDidSelectShare(sender) } - + @IBAction func cancelDownload(_ sender: NSView?) { delegate?.sessionActionsDidSelectCancelDownload(sender) } diff --git a/WWDC/SessionDetailsViewController.swift b/WWDC/SessionDetailsViewController.swift index 23445098..1e31722a 100644 --- a/WWDC/SessionDetailsViewController.swift +++ b/WWDC/SessionDetailsViewController.swift @@ -13,9 +13,9 @@ import RxCocoa class SessionDetailsViewController: NSViewController { private let disposeBag = DisposeBag() - + let listStyle: SessionsListStyle - + var viewModel: SessionViewModel? = nil { didSet { @@ -34,9 +34,9 @@ class SessionDetailsViewController: NSViewController { showOverview() } - + transcriptButton.isHidden = (viewModel.session.transcript() == nil) - + let shouldHideButtonsBar = transcriptButton.isHidden && bookmarksButton.isHidden self.menuButtonsContainer.isHidden = shouldHideButtonsBar @@ -57,163 +57,163 @@ class SessionDetailsViewController: NSViewController { } } } - + private lazy var overviewButton: WWDCTextButton = { let b = WWDCTextButton() - + b.title = "Overview" b.state = NSOnState b.target = self - b.action = #selector(tabButtonAction(_:)) - + b.action = #selector(tabButtonAction) + return b }() - + private lazy var transcriptButton: WWDCTextButton = { let b = WWDCTextButton() - + b.title = "Transcript" b.state = NSOffState b.target = self - b.action = #selector(tabButtonAction(_:)) + b.action = #selector(tabButtonAction) b.isHidden = true - + return b }() - + private lazy var bookmarksButton: WWDCTextButton = { let b = WWDCTextButton() - + b.title = "Bookmarks" b.state = NSOffState b.target = self - b.action = #selector(tabButtonAction(_:)) - + b.action = #selector(tabButtonAction) + // TODO: enable bookmarks section b.isHidden = true - + return b }() - + private lazy var buttonsStackView: NSStackView = { let v = NSStackView(views: [ self.overviewButton, self.transcriptButton, self.bookmarksButton ]) - + v.orientation = .horizontal v.alignment = .top v.spacing = 40 - + return v }() - + private lazy var menuButtonsContainer: WWDCBottomBorderView = { let v = WWDCBottomBorderView() - + v.isHidden = true v.wantsLayer = true - + v.heightAnchor.constraint(equalToConstant: 36).isActive = true - + v.addSubview(self.buttonsStackView) - + self.buttonsStackView.topAnchor.constraint(equalTo: v.topAnchor).isActive = true self.buttonsStackView.centerXAnchor.constraint(equalTo: v.centerXAnchor).isActive = true - + return v }() - + private lazy var tabContainer: SessionDetailsTabContainer = { let v = SessionDetailsTabContainer() - + v.wantsLayer = true v.setContentHuggingPriority(NSLayoutPriorityDefaultLow, for: .horizontal) v.setContentHuggingPriority(NSLayoutPriorityDefaultLow, for: .vertical) return v }() - + private lazy var informationStackView: NSStackView = { let v = NSStackView(views: [self.menuButtonsContainer, self.tabContainer]) - + v.orientation = .vertical v.spacing = 22 v.alignment = .leading v.distribution = .fill v.edgeInsets = EdgeInsets(top: 18, left: 0, bottom: 0, right: 0) - + self.tabContainer.leadingAnchor.constraint(equalTo: v.leadingAnchor).isActive = true self.tabContainer.trailingAnchor.constraint(equalTo: v.trailingAnchor).isActive = true - + return v }() - + let shelfController: ShelfViewController let summaryController: SessionSummaryViewController let transcriptController: TranscriptTableViewController - + init(listStyle: SessionsListStyle) { self.listStyle = listStyle - + self.shelfController = ShelfViewController() self.summaryController = SessionSummaryViewController() self.transcriptController = TranscriptTableViewController() - + super.init(nibName: nil, bundle: nil)! } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private lazy var shelfBottomConstraint: NSLayoutConstraint = { return self.shelfController.view.bottomAnchor.constraint(equalTo: self.informationStackView.topAnchor) }() - + private lazy var informationStackViewTopConstraint: NSLayoutConstraint = { return self.informationStackView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 22) }() - + private lazy var informationStackViewBottomConstraint: NSLayoutConstraint = { return self.informationStackView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -46) }() - + override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: MainWindowController.defaultRect.width - 300, height: MainWindowController.defaultRect.height)) view.wantsLayer = true - + shelfController.view.translatesAutoresizingMaskIntoConstraints = false informationStackView.translatesAutoresizingMaskIntoConstraints = false - + let constraint = shelfController.view.heightAnchor.constraint(greaterThanOrEqualToConstant: 280) constraint.priority = 999 constraint.isActive = true shelfController.view.setContentCompressionResistancePriority(NSLayoutPriorityDefaultHigh, for: .vertical) - + view.addSubview(shelfController.view) view.addSubview(informationStackView) shelfController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 46).isActive = true shelfController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -46).isActive = true shelfController.view.topAnchor.constraint(equalTo: view.topAnchor, constant: 22).isActive = true - + informationStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 46).isActive = true informationStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -46).isActive = true informationStackViewBottomConstraint.isActive = true - + shelfBottomConstraint.isActive = true informationStackViewTopConstraint.isActive = false - + showOverview() } - + override func viewDidLoad() { super.viewDidLoad() } - + @objc private func tabButtonAction(_ sender: WWDCTextButton) { if sender == overviewButton { showOverview() @@ -223,23 +223,23 @@ class SessionDetailsViewController: NSViewController { showBookmarks() } } - + func showOverview() { overviewButton.state = NSOnState transcriptButton.state = NSOffState bookmarksButton.state = NSOffState - + tabContainer.currentView = summaryController.view } - + func showTranscript() { transcriptButton.state = NSOnState overviewButton.state = NSOffState bookmarksButton.state = NSOffState - + tabContainer.currentView = transcriptController.view } - + func showBookmarks() { bookmarksButton.state = NSOnState overviewButton.state = NSOffState diff --git a/WWDC/SessionRow.swift b/WWDC/SessionRow.swift index 2d3b3a1c..17c5dea1 100644 --- a/WWDC/SessionRow.swift +++ b/WWDC/SessionRow.swift @@ -15,22 +15,22 @@ enum SessionRowKind { } final class SessionRow: NSObject { - + let kind: SessionRowKind - + init(viewModel: SessionViewModel) { self.kind = .session(viewModel) - + super.init() } - + init(title: String) { self.kind = .sectionHeader(title) } - + convenience init(date: Date, showTimeZone: Bool = false) { let title = SessionViewModel.standardFormatted(date: date, withTimeZoneName: showTimeZone) - + self.init(title: title) } @@ -59,7 +59,7 @@ final class SessionRow: NSObject { } extension SessionRow: IGListDiffable { - + func diffIdentifier() -> NSObjectProtocol { switch self.kind { case .sectionHeader(let title): @@ -68,10 +68,10 @@ extension SessionRow: IGListDiffable { return viewModel.diffIdentifier() } } - + func isEqual(toDiffableObject object: IGListDiffable?) -> Bool { guard let other = object as? SessionRow else { return false } - + if case .session(let otherViewModel) = other.kind, case .session(let viewModel) = self.kind { return otherViewModel.isEqual(toDiffableObject: viewModel) } else if case .sectionHeader(let otherTitle) = other.kind, case .sectionHeader(let title) = self.kind { @@ -80,5 +80,4 @@ extension SessionRow: IGListDiffable { return false } } - } diff --git a/WWDC/SessionSummaryViewController.swift b/WWDC/SessionSummaryViewController.swift index c5d85b80..1f937683 100644 --- a/WWDC/SessionSummaryViewController.swift +++ b/WWDC/SessionSummaryViewController.swift @@ -13,21 +13,21 @@ import RxCocoa class SessionSummaryViewController: NSViewController { private var disposeBag = DisposeBag() - + var viewModel: SessionViewModel? = nil { didSet { updateBindings() } } - + init() { super.init(nibName: nil, bundle: nil)! } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private lazy var titleLabel: WWDCTextField = { let l = WWDCTextField(labelWithString: "") l.cell?.backgroundStyle = .dark @@ -36,22 +36,22 @@ class SessionSummaryViewController: NSViewController { l.allowsDefaultTighteningForTruncation = true l.maximumNumberOfLines = 2 l.translatesAutoresizingMaskIntoConstraints = false - + return l }() - + lazy var actionsViewController: SessionActionsViewController = { let v = SessionActionsViewController() - + v.view.isHidden = true v.view.translatesAutoresizingMaskIntoConstraints = false - + return v }() - + private lazy var summaryLabel: WWDCTextField = { let l = WWDCTextField(labelWithString: "") - l.font = NSFont.systemFont(ofSize: 18) + l.font = .systemFont(ofSize: 18) l.textColor = .secondaryText l.cell?.backgroundStyle = .dark l.isSelectable = true @@ -59,76 +59,76 @@ class SessionSummaryViewController: NSViewController { l.setContentCompressionResistancePriority(NSLayoutPriorityDefaultLow, for: .horizontal) l.allowsDefaultTighteningForTruncation = true l.maximumNumberOfLines = 5 - + return l }() - + private lazy var contextLabel: NSTextField = { let l = NSTextField(labelWithString: "") - l.font = NSFont.systemFont(ofSize: 16) + l.font = .systemFont(ofSize: 16) l.textColor = .tertiaryText l.cell?.backgroundStyle = .dark l.lineBreakMode = .byTruncatingTail l.allowsDefaultTighteningForTruncation = true - + return l }() - + private lazy var stackView: NSStackView = { let v = NSStackView(views: [self.summaryLabel, self.contextLabel]) - + v.orientation = .vertical v.alignment = .leading v.distribution = .fill v.spacing = 24 v.translatesAutoresizingMaskIntoConstraints = false - + return v }() - + override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: MainWindowController.defaultRect.width - 300, height: MainWindowController.defaultRect.height / 2)) view.wantsLayer = true - + view.addSubview(titleLabel) view.addSubview(actionsViewController.view) view.addSubview(stackView) - + titleLabel.setContentHuggingPriority(NSLayoutPriorityDefaultLow, for: .horizontal) - + actionsViewController.view.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor).isActive = true actionsViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: actionsViewController.view.leadingAnchor, constant: -24).isActive = true titleLabel.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - + stackView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 24).isActive = true - + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true } - + override func viewDidLoad() { super.viewDidLoad() - + updateBindings() } - + private func updateBindings() { actionsViewController.view.isHidden = (viewModel == nil) actionsViewController.viewModel = viewModel - + guard let viewModel = viewModel else { return } - + self.disposeBag = DisposeBag() - + viewModel.rxTitle.map(NSAttributedString.attributedBoldTitle(with:)).subscribe(onNext: { [weak self] title in self?.titleLabel.attributedStringValue = title }).addDisposableTo(self.disposeBag) viewModel.rxSummary.bind(to: summaryLabel.rx.text).addDisposableTo(self.disposeBag) viewModel.rxFooter.bind(to: contextLabel.rx.text).addDisposableTo(self.disposeBag) } - + } diff --git a/WWDC/SessionTableCellView.swift b/WWDC/SessionTableCellView.swift index 9144657f..d6b3bc75 100644 --- a/WWDC/SessionTableCellView.swift +++ b/WWDC/SessionTableCellView.swift @@ -11,61 +11,61 @@ import RxSwift import RxCocoa final class SessionTableCellView: NSTableCellView { - + private var disposeBag = DisposeBag() - + var viewModel: SessionViewModel? { didSet { guard viewModel != oldValue else { return } - + self.disposeBag = DisposeBag() - + thumbnailImageView.image = #imageLiteral(resourceName: "noimage") - + bindUI() } } - + private var imageDownloadOperation: Operation? = nil - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + buildUI() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func prepareForReuse() { super.prepareForReuse() - + imageDownloadOperation?.cancel() - + downloadedImageView.isHidden = true favoritedImageView.isHidden = true - + thumbnailImageView.image = #imageLiteral(resourceName: "noimage") } - + private func bindUI() { self.disposeBag = DisposeBag() - + guard let viewModel = viewModel else { return } - + viewModel.rxTitle.distinctUntilChanged().asDriver(onErrorJustReturn: "").drive(titleLabel.rx.text).addDisposableTo(self.disposeBag) viewModel.rxSubtitle.distinctUntilChanged().asDriver(onErrorJustReturn: "").drive(subtitleLabel.rx.text).addDisposableTo(self.disposeBag) viewModel.rxContext.distinctUntilChanged().asDriver(onErrorJustReturn: "").drive(contextLabel.rx.text).addDisposableTo(self.disposeBag) - + viewModel.rxIsFavorite.distinctUntilChanged().map({ !$0 }).bind(to: favoritedImageView.rx.isHidden).addDisposableTo(self.disposeBag) viewModel.rxIsDownloaded.distinctUntilChanged().map({ !$0 }).bind(to: downloadedImageView.rx.isHidden).addDisposableTo(self.disposeBag) - + let isSnowFlake = Observable.zip(viewModel.rxIsCurrentlyLive, viewModel.rxIsLab) - + isSnowFlake.map({ !$0.0 && !$0.1 }).bind(to: self.snowFlakeView.rx.isHidden).addDisposableTo(self.disposeBag) isSnowFlake.map({ $0.0 || $0.1 }).bind(to: self.thumbnailImageView.rx.isHidden).addDisposableTo(self.disposeBag) - + isSnowFlake.subscribe(onNext: { [weak self] (isLive: Bool, isLab: Bool) -> Void in if isLive { self?.snowFlakeView.image = #imageLiteral(resourceName: "live-indicator") @@ -73,27 +73,27 @@ final class SessionTableCellView: NSTableCellView { self?.snowFlakeView.image = #imageLiteral(resourceName: "lab-indicator") } }).addDisposableTo(self.disposeBag) - + viewModel.rxImageUrl.distinctUntilChanged({ $0 != $1 }).subscribe(onNext: { [weak self] imageUrl in guard let imageUrl = imageUrl else { return } - + self?.imageDownloadOperation?.cancel() - + self?.imageDownloadOperation = ImageDownloadCenter.shared.downloadImage(from: imageUrl, thumbnailHeight: Constants.thumbnailHeight) { [weak self] url, _, thumb in guard url == imageUrl else { return } - + self?.thumbnailImageView.image = thumb } }).addDisposableTo(self.disposeBag) - + viewModel.rxColor.distinctUntilChanged({ $0 == $1 }).subscribe(onNext: { [weak self] color in self?.contextColorView.color = color }).addDisposableTo(self.disposeBag) - + viewModel.rxDarkColor.distinctUntilChanged({ $0 == $1 }).subscribe(onNext: { [weak self] color in self?.snowFlakeView.backgroundColor = color }).addDisposableTo(self.disposeBag) - + viewModel.rxProgresses.subscribe(onNext: { [weak self] progresses in if let progress = progresses.first { self?.contextColorView.hasValidProgress = true @@ -103,10 +103,10 @@ final class SessionTableCellView: NSTableCellView { self?.contextColorView.progress = 0 } }).addDisposableTo(self.disposeBag) - + viewModel.rxSessionType.distinctUntilChanged().subscribe(onNext: { [weak self] type in guard type != .session && type != .lab else { return } - + switch type { case .getTogether: self?.thumbnailImageView.image = #imageLiteral(resourceName: "get-together") @@ -115,136 +115,136 @@ final class SessionTableCellView: NSTableCellView { } }).addDisposableTo(self.disposeBag) } - + private lazy var titleLabel: NSTextField = { let l = NSTextField(labelWithString: "") - l.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium) + l.font = .systemFont(ofSize: 14, weight: NSFontWeightMedium) l.textColor = .primaryText l.cell?.backgroundStyle = .dark l.lineBreakMode = .byTruncatingTail - + return l }() - + private lazy var subtitleLabel: NSTextField = { let l = NSTextField(labelWithString: "") - l.font = NSFont.systemFont(ofSize: 12) + l.font = .systemFont(ofSize: 12) l.textColor = .secondaryText l.cell?.backgroundStyle = .dark l.lineBreakMode = .byTruncatingTail - + return l }() - + private lazy var contextLabel: NSTextField = { let l = NSTextField(labelWithString: "") - l.font = NSFont.systemFont(ofSize: 12) + l.font = .systemFont(ofSize: 12) l.textColor = .tertiaryText l.cell?.backgroundStyle = .dark l.lineBreakMode = .byTruncatingTail - + return l }() - + private lazy var thumbnailImageView: WWDCImageView = { let v = WWDCImageView() - + v.heightAnchor.constraint(equalToConstant: 48).isActive = true v.widthAnchor.constraint(equalToConstant: 85).isActive = true v.backgroundColor = .black - + return v }() - + private lazy var snowFlakeView: WWDCImageView = { let v = WWDCImageView() - + v.heightAnchor.constraint(equalToConstant: 48).isActive = true v.widthAnchor.constraint(equalToConstant: 85).isActive = true v.isHidden = true v.image = #imageLiteral(resourceName: "lab-indicator") - + return v }() - + private lazy var contextColorView: TrackColorView = { let v = TrackColorView() - + v.widthAnchor.constraint(equalToConstant: 4).isActive = true - + return v }() - + private lazy var textStackView: NSStackView = { let v = NSStackView(views: [self.titleLabel, self.subtitleLabel, self.contextLabel]) - + v.orientation = .vertical v.alignment = .leading v.distribution = .equalSpacing v.spacing = 0 - + return v }() - + private lazy var favoritedImageView: WWDCImageView = { let v = WWDCImageView() - + v.heightAnchor.constraint(equalToConstant: 14).isActive = true v.drawsBackground = false v.image = #imageLiteral(resourceName: "star-small") - + return v }() - + private lazy var downloadedImageView: WWDCImageView = { let v = WWDCImageView() - + v.heightAnchor.constraint(equalToConstant: 11).isActive = true v.drawsBackground = false v.image = #imageLiteral(resourceName: "download-small") - + return v }() - + private lazy var iconsStackView: NSStackView = { let v = NSStackView(views: []) - + v.distribution = .gravityAreas v.orientation = .vertical v.spacing = 4 v.addView(self.favoritedImageView, in: .top) v.addView(self.downloadedImageView, in: .bottom) v.translatesAutoresizingMaskIntoConstraints = false - + v.widthAnchor.constraint(equalToConstant: 12).isActive = true - + return v }() - + private func buildUI() { wantsLayer = true - + contextColorView.translatesAutoresizingMaskIntoConstraints = false thumbnailImageView.translatesAutoresizingMaskIntoConstraints = false snowFlakeView.translatesAutoresizingMaskIntoConstraints = false textStackView.translatesAutoresizingMaskIntoConstraints = false iconsStackView.translatesAutoresizingMaskIntoConstraints = false - + addSubview(contextColorView) addSubview(thumbnailImageView) addSubview(snowFlakeView) addSubview(textStackView) addSubview(iconsStackView) - + contextColorView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10).isActive = true contextColorView.topAnchor.constraint(equalTo: topAnchor, constant: 8).isActive = true contextColorView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8).isActive = true - + thumbnailImageView.centerYAnchor.constraint(equalTo: contextColorView.centerYAnchor).isActive = true thumbnailImageView.leadingAnchor.constraint(equalTo: contextColorView.trailingAnchor, constant: 8).isActive = true snowFlakeView.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor).isActive = true snowFlakeView.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor).isActive = true - + textStackView.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor, constant: -1).isActive = true textStackView.leadingAnchor.constraint(equalTo: thumbnailImageView.trailingAnchor, constant: 8).isActive = true textStackView.trailingAnchor.constraint(equalTo: iconsStackView.leadingAnchor, constant: -2).isActive = true diff --git a/WWDC/SessionViewModel.swift b/WWDC/SessionViewModel.swift index 03ae3cd7..d3abd92e 100644 --- a/WWDC/SessionViewModel.swift +++ b/WWDC/SessionViewModel.swift @@ -15,7 +15,7 @@ import RxCocoa import RealmSwift final class SessionViewModel: NSObject { - + let style: SessionsListStyle var title: String let session: Session @@ -24,153 +24,153 @@ final class SessionViewModel: NSObject { var webUrl: URL? var imageUrl: URL? let trackName: String - + private var disposeBag = DisposeBag() - + lazy var rxSession: Observable = { return Observable.from(object: self.session) }() - + lazy var rxTitle: Observable = { return Observable.from(object: self.session).map({ $0.title }) }() - + lazy var rxSubtitle: Observable = { return Observable.from(object: self.session).map({ SessionViewModel.subtitle(from: $0, at: $0.event.first) }) }() - + lazy var rxTrackName: Observable = { return Observable.from(object: self.session).map({ $0.track.first?.name }).ignoreNil() }() - + lazy var rxSummary: Observable = { return Observable.from(object: self.session).map({ $0.summary }) }() - + lazy var rxContext: Observable = { if self.style == .schedule { let so = Observable.from(object: self.session) let io = Observable.from(object: self.sessionInstance) - + return Observable.zip(so, io).map({ SessionViewModel.context(for: $0.0, instance: $0.1) }) } else { return Observable.from(object: self.session).map({ SessionViewModel.context(for: $0) }) } }() - + lazy var rxFooter: Observable = { return Observable.from(object: self.session).map({ SessionViewModel.footer(for: $0, at: $0.event.first) }) }() - + lazy var rxSessionType: Observable = { return Observable.from(object: self.session).map({ $0.instances.first?.type }).ignoreNil() }() - + lazy var rxColor: Observable = { return Observable.from(object: self.session).map({ SessionViewModel.trackColor(for: $0) }).ignoreNil() }() - + lazy var rxDarkColor: Observable = { return Observable.from(object: self.session).map({ SessionViewModel.darkTrackColor(for: $0) }).ignoreNil() }() - + lazy var rxImageUrl: Observable = { return Observable.from(object: self.session).map({ SessionViewModel.imageUrl(for: $0) }) }() - + lazy var rxWebUrl: Observable = { return Observable.from(object: self.session).map({ SessionViewModel.webUrl(for: $0) }) }() - + lazy var rxIsDownloaded: Observable = { return Observable.from(object: self.session).map({ $0.isDownloaded }) }() - + lazy var rxIsFavorite: Observable = { return Observable.collection(from: self.session.favorites.filter("isDeleted == false")).map({ $0.count > 0 }) }() - + lazy var rxIsCurrentlyLive: Observable = { guard self.sessionInstance.realm != nil else { return Observable.just(false) } - + return Observable.from(object: self.sessionInstance).map({ $0.isCurrentlyLive }) }() - + lazy var rxIsLab: Observable = { guard self.sessionInstance.realm != nil else { return Observable.just(false) } - + return Observable.from(object: self.sessionInstance).map({ $0.type == .lab }) }() - + lazy var rxPlayableContent: Observable> = { let playableAssets = self.session.assets.filter("rawAssetType == %@ OR rawAssetType == %@", SessionAssetType.streamingVideo.rawValue, SessionAssetType.liveStreamVideo.rawValue) - + return Observable.collection(from: playableAssets) }() - + lazy var rxCanBePlayed: Observable = { 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) - + return validAssetsObservable.map({ $0.count > 0 }) }() - + lazy var rxDownloadableContent: Observable> = { let downloadableAssets = self.session.assets.filter("(rawAssetType == %@ AND remoteURL != '')", SessionAssetType.hdVideo.rawValue) - + return Observable.collection(from: downloadableAssets) }() - + lazy var rxProgresses: Observable> = { let progresses = self.session.progresses.filter(NSPredicate(value: true)) - + return Observable.collection(from: progresses) }() - + convenience init?(session: Session) { self.init(session: session, instance: nil, style: .videos) } - + init?(session: Session?, instance: SessionInstance?, style: SessionsListStyle) { self.style = style - + guard let session = session ?? instance?.session else { return nil } guard let track = session.track.first ?? instance?.track.first else { return nil } - + self.trackName = track.name self.session = session self.sessionInstance = instance ?? session.instances.first ?? SessionInstance() self.title = session.title self.identifier = session.identifier self.imageUrl = SessionViewModel.imageUrl(for: session) - + if let webUrlStr = session.asset(of: .webpage)?.remoteURL { self.webUrl = URL(string: webUrlStr) } - + super.init() } - + static func subtitle(from session: Session, at event: ConfCore.Event?) -> String { guard let event = event else { return "" } - + let year = Calendar.current.component(.year, from: event.startDate) - + return "WWDC \(year) · Session \(session.number)" } - + static func focusesDescription(from focuses: [Focus], collapse: Bool) -> String { var result: String - + if focuses.count == 4 && collapse { result = "All Platforms" } else { let separator = ", " - + result = focuses.reduce("") { partial, focus in if partial.isEmpty { return focus.name + separator @@ -178,107 +178,107 @@ final class SessionViewModel: NSObject { return partial + focus.name + separator } } - + if let lastCommaRange = result.range(of: separator, options: .backwards, range: nil, locale: nil) { result = result.replacingCharacters(in: lastCommaRange, with: "") } } - + return result } - + static func context(for session: Session, instance: SessionInstance? = nil) -> String { if let instance = instance { var result = timeFormatter.string(from: instance.startTime) + " - " + timeFormatter.string(from: instance.endTime) - + result += " · " + instance.roomName - + return result } else { let focusesArray = session.focuses.toArray() - + let focuses = SessionViewModel.focusesDescription(from: focusesArray, collapse: true) - + var result = session.track.first?.name ?? "" - + if focusesArray.count > 0 { result = "\(focuses) · " + result } - + return result } } - + static func footer(for session: Session, at event: ConfCore.Event?) -> String { guard let event = event else { return "" } - + let year = Calendar.current.component(.year, from: event.startDate) - + let focusesArray = session.focuses.toArray() - + let allFocuses = SessionViewModel.focusesDescription(from: focusesArray, collapse: false) - + var result = "WWDC \(year) · Session \(session.number)" - + if focusesArray.count > 0 { result += " · \(allFocuses)" } - + return result } - + static func imageUrl(for session: Session) -> URL? { if let instance = session.instances.first { guard instance.type == .session || instance.type == .lab else { return nil } } - + let imageAsset = session.asset(of: .image) - + guard let thumbnail = imageAsset?.remoteURL, let thumbnailUrl = URL(string: thumbnail) else { return nil } - + return thumbnailUrl } - + static func webUrl(for session: Session) -> URL? { guard let url = session.asset(of: .webpage)?.remoteURL else { return nil } - + return URL(string: url) } - + static func trackColor(for session: Session) -> NSColor? { guard let code = session.track.first?.lightColor else { return nil } - + return NSColor.fromHexString(hexString: code) } - + static func darkTrackColor(for session: Session) -> NSColor? { guard let code = session.track.first?.darkColor else { return nil } - + return NSColor.fromHexString(hexString: code) } - + static let dateFormatter: DateFormatter = { let df = DateFormatter() - + df.locale = Locale.current df.timeZone = TimeZone.current df.dateFormat = "EEEE" - + return df }() - + static let timeFormatter: DateFormatter = { let tf = DateFormatter() - + tf.locale = Locale.current tf.timeZone = TimeZone.current tf.timeStyle = .short - + return tf }() - + static var timeZoneNameSuffix: String { if let name = TimeZone.current.localizedName(for: .shortGeneric, locale: Locale.current) { return " " + name @@ -286,17 +286,17 @@ final class SessionViewModel: NSObject { return "" } } - + static func standardFormatted(date: Date, withTimeZoneName: Bool) -> String { let result = dateFormatter.string(from: date) + ", " + timeFormatter.string(from: date) - + return withTimeZoneName ? result + timeZoneNameSuffix : result } - + } extension SessionViewModel: IGListDiffable { - + func diffIdentifier() -> NSObjectProtocol { return identifier as NSObjectProtocol } @@ -304,8 +304,8 @@ extension SessionViewModel: IGListDiffable { func isEqual(toDiffableObject object: IGListDiffable?) -> Bool { guard let other = object as? SessionViewModel else { return false } - return self.identifier == other.identifier && - self.title == other.title + return identifier == other.identifier && + title == other.title } } diff --git a/WWDC/SessionsSplitViewController.swift b/WWDC/SessionsSplitViewController.swift index 3a0fa985..e72f9be5 100644 --- a/WWDC/SessionsSplitViewController.swift +++ b/WWDC/SessionsSplitViewController.swift @@ -14,53 +14,53 @@ enum SessionsListStyle { } final class SessionsSplitViewController: NSSplitViewController { - + var listViewController: SessionsTableViewController var detailViewController: SessionDetailsViewController var isResizingSplitView: Bool = false var windowController: MainWindowController var setupDone: Bool = false - + init(windowController: MainWindowController, listStyle: SessionsListStyle) { self.windowController = windowController listViewController = SessionsTableViewController(style: listStyle) detailViewController = SessionDetailsViewController(listStyle: listStyle) - + super.init(nibName: nil, bundle: nil)! NotificationCenter.default.addObserver(self, selector: #selector(syncSplitView(notification:)), name: Notification.Name.NSSplitViewDidResizeSubviews, object: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - + view.wantsLayer = true - + let listItem = NSSplitViewItem(sidebarWithViewController: listViewController) listItem.canCollapse = false let detailItem = NSSplitViewItem(viewController: detailViewController) - + addSplitViewItem(listItem) addSplitViewItem(detailItem) - + listViewController.view.setContentHuggingPriority(NSLayoutPriorityDefaultHigh, for: .horizontal) detailViewController.view.setContentHuggingPriority(NSLayoutPriorityDefaultLow, for: .horizontal) detailViewController.view.setContentCompressionResistancePriority(NSLayoutPriorityDefaultHigh, for: .horizontal) } - + override func viewDidAppear() { super.viewDidAppear() - + if !setupDone { if let sidebarInitWidth = windowController.sidebarInitWidth { self.splitView.setPosition(sidebarInitWidth, ofDividerAt: 0) } } } - + @objc private func syncSplitView(notification: Notification) { guard isResizingSplitView == false else { return @@ -82,7 +82,7 @@ final class SessionsSplitViewController: NSSplitViewController { guard splitView.subviews[0].bounds.width != otherSplitView.subviews[0].bounds.width else { return } - + isResizingSplitView = true splitView.setPosition(otherSplitView.subviews[0].bounds.width, ofDividerAt: 0) isResizingSplitView = false diff --git a/WWDC/SessionsTableViewController.swift b/WWDC/SessionsTableViewController.swift index dcb85e30..ce678a34 100644 --- a/WWDC/SessionsTableViewController.swift +++ b/WWDC/SessionsTableViewController.swift @@ -13,7 +13,7 @@ import RealmSwift import ConfCore protocol SessionsTableViewControllerDelegate: class { - + func sessionTableViewContextMenuActionWatch(viewModels: [SessionViewModel]) func sessionTableViewContextMenuActionUnWatch(viewModels: [SessionViewModel]) func sessionTableViewContextMenuActionFavorite(viewModels: [SessionViewModel]) @@ -24,32 +24,32 @@ protocol SessionsTableViewControllerDelegate: class { } class SessionsTableViewController: NSViewController { - + fileprivate struct Metrics { static let headerRowHeight: CGFloat = 20 static let sessionRowHeight: CGFloat = 64 } - + private let disposeBag = DisposeBag() - + weak var delegate: SessionsTableViewControllerDelegate? - + var selectedSession = Variable(nil) - + let style: SessionsListStyle - + var searchResults: Results? { didSet { updateWithSearchResults() } } - + var tracks: Results? { didSet { updateVideosList() } } - + var scheduleSections: Results? { didSet { updateScheduleList() @@ -57,7 +57,7 @@ class SessionsTableViewController: NSViewController { } private var allRows: [SessionRow] = [] - + private(set) var displayedRows: [SessionRow] = [] func setDisplayedRows(_ newValue: [SessionRow], animated: Bool) { @@ -146,12 +146,12 @@ class SessionsTableViewController: NSViewController { init(style: SessionsListStyle) { self.style = style - + super.init(nibName: nil, bundle: nil)! - + identifier = "videosList" } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -159,36 +159,36 @@ class SessionsTableViewController: NSViewController { func selectSession(with identifier: String, scrollOnly: Bool = false) { guard let index = displayedRows.index(where: { row in guard case .session(let viewModel) = row.kind else { return false } - + return viewModel.identifier == identifier }) else { return } - + if !scrollOnly { tableView.selectRowIndexes(IndexSet([index]), byExtendingSelection: false) } - + tableView.scrollRowToVisible(index) } - + func scrollToToday() { guard let sections = scheduleSections else { return } - + guard let section = sections.filter("representedDate >= %@", Today()).first else { return } - + guard let identifier = section.instances.first?.session?.identifier else { return } - + selectSession(with: identifier, scrollOnly: true) } - + private var setupDone = false - + override func viewDidAppear() { super.viewDidAppear() - + performFirstUpdateIfNeeded() - + searchController.searchField.isEnabled = false view.window?.makeFirstResponder(tableView) searchController.searchField.isEnabled = true @@ -202,59 +202,59 @@ class SessionsTableViewController: NSViewController { case .videos: guard !setupDone else { return } setupDone = true - + updateVideosList() } } - + private func updateVideosList() { guard searchResults == nil else { return } guard view.window != nil else { return } - + guard let tracks = tracks else { return } - + let rows: [SessionRow] = tracks.flatMap { track -> [SessionRow] in let titleRow = SessionRow(title: track.name) - + let sessionRows: [SessionRow] = track.sessions.filter(Session.videoPredicate).sorted(by: Session.standardSort).flatMap { session in guard let viewModel = SessionViewModel(session: session) else { return nil } - + return SessionRow(viewModel: viewModel) } - + return [titleRow] + sessionRows } - + self.allRows = rows setDisplayedRows(allRows, animated: false) } - + private func updateScheduleList() { guard searchResults == nil else { return } guard let sections = scheduleSections else { return } - + var shownTimeZone = false - + let rows: [SessionRow] = sections.flatMap { section -> [SessionRow] in let titleRow = SessionRow(date: section.representedDate, showTimeZone: !shownTimeZone) - + shownTimeZone = true - + let instanceRows: [SessionRow] = section.instances.sorted(by: SessionInstance.standardSort).flatMap { instance in guard let viewModel = SessionViewModel(session: instance.session, instance: instance, style: .schedule) else { return nil } - + return SessionRow(viewModel: viewModel) } - + return [titleRow] + instanceRows } - + self.allRows = rows setDisplayedRows(allRows, animated: false) } - + private func updateWithSearchResults() { guard let results = searchResults else { if !allRows.isEmpty { @@ -267,7 +267,7 @@ class SessionsTableViewController: NSViewController { updateVideosList() } } - + return } @@ -281,7 +281,7 @@ class SessionsTableViewController: NSViewController { } let sortingFunction = (style == .schedule) ? Session.standardSortForSchedule : Session.standardSort - + let sessionRows: [SessionRow] = results.sorted(by: sortingFunction).flatMap { session in guard let viewModel = SessionViewModel(session: session) else { return nil } @@ -292,17 +292,17 @@ class SessionsTableViewController: NSViewController { } } } - + return SessionRow(viewModel: viewModel) } setDisplayedRows(sessionRows, animated: true) } - + lazy var searchController: SearchFiltersViewController = { return SearchFiltersViewController.loadFromStoryboard() }() - + lazy var tableView: WWDCTableView = { let v = WWDCTableView() @@ -317,16 +317,16 @@ class SessionsTableViewController: NSViewController { v.floatsGroupRows = true v.gridStyleMask = .solidHorizontalGridLineMask v.gridColor = .darkGridColor - + let column = NSTableColumn(identifier: "session") v.addTableColumn(column) - + return v }() - + lazy var scrollView: NSScrollView = { let v = NSScrollView() - + v.focusRingType = .none v.backgroundColor = .listBackground v.borderType = .noBorder @@ -334,55 +334,55 @@ class SessionsTableViewController: NSViewController { v.hasVerticalScroller = true v.hasHorizontalScroller = false v.translatesAutoresizingMaskIntoConstraints = false - + return v }() - + override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: MainWindowController.defaultRect.height)) view.wantsLayer = true view.layer?.backgroundColor = NSColor.darkWindowBackground.cgColor view.widthAnchor.constraint(lessThanOrEqualToConstant: 675).isActive = true - + scrollView.frame = view.bounds tableView.frame = view.bounds - + scrollView.widthAnchor.constraint(greaterThanOrEqualToConstant: 300).isActive = true - + view.addSubview(scrollView) view.addSubview(searchController.view) - + searchController.view.translatesAutoresizingMaskIntoConstraints = false - + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - + scrollView.topAnchor.constraint(equalTo: searchController.view.bottomAnchor).isActive = true - + searchController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true searchController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true searchController.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true } - + override func viewDidLoad() { super.viewDidLoad() - + tableView.dataSource = self 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).addDisposableTo(self.disposeBag) + }.bind(to: selectedSession).addDisposableTo(self.disposeBag) } - + // MARK: - Contextual menu - + fileprivate enum ContextualMenuOption: Int { case watched = 1000 case unwatched = 1001 @@ -392,49 +392,49 @@ class SessionsTableViewController: NSViewController { case cancelDownload = 1005 case revealInFinder = 1006 } - + private func setupContextualMenu() { let contextualMenu = NSMenu(title: "TableView Menu") - + let watchedMenuItem = NSMenuItem(title: "Mark as Watched", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") watchedMenuItem.option = .watched contextualMenu.addItem(watchedMenuItem) - + let unwatchedMenuItem = NSMenuItem(title: "Mark as Unwatched", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") unwatchedMenuItem.option = .unwatched contextualMenu.addItem(unwatchedMenuItem) - + contextualMenu.addItem(.separator()) - + let favoriteMenuItem = NSMenuItem(title: "Add to Favorites", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") favoriteMenuItem.option = .favorite contextualMenu.addItem(favoriteMenuItem) - + let removeFavoriteMenuItem = NSMenuItem(title: "Remove From Favorites", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") removeFavoriteMenuItem.option = .removeFavorite contextualMenu.addItem(removeFavoriteMenuItem) - + contextualMenu.addItem(.separator()) - + let downloadMenuItem = NSMenuItem(title: "Download", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") downloadMenuItem.option = .download contextualMenu.addItem(downloadMenuItem) - + let cancelDownloadMenuItem = NSMenuItem(title: "Cancel Download", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") contextualMenu.addItem(cancelDownloadMenuItem) cancelDownloadMenuItem.option = .cancelDownload - + let revealInFinderMenuItem = NSMenuItem(title: "Reveal in Finder", action: #selector(tableViewMenuItemClicked(_:)), keyEquivalent: "") contextualMenu.addItem(revealInFinderMenuItem) revealInFinderMenuItem.option = .revealInFinder - + tableView.menu = contextualMenu } - + private func selectedRowIndexes() -> IndexSet { let clickedRow = self.tableView.clickedRow let selectedRowIndexes = self.tableView.selectedRowIndexes - + if clickedRow < 0 || selectedRowIndexes.contains(clickedRow) { return selectedRowIndexes } else { @@ -444,15 +444,15 @@ class SessionsTableViewController: NSViewController { @objc private func tableViewMenuItemClicked(_ menuItem: NSMenuItem) { var viewModels = [SessionViewModel]() - + selectedRowIndexes().forEach { row in guard case .session(let viewModel) = displayedRows[row].kind else { return } - + viewModels.append(viewModel) } guard !viewModels.isEmpty else { return } - + switch menuItem.option { case .watched: delegate?.sessionTableViewContextMenuActionWatch(viewModels: viewModels) @@ -470,19 +470,19 @@ class SessionsTableViewController: NSViewController { delegate?.sessionTableViewContextMenuActionRevealInFinder(viewModels: viewModels) } } - + override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { for row in selectedRowIndexes() { let sessionRow = displayedRows[row] - + guard case .session(let viewModel) = sessionRow.kind else { break } - + if shouldEnableMenuItem(menuItem: menuItem, viewModel: viewModel) { return true } } - + return false } - + private func shouldEnableMenuItem(menuItem: NSMenuItem, viewModel: SessionViewModel) -> Bool { switch menuItem.option { case .watched: @@ -503,33 +503,33 @@ class SessionsTableViewController: NSViewController { } case .cancelDownload: if let sessionAsset = viewModel.session.assets.filter({ $0.assetType == .hdVideo }).first { - if DownloadManager.shared.isDownloading(sessionAsset.remoteURL) { return true } + if DownloadManager.shared.isDownloading(sessionAsset.remoteURL) { return true } } case .revealInFinder: if let sessionAsset = viewModel.session.assets.filter({ $0.assetType == .hdVideo }).first { if DownloadManager.shared.hasVideo(sessionAsset.remoteURL) { return true } } } - + return false } } extension SessionsTableViewController: NSTableViewDataSource, NSTableViewDelegate { - + private struct Constants { static let sessionCellIdentifier = "sessionCell" static let titleCellIdentifier = "titleCell" static let rowIdentifier = "row" } - + func numberOfRows(in tableView: NSTableView) -> Int { return displayedRows.count } - + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { let sessionRow = displayedRows[row] - + switch sessionRow.kind { case .session(let viewModel): return cellForSessionViewModel(viewModel) @@ -537,52 +537,52 @@ extension SessionsTableViewController: NSTableViewDataSource, NSTableViewDelegat return cellForSectionTitle(title) } } - + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { var rowView = tableView.make(withIdentifier: Constants.rowIdentifier, owner: tableView) as? WWDCTableRowView - + if rowView == nil { rowView = WWDCTableRowView(frame: .zero) rowView?.identifier = Constants.rowIdentifier } - + switch displayedRows[row].kind { case .sectionHeader: rowView?.isGroupRowStyle = true default: rowView?.isGroupRowStyle = false } - + return rowView } - + private func cellForSessionViewModel(_ viewModel: SessionViewModel) -> SessionTableCellView? { var cell = tableView.make(withIdentifier: Constants.sessionCellIdentifier, owner: tableView) as? SessionTableCellView - + if cell == nil { cell = SessionTableCellView(frame: .zero) cell?.identifier = Constants.sessionCellIdentifier } - + cell?.viewModel = viewModel - + return cell } - + private func cellForSectionTitle(_ title: String) -> TitleTableCellView? { var cell = tableView.make(withIdentifier: Constants.titleCellIdentifier, owner: tableView) as? TitleTableCellView - + if cell == nil { cell = TitleTableCellView(frame: .zero) cell?.identifier = Constants.titleCellIdentifier } - + cell?.title = title - + return cell } - - func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { switch displayedRows[row].kind { case .session: return Metrics.sessionRowHeight @@ -590,7 +590,7 @@ extension SessionsTableViewController: NSTableViewDataSource, NSTableViewDelegat return Metrics.headerRowHeight } } - + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { switch displayedRows[row].kind { case .sectionHeader: @@ -599,7 +599,7 @@ extension SessionsTableViewController: NSTableViewDataSource, NSTableViewDelegat return true } } - + func tableView(_ tableView: NSTableView, isGroupRow row: Int) -> Bool { switch displayedRows[row].kind { case .sectionHeader: @@ -608,11 +608,11 @@ extension SessionsTableViewController: NSTableViewDataSource, NSTableViewDelegat return false } } - + } private extension NSMenuItem { - + var option: SessionsTableViewController.ContextualMenuOption { get { guard let value = SessionsTableViewController.ContextualMenuOption(rawValue: self.tag) else { diff --git a/WWDC/ShelfView.swift b/WWDC/ShelfView.swift index 2b56ff7e..9531baa6 100644 --- a/WWDC/ShelfView.swift +++ b/WWDC/ShelfView.swift @@ -9,31 +9,31 @@ import Cocoa final class ShelfView: NSView { - + var image: NSImage? { didSet { imageLayer.contents = image } } - + private var imageLayer: CALayer! - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + wantsLayer = true layer = CALayer() - + imageLayer = CALayer() imageLayer.contentsGravity = kCAGravityResizeAspectFill imageLayer.autoresizingMask = [.layerHeightSizable, .layerWidthSizable] imageLayer.frame = bounds - + layer?.addSublayer(imageLayer) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + } diff --git a/WWDC/ShelfViewController.swift b/WWDC/ShelfViewController.swift index 4b4fb62c..e4f57571 100644 --- a/WWDC/ShelfViewController.swift +++ b/WWDC/ShelfViewController.swift @@ -17,85 +17,85 @@ protocol ShelfViewControllerDelegate: class { class ShelfViewController: NSViewController { weak var delegate: ShelfViewControllerDelegate? - + private var disposeBag = DisposeBag() - + var viewModel: SessionViewModel? = nil { didSet { updateBindings() } } - + lazy var shelfView: ShelfView = { let v = ShelfView() - + v.translatesAutoresizingMaskIntoConstraints = false - + return v }() - + lazy var playButton: VibrantButton = { let b = VibrantButton(frame: .zero) - + b.title = "Play" b.translatesAutoresizingMaskIntoConstraints = false b.target = self - b.action = #selector(play(_:)) + b.action = #selector(play) b.isHidden = true - + return b }() - + init() { super.init(nibName: nil, bundle: nil)! } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: MainWindowController.defaultRect.width - 300, height: MainWindowController.defaultRect.height / 2)) view.wantsLayer = true - + view.addSubview(shelfView) shelfView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true shelfView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true shelfView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true shelfView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - + view.addSubview(playButton) playButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true playButton.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true } - + override func viewDidLoad() { super.viewDidLoad() - + updateBindings() } - + private var currentImageDownloadOperation: Operation? - + private func updateBindings() { self.disposeBag = DisposeBag() - + guard let viewModel = viewModel else { self.shelfView.image = nil return } - + viewModel.rxCanBePlayed.map({ !$0 }).bind(to: playButton.rx.isHidden).addDisposableTo(self.disposeBag) - + viewModel.rxImageUrl.subscribe(onNext: { [weak self] imageUrl in self?.currentImageDownloadOperation?.cancel() self?.currentImageDownloadOperation = nil - + guard let imageUrl = imageUrl else { self?.shelfView.image = #imageLiteral(resourceName: "noimage") return } - + self?.currentImageDownloadOperation = ImageDownloadCenter.shared.downloadImage(from: imageUrl, thumbnailHeight: Constants.thumbnailHeight) { url, original, _ in guard url == imageUrl else { return } @@ -103,7 +103,7 @@ class ShelfViewController: NSViewController { } }).addDisposableTo(self.disposeBag) } - + @objc private func play(_ sender: Any?) { delegate?.shelfViewControllerDidSelectPlay(self) } diff --git a/WWDC/TabItemView.swift b/WWDC/TabItemView.swift index 5042bec5..a495b8bc 100644 --- a/WWDC/TabItemView.swift +++ b/WWDC/TabItemView.swift @@ -9,31 +9,31 @@ import Cocoa extension NSStackView { - + var computedContentSize: CGSize { switch orientation { case .horizontal: let height = arrangedSubviews.map({ $0.bounds.height }).max() ?? CGFloat(0) let width = arrangedSubviews.reduce(CGFloat(0), { $0 + $1.intrinsicContentSize.width + self.spacing }) - + return CGSize(width: width - spacing, height: height) case .vertical: let width = arrangedSubviews.map({ $0.bounds.width }).max() ?? CGFloat(0) let height = arrangedSubviews.reduce(CGFloat(0), { $0 + $1.intrinsicContentSize.height + self.spacing }) - + return CGSize(width: width, height: height - spacing) } } - + } final class TabItemView: NSView { - + var target: Any? var action: Selector? - + var controllerIdentifier: String = "" - + var title: String? { didSet { titleLabel.stringValue = title ?? "" @@ -41,7 +41,7 @@ final class TabItemView: NSView { sizeToFit() } } - + var image: NSImage? { didSet { if state == NSOffState { @@ -50,7 +50,7 @@ final class TabItemView: NSView { } } } - + var alternateImage: NSImage? { didSet { if state == NSOnState { @@ -59,54 +59,54 @@ final class TabItemView: NSView { } } } - + var state: Int = NSOffState { didSet { if state == NSOnState { imageView.tintColor = .toolbarTintActive imageView.image = alternateImage ?? image titleLabel.textColor = .toolbarTintActive - titleLabel.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium) + titleLabel.font = .systemFont(ofSize: 14, weight: NSFontWeightMedium) } else { imageView.tintColor = .toolbarTint imageView.image = image titleLabel.textColor = .toolbarTint - titleLabel.font = NSFont.systemFont(ofSize: 14) + titleLabel.font = .systemFont(ofSize: 14) } } } - + lazy var imageView: MaskImageView = { let v = MaskImageView() - + v.tintColor = .toolbarTint v.widthAnchor.constraint(equalToConstant: 20).isActive = true v.heightAnchor.constraint(equalToConstant: 15).isActive = true - + return v }() - + lazy var titleLabel: NSTextField = { let l = NSTextField(labelWithString: "") - - l.font = NSFont.systemFont(ofSize: 14) + + l.font = .systemFont(ofSize: 14) l.textColor = .toolbarTint l.cell?.backgroundStyle = .dark - + return l }() - + lazy var stackView: NSStackView = { let v = NSStackView(views: [self.imageView, self.titleLabel]) - + v.orientation = .horizontal v.spacing = 10 v.alignment = .centerY v.distribution = .equalCentering - + return v }() - + override var intrinsicContentSize: NSSize { get { var s = stackView.computedContentSize @@ -114,34 +114,34 @@ final class TabItemView: NSView { return s } set { - + } } - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + wantsLayer = true - + addSubview(stackView) stackView.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true stackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - + titleLabel.centerYAnchor.constraint(equalTo: stackView.centerYAnchor, constant: -1).isActive = true } - + required init?(coder: NSCoder) { fatalError() } - + func sizeToFit() { frame = NSRect(x: frame.origin.x, y: frame.origin.y, width: intrinsicContentSize.width, height: intrinsicContentSize.height) } - + override func mouseDown(with event: NSEvent) { if let target = target, let action = action { NSApp.sendAction(action, to: target, from: self) } } - + } diff --git a/WWDC/TextualFilter.swift b/WWDC/TextualFilter.swift index 16f922cb..c8c92366 100644 --- a/WWDC/TextualFilter.swift +++ b/WWDC/TextualFilter.swift @@ -10,22 +10,22 @@ import Foundation import ConfCore struct TextualFilter: FilterType { - + var identifier: String var value: String? - + init(identifier: String, value: String?) { self.identifier = identifier self.value = value } - + private var modelKeys: [String] = ["title"] private var subqueryKeys: [String: String] = [:] - + var isEmpty: Bool { return predicate == nil } - + var predicate: NSPredicate? { guard let value = value else { return nil } guard value.characters.count > 2 else { return nil } @@ -37,20 +37,20 @@ struct TextualFilter: FilterType { var subpredicates = modelKeys.map { key -> NSPredicate in return NSPredicate(format: "\(key) CONTAINS[cd] %@", value) } - + let keywords = NSPredicate(format: "SUBQUERY(instances, $instances, ANY $instances.keywords.name CONTAINS[cd] %@).@count > 0", value) subpredicates.append(keywords) - + if Preferences.shared.searchInBookmarks { let bookmarks = NSPredicate(format: "ANY bookmarks.body CONTAINS[cd] %@", value) subpredicates.append(bookmarks) } - + if Preferences.shared.searchInTranscripts { let transcripts = NSPredicate(format: "transcriptText CONTAINS[cd] %@", value) subpredicates.append(transcripts) } - + return NSCompoundPredicate(orPredicateWithSubpredicates: subpredicates) } @@ -61,5 +61,4 @@ struct TextualFilter: FilterType { return dictionary } - } diff --git a/WWDC/TitleTableCellView.swift b/WWDC/TitleTableCellView.swift index c0221abe..990dbe13 100644 --- a/WWDC/TitleTableCellView.swift +++ b/WWDC/TitleTableCellView.swift @@ -15,34 +15,34 @@ class TitleTableCellView: NSTableCellView { self.titleLabel.stringValue = title ?? "" } } - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + buildUI() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private lazy var titleLabel: NSTextField = { let l = NSTextField(labelWithString: "") - l.font = NSFont.systemFont(ofSize: 12, weight: NSFontWeightMedium) + l.font = .systemFont(ofSize: 12, weight: NSFontWeightMedium) l.textColor = .darkText // l.cell?.backgroundStyle = .dark l.lineBreakMode = .byTruncatingTail l.translatesAutoresizingMaskIntoConstraints = false - + return l }() - + private func buildUI() { addSubview(titleLabel) - + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10).isActive = true titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -10).isActive = true titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true } - + } diff --git a/WWDC/ToggleFilter.swift b/WWDC/ToggleFilter.swift index 460b265d..30bfd254 100644 --- a/WWDC/ToggleFilter.swift +++ b/WWDC/ToggleFilter.swift @@ -9,19 +9,19 @@ import Foundation struct ToggleFilter: FilterType { - + var identifier: String var isOn: Bool - + var customPredicate: NSPredicate? - + var isEmpty: Bool { return !isOn } - + var predicate: NSPredicate? { guard isOn else { return nil } - + return customPredicate } @@ -32,5 +32,4 @@ struct ToggleFilter: FilterType { return dictionary } - } diff --git a/WWDC/TrackColorView.swift b/WWDC/TrackColorView.swift index 537e3b87..cd0fad3b 100644 --- a/WWDC/TrackColorView.swift +++ b/WWDC/TrackColorView.swift @@ -9,71 +9,70 @@ import Cocoa final class TrackColorView: NSView { - + private lazy var progressLayer: WWDCLayer = { let l = WWDCLayer() - + l.autoresizingMask = [.layerWidthSizable] - + return l }() - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + wantsLayer = true - layer?.cornerRadius = 2 layer?.masksToBounds = true - + progressLayer.frame = bounds - + layer?.addSublayer(progressLayer) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + var hasValidProgress = false { didSet { needsLayout = true } } - + var progress: Double = 0.5 { didSet { // hide when fully watched self.alphaValue = progress >= Constants.watchedVideoRelativePosition ? 0 : 1 - + if hasValidProgress && progress < 1 { layer?.borderWidth = 1 } else { layer?.borderWidth = 0 } - + needsLayout = true } } - + var color: NSColor = .black { didSet { layer?.borderColor = color.cgColor progressLayer.backgroundColor = color.cgColor } } - + override var intrinsicContentSize: NSSize { return NSSize(width: 4, height: -1) } - + override func layout() { super.layout() - + let progressHeight = hasValidProgress ? bounds.height * CGFloat(progress) : bounds.height - + guard !progressHeight.isNaN && !progressHeight.isInfinite else { return } - + let progressFrame = NSRect(x: 0, y: 0, width: bounds.width, height: progressHeight) progressLayer.frame = progressFrame } @@ -81,5 +80,4 @@ final class TrackColorView: NSView { override func makeBackingLayer() -> CALayer { return WWDCLayer() as CALayer } - } diff --git a/WWDC/TranscriptTableCellView.swift b/WWDC/TranscriptTableCellView.swift index aa310a51..b1b24008 100644 --- a/WWDC/TranscriptTableCellView.swift +++ b/WWDC/TranscriptTableCellView.swift @@ -17,59 +17,59 @@ class TranscriptTableCellView: NSTableCellView { updateUI() } } - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + buildUI() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private lazy var titleLabel: NSTextField = { let l = NSTextField(labelWithString: "") - + l.translatesAutoresizingMaskIntoConstraints = false - l.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium) + l.font = .systemFont(ofSize: 14, weight: NSFontWeightMedium) l.textColor = .primaryText l.cell?.backgroundStyle = .dark l.lineBreakMode = .byTruncatingTail - + return l }() - + private lazy var subtitleLabel: NSTextField = { let l = NSTextField(labelWithString: "") - + l.translatesAutoresizingMaskIntoConstraints = false - l.font = NSFont.systemFont(ofSize: 12) + l.font = .systemFont(ofSize: 12) l.textColor = .secondaryText l.cell?.backgroundStyle = .dark l.lineBreakMode = .byTruncatingTail - + return l }() - + private func buildUI() { addSubview(titleLabel) addSubview(subtitleLabel) - + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true subtitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - + subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12).isActive = true titleLabel.leadingAnchor.constraint(equalTo: subtitleLabel.trailingAnchor, constant: 8).isActive = true - + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12).isActive = true } - + private func updateUI() { guard let annotation = annotation else { return } - + titleLabel.stringValue = annotation.body subtitleLabel.stringValue = String(timestamp: annotation.timecode) ?? "" } - + } diff --git a/WWDC/TranscriptTableViewController.swift b/WWDC/TranscriptTableViewController.swift index a5336f94..cba0a1d5 100644 --- a/WWDC/TranscriptTableViewController.swift +++ b/WWDC/TranscriptTableViewController.swift @@ -19,24 +19,24 @@ class TranscriptTableViewController: NSViewController { var viewModel: SessionViewModel? { didSet { guard viewModel?.identifier != oldValue?.identifier else { return } - + updateUI() } } - + init() { super.init(nibName: nil, bundle: nil)! - + identifier = "transcriptList" } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + lazy var tableView: WWDCTableView = { let v = WWDCTableView() - + v.wantsLayer = true v.focusRingType = .none v.allowsMultipleSelection = true @@ -45,16 +45,16 @@ class TranscriptTableViewController: NSViewController { v.rowHeight = 36 v.autoresizingMask = [.viewWidthSizable, .viewHeightSizable] v.floatsGroupRows = true - + let column = NSTableColumn(identifier: "transcript") v.addTableColumn(column) - + return v }() - + lazy var scrollView: NSScrollView = { let v = NSScrollView() - + v.focusRingType = .none v.backgroundColor = .clear v.drawsBackground = false @@ -63,126 +63,126 @@ class TranscriptTableViewController: NSViewController { v.hasVerticalScroller = true v.hasHorizontalScroller = false v.translatesAutoresizingMaskIntoConstraints = false - + return v }() - + override func loadView() { view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: MainWindowController.defaultRect.height)) view.wantsLayer = true view.layer?.backgroundColor = NSColor.darkWindowBackground.cgColor - + scrollView.frame = view.bounds tableView.frame = view.bounds - + view.heightAnchor.constraint(equalToConstant: 180).isActive = true view.setContentCompressionResistancePriority(NSLayoutPriorityDefaultLow, for: .vertical) - + view.addSubview(scrollView) - + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true scrollView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - + tableView.dataSource = self tableView.delegate = self } - + fileprivate var transcript: Transcript? - + override func viewDidLoad() { super.viewDidLoad() - - NotificationCenter.default.addObserver(self, selector: #selector(highlightTranscriptLine(_:)), name: .HighlightTranscriptAtCurrentTimecode, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(highlightTranscriptLine), name: .HighlightTranscriptAtCurrentTimecode, object: nil) } - + private func updateUI() { guard let transcript = viewModel?.session.transcript() else { return } - + self.transcript = transcript - + tableView.reloadData() } - + fileprivate var selectionLocked = false - + fileprivate func withTableViewSelectionLocked(then executeBlock: () -> Void) { selectionLocked = true - + executeBlock() - + perform(#selector(unlockTableViewSelection), with: nil, afterDelay: 0) } - + @objc fileprivate func unlockTableViewSelection() { selectionLocked = false } - + @objc private func highlightTranscriptLine(_ note: Notification) { withTableViewSelectionLocked { guard let transcript = transcript else { return } guard let timecode = note.object as? String else { return } - + guard let annotation = transcript.annotations.filter({ Transcript.roundedStringFromTimecode($0.timecode) == timecode }).first else { return } - + guard let row = transcript.annotations.index(of: annotation) else { return } - + tableView.selectRowIndexes(IndexSet([row]), byExtendingSelection: false) tableView.scrollRowToVisible(row) } } - + } extension TranscriptTableViewController: NSTableViewDataSource, NSTableViewDelegate { - + private struct Constants { static let cellIdentifier = "annotation" static let rowIdentifier = "row" } - + func numberOfRows(in tableView: NSTableView) -> Int { return transcript?.annotations.count ?? 0 } - + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard let annotations = transcript?.annotations else { return nil } - + var cell = tableView.make(withIdentifier: Constants.cellIdentifier, owner: tableView) as? TranscriptTableCellView - + if cell == nil { cell = TranscriptTableCellView(frame: .zero) cell?.identifier = Constants.cellIdentifier } - + cell?.annotation = annotations[row] - + return cell } - + func tableViewSelectionDidChange(_ notification: Notification) { guard !selectionLocked else { return } - + guard let transcript = transcript else { return } guard tableView.selectedRow >= 0 && tableView.selectedRow < transcript.annotations.count else { return } - + let row = tableView.selectedRow - + let notificationObject = (transcript, transcript.annotations[row]) - + NotificationCenter.default.post(name: NSNotification.Name.TranscriptControllerDidSelectAnnotation, object: notificationObject) } - + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { var rowView = tableView.make(withIdentifier: Constants.rowIdentifier, owner: tableView) as? WWDCTableRowView - + if rowView == nil { rowView = WWDCTableRowView(frame: .zero) rowView?.identifier = Constants.rowIdentifier } - + return rowView } - + } diff --git a/WWDC/VibrantButton.swift b/WWDC/VibrantButton.swift index 2a585db9..d65d2b16 100644 --- a/WWDC/VibrantButton.swift +++ b/WWDC/VibrantButton.swift @@ -12,14 +12,14 @@ class VibrantButton: NSView { var target: Any? var action: Selector? - + var title: String? { didSet { titleLabel.stringValue = title ?? "" sizeToFit() } } - + var state: Int = NSOffState { didSet { if state == NSOnState { @@ -29,79 +29,79 @@ class VibrantButton: NSView { } } } - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + wantsLayer = true layer?.masksToBounds = true layer?.cornerRadius=10 - + buildUI() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private lazy var titleLabel: NSTextField = { let l = NSTextField(labelWithString: "") - l.font = NSFont.systemFont(ofSize: 18) + l.font = .systemFont(ofSize: 18) l.textColor = .primaryText l.lineBreakMode = .byTruncatingTail l.alignment = .center l.translatesAutoresizingMaskIntoConstraints = false - + return l }() - + private lazy var vfxView: NSVisualEffectView = { let v = NSVisualEffectView(frame: .zero) - + v.translatesAutoresizingMaskIntoConstraints = false v.blendingMode = .withinWindow v.material = .ultraDark v.appearance = NSAppearance(named: NSAppearanceNameVibrantDark) v.state = .active - + return v }() - + private func buildUI() { addSubview(vfxView) vfxView.addSubview(titleLabel) - + vfxView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true vfxView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true vfxView.topAnchor.constraint(equalTo: topAnchor).isActive = true vfxView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true - + titleLabel.centerXAnchor.constraint(equalTo: vfxView.centerXAnchor).isActive = true titleLabel.centerYAnchor.constraint(equalTo: vfxView.centerYAnchor).isActive = true - + sizeToFit() } - + override var intrinsicContentSize: NSSize { return NSSize(width: titleLabel.intrinsicContentSize.width + 74, height: titleLabel.intrinsicContentSize.height + 24) } - + func sizeToFit() { titleLabel.sizeToFit() self.frame = NSRect(origin: frame.origin, size: intrinsicContentSize) } - + override func mouseDown(with event: NSEvent) { state = NSOnState } - + override func mouseUp(with event: NSEvent) { if let target = target, let action = action { NSApp.sendAction(action, to: target, from: self) } - + state = NSOffState } - + } diff --git a/WWDC/VideoPlayerViewController.swift b/WWDC/VideoPlayerViewController.swift index dc0712f0..3f149f97 100644 --- a/WWDC/VideoPlayerViewController.swift +++ b/WWDC/VideoPlayerViewController.swift @@ -20,100 +20,100 @@ extension Notification.Name { } protocol VideoPlayerViewControllerDelegate: class { - + func createBookmark(at timecode: Double, with snapshot: NSImage?) - + } final class VideoPlayerViewController: NSViewController { private var disposeBag = DisposeBag() - + weak var delegate: VideoPlayerViewControllerDelegate? - + var playbackViewModel: PlaybackViewModel? - + var sessionViewModel: SessionViewModel { didSet { self.disposeBag = DisposeBag() - + updateUI() resetAppearanceDelegate() } } - + weak var player: AVPlayer! { didSet { reset(oldValue: oldValue) } } - + var detached = false - + var playerWillExitPictureInPicture: ((Bool) -> Void)? - + init(player: AVPlayer, session: SessionViewModel) { self.sessionViewModel = session self.player = player - + super.init(nibName: nil, bundle: nil)! } - + required public init?(coder: NSCoder) { fatalError("VideoPlayerViewController can't be initialized with a coder") } - + lazy var playerView: PUIPlayerView = { return PUIPlayerView(player: self.player) }() - + fileprivate lazy var progressIndicator: NSProgressIndicator = { let p = NSProgressIndicator(frame: NSZeroRect) - + p.controlSize = .regular p.style = .spinningStyle p.isIndeterminate = true p.translatesAutoresizingMaskIntoConstraints = false p.appearance = NSAppearance(named: "WhiteSpinner") - + p.sizeToFit() - + return p }() - + override func loadView() { view = NSView(frame: NSZeroRect) view.wantsLayer = true view.layer?.backgroundColor = NSColor.black.cgColor - + playerView.translatesAutoresizingMaskIntoConstraints = false playerView.frame = view.bounds view.addSubview(playerView) - + playerView.registerExternalPlaybackProvider(ChromeCastPlaybackProvider.self) - + playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true playerView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - + view.addSubview(progressIndicator) view.addConstraints([ NSLayoutConstraint(item: progressIndicator, attribute: .centerX, relatedBy: .equal, toItem: view, attribute: .centerX, multiplier: 1.0, constant: 0.0), NSLayoutConstraint(item: progressIndicator, attribute: .centerY, relatedBy: .equal, toItem: view, attribute: .centerY, multiplier: 1.0, constant: 0.0), - ]) - + ]) + progressIndicator.layer?.zPosition = 999 } - + override func viewDidLoad() { super.viewDidLoad() - + playerView.delegate = self resetAppearanceDelegate() reset(oldValue: nil) updateUI() - + NotificationCenter.default.addObserver(self, selector: #selector(annotationSelected(notification:)), name: .TranscriptControllerDidSelectAnnotation, object: nil) NotificationCenter.default.addObserver(forName: .SkipBackAndForwardBy30SecondsPreferenceDidChange, object: nil, queue: OperationQueue.main) { _ in @@ -121,64 +121,65 @@ final class VideoPlayerViewController: NSViewController { self.playerView.invalidateAppearance() } } - + func resetAppearanceDelegate() { playerView.appearanceDelegate = self } + func reset(oldValue: AVPlayer?) { if let oldPlayer = oldValue { if let boundaryObserver = boundaryObserver { oldPlayer.removeTimeObserver(boundaryObserver) self.boundaryObserver = nil } - + playerView.player = nil - + oldPlayer.pause() oldPlayer.cancelPendingPrerolls() oldPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.status)) oldPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.presentationSize)) } - + player.addObserver(self, forKeyPath: #keyPath(AVPlayer.status), options: [.initial, .new], context: nil) player.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.presentationSize), options: [.initial, .new], context: nil) - + player.play() - + progressIndicator.startAnimation(nil) - + playerView.player = player - + if let playbackViewModel = playbackViewModel { playerView.remoteMediaUrl = playbackViewModel.remoteMediaURL playerView.mediaTitle = playbackViewModel.title playerView.mediaPosterUrl = playbackViewModel.imageURL playerView.mediaIsLiveStream = playbackViewModel.isLiveStream } - + setupTranscriptSync() } - + func updateUI() { let bookmarks = sessionViewModel.session.bookmarks.sorted(byKeyPath: "timecode") Observable.collection(from: bookmarks).observeOn(MainScheduler.instance).subscribe(onNext: { [weak self] bookmarks in self?.playerView.annotations = bookmarks.toArray() }).addDisposableTo(self.disposeBag) } - + @objc private func annotationSelected(notification: Notification) { guard let (transcript, annotation) = notification.object as? (Transcript, TranscriptAnnotation) else { return } guard transcript.identifier == sessionViewModel.session.transcriptIdentifier else { return } - + let time = CMTimeMakeWithSeconds(annotation.timecode, 90000) player.seek(to: time) } - + // MARK: - Player Observation - + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { guard let keyPath = keyPath else { return } - + if keyPath == #keyPath(AVPlayer.currentItem.presentationSize) { DispatchQueue.main.async(execute: playerItemPresentationSizeDidChange) } else if keyPath == #keyPath(AVPlayer.status) { @@ -187,13 +188,13 @@ final class VideoPlayerViewController: NSViewController { super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) } } - + private func playerItemPresentationSizeDidChange() { guard let size = player.currentItem?.presentationSize, size != NSZeroSize else { return } - + (view.window as? PUIPlayerWindow)?.aspectRatio = size } - + private func playerStatusDidChange() { switch player.status { case .readyToPlay, .failed: @@ -202,60 +203,60 @@ final class VideoPlayerViewController: NSViewController { default: break } } - + // MARK: - Transcript sync - + private var boundaryObserver: Any? - + private func setupTranscriptSync() { guard let player = player else { return } guard let transcript = sessionViewModel.session.transcript() else { return } - + let timecodes = transcript.timecodesWithTimescale(9000) guard timecodes.count > 0 else { return } - + boundaryObserver = player.addBoundaryTimeObserver(forTimes: timecodes, queue: DispatchQueue.main) { [unowned self] in guard !transcript.isInvalidated, self.player != nil else { return } - + let ct = CMTimeGetSeconds(self.player.currentTime()) let roundedTimecode = Transcript.roundedStringFromTimecode(ct) - + NotificationCenter.default.post(name: .HighlightTranscriptAtCurrentTimecode, object: roundedTimecode) } } - + // MARK: - Detach - + var detachedWindowController: VideoPlayerWindowController! - + func detach(forEnteringFullscreen fullscreen: Bool = false) { view.translatesAutoresizingMaskIntoConstraints = true - + detachedWindowController = VideoPlayerWindowController(playerViewController: self, fullscreenOnly: fullscreen, originalContainer: view.superview) detachedWindowController.contentViewController = self - + detachedWindowController.actionOnWindowClosed = { [weak self] in self?.detachedWindowController = nil } - + detachedWindowController.showWindow(self) - + detached = true } - + deinit { #if DEBUG Swift.print("VideoPlayerViewController is gone") #endif - + player.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.presentationSize)) player.removeObserver(self, forKeyPath: #keyPath(AVPlayer.status)) } - + } extension VideoPlayerViewController: PUIPlayerViewDelegate { - + func playerViewDidSelectToggleFullScreen(_ playerView: PUIPlayerView) { if let playerWindow = playerView.window as? PUIPlayerWindow { playerWindow.toggleFullScreen(self) @@ -263,25 +264,25 @@ extension VideoPlayerViewController: PUIPlayerViewDelegate { detach(forEnteringFullscreen: true) } } - + func playerViewDidSelectAddAnnotation(_ playerView: PUIPlayerView, at timestamp: Double) { snapshotPlayer { snapshot in self.delegate?.createBookmark(at: timestamp, with: snapshot) } } - + private func snapshotPlayer(completion: @escaping (NSImage?) -> Void) { playerView.snapshotPlayer(completion: completion) } - + func playerViewWillExitPictureInPictureMode(_ playerView: PUIPlayerView, isReturningFromPiP: Bool) { self.playerWillExitPictureInPicture?(isReturningFromPiP) } - + func playerViewWillEnterPictureInPictureMode(_ playerView: PUIPlayerView) { - + } - + } extension VideoPlayerViewController: PUIPlayerViewAppearanceDelegate { @@ -328,19 +329,19 @@ extension VideoPlayerViewController: PUIPlayerViewAppearanceDelegate { } extension Transcript { - + func timecodesWithTimescale(_ timescale: Int32) -> [NSValue] { return annotations.map { annotation -> NSValue in let time = CMTimeMakeWithSeconds(annotation.timecode, timescale) - + return NSValue(time: time) } } - + class func roundedStringFromTimecode(_ timecode: Float64) -> String { let formatter = NumberFormatter() formatter.positiveFormat = "0.#" - + return formatter.string(from: NSNumber(value: timecode)) ?? "0.0" } diff --git a/WWDC/VideoPlayerWindowController.swift b/WWDC/VideoPlayerWindowController.swift index 3833cdff..de4bf318 100644 --- a/WWDC/VideoPlayerWindowController.swift +++ b/WWDC/VideoPlayerWindowController.swift @@ -10,126 +10,126 @@ import Cocoa import PlayerUI public enum PUIPlayerWindowSizePreset: CGFloat { - + case quarter = 0.25 case half = 0.50 case max = 1.0 - + } final class VideoPlayerWindowController: NSWindowController, NSWindowDelegate { fileprivate let fullscreenOnly: Bool fileprivate let originalContainer: NSView! - + var actionOnWindowClosed = {} - + init(playerViewController: VideoPlayerViewController, fullscreenOnly: Bool = false, originalContainer: NSView? = nil) { self.fullscreenOnly = fullscreenOnly self.originalContainer = originalContainer - + let styleMask: NSWindowStyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] - + var rect = PUIPlayerWindow.bestScreenRectFromDetachingContainer(playerViewController.view.superview) if rect == NSZeroRect { rect = PUIPlayerWindow.centerRectForProposedContentRect(playerViewController.view.bounds) } - + let window = PUIPlayerWindow(contentRect: rect, styleMask: styleMask, backing: .buffered, defer: false) window.isReleasedWhenClosed = true - + if #available(OSX 10.11, *) { // ¯\_(ツ)_/¯ } else { window.collectionBehavior = .fullScreenPrimary } - + super.init(window: window) - + window.delegate = self - + contentViewController = playerViewController window.title = playerViewController.title ?? "" - + if let aspect = playerViewController.player.currentItem?.presentationSize, aspect != NSZeroSize { window.aspectRatio = aspect } } - + required public init?(coder: NSCoder) { fatalError("VideoPlayerWindowController can't be initialized with a coder") } - + override func showWindow(_ sender: Any?) { super.showWindow(sender) - + if !fullscreenOnly { (window as! PUIPlayerWindow).applySizePreset(.half) } else { window?.toggleFullScreen(sender) } } - + // MARK: - Reattachment and fullscreen support - + func windowWillClose(_ notification: Notification) { (contentViewController as? VideoPlayerViewController)?.player.cancelPendingPrerolls() (contentViewController as? VideoPlayerViewController)?.player.pause() - + actionOnWindowClosed() - + guard fullscreenOnly && contentViewController is VideoPlayerViewController else { return } - + reattachContentViewController() } - + func windowWillExitFullScreen(_ notification: Notification) { guard fullscreenOnly && contentViewController is VideoPlayerViewController else { return } - + window?.resizeIncrements = NSSize(width: 1.0, height: 1.0) } - + func windowDidExitFullScreen(_ notification: Notification) { guard fullscreenOnly && contentViewController is VideoPlayerViewController else { return } - + reattachContentViewController() } - + fileprivate func reattachContentViewController() { contentViewController!.view.frame = originalContainer.bounds originalContainer.addSubview(contentViewController!.view) - + originalContainer.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "|-(0)-[playerView]-(0)-|", options: [], metrics: nil, views: ["playerView": contentViewController!.view])) originalContainer.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-(0)-[playerView]-(0)-|", options: [], metrics: nil, views: ["playerView": contentViewController!.view])) - + contentViewController = nil close() } - + func customWindowsToExitFullScreen(for window: NSWindow) -> [NSWindow]? { guard fullscreenOnly else { return nil } - + return [window] } - + func window(_ window: NSWindow, startCustomAnimationToExitFullScreenWithDuration duration: TimeInterval) { NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = duration let frame = PUIPlayerWindow.bestScreenRectFromDetachingContainer(self.originalContainer) window.animator().setFrame(frame, display: false) - }, completionHandler: nil) + }, completionHandler: nil) } - + @IBAction func sizeWindowToHalfSize(_ sender: AnyObject?) { (window as! PUIPlayerWindow).applySizePreset(.half) } - + @IBAction func sizeWindowToQuarterSize(_ sender: AnyObject?) { (window as! PUIPlayerWindow).applySizePreset(.quarter) } - + @IBAction func sizeWindowToFill(_ sender: AnyObject?) { (window as! PUIPlayerWindow).applySizePreset(.max) } - + @IBAction func floatOnTop(_ sender: NSMenuItem) { if sender.state == NSOnState { toggleFloatOnTop(false) @@ -139,37 +139,37 @@ final class VideoPlayerWindowController: NSWindowController, NSWindowDelegate { sender.state = NSOnState } } - + fileprivate func toggleFloatOnTop(_ enable: Bool) { let level = enable ? Int(CGWindowLevelForKey(CGWindowLevelKey.floatingWindow)) : Int(CGWindowLevelForKey(CGWindowLevelKey.normalWindow)) window?.level = level } - + deinit { #if DEBUG - Swift.print("VideoPlayerWindowController is gone") + Swift.print("VideoPlayerWindowController is gone") #endif } } private extension PUIPlayerWindow { - + class func centerRectForProposedContentRect(_ rect: NSRect) -> NSRect { guard let screen = NSScreen.main() else { return NSZeroRect } - + return NSRect(x: screen.frame.width / 2.0 - rect.width / 2.0, y: screen.frame.height / 2.0 - rect.height / 2.0, width: rect.width, height: rect.height) } - + class func bestScreenRectFromDetachingContainer(_ containerView: NSView?) -> NSRect { guard let view = containerView, let superview = view.superview else { return NSZeroRect } - + return view.window?.convertToScreen(superview.convert(view.frame, to: nil)) ?? NSZeroRect } - + func applySizePreset(_ preset: PUIPlayerWindowSizePreset, center: Bool = true, animated: Bool = true) { guard let screen = screen else { return } - + let proportion = frame.size.width / screen.visibleFrame.size.width let idealSize: NSSize if proportion != preset.rawValue { @@ -178,15 +178,15 @@ private extension PUIPlayerWindow { } else { idealSize = constrainFrameRect(frame, to: screen).size } - + let origin: NSPoint - + if center { origin = NSPoint(x: screen.frame.width / 2.0 - idealSize.width / 2.0, y: screen.frame.height / 2.0 - idealSize.height / 2.0) } else { origin = frame.origin } - + setFrame(NSRect(origin: origin, size: idealSize), display: true, animate: animated) } diff --git a/WWDC/WWDCAlert.swift b/WWDC/WWDCAlert.swift index d25015d3..f98adca8 100644 --- a/WWDC/WWDCAlert.swift +++ b/WWDC/WWDCAlert.swift @@ -12,18 +12,18 @@ final class WWDCAlert { static func show(with error: Error) { let alert = NSAlert(error: error) - + alert.window.appearance = WWDCAppearance.appearance() - + alert.runModal() } - + static func create() -> NSAlert { let alert = NSAlert() - + alert.window.appearance = WWDCAppearance.appearance() - + return alert } - + } diff --git a/WWDC/WWDCImageView.swift b/WWDC/WWDCImageView.swift index 592cbb57..aa7fb448 100644 --- a/WWDC/WWDCImageView.swift +++ b/WWDC/WWDCImageView.swift @@ -9,37 +9,37 @@ import Cocoa protocol WWDCImageViewDelegate: class { - + func wwdcImageView(_ imageView: WWDCImageView, didReceiveNewImageWithFileURL url: URL) - + } class WWDCImageView: NSView { - + weak var delegate: WWDCImageViewDelegate? - + var isRounded = false { didSet { updateRoundness() } } - + var drawsBackground = true { didSet { self.backgroundLayer.isHidden = !drawsBackground } } - + override var isOpaque: Bool { return drawsBackground && !isRounded } - + var backgroundColor: NSColor = .clear { didSet { backgroundLayer.backgroundColor = backgroundColor.cgColor } } - + var isEditable: Bool = false { didSet { if isEditable { @@ -49,88 +49,88 @@ class WWDCImageView: NSView { } } } - + var image: NSImage? = nil { didSet { imageLayer.contents = image } } - + override init(frame frameRect: NSRect) { super.init(frame: frameRect) - + buildUI() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private lazy var backgroundLayer: WWDCLayer = { let l = WWDCLayer() - + l.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] - + return l }() - + private lazy var imageLayer: WWDCLayer = { let l = WWDCLayer() - + l.contentsGravity = kCAGravityResizeAspect l.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] l.zPosition = 1 - + return l }() - + private func buildUI() { self.wantsLayer = true self.layer?.cornerRadius = 2 self.layer?.masksToBounds = true - + backgroundLayer.frame = bounds imageLayer.frame = bounds - + layer?.addSublayer(backgroundLayer) layer?.addSublayer(imageLayer) } - + override func layout() { super.layout() - + updateRoundness() } override func makeBackingLayer() -> CALayer { return WWDCLayer() as CALayer } - + // MARK: - Editing - + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { return .copy } - + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { guard let file = (sender.draggingPasteboard().propertyList(forType: NSFilenamesPboardType) as? [String])?.first else { return false } - + let fileURL = URL(fileURLWithPath: file) - + guard let image = NSImage.thumbnailImage(with: fileURL, maxWidth: 400) else { return false } - + self.image = image delegate?.wwdcImageView(self, didReceiveNewImageWithFileURL: fileURL) - + return true } - + private func updateRoundness() { guard let layer = layer else { return } - + layer.cornerRadius = isRounded ? layer.bounds.height / 2 : 0 } - + } diff --git a/WWDC/WWDCLayer.swift b/WWDC/WWDCLayer.swift index dff0fe03..ec9432a2 100644 --- a/WWDC/WWDCLayer.swift +++ b/WWDC/WWDCLayer.swift @@ -9,17 +9,17 @@ import QuartzCore final class WWDCLayer: CALayer { - + override func action(forKey event: String) -> CAAction? { return nil } - + } final class WWDCShapeLayer: CAShapeLayer { - + override func action(forKey event: String) -> CAAction? { return nil } - + } diff --git a/WWDC/WWDCSegmentedControl.swift b/WWDC/WWDCSegmentedControl.swift index f24fcd65..f99ad153 100644 --- a/WWDC/WWDCSegmentedControl.swift +++ b/WWDC/WWDCSegmentedControl.swift @@ -9,53 +9,53 @@ import Cocoa final class WWDCSegmentedControl: NSSegmentedControl { - + var padding: EdgeInsets = EdgeInsets(top: 0, left: 12, bottom: 0, right: 12) { didSet { resizeSegments() } } - + override func viewWillMove(toSuperview newSuperview: NSView?) { NotificationCenter.default.removeObserver(self, name: .NSViewFrameDidChange, object: superview) superview?.postsFrameChangedNotifications = false } - + override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() - + setupForSuperview() } - + override func awakeFromNib() { super.awakeFromNib() - + setupForSuperview() } - + private func setupForSuperview() { guard let superview = superview else { return } - + superview.postsFrameChangedNotifications = true - + NotificationCenter.default.addObserver(self, selector: #selector(resizeSegments), name: .NSViewFrameDidChange, object: superview) - + resizeSegments() } - + private let nudge: CGFloat = 2.0 - + @objc private func resizeSegments() { guard let superview = superview else { return } - + let containmentWidth = superview.bounds.width - padding.left - padding.right - nudge - + let segmentWidth = floor(containmentWidth / CGFloat(segmentCount)) - + (0..: NSTabViewController where Tab.RawValue == Int { - + var activeTab: Tab { get { return Tab(rawValue: self.selectedTabViewItemIndex)! @@ -24,13 +24,13 @@ class WWDCTabViewController: NSTabViewController where Ta self.selectedTabViewItemIndex = newValue.rawValue } } - + private var activeTabVar = Variable(Tab(rawValue: 0)!) - + var rxActiveTab: Observable { return activeTabVar.asObservable() } - + init(windowController: NSWindowController) { super.init(nibName: nil, bundle: nil)! @@ -42,123 +42,123 @@ class WWDCTabViewController: NSTabViewController where Ta identifier = "tabs" } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - + transitionOptions = [.allowUserInteraction] - + tabStyle = .toolbar view.wantsLayer = true } - + private var sentStatupNotification = false - + private var isConfigured = false - + override func viewDidAppear() { super.viewDidAppear() - + configureIfNeeded() } - + private func configureIfNeeded() { guard !isConfigured else { return } - + guard let toolbar = view.window?.toolbar else { return } - + isConfigured = true - + toolbar.insertItem(withItemIdentifier: NSToolbarFlexibleSpaceItemIdentifier, at: 0) toolbar.insertItem(withItemIdentifier: NSToolbarFlexibleSpaceItemIdentifier, at: toolbar.items.count) - + addObserver(self, forKeyPath: #keyPath(selectedTabViewItemIndex), options: [.initial, .new], context: nil) - + if !sentStatupNotification { sentStatupNotification = true NotificationCenter.default.post(name: .WWDCTabViewControllerDidFinishLoading, object: self) } } - + deinit { removeObserver(self, forKeyPath: #keyPath(selectedTabViewItemIndex)) } - + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == #keyPath(selectedTabViewItemIndex) { guard selectedTabViewItemIndex >= 0 && selectedTabViewItemIndex < tabViewItems.count else { return } - + tabViewItems.forEach { item in guard let identifier = item.viewController?.identifier else { return } guard let view = self.tabItemViews.first(where: { $0.controllerIdentifier == identifier }) else { return } - + if self.indexForChild(with: identifier) == self.selectedTabViewItemIndex { view.state = NSOnState } else { view.state = NSOffState } } - + self.activeTabVar.value = Tab(rawValue: self.selectedTabViewItemIndex)! } else { super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) } } - + private func tabItem(with identifier: String) -> NSTabViewItem? { return tabViewItems.filter({ $0.identifier as? String == identifier }).first } - + override func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: String, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { guard let tabItem = tabItem(with: itemIdentifier) else { return nil } - + let itemView = TabItemView(frame: .zero) - + itemView.title = tabItem.label itemView.controllerIdentifier = tabItem.viewController?.identifier ?? "" itemView.image = NSImage(named: itemView.controllerIdentifier.lowercased()) itemView.alternateImage = NSImage(named: itemView.controllerIdentifier.lowercased() + "-filled") - + let item = NSToolbarItem(itemIdentifier: itemIdentifier) - + item.minSize = itemView.bounds.size item.maxSize = itemView.bounds.size item.view = itemView - + item.target = self - item.action = #selector(changeTab(_:)) - + item.action = #selector(changeTab) + itemView.state = (tabViewItems.index(of: tabItem) == self.selectedTabViewItemIndex) ? NSOnState : NSOffState - + return item } - + @objc private func changeTab(_ sender: TabItemView) { guard let index = indexForChild(with: sender.controllerIdentifier) else { return } - + self.selectedTabViewItemIndex = index } - + private func indexForChild(with identifier: String) -> Int? { - return tabViewItems.index(where: { $0.viewController?.identifier == identifier }) + return tabViewItems.index { $0.viewController?.identifier == identifier } } - + private var tabItemViews: [TabItemView] { - return self.view.window?.toolbar?.items.flatMap({ $0.view as? TabItemView }) ?? [] + return self.view.window?.toolbar?.items.flatMap { $0.view as? TabItemView } ?? [] } - + private var loadingView: ModalLoadingView? - + func showLoading() { loadingView = ModalLoadingView.show(attachedTo: self.view) } - + func hideLoading() { loadingView?.hide() } - + } diff --git a/WWDC/WWDCTableRowView.swift b/WWDC/WWDCTableRowView.swift index d1a85a71..e6a22237 100644 --- a/WWDC/WWDCTableRowView.swift +++ b/WWDC/WWDCTableRowView.swift @@ -9,18 +9,18 @@ import Cocoa class WWDCTableRowView: NSTableRowView { - + override var isGroupRowStyle: Bool { didSet { setNeedsDisplay(bounds) } } - + override func drawSelection(in dirtyRect: NSRect) { NSColor.selection.set() NSRectFill(dirtyRect) } - + override func drawBackground(in dirtyRect: NSRect) { if isGroupRowStyle { NSColor.sectionHeaderBackground.set() @@ -29,5 +29,5 @@ class WWDCTableRowView: NSTableRowView { super.drawBackground(in: dirtyRect) } } - + } diff --git a/WWDC/WWDCTableView.swift b/WWDC/WWDCTableView.swift index 94a65838..f8fc3eed 100644 --- a/WWDC/WWDCTableView.swift +++ b/WWDC/WWDCTableView.swift @@ -11,7 +11,7 @@ import Cocoa class WWDCTableView: NSTableView { private var selectedRowIndexesBeforeReload: IndexSet = IndexSet([]) - + func reloadPreservingSelection() { selectedRowIndexesBeforeReload = selectedRowIndexes reloadData() @@ -21,5 +21,4 @@ class WWDCTableView: NSTableView { override var effectiveAppearance: NSAppearance { return NSAppearance(named: NSAppearanceNameVibrantDark)! } - } diff --git a/WWDC/WWDCTextField.swift b/WWDC/WWDCTextField.swift index dc7622eb..756e94c6 100644 --- a/WWDC/WWDCTextField.swift +++ b/WWDC/WWDCTextField.swift @@ -9,49 +9,49 @@ import Cocoa class WWDCTextField: NSTextField { - + override func viewWillMove(toSuperview newSuperview: NSView?) { NotificationCenter.default.removeObserver(self, name: .NSViewFrameDidChange, object: superview) - + super.viewWillMove(toSuperview: newSuperview) } - + override func awakeFromNib() { super.awakeFromNib() - + viewDidMoveToSuperview() } - + override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() - + fixApplesTextFieldSizingBehavior() } - + override var stringValue: String { didSet { invalidateIntrinsicContentSize() superview?.needsUpdateConstraints = true } } - + public override func prepareForReuse() { invalidateIntrinsicContentSize() } - + func fixApplesTextFieldSizingBehavior() { superview?.postsFrameChangedNotifications = true NotificationCenter.default.addObserver(self, selector: #selector(superviewFrameDidChange), name: .NSViewFrameDidChange, object: superview) } - + @objc private func superviewFrameDidChange() { invalidateIntrinsicContentSize() } - + override func textDidChange(_ notification: Notification) { super.textDidChange(notification as Notification) - + invalidateIntrinsicContentSize() } - + } diff --git a/WWDC/WWDCWindow.swift b/WWDC/WWDCWindow.swift index 961e9ede..87bbeb93 100644 --- a/WWDC/WWDCWindow.swift +++ b/WWDC/WWDCWindow.swift @@ -11,42 +11,42 @@ import Cocoa class WWDCWindow: NSWindow { // MARK: - Initialization - + public override init(contentRect: NSRect, styleMask style: NSWindowStyleMask, backing bufferingType: NSBackingStoreType, defer flag: Bool) { super.init(contentRect: contentRect, styleMask: style, backing: bufferingType, defer: flag) - + applyCustomizations() } - + open override func awakeFromNib() { super.awakeFromNib() - + applyCustomizations() } - + // MARK: - Custom appearance - + fileprivate var _storedTitlebarView: NSVisualEffectView? open var titlebarView: NSVisualEffectView? { guard _storedTitlebarView == nil else { return _storedTitlebarView } guard let containerClass = NSClassFromString("NSTitlebarContainerView") else { return nil } - + guard let containerView = contentView?.superview?.subviews.filter({ $0.isKind(of: containerClass) }).last else { return nil } - + guard let titlebar = containerView.subviews.filter({ $0.isKind(of: NSVisualEffectView.self) }).last as? NSVisualEffectView else { return nil } - + _storedTitlebarView = titlebar - + return _storedTitlebarView } - + fileprivate func applyCustomizations(_ note: Notification? = nil) { backgroundColor = .darkWindowBackground - + titleVisibility = .hidden isMovableByWindowBackground = true - tabbingMode = NSWindowTabbingMode.disallowed - + tabbingMode = NSWindowTabbingMode.disallowed + titlebarView?.material = .ultraDark titlebarView?.state = .inactive titlebarView?.layer?.backgroundColor = NSColor.darkTitlebarBackground.cgColor