├── .gitignore ├── CHANGELOG.md ├── Cartfile ├── Cartfile.resolved ├── LICENSE ├── MajorInput.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcshareddata │ └── xcschemes │ ├── MajorInput.xcscheme │ └── MajorInputTests.xcscheme ├── MajorInput.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── WorkspaceSettings.xcsettings ├── MajorInput ├── AVFoundationExtensions.swift ├── AppBuilder.swift ├── AppDelegate.swift ├── AppNavigationController.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── iTunesArtwork@2x-20.png │ │ ├── iTunesArtwork@2x-20@2x.png │ │ ├── iTunesArtwork@2x-20@3x.png │ │ ├── iTunesArtwork@2x-29.png │ │ ├── iTunesArtwork@2x-29@2x.png │ │ ├── iTunesArtwork@2x-29@3x.png │ │ ├── iTunesArtwork@2x-40.png │ │ ├── iTunesArtwork@2x-40@2x.png │ │ ├── iTunesArtwork@2x-40@3x.png │ │ ├── iTunesArtwork@2x-60@2x.png │ │ ├── iTunesArtwork@2x-60@3x.png │ │ ├── iTunesArtwork@2x-76.png │ │ ├── iTunesArtwork@2x-76@2x.png │ │ └── iTunesArtwork@2x-83.5@2x.png │ └── Contents.json ├── Base.lproj │ └── LaunchScreen.storyboard ├── Caption.swift ├── CaptionCell.swift ├── CaptionsView.swift ├── CaptionsViewController.swift ├── ClassStringProviding.swift ├── CoreGraphicsExtensions.swift ├── DependencyProtocols.swift ├── DownloadsService.swift ├── FilmstripCell.swift ├── FilmstripViewController.swift ├── FoundationExtensions.swift ├── Identifiable.swift ├── Info.plist ├── MajorInputView.swift ├── MajorInputViewController.swift ├── PlayerSeeker.swift ├── PlayerView.swift ├── Session+JSON.swift ├── Session.swift ├── SessionCell.swift ├── SessionsService.swift ├── Settings.bundle │ ├── Root.plist │ ├── com.mono0926.LicensePlist.latest_result.txt │ ├── com.mono0926.LicensePlist.plist │ ├── com.mono0926.LicensePlist │ │ ├── Anchorage.plist │ │ ├── Buckets-Swift.plist │ │ ├── HTMLEntities.plist │ │ ├── Kingfisher.plist │ │ ├── LicensePlist.plist │ │ ├── Nimble.plist │ │ ├── Quick.plist │ │ ├── ReactiveCocoa.plist │ │ ├── ReactiveSwift.plist │ │ ├── Result.plist │ │ ├── STRegex.plist │ │ ├── Strongify.plist │ │ └── SwiftyJSON.plist │ └── en.lproj │ │ └── Root.strings ├── ShelfView.swift ├── ShelfViewController.swift ├── SortDescriptor.swift ├── StdLibExtensions.swift ├── TouchStencilingButton.swift ├── TransformerView.swift ├── UIKitExtensions.swift ├── ViewDowncasting.swift └── ViewInitializing.swift ├── MajorInputTests ├── CaptionSpec.swift ├── Info.plist ├── MajorInputSanityCheckTests.swift ├── QuickIntegrationTests.swift ├── StringExtensionsSpec.swift └── VideosJsonImportingSpec.swift ├── Podfile ├── Podfile.lock ├── README.md ├── Resources ├── screenshots │ ├── majorinput.png │ ├── shelf.png │ └── tour.gif └── sessions.json ├── Tools ├── Scripts │ └── generate-acknowledgements.sh └── bin │ ├── license-plist │ └── license-plist.LICENSE └── license_plist.yml /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,xcode,swift,objective-c,jetbrains,carthage 3 | 4 | ### OSX ### 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | 9 | # Icon must end with two \r 10 | Icon 11 | 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear in the root of a volume 17 | .DocumentRevisions-V100 18 | .fseventsd 19 | .Spotlight-V100 20 | .TemporaryItems 21 | .Trashes 22 | .VolumeIcon.icns 23 | 24 | # Directories potentially created on remote AFP share 25 | .AppleDB 26 | .AppleDesktop 27 | Network Trash Folder 28 | Temporary Items 29 | .apdisk 30 | 31 | 32 | ### Xcode ### 33 | # Xcode 34 | # 35 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 36 | 37 | ## Build generated 38 | build/ 39 | DerivedData 40 | 41 | ## Various settings 42 | *.pbxuser 43 | !default.pbxuser 44 | *.mode1v3 45 | !default.mode1v3 46 | *.mode2v3 47 | !default.mode2v3 48 | *.perspectivev3 49 | !default.perspectivev3 50 | xcuserdata 51 | 52 | ## Other 53 | *.xccheckout 54 | *.moved-aside 55 | *.xcuserstate 56 | 57 | 58 | ### Swift ### 59 | # Xcode 60 | # 61 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 62 | 63 | ## Build generated 64 | build/ 65 | DerivedData 66 | 67 | ## Various settings 68 | *.pbxuser 69 | !default.pbxuser 70 | *.mode1v3 71 | !default.mode1v3 72 | *.mode2v3 73 | !default.mode2v3 74 | *.perspectivev3 75 | !default.perspectivev3 76 | xcuserdata 77 | 78 | ## Other 79 | *.xccheckout 80 | *.moved-aside 81 | *.xcuserstate 82 | *.xcscmblueprint 83 | 84 | ## Obj-C/Swift specific 85 | *.hmap 86 | *.ipa 87 | 88 | ## Playgrounds 89 | timeline.xctimeline 90 | playground.xcworkspace 91 | 92 | # Swift Package Manager 93 | # 94 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 95 | # Packages/ 96 | .build/ 97 | 98 | Pods/ 99 | Carthage/ 100 | 101 | # fastlane 102 | # 103 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 104 | # screenshots whenever they are needed. 105 | # For more information about the recommended setup visit: 106 | # https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md 107 | 108 | fastlane/report.xml 109 | fastlane/screenshots 110 | 111 | 112 | ### Objective-C ### 113 | # Xcode 114 | # 115 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 116 | 117 | ## Build generated 118 | build/ 119 | DerivedData 120 | 121 | ## Various settings 122 | *.pbxuser 123 | !default.pbxuser 124 | *.mode1v3 125 | !default.mode1v3 126 | *.mode2v3 127 | !default.mode2v3 128 | *.perspectivev3 129 | !default.perspectivev3 130 | xcuserdata 131 | 132 | ## Other 133 | *.xccheckout 134 | *.moved-aside 135 | *.xcuserstate 136 | *.xcscmblueprint 137 | 138 | ## Obj-C/Swift specific 139 | *.hmap 140 | *.ipa 141 | 142 | # CocoaPods 143 | # 144 | # We recommend against adding the Pods directory to your .gitignore. However 145 | # you should judge for yourself, the pros and cons are mentioned at: 146 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 147 | # 148 | # Pods/ 149 | 150 | # Carthage 151 | # 152 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 153 | # Carthage/Checkouts 154 | 155 | Carthage/Build 156 | 157 | # fastlane 158 | # 159 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 160 | # screenshots whenever they are needed. 161 | # For more information about the recommended setup visit: 162 | # https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md 163 | 164 | fastlane/report.xml 165 | fastlane/screenshots 166 | 167 | ### Objective-C Patch ### 168 | *.xcscmblueprint 169 | 170 | 171 | ### JetBrains ### 172 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 173 | 174 | *.iml 175 | 176 | ## Directory-based project format: 177 | .idea/ 178 | # if you remove the above rule, at least ignore the following: 179 | 180 | # User-specific stuff: 181 | # .idea/workspace.xml 182 | # .idea/tasks.xml 183 | # .idea/dictionaries 184 | # .idea/shelf 185 | 186 | # Sensitive or high-churn files: 187 | # .idea/dataSources.ids 188 | # .idea/dataSources.xml 189 | # .idea/sqlDataSources.xml 190 | # .idea/dynamic.xml 191 | # .idea/uiDesigner.xml 192 | 193 | # Gradle: 194 | # .idea/gradle.xml 195 | # .idea/libraries 196 | 197 | # Mongo Explorer plugin: 198 | # .idea/mongoSettings.xml 199 | 200 | ## File-based project format: 201 | *.ipr 202 | *.iws 203 | 204 | ## Plugin-specific files: 205 | 206 | # IntelliJ 207 | /out/ 208 | 209 | # mpeltonen/sbt-idea plugin 210 | .idea_modules/ 211 | 212 | # JIRA plugin 213 | atlassian-ide-plugin.xml 214 | 215 | # Crashlytics plugin (for Android Studio and IntelliJ) 216 | com_crashlytics_export_strings.xml 217 | crashlytics.properties 218 | crashlytics-build.properties 219 | fabric.properties 220 | 221 | 222 | ### Carthage ### 223 | # Carthage - A simple, decentralized dependency manager for Cocoa 224 | Carthage.checkout 225 | Carthage.build 226 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.5.0 2 | 4/15/2019 3 | 4 | [new] Migrate to Swift 5. 5 | [new] Migrate most dependency management from Carthage-with-manual-integration to Cocoapods. 6 | [improved] License is now a standard. GPLv3 is a better way to communicate the restriction against submitting to any of Apple's app stores. 7 | 8 | ### 1.4.0 9 | 5/27/2018 10 | 11 | [new] Migrated to Swift 4.2. 12 | [improved] Back to mainline for dependencies that had Swift 4 migration issues. 13 | 14 | ### 1.3.0 15 | 8/15/2017 16 | 17 | [new] Migrated to Swift 4. 18 | [new] Deployment target iOS 11. 19 | 20 | ### 1.2.1 21 | 8/13/2017 22 | 23 | [improved] Larger text size while awaiting dynamic text support. 24 | [new] Bumped transcripts repo to include new sessions from 2017 and 2016, along with improvements for some transcripts. 25 | 26 | ### 1.2.0 27 | 6/20/2017 28 | 29 | [fixed] [#7](https://github.com/rlwimi/major-input/issues/7): The `DOWNLOAD` button is no longer shown for a session with missing transcript. If the session cell is selected, the user is alerted to the transcript's unavailability and given the option to check the project repo for any app update that may integrate the missing transcript. 30 | 31 | ### 1.1.1 32 | 6/20/2017 33 | 34 | [new] Added large initial batch of 2017 session transcripts. 35 | 36 | ### 1.0.1 37 | 6/16/2017 38 | 39 | [improved] Minor improvements to several session descriptions by fixing a text encoding issue. 40 | 41 | ### 1.0.0 42 | 5/25/2017 43 | 44 | [new] MVP release with support for WWDC sessions from 2013 through 2016. 45 | -------------------------------------------------------------------------------- /Cartfile: -------------------------------------------------------------------------------- 1 | github "mauriciosantos/Buckets-Swift" 2 | github "rlwimi/wwdc-session-transcripts" "whats-new-in-captions" 3 | 4 | # Listed here only to include its own license in Settings. Use released binary. 5 | # Pinned to match/document binary in Tools. 6 | github "mono0926/LicensePlist" == 2.5.0 7 | -------------------------------------------------------------------------------- /Cartfile.resolved: -------------------------------------------------------------------------------- 1 | github "mauriciosantos/Buckets-Swift" "2.0.0" 2 | github "mono0926/LicensePlist" "2.5.0" 3 | github "rlwimi/wwdc-session-transcripts" "9fed0a8e84df6586f730fa42798cb22dd5590a23" 4 | -------------------------------------------------------------------------------- /MajorInput.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MajorInput.xcodeproj/xcshareddata/xcschemes/MajorInput.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 50 | 51 | 52 | 53 | 54 | 55 | 65 | 67 | 73 | 74 | 75 | 76 | 77 | 78 | 84 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /MajorInput.xcodeproj/xcshareddata/xcschemes/MajorInputTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 41 | 42 | 48 | 49 | 51 | 52 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /MajorInput.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /MajorInput.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MajorInput.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /MajorInput/AVFoundationExtensions.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import UIKit 3 | import ReactiveSwift 4 | import Result 5 | import Strongify 6 | 7 | extension AVAsset { 8 | func images(for times: [TimeInterval], size: CGSize) -> SignalProducer<[UIImage], NoError> { 9 | return SignalProducer(strongify(weak: self) { `self`, observer, disposable in 10 | let generator = AVAssetImageGenerator(asset: self) 11 | generator.maximumSize = size 12 | 13 | let requestedTimes = times.map { NSValue(time: self.makeTime(with: $0)) } 14 | var generatingTimes = requestedTimes 15 | var frames: [NSValue: UIImage] = [:] 16 | 17 | generator.generateCGImagesAsynchronously(forTimes: requestedTimes) { requestedTime, image, actualTime, result, error in 18 | let time = NSValue(time: requestedTime) 19 | 20 | guard case .succeeded = result, let cgImage = image, let index = generatingTimes.firstIndex(of: time) 21 | else { fatalError() } 22 | 23 | frames[time] = UIImage(cgImage: cgImage) 24 | generatingTimes.remove(at: index) 25 | observer.send(value: requestedTimes.compactMap { frames[$0] }) 26 | if generatingTimes.isEmpty { 27 | observer.sendCompleted() 28 | } 29 | } 30 | }) 31 | } 32 | 33 | /// Convenience accessor when assuming a single video track is available 34 | var videoTrack: AVAssetTrack { 35 | guard let videoTrack = tracks.first(where: { $0.mediaType == .video }) 36 | else { fatalError("Video track unavailable for asset: \(String(reflecting: self))") } 37 | return videoTrack 38 | } 39 | 40 | func makeTime(with interval: TimeInterval) -> CMTime { 41 | let time = CMTime(seconds: interval, preferredTimescale: self.videoTrack.naturalTimeScale) 42 | return time 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /MajorInput/AppBuilder.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct AppDependencies: HasSessionsService, HasDownloadsService { 4 | let sessionsService: SessionsService 5 | let downloadsService: DownloadsService 6 | } 7 | 8 | final class AppBuilder { 9 | 10 | let dependencies: AppDependencies 11 | 12 | init() { 13 | let sessionsService = SessionsService() 14 | let downloadsService = DownloadsService(sessions: sessionsService.sessions) 15 | dependencies = AppDependencies(sessionsService: sessionsService, downloadsService: downloadsService) 16 | } 17 | 18 | func makeAppNavigationController() -> AppNavigationController { 19 | return AppNavigationController(builder: self) 20 | } 21 | 22 | func makeShelfViewController() -> ShelfViewController { 23 | return ShelfViewController(dependencies: dependencies) 24 | } 25 | 26 | func makeMajorInputViewController(session: Session) -> MajorInputViewController { 27 | return MajorInputViewController(session: session, dependencies: dependencies) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /MajorInput/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | final class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | var window: UIWindow? 7 | 8 | fileprivate lazy var builder: AppBuilder = { 9 | return AppBuilder() 10 | }() 11 | 12 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 13 | let window = UIWindow(frame: UIScreen.main.bounds) 14 | window.rootViewController = builder.makeAppNavigationController() 15 | window.makeKeyAndVisible() 16 | self.window = window 17 | return true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /MajorInput/AppNavigationController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Strongify 3 | 4 | final class AppNavigationController: UINavigationController { 5 | 6 | let builder: AppBuilder 7 | let shelf: ShelfViewController 8 | 9 | init(builder: AppBuilder) { 10 | self.builder = builder 11 | shelf = builder.makeShelfViewController() 12 | super.init(nibName: nil, bundle: nil) 13 | configureAppearance() 14 | setViewControllers([shelf], animated: false) 15 | } 16 | 17 | required init?(coder aDecoder: NSCoder) { 18 | fatalError("init(coder:) has not been implemented") 19 | } 20 | 21 | override var preferredStatusBarStyle: UIStatusBarStyle { 22 | return .lightContent 23 | } 24 | 25 | override func viewDidAppear(_ animated: Bool) { 26 | super.viewDidAppear(animated) 27 | 28 | shelf.onSelectSession = strongify(weak: self) { `self`, session in 29 | self.shelfDidSelect(session) 30 | } 31 | } 32 | } 33 | 34 | fileprivate extension AppNavigationController { 35 | func configureAppearance() { 36 | let container = UINavigationBar.appearance(whenContainedInInstancesOf: [type(of: self)]) 37 | container.barTintColor = .systemTintColor 38 | container.titleTextAttributes = [ 39 | .foregroundColor: UIColor.white, 40 | .font: UIFont.boldSystemFont(ofSize: 24) 41 | ] 42 | } 43 | 44 | func shelfDidSelect(_ session: Session) { 45 | let majorInput = builder.makeMajorInputViewController(session: session) 46 | majorInput.navigationItem.leftBarButtonItem = makeDoneBarButtonItem() 47 | 48 | majorInput.downcastView.player.showsOverlay 49 | .producer 50 | .take(during: majorInput.reactive.lifetime) 51 | .startWithValues(strongify(weak: self) { `self`, showsOverlay in 52 | self.setNeedsStatusBarAppearanceUpdate() 53 | self.setNavigationBarHidden(!showsOverlay, animated: true) 54 | }) 55 | pushViewController(majorInput, animated: true) 56 | } 57 | 58 | func makeDoneBarButtonItem() -> UIBarButtonItem { 59 | let bbi = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)) 60 | bbi.tintColor = .white 61 | return bbi 62 | } 63 | 64 | @objc func done() { 65 | popViewController(animated: true) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "iTunesArtwork@2x-20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "iTunesArtwork@2x-20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "iTunesArtwork@2x-29@2x.png", 19 | "scale" : "2x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "iTunesArtwork@2x-29@3x.png", 25 | "scale" : "3x" 26 | }, 27 | { 28 | "size" : "40x40", 29 | "idiom" : "iphone", 30 | "filename" : "iTunesArtwork@2x-40@2x.png", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "iTunesArtwork@2x-40@3x.png", 37 | "scale" : "3x" 38 | }, 39 | { 40 | "size" : "60x60", 41 | "idiom" : "iphone", 42 | "filename" : "iTunesArtwork@2x-60@2x.png", 43 | "scale" : "2x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "iTunesArtwork@2x-60@3x.png", 49 | "scale" : "3x" 50 | }, 51 | { 52 | "size" : "20x20", 53 | "idiom" : "ipad", 54 | "filename" : "iTunesArtwork@2x-20.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "iTunesArtwork@2x-20@2x.png", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "size" : "29x29", 65 | "idiom" : "ipad", 66 | "filename" : "iTunesArtwork@2x-29.png", 67 | "scale" : "1x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "iTunesArtwork@2x-29@2x.png", 73 | "scale" : "2x" 74 | }, 75 | { 76 | "size" : "40x40", 77 | "idiom" : "ipad", 78 | "filename" : "iTunesArtwork@2x-40.png", 79 | "scale" : "1x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "iTunesArtwork@2x-40@2x.png", 85 | "scale" : "2x" 86 | }, 87 | { 88 | "size" : "76x76", 89 | "idiom" : "ipad", 90 | "filename" : "iTunesArtwork@2x-76.png", 91 | "scale" : "1x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "iTunesArtwork@2x-76@2x.png", 97 | "scale" : "2x" 98 | }, 99 | { 100 | "size" : "83.5x83.5", 101 | "idiom" : "ipad", 102 | "filename" : "iTunesArtwork@2x-83.5@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "idiom" : "ios-marketing", 107 | "size" : "1024x1024", 108 | "scale" : "1x" 109 | } 110 | ], 111 | "info" : { 112 | "version" : 1, 113 | "author" : "xcode" 114 | } 115 | } -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-20.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-20@2x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-20@3x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-29.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-29@2x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-29@3x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-40.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-40@2x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-40@3x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-60@2x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-60@3x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-76.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-76@2x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-83.5@2x.png -------------------------------------------------------------------------------- /MajorInput/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /MajorInput/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /MajorInput/Caption.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Caption { 4 | var start: TimeInterval 5 | var end: TimeInterval 6 | var text: String 7 | } 8 | 9 | extension Caption { 10 | init(text: String = "", startTime: TimeInterval = 0, endTime: TimeInterval = 0) { 11 | self.text = text 12 | self.start = startTime 13 | self.end = endTime 14 | } 15 | } 16 | 17 | extension Caption: Equatable { 18 | static func == (lhs: Caption, rhs: Caption) -> Bool { 19 | return 20 | lhs.start == rhs.start && 21 | lhs.end == rhs.end && 22 | lhs.text == rhs.text 23 | } 24 | } 25 | 26 | extension Caption { 27 | var middle: TimeInterval { 28 | return (end + start) / 2 29 | } 30 | 31 | var duration: TimeInterval { 32 | return end - start 33 | } 34 | } 35 | 36 | extension Caption { 37 | mutating func merge(_ other: Caption) { 38 | self.start = min(self.start, other.start) 39 | self.end = max(self.end, other.end) 40 | self.text = [self.text, other.text].joined(separator: " ") 41 | } 42 | 43 | func merging(_ other: Caption) -> Caption { 44 | var copy = self 45 | copy.merge(other) 46 | return copy 47 | } 48 | } 49 | 50 | extension Array where Element == Caption { 51 | var sentencifying: [Caption] { 52 | var sentences: [Caption] = [] 53 | 54 | for caption in self { 55 | if case let .some(last) = sentences.last, last.completesSentence == false { 56 | sentences[sentences.index(before: sentences.endIndex)] = last.merging(caption) 57 | } else { 58 | sentences.append(caption) 59 | } 60 | } 61 | return sentences 62 | } 63 | } 64 | 65 | fileprivate extension Caption { 66 | /// True unless `text` ends in a terminal point (`[.!?]`) and except for square-bracketed values. 67 | var completesSentence: Bool { 68 | return text.endsInTerminalPoint || text.isBracketed 69 | } 70 | } 71 | 72 | extension Array where Element == Caption { 73 | /// Index of the element whose time interval contains `time`. If `time` falls between captions, 74 | /// return the index of the first caption following `time`, or the index of the last caption for 75 | /// all `time`s past the end. 76 | func index(for time: TimeInterval) -> Int { 77 | precondition(isEmpty == false) 78 | let index: Int 79 | if let first = self.firstIndex(where: { time < $0.end }) { 80 | index = first 81 | } else { 82 | index = indices.last! 83 | } 84 | return index 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /MajorInput/CaptionCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Anchorage 3 | 4 | struct CaptionCellProps { 5 | var text = "" 6 | } 7 | 8 | final class CaptionCell: UICollectionViewCell { 9 | 10 | var props = CaptionCellProps() { 11 | didSet { 12 | didSetProps() 13 | } 14 | } 15 | 16 | let caption = UILabel() 17 | 18 | var contentWidth: NSLayoutConstraint! 19 | 20 | override init(frame: CGRect) { 21 | super.init(frame: frame) 22 | initialize() 23 | } 24 | 25 | required init?(coder aDecoder: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | fileprivate func didSetProps() { 30 | caption.text = props.text 31 | } 32 | } 33 | 34 | extension CaptionCell { // ViewInitializing 35 | override func configure() { 36 | didSetProps() 37 | caption.textColor = .black 38 | caption.numberOfLines = 0 39 | contentView.layoutMargins = UIEdgeInsets(top: 0, left: 40, bottom: 16, right: 16) 40 | caption.font = UIFont.systemFont(ofSize: 24) 41 | } 42 | 43 | override func buildUserInterface() { 44 | contentView.addSubview(caption) 45 | } 46 | 47 | override func activateDefaultLayout() { 48 | // Content will determine height. Minimally hugging prevents unwanted sprawl. 49 | contentView.heightAnchor == 0 ~ Priority(1) 50 | 51 | // Autosizing will inject the width, 52 | contentWidth = (contentView.widthAnchor == 0) 53 | // but avoid conflict before that time. 54 | contentWidth.isActive = false 55 | 56 | caption.edgeAnchors == contentView.layoutMarginsGuide.edgeAnchors 57 | } 58 | } 59 | 60 | extension CaptionCell { // UICollectionViewCell 61 | override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { 62 | layoutAttributes.frame.size = contentView.systemLayoutSizeFitting( 63 | layoutAttributes.frame.size, 64 | withHorizontalFittingPriority: .required, 65 | verticalFittingPriority: UILayoutPriority(1) 66 | ) 67 | contentWidth.constant = layoutAttributes.frame.size.width 68 | contentWidth.isActive = true 69 | return layoutAttributes 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /MajorInput/CaptionsView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Anchorage 3 | 4 | final class CaptionsView: UIView { 5 | 6 | let captions: UICollectionView 7 | let captionsLayout: UICollectionViewFlowLayout 8 | 9 | let transformer = TransformerView() 10 | 11 | let spring = UIView() 12 | 13 | var springTop: NSLayoutConstraint! 14 | var springBottom: NSLayoutConstraint! 15 | 16 | override init(frame: CGRect) { 17 | captionsLayout = UICollectionViewFlowLayout() 18 | captions = UICollectionView(frame: .zero, collectionViewLayout: captionsLayout) 19 | super.init(frame: frame) 20 | initialize() 21 | } 22 | 23 | required init?(coder aDecoder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | } 27 | 28 | extension CaptionsView { // ViewInitializing 29 | override func configure() { 30 | captions.backgroundColor = .white 31 | captions.contentInset = .zero 32 | captions.showsVerticalScrollIndicator = false 33 | captions.decelerationRate = .fast 34 | 35 | captionsLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize 36 | captionsLayout.minimumInteritemSpacing = 0 37 | captionsLayout.minimumLineSpacing = 0 38 | 39 | spring.backgroundColor = .systemTintColor 40 | } 41 | 42 | override func buildUserInterface() { 43 | addSubview(captions) 44 | addSubview(spring) 45 | addSubview(transformer) 46 | } 47 | 48 | override func activateDefaultLayout() { 49 | captions.verticalAnchors == self.verticalAnchors 50 | // transformer vertical layout is managed 51 | 52 | captions.leadingAnchor == leadingAnchor 53 | transformer.leadingAnchor == captions.trailingAnchor 54 | transformer.trailingAnchor == trailingAnchor 55 | 56 | springTop = (spring.topAnchor == transformer.centerYAnchor) 57 | springBottom = (spring.bottomAnchor == transformer.centerYAnchor) 58 | spring.trailingAnchor == self.trailingAnchor 59 | spring.widthAnchor == 4 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /MajorInput/CaptionsViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Anchorage 3 | import ReactiveCocoa 4 | import ReactiveSwift 5 | import Strongify 6 | 7 | final class CaptionsViewController: UIViewController, ViewDowncasting { 8 | 9 | var onTransformerDoubleTap: (() -> Void)? 10 | 11 | fileprivate let captions: [Caption] 12 | 13 | typealias DowncastView = CaptionsView 14 | 15 | /// Offset into the captions where the transformer is anchored. 16 | fileprivate var transformerAnchor: CGFloat = 60 { // roughly starts things at the title screen (will need revisited, not consistent across years) 17 | didSet { 18 | downcastView.setNeedsUpdateConstraints() 19 | } 20 | } 21 | 22 | fileprivate var transformerOffset: CGFloat { 23 | get { 24 | return transformerY.constant 25 | } 26 | set { 27 | transformerY.constant = newValue 28 | downcastView.setNeedsUpdateConstraints() 29 | } 30 | } 31 | 32 | fileprivate var transformerY: NSLayoutConstraint! 33 | 34 | fileprivate let transformerTap = UITapGestureRecognizer() 35 | fileprivate let transformerDoubleTap = UITapGestureRecognizer() 36 | 37 | fileprivate let transformerPan = UIPanGestureRecognizer() 38 | fileprivate let transformerTapDrag = UILongPressGestureRecognizer() 39 | 40 | fileprivate var transformerIsPanning: Bool { 41 | return transformerIsExtending || transformerIsReanchoring 42 | } 43 | 44 | fileprivate var transformerIsReanchoring: Bool { 45 | return transformerTapDrag.state != .possible 46 | } 47 | 48 | fileprivate var transformerIsExtending: Bool { 49 | return transformerPan.state != .possible 50 | } 51 | 52 | fileprivate var transformerIsReeling = false 53 | fileprivate var isScrollingToCaptionTap = false 54 | 55 | let time: Property 56 | fileprivate let _time: MutableProperty 57 | 58 | fileprivate var isExternallyDriven: Bool = false 59 | var isUserDriven: Bool { 60 | return 61 | isExternallyDriven == false && 62 | (downcastView.captions.isDragging || 63 | (transformerIsPanning && transformerIsReeling == false) || 64 | isScrollingToCaptionTap) 65 | } 66 | 67 | init(captions: [Caption]) { 68 | self.captions = captions 69 | _time = MutableProperty(0) 70 | time = Property(_time) 71 | super.init(nibName: nil, bundle: nil) 72 | } 73 | 74 | required init?(coder aDecoder: NSCoder) { 75 | fatalError("init(coder:) has not been implemented") 76 | } 77 | 78 | override func loadView() { 79 | view = CaptionsView() 80 | } 81 | 82 | override func viewDidLoad() { 83 | super.viewDidLoad() 84 | initialize() 85 | observeCaptionsScrolling() 86 | observeTransformerPanning() 87 | } 88 | 89 | override func updateViewConstraints() { 90 | super.updateViewConstraints() 91 | 92 | let delta = transformerAnchor - transformerOffset 93 | downcastView.springTop.constant = min(0, delta) 94 | downcastView.springBottom.constant = max(0, delta) 95 | } 96 | } 97 | 98 | extension CaptionsViewController { // ViewInitializing 99 | 100 | override func configure() { 101 | downcastView.captions.delegate = self 102 | downcastView.captions.dataSource = self 103 | downcastView.captions.registerReusableCell(CaptionCell.self) 104 | 105 | transformerTap.addTarget(self, action: #selector(transformerTapped)) 106 | 107 | transformerDoubleTap.numberOfTapsRequired = 2 108 | transformerDoubleTap.addTarget(self, action: #selector(transformerDoubleTapped)) 109 | 110 | transformerPan.addTarget(self, action: #selector(panTransformer(with:))) 111 | 112 | transformerTapDrag.numberOfTapsRequired = 1 113 | transformerTapDrag.minimumPressDuration = 0 114 | transformerTapDrag.addTarget(self, action: #selector(tapDragTransformer(with:))) 115 | 116 | transformerTap.require(toFail: transformerDoubleTap) 117 | transformerTapDrag.require(toFail: transformerDoubleTap) 118 | } 119 | 120 | override func buildUserInterface() { 121 | downcastView.transformer.addGestureRecognizer(transformerTap) 122 | downcastView.transformer.addGestureRecognizer(transformerDoubleTap) 123 | downcastView.transformer.addGestureRecognizer(transformerPan) 124 | downcastView.transformer.addGestureRecognizer(transformerTapDrag) 125 | } 126 | 127 | override func activateDefaultLayout() { 128 | transformerY = (downcastView.transformer.centerYAnchor == downcastView.captions.topAnchor + transformerAnchor) 129 | } 130 | } 131 | 132 | fileprivate extension CaptionsViewController { 133 | @objc func transformerTapped() { 134 | reelTransformerToAnchor(animated: true) 135 | } 136 | 137 | @objc func transformerDoubleTapped() { 138 | onTransformerDoubleTap?() 139 | } 140 | 141 | @objc func panTransformer(with recognizer: UIGestureRecognizer) { 142 | panTransformer(to: transformerPosition(from: recognizer)) 143 | } 144 | 145 | @objc func tapDragTransformer(with recognizer: UIGestureRecognizer) { 146 | panTransformer(to: transformerPosition(from: recognizer)) 147 | transformerAnchor = transformerOffset 148 | } 149 | 150 | func transformerPosition(from recognizer: UIGestureRecognizer) -> CGFloat { 151 | return recognizer.location(in: downcastView.captions).y - downcastView.captions.contentOffset.y 152 | } 153 | 154 | func panTransformer(to yPosition: CGFloat) { 155 | let transformerHeight = downcastView.transformer.bounds.height 156 | transformerOffset = clamp(yPosition, 157 | above: downcastView.bounds.minY + transformerHeight / 2, 158 | below: downcastView.bounds.maxY - transformerHeight / 2) 159 | } 160 | 161 | func clamp(_ position: CGFloat, above lower: CGFloat, below upper: CGFloat, insetBy inset: CGFloat = 0) -> CGFloat { 162 | let min = lower + inset 163 | let max = upper - inset 164 | let clamped = (position...position).clamped(to: (min...max)).lowerBound 165 | return clamped 166 | } 167 | 168 | 169 | func reelTransformerToAnchor(animated: Bool = false) { 170 | let captionsOffset = CGPoint(x: downcastView.captions.contentOffset.x, 171 | y: downcastView.captions.contentOffset.y - transformerAnchor + transformerOffset) 172 | transformerIsReeling = true 173 | 174 | transformerOffset = transformerAnchor 175 | downcastView.captions.setContentOffset(captionsOffset, animated: animated) 176 | 177 | UIView.animate( 178 | withDuration: animated ? 0.3 : 0, 179 | delay: 0, 180 | options: [.beginFromCurrentState], 181 | animations: { 182 | self.view.layoutImmediately() 183 | }, 184 | completion: { finished in 185 | self.transformerIsReeling = false 186 | } 187 | ) 188 | } 189 | } 190 | 191 | fileprivate extension CaptionsViewController { 192 | func seek(tapped caption: Caption) { 193 | guard isScrollingToCaptionTap == false 194 | else { return } 195 | isScrollingToCaptionTap = true 196 | scrollCaptions(to: caption.start, animated: true) 197 | afterSystemAnimation(do: strongify(weak: self) { `self` in 198 | self.isScrollingToCaptionTap = false 199 | }) 200 | } 201 | } 202 | 203 | extension CaptionsViewController { 204 | func scrollCaptions(to time: TimeInterval) { 205 | isExternallyDriven = true 206 | scrollCaptions(to: time, animated: false) 207 | isExternallyDriven = false 208 | } 209 | 210 | fileprivate func scrollCaptions(to time: TimeInterval, animated: Bool) { 211 | let index = captions.index(for: time) 212 | let indexPath = IndexPath(row: index, section: 0) 213 | let captionProgress = CGFloat(time.progress(through: captions[index])) 214 | 215 | guard let layout = downcastView.captions.layoutAttributesForItem(at: indexPath) 216 | else { fatalError() } 217 | let itemOffset = layout.frame.minY + captionProgress * layout.frame.height 218 | let collectionOffset = itemOffset - transformerY.constant 219 | 220 | let target = CGPoint(x: 0, y: collectionOffset) 221 | downcastView.captions.setContentOffset(target, animated: animated) 222 | } 223 | } 224 | 225 | fileprivate extension CaptionsViewController { 226 | func observeCaptionsScrolling() { 227 | DynamicProperty(object: downcastView.captions, keyPath: #keyPath(UIScrollView.contentOffset)) 228 | .producer 229 | .take(during: reactive.lifetime) 230 | .observe(on: UIScheduler()) 231 | .startWithValues(strongify(weak: self) { `self`, _ in 232 | self.captionsDidScroll() 233 | }) 234 | } 235 | 236 | func observeTransformerPanning() { 237 | DynamicProperty(object: transformerY, keyPath: #keyPath(NSLayoutConstraint.constant)) 238 | .producer 239 | .take(during: reactive.lifetime) 240 | .observe(on: UIScheduler()) 241 | .startWithValues(strongify(weak: self) { `self`, _ in 242 | self.captionsDidScroll() 243 | }) 244 | } 245 | 246 | func captionsDidScroll() { 247 | guard isUserDriven else { return } 248 | let collectionView = downcastView.captions 249 | let offset = collectionView.contentOffset.y + transformerY.constant 250 | 251 | guard 252 | let indexPath = collectionView.indexPathForItem(at: CGPoint(x: 0, y: offset)), 253 | let cell = collectionView.cellForItem(at: indexPath) 254 | else { return } 255 | 256 | let caption = captions[indexPath.item] 257 | let cellProgress = Double((offset - cell.frame.minY) / cell.frame.height) 258 | let time = caption.start + cellProgress * caption.duration 259 | _time.value = time 260 | } 261 | } 262 | 263 | extension CaptionsViewController: UICollectionViewDataSource { 264 | 265 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 266 | return captions.count 267 | } 268 | 269 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 270 | let cell: CaptionCell = collectionView.dequeueReusableCell(indexPath: indexPath) 271 | let caption = captions[indexPath.row] 272 | cell.props = CaptionCellProps(text: caption.text) 273 | return cell 274 | } 275 | } 276 | 277 | extension CaptionsViewController: UICollectionViewDelegateFlowLayout { 278 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 279 | // inject width, leave height to each cell, but a good estimate here helps the scrollbar 280 | return CGSize(width: collectionView.bounds.size.width, height: 30) 281 | } 282 | 283 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 284 | seek(tapped: captions[indexPath.item]) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /MajorInput/ClassStringProviding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ClassStringProviding: class { 4 | static var classString: String { get } 5 | } 6 | 7 | extension ClassStringProviding { 8 | static var classString: String { 9 | return NSStringFromClass(self).components(separatedBy: ".").last! 10 | } 11 | } 12 | 13 | extension NSObject: ClassStringProviding {} 14 | -------------------------------------------------------------------------------- /MajorInput/CoreGraphicsExtensions.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | extension CGRect { 4 | var aspectRatio: CGFloat { 5 | return size.aspectRatio 6 | } 7 | } 8 | 9 | extension CGSize { 10 | var aspectRatio: CGFloat { 11 | return width / height 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /MajorInput/DependencyProtocols.swift: -------------------------------------------------------------------------------- 1 | protocol HasSessionsService { 2 | var sessionsService: SessionsService { get } 3 | } 4 | 5 | protocol HasDownloadsService { 6 | var downloadsService: DownloadsService { get } 7 | } 8 | -------------------------------------------------------------------------------- /MajorInput/DownloadsService.swift: -------------------------------------------------------------------------------- 1 | import ReactiveSwift 2 | 3 | enum DownloadStatus { 4 | case remote 5 | case downloading(progress: Float) 6 | case downloaded(url: URL) 7 | // TODO: case failed(error: Error), needs design 8 | } 9 | 10 | fileprivate extension Session { 11 | var filename: String { 12 | return identifier.rawValue + ".mp4" 13 | } 14 | } 15 | 16 | final class DownloadsService: NSObject { 17 | 18 | fileprivate let sessions: [Session] 19 | 20 | // When we implement filtering, we'll need to rework this as 21 | // `MutableProperty<[Session: MutableProperty]>` 22 | fileprivate lazy var statuses: [Session: MutableProperty] = { 23 | return self.existingDownloadsByInspectingFilesystem 24 | }() 25 | 26 | fileprivate lazy var urlSession: URLSession = { 27 | return URLSession( 28 | configuration: self.urlSessionConfig, 29 | delegate: self, 30 | delegateQueue: self.urlSessionOpQueue 31 | ) 32 | }() 33 | fileprivate lazy var urlSessionConfig: URLSessionConfiguration = { 34 | let config = URLSessionConfiguration.background(withIdentifier: "io.smike.majorinput.downloads") 35 | config.allowsCellularAccess = false 36 | config.isDiscretionary = true 37 | config.networkServiceType = .background 38 | config.sessionSendsLaunchEvents = false 39 | return config 40 | }() 41 | fileprivate var urlSessionOpQueue: OperationQueue = { 42 | let q = OperationQueue() 43 | q.underlyingQueue = DispatchQueue.main 44 | return q 45 | }() 46 | 47 | // synchronize usage on main thread 48 | fileprivate var downloads = Bimap() 49 | 50 | fileprivate let fileManager = FileManager.default 51 | 52 | /// Downloads are placed in /Library/Caches//Downloads, intending to exclude 53 | /// them from backup. 54 | fileprivate var downloadsDirectory: URL { 55 | let caches = self.fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! 56 | let bundleId = Bundle.main.bundleIdentifier! 57 | let cache = caches.appendingPathComponent(bundleId).appendingPathComponent("Downloads") 58 | return cache 59 | } 60 | 61 | init(sessions: [Session]) { 62 | self.sessions = sessions 63 | super.init() 64 | touchDownloadsDirectory() 65 | } 66 | 67 | func status(for session: Session) -> Property { 68 | let status = _status(for: session) 69 | return Property(status) 70 | } 71 | 72 | func downloadVideo(for session: Session) { 73 | _status(for: session).value = .downloading(progress: 0) 74 | 75 | let task = urlSession.downloadTask(with: session.downloadSD) 76 | downloads[key: session] = task 77 | task.resume() 78 | } 79 | 80 | func cancelDownload(for session: Session) { 81 | downloads[key: session]?.cancel() 82 | downloads.removeValueForKey(session) 83 | _status(for: session).value = .remote 84 | } 85 | 86 | func deleteDownload(for session: Session) { 87 | let url = self.url(for: session) 88 | do { 89 | try fileManager.removeItem(at: url) 90 | } catch { 91 | print("Could not remove item at \(url)\n\(error)") 92 | return 93 | } 94 | _status(for: session).value = .remote 95 | } 96 | } 97 | 98 | extension DownloadsService: URLSessionDownloadDelegate { 99 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 100 | guard 101 | let error = error, 102 | let task = task as? URLSessionDownloadTask, 103 | let session = downloads[value: task] 104 | else { return } 105 | print("Download for \(session.identifier) failed: \(error)") 106 | _status(for: session).value = .remote 107 | } 108 | 109 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 110 | guard let session = downloads[value: downloadTask] 111 | else { return } 112 | downloads.removeValueForKey(session) 113 | do { 114 | try fileManager.moveItem(at: location, to: url(for: session)) 115 | } catch { 116 | _status(for: session).value = .remote 117 | return 118 | } 119 | _status(for: session).value = .downloaded(url: url(for: session)) 120 | } 121 | 122 | func urlSession(_ session: URLSession, 123 | downloadTask: URLSessionDownloadTask, 124 | didWriteData bytesWritten: Int64, 125 | totalBytesWritten: Int64, 126 | totalBytesExpectedToWrite: Int64) { 127 | 128 | guard let session = downloads[value: downloadTask] 129 | else { return } 130 | _status(for: session).value = .downloading(progress: Float(totalBytesWritten)/Float(totalBytesExpectedToWrite)) 131 | } 132 | } 133 | 134 | fileprivate extension DownloadsService { 135 | func touchDownloadsDirectory() { 136 | try? fileManager.createDirectory(at: self.downloadsDirectory, withIntermediateDirectories: true, attributes: nil) 137 | } 138 | 139 | var existingDownloadsByInspectingFilesystem: [Session: MutableProperty] { 140 | do { 141 | let urls = try fileManager.contentsOfDirectory(at: self.downloadsDirectory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) 142 | let sessions: [Session] = urls.map { url in 143 | let filename = url.deletingPathExtension().lastPathComponent 144 | let components = filename.components(separatedBy: "-") 145 | let identifier = Session.makeIdentifier(conference: .wwdc, year: components[1], number: components[2]) 146 | let session = self.sessions.first { $0.identifier == identifier }! 147 | return session 148 | } 149 | var statuses: [Session: MutableProperty] = [:] 150 | for (session, url) in zip(sessions, urls) { 151 | statuses[session] = MutableProperty(.downloaded(url: url)) 152 | } 153 | return statuses 154 | } catch { 155 | print("Problem scanning Library/Caches for downloads: \(error)") 156 | return [:] 157 | } 158 | } 159 | 160 | func _status(for session: Session) -> MutableProperty { 161 | let status: MutableProperty 162 | if let existing = statuses[session] { 163 | status = existing 164 | } else { 165 | status = MutableProperty(.remote) 166 | statuses[session] = status 167 | } 168 | return status 169 | } 170 | 171 | func url(for session: Session) -> URL { 172 | return downloadsDirectory.appendingPathComponent(session.filename) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /MajorInput/FilmstripCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Anchorage 3 | 4 | struct FilmstripCellProps { 5 | var image = UIImage() 6 | } 7 | 8 | final class FilmstripCell: UICollectionViewCell { 9 | 10 | var props = FilmstripCellProps() { 11 | didSet { 12 | didSetProps() 13 | } 14 | } 15 | 16 | let image = UIImageView() 17 | 18 | override init(frame: CGRect) { 19 | super.init(frame: frame) 20 | initialize() 21 | } 22 | 23 | required init?(coder aDecoder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | fileprivate func didSetProps() { 28 | image.image = props.image 29 | } 30 | } 31 | 32 | extension FilmstripCell { // ViewInitializing 33 | override func configure() { 34 | didSetProps() 35 | } 36 | 37 | override func buildUserInterface() { 38 | contentView.addSubview(image) 39 | } 40 | 41 | override func activateDefaultLayout() { 42 | // Respect `thumbnail.size`. 43 | image.setContentHuggingPriority(.required, for: .horizontal) 44 | image.setContentHuggingPriority(.required, for: .vertical) 45 | image.setContentCompressionResistancePriority(.required, for: .horizontal) 46 | image.setContentCompressionResistancePriority(.required, for: .vertical) 47 | 48 | image.edgeAnchors == contentView.edgeAnchors 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /MajorInput/FilmstripViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class FilmstripViewController: UIViewController, ViewDowncasting { 4 | 5 | typealias DowncastView = UICollectionView 6 | 7 | let layout = UICollectionViewFlowLayout() 8 | 9 | var images: [UIImage] = [] { 10 | didSet { 11 | downcastView.reloadDataImmediately() 12 | } 13 | } 14 | 15 | override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { 16 | super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) 17 | initialize() 18 | } 19 | 20 | required init?(coder aDecoder: NSCoder) { 21 | fatalError("init(coder:) has not been implemented") 22 | } 23 | 24 | override func loadView() { 25 | view = UICollectionView(frame: UIScreen.main.bounds, collectionViewLayout: layout) 26 | } 27 | } 28 | 29 | extension FilmstripViewController { // ViewInitializing 30 | 31 | override func configure() { 32 | downcastView.backgroundColor = .black 33 | downcastView.contentInset = .zero 34 | downcastView.decelerationRate = .fast 35 | 36 | downcastView.dataSource = self 37 | downcastView.registerReusableCell(FilmstripCell.self) 38 | } 39 | 40 | override func activateDefaultLayout() { 41 | layout.scrollDirection = .horizontal 42 | layout.minimumInteritemSpacing = 0 43 | layout.minimumLineSpacing = 0 44 | } 45 | } 46 | 47 | extension FilmstripViewController: UICollectionViewDataSource { 48 | 49 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 50 | return images.count 51 | } 52 | 53 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 54 | let cell: FilmstripCell = collectionView.dequeueReusableCell(indexPath: indexPath) 55 | cell.props = FilmstripCellProps(image: images[indexPath.item]) 56 | return cell 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /MajorInput/FoundationExtensions.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Foundation 3 | 4 | extension CharacterSet { 5 | 6 | /// The set of sentence-terminating punctuation marks. 7 | static var terminalPunctuation: CharacterSet { 8 | return CharacterSet(charactersIn: ".!?") 9 | } 10 | } 11 | 12 | extension TimeInterval { 13 | init(hours: Int = 0, minutes: Int = 0, seconds: Int = 0, milliseconds: Int = 0) { 14 | let milliseconds = Double(milliseconds) / 1000 15 | let seconds = Double(seconds) 16 | let minutes = Double(minutes * 60) 17 | let hours = Double(hours * 60 * 60) 18 | self = TimeInterval(hours + minutes + seconds + milliseconds) 19 | } 20 | } 21 | 22 | extension TimeInterval { 23 | 24 | init(for progress: Double, through caption: Caption) { 25 | self = caption.start + progress * caption.duration 26 | } 27 | 28 | func progress(through caption: Caption) -> Double { 29 | if self <= caption.start { 30 | return 0 31 | } 32 | if self >= caption.end { 33 | return 1 34 | } 35 | let progress = (self - caption.start) / caption.duration 36 | return Double(progress) 37 | } 38 | } 39 | 40 | extension TimeInterval { 41 | 42 | static let MinutesSecondsFormatter: NumberFormatter = { 43 | let f = NumberFormatter() 44 | f.maximumFractionDigits = 0 45 | f.minimumIntegerDigits = 2 46 | f.maximumIntegerDigits = 2 47 | return f 48 | }() 49 | 50 | /// A `String` with the instance formatted as HHH:MM:SS. Minutes and seconds are always present, 51 | /// but hours is present only when non-zero. 52 | var digitalClockText: String { 53 | let seconds = Int(self) % 60 54 | let minutes = (Int(self) / 60) % 60 55 | let hours = Int(self) / 3600 56 | 57 | let formatter = TimeInterval.MinutesSecondsFormatter 58 | 59 | let hoursText: String? = (hours < 1 ? nil : "\(hours)") 60 | let minutesText = formatter.string(from: minutes as NSNumber) 61 | let secondsText = formatter.string(from: seconds as NSNumber) 62 | 63 | let text = [hoursText, minutesText, secondsText].compactMap { $0 }.joined(separator: ":") 64 | return text 65 | } 66 | } 67 | 68 | public extension URL { 69 | /// Return a URL with query removed. If something goes wrong, return the instance. 70 | var deletingQuery: URL { 71 | guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { 72 | return self 73 | } 74 | components.query = nil 75 | guard let url = components.url else { 76 | return self 77 | } 78 | return url 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /MajorInput/Identifiable.swift: -------------------------------------------------------------------------------- 1 | struct Identifier: RawRepresentable, Hashable { 2 | let rawValue: String 3 | 4 | init(rawValue: String) { 5 | self.rawValue = rawValue 6 | } 7 | } 8 | 9 | protocol Identifiable: Hashable { 10 | associatedtype IdentifierType: Hashable 11 | var identifier: IdentifierType { get } 12 | } 13 | -------------------------------------------------------------------------------- /MajorInput/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.3.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UIRequiresFullScreen 30 | 31 | UISupportedInterfaceOrientations~ipad 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationPortraitUpsideDown 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /MajorInput/MajorInputView.swift: -------------------------------------------------------------------------------- 1 | import Anchorage 2 | import UIKit 3 | 4 | final class MajorInputView: UIView { 5 | 6 | let player = PlayerView() 7 | let filmstripLayoutGuide = UILayoutGuide(identifier: "filmstrip") 8 | let captionsLayoutGuide = UILayoutGuide(identifier: "captions") 9 | 10 | let filmstripTimeIndicator = UIView() 11 | var filmstripTimeIndicatorCenter: NSLayoutConstraint! 12 | 13 | var filmstripTop: NSLayoutConstraint! 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | initialize() 18 | } 19 | 20 | required init?(coder aDecoder: NSCoder) { 21 | fatalError("init(coder:) has not been implemented") 22 | } 23 | } 24 | 25 | extension MajorInputView { // ViewInitializing 26 | override func configure() { 27 | backgroundColor = .white 28 | filmstripTimeIndicator.backgroundColor = .systemTintColor 29 | } 30 | 31 | override func buildUserInterface() { 32 | addSubview(player) 33 | addLayoutGuide(filmstripLayoutGuide) 34 | addLayoutGuide(captionsLayoutGuide) 35 | addSubview(filmstripTimeIndicator) 36 | } 37 | 38 | override func activateDefaultLayout() { 39 | player.horizontalAnchors == self.horizontalAnchors 40 | filmstripLayoutGuide.horizontalAnchors == self.horizontalAnchors 41 | captionsLayoutGuide.horizontalAnchors == self.horizontalAnchors 42 | 43 | player.topAnchor == self.topAnchor 44 | filmstripLayoutGuide.topAnchor == player.bottomAnchor 45 | 46 | filmstripTop = (filmstripLayoutGuide.topAnchor == player.bottomAnchor) 47 | captionsLayoutGuide.topAnchor == filmstripLayoutGuide.bottomAnchor 48 | captionsLayoutGuide.bottomAnchor == self.bottomAnchor 49 | 50 | filmstripTimeIndicator.widthAnchor == filmstripTimeIndicator.heightAnchor 51 | filmstripTimeIndicator.widthAnchor == 16 52 | filmstripTimeIndicator.centerYAnchor == filmstripLayoutGuide.bottomAnchor 53 | filmstripTimeIndicator.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi / 4)) 54 | 55 | filmstripTimeIndicatorCenter = (filmstripTimeIndicator.centerXAnchor == filmstripLayoutGuide.leadingAnchor) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /MajorInput/MajorInputViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import AVFoundation 3 | import Anchorage 4 | import ReactiveCocoa 5 | import ReactiveSwift 6 | import Strongify 7 | 8 | final class MajorInputViewController: UIViewController, ViewDowncasting { 9 | 10 | typealias Dependencies = HasSessionsService & HasDownloadsService 11 | typealias DowncastView = MajorInputView 12 | 13 | fileprivate let session: Session 14 | fileprivate let captions: [Caption] 15 | 16 | fileprivate let filmstripViewController = FilmstripViewController() 17 | fileprivate let captionsViewController: CaptionsViewController 18 | 19 | fileprivate var playerView: PlayerView { 20 | return downcastView.player 21 | } 22 | 23 | fileprivate var filmstripCollectionView: UICollectionView { 24 | return filmstripViewController.downcastView 25 | } 26 | 27 | fileprivate var captionsCollectionView: UICollectionView { 28 | return captionsViewController.downcastView.captions 29 | } 30 | 31 | fileprivate let asset: AVAsset 32 | fileprivate let player: AVPlayer 33 | fileprivate var seeker: PlayerSeeker! 34 | fileprivate var time: TimeInterval { 35 | return player.currentTime().seconds 36 | } 37 | 38 | /// Simplify filmstrip by disabling during image generation. 39 | fileprivate var filmstripEnabled = false 40 | 41 | fileprivate var filmstripHeight: NSLayoutConstraint! 42 | fileprivate let filmstripCellWidth: CGFloat = 96 43 | 44 | /// Offset into the filmstrip aligning the current caption's image (center) 45 | fileprivate var filmstripTimeIndicatorOffset: CGFloat = 0 46 | 47 | override var prefersStatusBarHidden: Bool { 48 | return playerView.showsOverlay.value == false 49 | } 50 | 51 | init(session: Session, dependencies: Dependencies) { 52 | self.session = session 53 | 54 | guard case .downloaded(let url) = dependencies.downloadsService.status(for: session).value 55 | else { fatalError() } 56 | 57 | asset = AVAsset(url: url) 58 | player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) 59 | captions = dependencies.sessionsService.captions(for: session) 60 | captionsViewController = CaptionsViewController(captions: captions) 61 | 62 | super.init(nibName: nil, bundle: nil) 63 | } 64 | 65 | required init?(coder aDecoder: NSCoder) { 66 | fatalError("init(coder:) has not been implemented") 67 | } 68 | 69 | override func loadView() { 70 | view = MajorInputView(frame: UIScreen.main.bounds) 71 | } 72 | 73 | override func viewDidLoad() { 74 | super.viewDidLoad() 75 | initialize() 76 | loadVideo() 77 | } 78 | 79 | override func viewWillAppear(_ animated: Bool) { 80 | super.viewWillAppear(animated) 81 | if player.status == .readyToPlay { 82 | pushInitialTime() 83 | } 84 | } 85 | 86 | override func viewDidLayoutSubviews() { 87 | super.viewDidLayoutSubviews() 88 | 89 | // Captions need to cover the filmstrip time indicator. The player should cover the filmstrip 90 | // and its time indicator to support the filmstrip's animated appearance. The button for 91 | // initial transfer of playback controls control must be above the player. 92 | downcastView.bringSubviewToFront(downcastView.filmstripTimeIndicator) 93 | downcastView.bringSubviewToFront(captionsViewController.downcastView) 94 | downcastView.bringSubviewToFront(downcastView.player) 95 | 96 | let filmstrip = filmstripViewController.downcastView 97 | filmstripTimeIndicatorOffset = filmstrip.frame.minX + 2 * filmstripCellWidth 98 | 99 | downcastView.filmstripTimeIndicatorCenter.constant = filmstripTimeIndicatorOffset 100 | } 101 | } 102 | 103 | extension MajorInputViewController { // ViewInitializing 104 | override func configure() { 105 | title = "\(session.conference.rawValue) \(session.year) | \(session.number) | \(session.title)" 106 | 107 | playerView.setShowsOverlay(false) 108 | 109 | downcastView.filmstripTimeIndicator.isHidden = true 110 | 111 | // Disable scrolling until inductions are configured after player is `readyToPlay` 112 | filmstripCollectionView.isScrollEnabled = false 113 | captionsCollectionView.isScrollEnabled = false 114 | 115 | captionsViewController.onTransformerDoubleTap = strongify(weak: self) { `self` in 116 | self.togglePlayback() 117 | } 118 | } 119 | 120 | override func buildUserInterface() { 121 | addChild(filmstripViewController) 122 | view.addSubview(filmstripViewController.view) 123 | filmstripViewController.didMove(toParent: self) 124 | 125 | addChild(captionsViewController) 126 | view.addSubview(captionsViewController.view) 127 | captionsViewController.didMove(toParent: self) 128 | } 129 | 130 | override func activateDefaultLayout() { 131 | 132 | let size = asset.videoTrack.naturalSize 133 | let aspectRatio = size.width / size.height 134 | 135 | playerView.widthAnchor == aspectRatio * playerView.heightAnchor 136 | 137 | let filmstrip = filmstripViewController.downcastView 138 | filmstrip.edgeAnchors == downcastView.filmstripLayoutGuide.edgeAnchors 139 | filmstripHeight = (filmstripViewController.downcastView.heightAnchor == 0) 140 | 141 | captionsViewController.downcastView.edgeAnchors == downcastView.captionsLayoutGuide.edgeAnchors 142 | } 143 | } 144 | 145 | fileprivate extension MajorInputViewController { 146 | func loadVideo() { 147 | DynamicProperty(object: player, keyPath: #keyPath(AVPlayer.status)) 148 | .producer 149 | .take(during: reactive.lifetime) 150 | .map(AVPlayer.Status.init(rawValue:)) 151 | .skipNil() 152 | .observe(on: UIScheduler()) 153 | .startWithValues(strongify(weak: self) { `self`, status in 154 | guard case .readyToPlay = status else { return } 155 | self.seeker = PlayerSeeker(player: self.player) 156 | self.generateThumbnails() 157 | self.configureInduction() 158 | self.pushInitialTime() 159 | }) 160 | 161 | playerView.player = player 162 | } 163 | 164 | func generateThumbnails() { 165 | let times = captions.map { $0.middle } 166 | var thumbnails: [UIImage] = [] 167 | asset.images(for: times, size: CGSize(width: filmstripCellWidth, height: filmstripCellWidth)) 168 | .observe(on: UIScheduler()) 169 | .start( 170 | Signal.Observer( 171 | value: strongify(weak: self) { `self`, images in 172 | thumbnails = images 173 | }, 174 | completed: strongify(weak: self) { `self` in 175 | self.didGenerateThumbnails(thumbnails) 176 | } 177 | ) 178 | ) 179 | } 180 | 181 | func didGenerateThumbnails(_ thumbnails: [UIImage]) { 182 | // Configure filmstrip with thumbnail size, animating appearance. 183 | if let sample = thumbnails[safe: 0] { 184 | assert(filmstripHeight.constant == 0) 185 | // UIKit "corrects" a negative contentOffset in layout, setting it to zero. We'll undo this. 186 | let captionsOffset = captionsCollectionView.contentOffset 187 | 188 | // Inflate filmstrip, hide under player 189 | filmstripHeight.constant = sample.size.height 190 | downcastView.filmstripTop.constant = -sample.size.height 191 | filmstripViewController.layout.itemSize = sample.size 192 | downcastView.layoutImmediately() 193 | 194 | // Slide filmstrip into place. 195 | downcastView.filmstripTimeIndicator.isHidden = false 196 | UIView.animate( 197 | withDuration: 0.25, 198 | animations: { 199 | self.downcastView.filmstripTop.constant = 0 200 | self.downcastView.layoutImmediately() 201 | self.captionsCollectionView.contentOffset = captionsOffset 202 | }) 203 | } 204 | 205 | // Inject content into filmstrip, reload, and sync with time. 206 | filmstripViewController.images = thumbnails 207 | filmstripEnabled = true 208 | updateFilmstrip(with: time) 209 | } 210 | 211 | func configureInduction() { 212 | // The captions collection and transformer position come together to select a cell representing 213 | // a caption time range, with the offset from the cell's beginning interpolated into the time 214 | // range. 215 | // 216 | // While either collection is interacted (with scroll/tap taking precedence over deceleration), 217 | // the other collection and the player should sync. 218 | // 219 | // When the player is playing, the collections should sync. 220 | 221 | filmstripCollectionView.isScrollEnabled = true 222 | captionsCollectionView.isScrollEnabled = true 223 | 224 | observeCaptionsTime() 225 | observeFilmstripScrolling() 226 | observePlayerPlaying() 227 | } 228 | 229 | func observeCaptionsTime() { 230 | captionsViewController.time 231 | .producer 232 | .take(during: reactive.lifetime) 233 | .observe(on: UIScheduler()) 234 | .startWithValues(strongify(weak: self) { `self`, time in 235 | self.captionsDidUpdate(time) 236 | }) 237 | } 238 | 239 | func observeFilmstripScrolling() { 240 | DynamicProperty(object: filmstripViewController.downcastView, keyPath: #keyPath(UIScrollView.contentOffset)) 241 | .producer 242 | .take(during: reactive.lifetime) 243 | .observe(on: UIScheduler()) 244 | .startWithValues(strongify(weak: self) { `self`, _ in 245 | self.filmstripDidScroll() 246 | }) 247 | } 248 | 249 | func observePlayerPlaying() { 250 | player.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 60), queue: DispatchQueue.main, using: strongify(weak: self) { `self`, time in 251 | self.didObservePlayerTimeUpdate() 252 | }) 253 | } 254 | 255 | /// Empirically-determined (quite hastily) time in which a WWDC session's intro slide is in-frame. 256 | var initialTime: TimeInterval { 257 | if let year = Int(session.year), year >= 2015 { 258 | return 21 259 | } else { 260 | return 15 261 | } 262 | } 263 | 264 | func pushInitialTime() { 265 | updatePlayer(with: initialTime) 266 | } 267 | 268 | func didObservePlayerTimeUpdate() { 269 | // Remember: periodic observers do not fire unless player is playing (rate > 0). 270 | if captionsViewController.isUserDriven == false && filmstripCollectionView.isDragging == false { 271 | pushTimeFromPlayer() 272 | } 273 | } 274 | 275 | func pushTimeFromPlayer() { 276 | self.captionsViewController.scrollCaptions(to: time) 277 | self.updateFilmstrip(with: time) 278 | } 279 | 280 | func captionsDidUpdate(_ time: TimeInterval) { 281 | if playerView.showsOverlay.value { 282 | playerView.setShowsOverlay(false, animated: true) 283 | } 284 | updatePlayer(with: time) 285 | updateFilmstrip(with: time) 286 | } 287 | 288 | func filmstripDidScroll() { 289 | guard filmstripCollectionView.isDragging && captionsViewController.isUserDriven == false 290 | else { return } 291 | 292 | if playerView.showsOverlay.value { 293 | playerView.setShowsOverlay(false, animated: true) 294 | } 295 | pushTimeFromFilmstrip() 296 | } 297 | 298 | func pushTimeFromFilmstrip() { 299 | let offset = filmstripCollectionView.contentOffset.x + filmstripTimeIndicatorOffset 300 | 301 | guard 302 | let indexPath = filmstripCollectionView.indexPathForItem(at: CGPoint(x: offset, y: 0)), 303 | let cell = filmstripCollectionView.cellForItem(at: indexPath) 304 | else { return } 305 | 306 | let progress = Double((offset - cell.frame.minX) / cell.frame.width) 307 | let caption = captions[indexPath.item] 308 | let time = TimeInterval(for: progress, through: caption) 309 | 310 | updatePlayer(with: time) 311 | captionsViewController.scrollCaptions(to: time) 312 | } 313 | 314 | func updatePlayer(with time: TimeInterval) { 315 | seeker.seek(to: asset.makeTime(with: time)) 316 | } 317 | 318 | func updateFilmstrip(with time: TimeInterval, animated: Bool = false) { 319 | guard filmstripEnabled else { return } 320 | 321 | let index = captions.index(for: time) 322 | let indexPath = IndexPath(row: index, section: 0) 323 | let captionProgress = CGFloat(time.progress(through: captions[index])) 324 | 325 | guard let layout = filmstripCollectionView.layoutAttributesForItem(at: indexPath) 326 | else { return } 327 | 328 | let offset = layout.frame.minX + captionProgress * layout.frame.width - filmstripTimeIndicatorOffset 329 | 330 | filmstripCollectionView.setContentOffset(CGPoint(x: offset, y: 0), animated: animated) 331 | } 332 | 333 | func togglePlayback() { 334 | let style: TransformerView.AntennaStyle 335 | if player.rate > 0 { 336 | player.pause() 337 | style = .play 338 | } else { 339 | player.play() 340 | style = .pause 341 | } 342 | captionsViewController.downcastView.transformer.style = style 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /MajorInput/PlayerSeeker.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Strongify 3 | 4 | final class PlayerSeeker { 5 | 6 | enum defaults { 7 | static let tolerance = CMTime(seconds: 0.5, preferredTimescale: 60) 8 | } 9 | 10 | let player: AVPlayer 11 | let tolerance: CMTime 12 | 13 | /// player is currently seeking to this time 14 | fileprivate var seeking: CMTime? 15 | 16 | var isSeeking: Bool { 17 | return seeking != nil 18 | } 19 | 20 | /// The most recent target for seek. When set, this will start a new seek when no seek is 21 | /// currently in progress. When set, if a seek is currently in progress, a new seek will start at 22 | /// the current seek's completion. 23 | fileprivate var target: CMTime? { 24 | didSet { 25 | guard let target = target, isSeeking == false else { return } 26 | _seek(to: target) 27 | } 28 | } 29 | 30 | fileprivate var isDoneSeeking: Bool { 31 | return seeking == target 32 | } 33 | 34 | init(player: AVPlayer, tolerance: CMTime = defaults.tolerance) { 35 | self.player = player 36 | self.tolerance = tolerance 37 | } 38 | 39 | func seek(to time: CMTime) { 40 | target = time 41 | } 42 | 43 | fileprivate func _seek(to time: CMTime) { 44 | seeking = time 45 | player.seek(to: time, toleranceBefore: tolerance, toleranceAfter: tolerance, completionHandler: strongify(weak: self) { `self`, _ in 46 | if let target = self.target, self.isDoneSeeking == false { 47 | self._seek(to: target) 48 | } else { 49 | self.seeking = nil 50 | self.target = nil 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /MajorInput/PlayerView.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import UIKit 3 | import Anchorage 4 | import ReactiveCocoa 5 | import ReactiveSwift 6 | import Strongify 7 | 8 | final class PlayerView: UIView { 9 | 10 | let overlayToggle = TouchStencilingButton() 11 | let overlay = UIView() 12 | 13 | let bottomBar = UIView() 14 | let currentTime = UILabel() 15 | let totalTime = UILabel() 16 | let scrubber = UISlider() 17 | 18 | /// Subtly indicates the current time through duration while controls are hidden. 19 | let progress = UIProgressView(progressViewStyle: .default) 20 | 21 | fileprivate var seeker: PlayerSeeker! 22 | 23 | /// Setter is one-shot. 24 | var player: AVPlayer? { 25 | get { 26 | return playerLayer.player 27 | } 28 | set { 29 | playerLayer.player = newValue 30 | observePlayer() 31 | } 32 | } 33 | 34 | fileprivate var playerLayer: AVPlayerLayer { 35 | return layer as! AVPlayerLayer 36 | } 37 | 38 | override static var layerClass: AnyClass { 39 | return AVPlayerLayer.self 40 | } 41 | 42 | let showsOverlay: Property 43 | fileprivate let _showsOverlay: MutableProperty 44 | 45 | override init(frame: CGRect) { 46 | _showsOverlay = MutableProperty(true) 47 | showsOverlay = Property(_showsOverlay) 48 | super.init(frame: frame) 49 | initialize() 50 | } 51 | 52 | required init?(coder aDecoder: NSCoder) { 53 | fatalError("init(coder:) has not been implemented") 54 | } 55 | 56 | override func layoutSubviews() { 57 | super.layoutSubviews() 58 | bringSubviewToFront(overlayToggle) 59 | } 60 | } 61 | 62 | extension PlayerView { // ViewInitializing 63 | override func configure() { 64 | layer.backgroundColor = UIColor.black.cgColor 65 | overlay.backgroundColor = .clear 66 | bottomBar.backgroundColor = .gray 67 | 68 | overlayToggle.addTarget(self, action: #selector(toggleOverlay), for: .touchUpInside) 69 | overlayToggle.stenciledViews = [bottomBar] 70 | 71 | currentTime.textColor = .white 72 | totalTime.textColor = .white 73 | 74 | currentTime.textAlignment = .center 75 | totalTime.textAlignment = .center 76 | 77 | progress.trackTintColor = .clear 78 | 79 | scrubber.addTarget(self, action: #selector(scrub), for: .valueChanged) 80 | } 81 | 82 | override func buildUserInterface() { 83 | addSubview(progress) 84 | addSubview(overlay) 85 | overlay.addSubview(bottomBar) 86 | bottomBar.addSubview(currentTime) 87 | bottomBar.addSubview(scrubber) 88 | bottomBar.addSubview(totalTime) 89 | addSubview(overlayToggle) 90 | } 91 | 92 | override func activateDefaultLayout() { 93 | overlay.edgeAnchors == self.edgeAnchors 94 | overlayToggle.edgeAnchors == self.edgeAnchors 95 | 96 | progress.horizontalAnchors == self.horizontalAnchors 97 | progress.bottomAnchor == self.bottomAnchor 98 | 99 | bottomBar.horizontalAnchors == self.horizontalAnchors 100 | bottomBar.bottomAnchor == self.bottomAnchor 101 | bottomBar.heightAnchor == 44 102 | 103 | currentTime.centerYAnchor == bottomBar.centerYAnchor 104 | scrubber.centerYAnchor == bottomBar.centerYAnchor 105 | totalTime.centerYAnchor == bottomBar.centerYAnchor 106 | 107 | currentTime.widthAnchor == labelWidth 108 | totalTime.widthAnchor == labelWidth 109 | 110 | currentTime.leadingAnchor == bottomBar.leadingAnchor 111 | scrubber.leadingAnchor == currentTime.trailingAnchor 112 | totalTime.leadingAnchor == scrubber.trailingAnchor 113 | bottomBar.trailingAnchor == totalTime.trailingAnchor 114 | } 115 | } 116 | 117 | extension PlayerView { 118 | func setShowsOverlay(_ shows: Bool, animated: Bool = false) { 119 | guard scrubber.isTracking == false else { return } 120 | 121 | if showsOverlay.value == false && shows { 122 | overlay.isHidden = false 123 | } 124 | UIView.animate( 125 | withDuration: animated ? 0.25 : 0, 126 | delay: 0, 127 | options: .beginFromCurrentState, 128 | animations: { 129 | self.overlay.alpha = (shows ? 1 : 0) 130 | }, 131 | completion: { finished in 132 | if shows == false && finished { 133 | self.overlay.isHidden = true 134 | } 135 | }) 136 | _showsOverlay.value = shows 137 | } 138 | } 139 | 140 | fileprivate extension PlayerView { 141 | var labelWidth: CGFloat { 142 | currentTime.text = "XX:XX:XX" 143 | currentTime.sizeToFit() 144 | let width = currentTime.bounds.width + 40 145 | return width 146 | } 147 | 148 | @objc func toggleOverlay() { 149 | setShowsOverlay(!showsOverlay.value, animated: true) 150 | } 151 | 152 | func observePlayer() { 153 | guard let player = player else { return } 154 | 155 | seeker = PlayerSeeker(player: player) 156 | 157 | player.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 60), queue: DispatchQueue.main, using: strongify(weak: self) { `self`, time in 158 | self.didObservePlayerTimeUpdate() 159 | }) 160 | 161 | DynamicProperty(object: player, keyPath: #keyPath(AVPlayer.status)) 162 | .producer 163 | .take(during: reactive.lifetime) 164 | .uniqueValues() 165 | .map(AVPlayer.Status.init(rawValue:)) 166 | .skipNil() 167 | .observe(on: UIScheduler()) 168 | .startWithValues(strongify(weak: self) { `self`, status in 169 | guard case .readyToPlay = status else { return } 170 | self.totalTime.text = player.currentItem?.asset.duration.seconds.digitalClockText 171 | }) 172 | } 173 | 174 | var playbackProgress: Float { 175 | guard 176 | let currentTime = player?.currentTime().seconds, 177 | let duration = player?.currentItem?.duration.seconds 178 | else { return 0 } 179 | 180 | return Float(currentTime / duration) 181 | } 182 | 183 | var currentTimeText: String { 184 | let time = player?.currentTime().seconds ?? 0 185 | return time.digitalClockText 186 | } 187 | 188 | func didObservePlayerTimeUpdate() { 189 | progress.progress = playbackProgress 190 | if scrubber.isTracking == false { 191 | scrubber.setValue(playbackProgress, animated: false) 192 | } 193 | currentTime.text = currentTimeText 194 | } 195 | 196 | @objc func scrub() { 197 | guard 198 | let player = player, player.status == .readyToPlay, 199 | let duration = player.currentItem?.duration.seconds, 200 | let asset = player.currentItem?.asset 201 | else { return } 202 | 203 | let time = Double(scrubber.value) * duration 204 | seeker.seek(to: asset.makeTime(with: time)) 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /MajorInput/Session+JSON.swift: -------------------------------------------------------------------------------- 1 | import SwiftyJSON 2 | 3 | extension Session { 4 | init?(json: JSON) { 5 | guard 6 | let yearValue = json["year"].int, 7 | yearValue > 2012, // 2012 videos are missing subtitles, earlier years are not available and/or missing subtitles 8 | let year = json["year"].int.flatMap(String.init(describing:)), 9 | let description = json["description"].string, 10 | let downloadSD = json["download_sd"].url, 11 | let downloadHD = json["download_hd"].url, 12 | let focuses = json["focus"].array?.compactMap({ $0.string }).compactMap(Focus.init(rawValue:)), 13 | let number = json["id"].string, 14 | let title = json["title"].string, 15 | let trackString = json["track"].string, 16 | let track = Track(rawValue: trackString) 17 | else { 18 | return nil 19 | } 20 | self.identifier = Session.makeIdentifier(conference: .wwdc, year: year, number: number) 21 | self.conference = .wwdc 22 | self.description = description 23 | self.downloadSD = downloadSD 24 | self.downloadHD = downloadHD 25 | self.duration = json["duration"].int 26 | self.focuses = focuses 27 | self.image = json["image"].url 28 | self.number = number 29 | self.title = title 30 | self.track = track 31 | self.year = year 32 | } 33 | 34 | var dictionary: [String: Any] { 35 | var d: [String: Any] = [:] 36 | d["description"] = description 37 | d["download_hd"] = downloadHD.absoluteString 38 | d["download_sd"] = downloadSD.absoluteString 39 | d["duration"] = duration ?? nil 40 | d["focus"] = focuses.map({ $0.rawValue }) 41 | d["image"] = image?.absoluteString 42 | d["id"] = number 43 | d["track"] = track.rawValue 44 | d["title"] = title 45 | d["year"] = Int(year) ?? nil 46 | return d 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /MajorInput/Session.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Focus: String { 4 | case iOS 5 | case macOS 6 | case tvOS 7 | case watchOS 8 | } 9 | 10 | enum Track: String { 11 | case appFrameworks = "App Frameworks" 12 | case systemFrameworks = "System Frameworks" 13 | case developerTools = "Developer Tools" 14 | case featured = "Featured" 15 | case graphicsAndGames = "Graphics and Games" 16 | case design = "Design" 17 | case media = "Media" 18 | case distribution = "Distribution" 19 | 20 | // pre-2015 tracks 21 | case appServices = "App Services" 22 | case coreOS = "Core OS" 23 | case essentials = "Essentials" 24 | case general = "General" 25 | case graphicsMediaAndGames = "Graphics, Media & Games" 26 | case safariAndWeb = "Safari & Web" 27 | case frameworks = "Frameworks" 28 | case services = "Services" 29 | case specialEvents = "Special Events" 30 | case tools = "Tools" 31 | } 32 | 33 | enum Conference: String, Hashable { 34 | case wwdc = "WWDC" 35 | } 36 | 37 | struct Session: Identifiable, Hashable { 38 | let identifier: Identifier 39 | let conference: Conference 40 | let description: String 41 | let downloadHD: URL 42 | let downloadSD: URL 43 | let duration: Int? 44 | let focuses: [Focus] 45 | let image: URL? 46 | let number: String 47 | let title: String 48 | let track: Track 49 | let year: String 50 | 51 | var durationText: String? { 52 | guard let duration = duration 53 | else { return nil } 54 | return TimeInterval(duration).digitalClockText 55 | } 56 | 57 | var webVttUrl: URL { 58 | var url = downloadSD.deletingQuery 59 | url.deletePathExtension() 60 | let basename = url.lastPathComponent 61 | url.deleteLastPathComponent() 62 | url.appendPathComponent("subtitles/eng/\(basename).vtt") 63 | return url 64 | } 65 | 66 | init( 67 | conference: Conference, 68 | description: String, 69 | downloadHD: URL, 70 | downloadSD: URL, 71 | duration: Int?, 72 | focuses: [Focus], 73 | image: URL?, 74 | number: String, 75 | title: String, 76 | track: Track, 77 | year: String) { 78 | 79 | self.identifier = Session.makeIdentifier(conference: conference, year: year, number: number) 80 | self.conference = conference 81 | self.description = description 82 | self.downloadHD = downloadHD 83 | self.downloadSD = downloadSD 84 | self.duration = duration 85 | self.focuses = focuses 86 | self.image = image 87 | self.number = number 88 | self.title = title 89 | self.track = track 90 | self.year = year 91 | } 92 | } 93 | 94 | extension Session { 95 | static func makeIdentifier(conference: Conference, year: String, number: String) -> Identifier { 96 | return Identifier(rawValue: [conference.rawValue, year, number].joined(separator: "-")) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /MajorInput/SessionCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Anchorage 3 | import Kingfisher 4 | import ReactiveSwift 5 | import Strongify 6 | 7 | final class SessionCell: UICollectionViewCell { 8 | 9 | let action = UIButton(type: .system) 10 | let progress = UIProgressView() 11 | let spinner = UIActivityIndicatorView(style: .gray) 12 | 13 | let title = UILabel() 14 | let image = UIImageView() 15 | let tags = UILabel() 16 | let focus = UILabel() 17 | let detail = UILabel() 18 | let separator = UIView() 19 | 20 | var contentWidth: NSLayoutConstraint! 21 | 22 | fileprivate var onActionTap: (() -> Void)? 23 | 24 | override init(frame: CGRect) { 25 | super.init(frame: frame) 26 | initialize() 27 | } 28 | 29 | required init?(coder aDecoder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | func configure(with session: Session, captionsAvailable: Bool, downloadStatus: Property, onActionTap: @escaping () -> Void) { 34 | self.onActionTap = onActionTap 35 | 36 | image.backgroundColor = .black 37 | if let url = session.image { 38 | image.kf.setImage(with: url) 39 | } 40 | 41 | title.text = "\(session.number): \(session.title)" 42 | detail.text = session.description 43 | 44 | let tagTexts: [String?] = [session.durationText, session.track.rawValue, session.year] 45 | tags.text = tagTexts.compactMap({ $0 }).joined(separator: " ") 46 | 47 | focus.text = "\(session.focuses.map({ $0.rawValue }).joined(separator: " | "))" 48 | 49 | action.isHidden = (captionsAvailable == false) 50 | 51 | downloadStatus 52 | .producer 53 | .take(until: reactive.prepareForReuse) 54 | .startWithValues(strongify(weak: self) { `self`, status in 55 | self.configure(with: status) 56 | }) 57 | } 58 | 59 | override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { 60 | layoutAttributes.frame.size = contentView.systemLayoutSizeFitting( 61 | layoutAttributes.frame.size, 62 | withHorizontalFittingPriority: .required, 63 | verticalFittingPriority: UILayoutPriority(1) 64 | ) 65 | contentWidth.constant = layoutAttributes.frame.size.width 66 | contentWidth.isActive = true 67 | return layoutAttributes 68 | } 69 | 70 | override func prepareForReuse() { 71 | super.prepareForReuse() 72 | image.kf.cancelDownloadTask() 73 | image.image = nil 74 | } 75 | } 76 | 77 | extension SessionCell { // ViewInitializing 78 | 79 | override func configure() { 80 | contentView.backgroundColor = .white 81 | 82 | detail.numberOfLines = 0 83 | 84 | title.font = UIFont.boldSystemFont(ofSize: 32) 85 | title.adjustsFontSizeToFitWidth = true 86 | 87 | title.textColor = UIColor(white: 34.0/255.0, alpha: 1) 88 | detail.textColor = UIColor(white: 34.0/255.0, alpha: 1) 89 | tags.textColor = .lightGray 90 | focus.textColor = .lightGray 91 | 92 | action.layer.borderColor = UIColor.systemTintColor.cgColor 93 | action.layer.borderWidth = 1 94 | action.layer.cornerRadius = 4 95 | action.layer.masksToBounds = true 96 | action.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) 97 | action.addTarget(self, action: #selector(tappedAction), for: .touchUpInside) 98 | 99 | progress.progressTintColor = .systemTintColor 100 | progress.trackTintColor = .clear 101 | 102 | progress.layer.cornerRadius = 4 103 | progress.layer.masksToBounds = true 104 | 105 | separator.backgroundColor = UIColor(white: 0, alpha: 0.25) 106 | } 107 | 108 | override func buildUserInterface() { 109 | contentView.addSubview(title) 110 | contentView.addSubview(image) 111 | contentView.addSubview(tags) 112 | contentView.addSubview(focus) 113 | contentView.addSubview(detail) 114 | contentView.addSubview(progress) 115 | contentView.addSubview(action) 116 | contentView.addSubview(spinner) 117 | contentView.addSubview(separator) 118 | } 119 | 120 | override func activateDefaultLayout() { 121 | // Content will determine height. Minimally hugging prevents unwanted sprawl. 122 | contentView.heightAnchor == 0 ~ Priority(2) 123 | 124 | // Autosizing will inject the width, 125 | contentWidth = (contentView.widthAnchor == 0) 126 | // but avoid conflict before that time. 127 | contentWidth.isActive = false 128 | 129 | // known aspect ratio = 734/413 130 | image.widthAnchor == 300 131 | image.heightAnchor == 169 132 | 133 | // leading column's vertical layout 134 | title.topAnchor == contentView.topAnchor + 20 135 | image.topAnchor == title.bottomAnchor + 20 136 | image.bottomAnchor <= contentView.bottomAnchor - 20 137 | 138 | // trailing column's vertical layout 139 | tags.topAnchor == title.bottomAnchor + (20 - (tags.font.ascender - tags.font.capHeight)) 140 | focus.topAnchor == tags.bottomAnchor + 8 141 | detail.topAnchor == focus.bottomAnchor + 8 142 | detail.bottomAnchor <= contentView.bottomAnchor - 20 143 | 144 | // horizontal layout 145 | title.leadingAnchor == contentView.leadingAnchor + 20 146 | title.trailingAnchor == contentView.trailingAnchor - 20 147 | 148 | image.leadingAnchor == contentView.leadingAnchor + 20 149 | 150 | tags.leadingAnchor == image.trailingAnchor + 20 151 | tags.trailingAnchor == contentView.trailingAnchor - 20 152 | 153 | focus.leadingAnchor == image.trailingAnchor + 20 154 | focus.trailingAnchor == contentView.trailingAnchor - 20 155 | 156 | detail.leadingAnchor == image.trailingAnchor + 20 157 | detail.trailingAnchor == contentView.trailingAnchor - 20 158 | 159 | // action 160 | action.firstBaselineAnchor == tags.firstBaselineAnchor 161 | action.trailingAnchor == contentView.trailingAnchor - 20 162 | 163 | progress.edgeAnchors == action.edgeAnchors 164 | // ^^^ will vertically squeeze button, so... 165 | action.setContentCompressionResistancePriority(.required, for: .vertical) 166 | 167 | spinner.centerYAnchor == action.centerYAnchor 168 | spinner.trailingAnchor == action.leadingAnchor - 8 169 | 170 | // separator 171 | separator.horizontalAnchors == contentView.horizontalAnchors + 20 172 | separator.heightAnchor == (1 / UIScreen.main.scale) 173 | separator.bottomAnchor == contentView.bottomAnchor 174 | } 175 | } 176 | 177 | fileprivate extension SessionCell { 178 | func configure(with status: DownloadStatus) { 179 | let enabled = UIView.areAnimationsEnabled 180 | UIView.setAnimationsEnabled(false) // Otherwise, UIKit animates title changes for `.system` buttons. 181 | switch status { 182 | case .remote: 183 | action.setTitle("DOWNLOAD", for: .normal) 184 | action.tintColor = .systemTintColor 185 | action.layer.borderColor = UIColor.systemTintColor.cgColor 186 | progress.progress = 0 187 | spinner.stopAnimating() 188 | case .downloading(let progress): 189 | action.setTitle("CANCEL", for: .normal) 190 | action.tintColor = .black 191 | action.layer.borderColor = UIColor.black.cgColor 192 | self.progress.progress = progress // DOH! 193 | spinner.startAnimating() 194 | case .downloaded: 195 | action.setTitle("DELETE", for: .normal) 196 | action.tintColor = .red 197 | action.layer.borderColor = UIColor.red.cgColor 198 | progress.progress = 0 199 | spinner.stopAnimating() 200 | } 201 | contentView.layoutImmediately() 202 | UIView.setAnimationsEnabled(enabled) 203 | } 204 | 205 | @objc func tappedAction() { 206 | onActionTap?() 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /MajorInput/SessionsService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Regex 3 | import SwiftyJSON 4 | import HTMLEntities 5 | 6 | final class SessionsService { 7 | 8 | let fileManager = FileManager.default 9 | 10 | let byConference: SortDescriptor = sortDescriptor(property: { $0.conference.rawValue }) 11 | let byYearDescending: SortDescriptor = sortDescriptor(property: { $0.year }, ascending: false) 12 | let byNumber: SortDescriptor = sortDescriptor(property: { $0.number }) 13 | 14 | var defaultSortDescriptors: SortDescriptor { 15 | return combine(sortDescriptors: [byConference, byYearDescending, byNumber]) 16 | } 17 | 18 | lazy var sessions: [Session] = { 19 | let url = Bundle.main.url(forResource: "sessions.json", withExtension: nil)! 20 | let data = try! Data(contentsOf: url) 21 | let json = try! JSON(data: data) 22 | let sessions = json.arrayValue 23 | .compactMap(Session.init(json:)) 24 | .sorted(by: self.defaultSortDescriptors) 25 | 26 | return sessions 27 | }() 28 | 29 | func canProvideCaptions(for session: Session) -> Bool { 30 | let url = session.localVttUrl 31 | let canProvide = fileManager.fileExists(atPath: url.path) 32 | return canProvide 33 | } 34 | 35 | func captions(for session: Session) -> [Caption] { 36 | return captions(withContentsOf: session.localVttUrl).sentencifying 37 | } 38 | 39 | func captions(withContentsOf url: URL) -> [Caption] { 40 | guard let vtt = try? String(contentsOf: url) 41 | else { return [] } 42 | 43 | // TODO: Swiftify this literal translation from Objective-C 44 | 45 | var captions: [Caption] = [] 46 | 47 | // Exclusively for recognizing repeated subtitles (we see them, not sure why) 48 | var previous: Caption? 49 | 50 | // Collects `Caption` line-by-line 51 | var current: Caption? 52 | 53 | let regex = Regex("^(\\d\\d):(\\d\\d):(\\d\\d)[,.](\\d\\d\\d) --> (\\d\\d):(\\d\\d):(\\d\\d)[,.](\\d\\d\\d).*$") 54 | 55 | vtt.enumerateLines { line, _ in 56 | if current != nil { 57 | if line.isEmpty { 58 | // Current caption is complete. Remove if redundant. Reset machinery. 59 | if previous == nil || previous! != current { 60 | captions.append(current!) 61 | previous = current 62 | } 63 | current = nil 64 | } else { 65 | if current!.text.isEmpty { 66 | current!.text = line.htmlUnescape() 67 | } else { 68 | current!.text = "\(current!.text) \(line)" 69 | } 70 | } 71 | } else { 72 | if 73 | let match = regex.firstMatch(in: line), 74 | match.captures.count == 8, 75 | let startHours = match.captures[0], 76 | let startMinutes = match.captures[1], 77 | let startSeconds = match.captures[2], 78 | let startMilliseconds = match.captures[3], 79 | let endHours = match.captures[4], 80 | let endMinutes = match.captures[5], 81 | let endSeconds = match.captures[6], 82 | let endMilliseconds = match.captures[7] 83 | { 84 | current = Caption() 85 | current?.start = TimeInterval(hours: Int(startHours)!, minutes: Int(startMinutes)!, seconds: Int(startSeconds)!, milliseconds: Int(startMilliseconds)!) 86 | current?.end = TimeInterval(hours: Int(endHours)!, minutes: Int(endMinutes)!, seconds: Int(endSeconds)!, milliseconds: Int(endMilliseconds)!) 87 | } 88 | } 89 | } 90 | 91 | // Enumerated text does not enumerate a final empty line upon newline/EOF, misses capturing last 92 | // caption. 93 | if current != nil && captions.isEmpty == false && current! != captions.last! { 94 | captions.append(current!) 95 | } 96 | 97 | return captions 98 | } 99 | } 100 | 101 | extension Session { 102 | var localVttUrl: URL { 103 | let base = Bundle.main.url(forResource: "wwdc-session-transcripts", withExtension: nil)! 104 | let url = base 105 | .appendingPathComponent(year, isDirectory: true) 106 | .appendingPathComponent(number) 107 | .appendingPathExtension("vtt") 108 | return url 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/Root.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | StringsTable 8 | Root 9 | PreferenceSpecifiers 10 | 11 | 12 | Type 13 | PSGroupSpecifier 14 | Title 15 | Acknowledgements 16 | 17 | 18 | Type 19 | PSChildPaneSpecifier 20 | Title 21 | Licenses & Copyrights 22 | File 23 | com.mono0926.LicensePlist 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist.latest_result.txt: -------------------------------------------------------------------------------- 1 | name: Anchorage, nameSpecified: 2 | body: The MIT License (MIT… 3 | version: 4.3 4 | 5 | name: Anchorage, nameSpecified: 6 | body: The MIT License (MIT… 7 | version: 4.3 8 | 9 | name: HTMLEntities, nameSpecified: 10 | body: … 11 | version: 3.0.12 12 | 13 | name: HTMLEntities, nameSpecified: 14 | body: … 15 | version: 3.0.12 16 | 17 | name: Kingfisher, nameSpecified: 18 | body: The MIT License (MIT… 19 | version: 5.3.1 20 | 21 | name: Kingfisher, nameSpecified: 22 | body: The MIT License (MIT… 23 | version: 5.3.1 24 | 25 | name: Nimble, nameSpecified: 26 | body: Apache License 27 | … 28 | version: 8.0.1 29 | 30 | name: Quick, nameSpecified: 31 | body: Apache License 32 | … 33 | version: 2.0.0 34 | 35 | name: ReactiveCocoa, nameSpecified: 36 | body: **Copyright (c) 2012… 37 | version: 9.0.0 38 | 39 | name: ReactiveCocoa, nameSpecified: 40 | body: **Copyright (c) 2012… 41 | version: 9.0.0 42 | 43 | name: ReactiveSwift, nameSpecified: 44 | body: **Copyright (c) 2012… 45 | version: 5.0.1 46 | 47 | name: ReactiveSwift, nameSpecified: 48 | body: **Copyright (c) 2012… 49 | version: 5.0.1 50 | 51 | name: Result, nameSpecified: 52 | body: The MIT License (MIT… 53 | version: 4.1.0 54 | 55 | name: Result, nameSpecified: 56 | body: The MIT License (MIT… 57 | version: 4.1.0 58 | 59 | name: STRegex, nameSpecified: 60 | body: The MIT License (MIT… 61 | version: 2.0.0 62 | 63 | name: STRegex, nameSpecified: 64 | body: The MIT License (MIT… 65 | version: 2.0.0 66 | 67 | name: Strongify, nameSpecified: 68 | body: The MIT License (MIT… 69 | version: 1.2 70 | 71 | name: Strongify, nameSpecified: 72 | body: The MIT License (MIT… 73 | version: 1.2 74 | 75 | name: SwiftyJSON, nameSpecified: 76 | body: The MIT License (MIT… 77 | version: 4.3.0 78 | 79 | name: SwiftyJSON, nameSpecified: 80 | body: The MIT License (MIT… 81 | version: 4.3.0 82 | 83 | name: Buckets-Swift, nameSpecified: , owner: mauriciosantos, version: 2.0.0 84 | 85 | name: LicensePlist, nameSpecified: , owner: mono0926, version: 2.5.0 86 | 87 | add-version-numbers: true 88 | 89 | LicensePlist Version: 1.8.3 -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | File 9 | com.mono0926.LicensePlist/Anchorage 10 | Title 11 | Anchorage (4.3) 12 | Type 13 | PSChildPaneSpecifier 14 | 15 | 16 | File 17 | com.mono0926.LicensePlist/Buckets-Swift 18 | Title 19 | Buckets-Swift (2.0.0) 20 | Type 21 | PSChildPaneSpecifier 22 | 23 | 24 | File 25 | com.mono0926.LicensePlist/HTMLEntities 26 | Title 27 | HTMLEntities (3.0.12) 28 | Type 29 | PSChildPaneSpecifier 30 | 31 | 32 | File 33 | com.mono0926.LicensePlist/Kingfisher 34 | Title 35 | Kingfisher (5.3.1) 36 | Type 37 | PSChildPaneSpecifier 38 | 39 | 40 | File 41 | com.mono0926.LicensePlist/LicensePlist 42 | Title 43 | LicensePlist (2.5.0) 44 | Type 45 | PSChildPaneSpecifier 46 | 47 | 48 | File 49 | com.mono0926.LicensePlist/Nimble 50 | Title 51 | Nimble (8.0.1) 52 | Type 53 | PSChildPaneSpecifier 54 | 55 | 56 | File 57 | com.mono0926.LicensePlist/Quick 58 | Title 59 | Quick (2.0.0) 60 | Type 61 | PSChildPaneSpecifier 62 | 63 | 64 | File 65 | com.mono0926.LicensePlist/ReactiveCocoa 66 | Title 67 | ReactiveCocoa (9.0.0) 68 | Type 69 | PSChildPaneSpecifier 70 | 71 | 72 | File 73 | com.mono0926.LicensePlist/ReactiveSwift 74 | Title 75 | ReactiveSwift (5.0.1) 76 | Type 77 | PSChildPaneSpecifier 78 | 79 | 80 | File 81 | com.mono0926.LicensePlist/Result 82 | Title 83 | Result (4.1.0) 84 | Type 85 | PSChildPaneSpecifier 86 | 87 | 88 | File 89 | com.mono0926.LicensePlist/STRegex 90 | Title 91 | STRegex (2.0.0) 92 | Type 93 | PSChildPaneSpecifier 94 | 95 | 96 | File 97 | com.mono0926.LicensePlist/Strongify 98 | Title 99 | Strongify (1.2) 100 | Type 101 | PSChildPaneSpecifier 102 | 103 | 104 | File 105 | com.mono0926.LicensePlist/SwiftyJSON 106 | Title 107 | SwiftyJSON (4.3.0) 108 | Type 109 | PSChildPaneSpecifier 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/Anchorage.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | The MIT License (MIT) 10 | 11 | Copyright (c) 2016 Raizlabs 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | 31 | Type 32 | PSGroupSpecifier 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/Buckets-Swift.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | The MIT License (MIT) 10 | 11 | Copyright (c) 2015 Mauricio Santos 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | Type 31 | PSGroupSpecifier 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/HTMLEntities.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | Apache License 10 | Version 2.0, January 2004 11 | http://www.apache.org/licenses/ 12 | 13 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 14 | 15 | 1. Definitions. 16 | 17 | "License" shall mean the terms and conditions for use, reproduction, 18 | and distribution as defined by Sections 1 through 9 of this document. 19 | 20 | "Licensor" shall mean the copyright owner or entity authorized by 21 | the copyright owner that is granting the License. 22 | 23 | "Legal Entity" shall mean the union of the acting entity and all 24 | other entities that control, are controlled by, or are under common 25 | control with that entity. For the purposes of this definition, 26 | "control" means (i) the power, direct or indirect, to cause the 27 | direction or management of such entity, whether by contract or 28 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 29 | outstanding shares, or (iii) beneficial ownership of such entity. 30 | 31 | "You" (or "Your") shall mean an individual or Legal Entity 32 | exercising permissions granted by this License. 33 | 34 | "Source" form shall mean the preferred form for making modifications, 35 | including but not limited to software source code, documentation 36 | source, and configuration files. 37 | 38 | "Object" form shall mean any form resulting from mechanical 39 | transformation or translation of a Source form, including but 40 | not limited to compiled object code, generated documentation, 41 | and conversions to other media types. 42 | 43 | "Work" shall mean the work of authorship, whether in Source or 44 | Object form, made available under the License, as indicated by a 45 | copyright notice that is included in or attached to the work 46 | (an example is provided in the Appendix below). 47 | 48 | "Derivative Works" shall mean any work, whether in Source or Object 49 | form, that is based on (or derived from) the Work and for which the 50 | editorial revisions, annotations, elaborations, or other modifications 51 | represent, as a whole, an original work of authorship. For the purposes 52 | of this License, Derivative Works shall not include works that remain 53 | separable from, or merely link (or bind by name) to the interfaces of, 54 | the Work and Derivative Works thereof. 55 | 56 | "Contribution" shall mean any work of authorship, including 57 | the original version of the Work and any modifications or additions 58 | to that Work or Derivative Works thereof, that is intentionally 59 | submitted to Licensor for inclusion in the Work by the copyright owner 60 | or by an individual or Legal Entity authorized to submit on behalf of 61 | the copyright owner. For the purposes of this definition, "submitted" 62 | means any form of electronic, verbal, or written communication sent 63 | to the Licensor or its representatives, including but not limited to 64 | communication on electronic mailing lists, source code control systems, 65 | and issue tracking systems that are managed by, or on behalf of, the 66 | Licensor for the purpose of discussing and improving the Work, but 67 | excluding communication that is conspicuously marked or otherwise 68 | designated in writing by the copyright owner as "Not a Contribution." 69 | 70 | "Contributor" shall mean Licensor and any individual or Legal Entity 71 | on behalf of whom a Contribution has been received by Licensor and 72 | subsequently incorporated within the Work. 73 | 74 | 2. Grant of Copyright License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | copyright license to reproduce, prepare Derivative Works of, 78 | publicly display, publicly perform, sublicense, and distribute the 79 | Work and such Derivative Works in Source or Object form. 80 | 81 | 3. Grant of Patent License. Subject to the terms and conditions of 82 | this License, each Contributor hereby grants to You a perpetual, 83 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 84 | (except as stated in this section) patent license to make, have made, 85 | use, offer to sell, sell, import, and otherwise transfer the Work, 86 | where such license applies only to those patent claims licensable 87 | by such Contributor that are necessarily infringed by their 88 | Contribution(s) alone or by combination of their Contribution(s) 89 | with the Work to which such Contribution(s) was submitted. If You 90 | institute patent litigation against any entity (including a 91 | cross-claim or counterclaim in a lawsuit) alleging that the Work 92 | or a Contribution incorporated within the Work constitutes direct 93 | or contributory patent infringement, then any patent licenses 94 | granted to You under this License for that Work shall terminate 95 | as of the date such litigation is filed. 96 | 97 | 4. Redistribution. You may reproduce and distribute copies of the 98 | Work or Derivative Works thereof in any medium, with or without 99 | modifications, and in Source or Object form, provided that You 100 | meet the following conditions: 101 | 102 | (a) You must give any other recipients of the Work or 103 | Derivative Works a copy of this License; and 104 | 105 | (b) You must cause any modified files to carry prominent notices 106 | stating that You changed the files; and 107 | 108 | (c) You must retain, in the Source form of any Derivative Works 109 | that You distribute, all copyright, patent, trademark, and 110 | attribution notices from the Source form of the Work, 111 | excluding those notices that do not pertain to any part of 112 | the Derivative Works; and 113 | 114 | (d) If the Work includes a "NOTICE" text file as part of its 115 | distribution, then any Derivative Works that You distribute must 116 | include a readable copy of the attribution notices contained 117 | within such NOTICE file, excluding those notices that do not 118 | pertain to any part of the Derivative Works, in at least one 119 | of the following places: within a NOTICE text file distributed 120 | as part of the Derivative Works; within the Source form or 121 | documentation, if provided along with the Derivative Works; or, 122 | within a display generated by the Derivative Works, if and 123 | wherever such third-party notices normally appear. The contents 124 | of the NOTICE file are for informational purposes only and 125 | do not modify the License. You may add Your own attribution 126 | notices within Derivative Works that You distribute, alongside 127 | or as an addendum to the NOTICE text from the Work, provided 128 | that such additional attribution notices cannot be construed 129 | as modifying the License. 130 | 131 | You may add Your own copyright statement to Your modifications and 132 | may provide additional or different license terms and conditions 133 | for use, reproduction, or distribution of Your modifications, or 134 | for any such Derivative Works as a whole, provided Your use, 135 | reproduction, and distribution of the Work otherwise complies with 136 | the conditions stated in this License. 137 | 138 | 5. Submission of Contributions. Unless You explicitly state otherwise, 139 | any Contribution intentionally submitted for inclusion in the Work 140 | by You to the Licensor shall be under the terms and conditions of 141 | this License, without any additional terms or conditions. 142 | Notwithstanding the above, nothing herein shall supersede or modify 143 | the terms of any separate license agreement you may have executed 144 | with Licensor regarding such Contributions. 145 | 146 | 6. Trademarks. This License does not grant permission to use the trade 147 | names, trademarks, service marks, or product names of the Licensor, 148 | except as required for reasonable and customary use in describing the 149 | origin of the Work and reproducing the content of the NOTICE file. 150 | 151 | 7. Disclaimer of Warranty. Unless required by applicable law or 152 | agreed to in writing, Licensor provides the Work (and each 153 | Contributor provides its Contributions) on an "AS IS" BASIS, 154 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 155 | implied, including, without limitation, any warranties or conditions 156 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 157 | PARTICULAR PURPOSE. You are solely responsible for determining the 158 | appropriateness of using or redistributing the Work and assume any 159 | risks associated with Your exercise of permissions under this License. 160 | 161 | 8. Limitation of Liability. In no event and under no legal theory, 162 | whether in tort (including negligence), contract, or otherwise, 163 | unless required by applicable law (such as deliberate and grossly 164 | negligent acts) or agreed to in writing, shall any Contributor be 165 | liable to You for damages, including any direct, indirect, special, 166 | incidental, or consequential damages of any character arising as a 167 | result of this License or out of the use or inability to use the 168 | Work (including but not limited to damages for loss of goodwill, 169 | work stoppage, computer failure or malfunction, or any and all 170 | other commercial damages or losses), even if such Contributor 171 | has been advised of the possibility of such damages. 172 | 173 | 9. Accepting Warranty or Additional Liability. While redistributing 174 | the Work or Derivative Works thereof, You may choose to offer, 175 | and charge a fee for, acceptance of support, warranty, indemnity, 176 | or other liability obligations and/or rights consistent with this 177 | License. However, in accepting such obligations, You may act only 178 | on Your own behalf and on Your sole responsibility, not on behalf 179 | of any other Contributor, and only if You agree to indemnify, 180 | defend, and hold each Contributor harmless for any liability 181 | incurred by, or claims asserted against, such Contributor by reason 182 | of your accepting any such warranty or additional liability. 183 | 184 | END OF TERMS AND CONDITIONS 185 | 186 | APPENDIX: How to apply the Apache License to your work. 187 | 188 | To apply the Apache License to your work, attach the following 189 | boilerplate notice, with the fields enclosed by brackets "{}" 190 | replaced with your own identifying information. (Don't include 191 | the brackets!) The text should be enclosed in the appropriate 192 | comment syntax for the file format. We also recommend that a 193 | file or class name and description of purpose be included on the 194 | same "printed page" as the copyright notice for easier 195 | identification within third-party archives. 196 | 197 | Copyright {yyyy} {name of copyright owner} 198 | 199 | Licensed under the Apache License, Version 2.0 (the "License"); 200 | you may not use this file except in compliance with the License. 201 | You may obtain a copy of the License at 202 | 203 | http://www.apache.org/licenses/LICENSE-2.0 204 | 205 | Unless required by applicable law or agreed to in writing, software 206 | distributed under the License is distributed on an "AS IS" BASIS, 207 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 208 | See the License for the specific language governing permissions and 209 | limitations under the License. 210 | 211 | Type 212 | PSGroupSpecifier 213 | 214 | 215 | 216 | 217 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/Kingfisher.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | The MIT License (MIT) 10 | 11 | Copyright (c) 2018 Wei Wang 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | 31 | 32 | Type 33 | PSGroupSpecifier 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/LicensePlist.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | MIT License 10 | 11 | Copyright (c) 2017 Masayuki Ono 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | 31 | Type 32 | PSGroupSpecifier 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/Nimble.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | Apache License 10 | Version 2.0, January 2004 11 | http://www.apache.org/licenses/ 12 | 13 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 14 | 15 | 1. Definitions. 16 | 17 | "License" shall mean the terms and conditions for use, reproduction, 18 | and distribution as defined by Sections 1 through 9 of this document. 19 | 20 | "Licensor" shall mean the copyright owner or entity authorized by 21 | the copyright owner that is granting the License. 22 | 23 | "Legal Entity" shall mean the union of the acting entity and all 24 | other entities that control, are controlled by, or are under common 25 | control with that entity. For the purposes of this definition, 26 | "control" means (i) the power, direct or indirect, to cause the 27 | direction or management of such entity, whether by contract or 28 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 29 | outstanding shares, or (iii) beneficial ownership of such entity. 30 | 31 | "You" (or "Your") shall mean an individual or Legal Entity 32 | exercising permissions granted by this License. 33 | 34 | "Source" form shall mean the preferred form for making modifications, 35 | including but not limited to software source code, documentation 36 | source, and configuration files. 37 | 38 | "Object" form shall mean any form resulting from mechanical 39 | transformation or translation of a Source form, including but 40 | not limited to compiled object code, generated documentation, 41 | and conversions to other media types. 42 | 43 | "Work" shall mean the work of authorship, whether in Source or 44 | Object form, made available under the License, as indicated by a 45 | copyright notice that is included in or attached to the work 46 | (an example is provided in the Appendix below). 47 | 48 | "Derivative Works" shall mean any work, whether in Source or Object 49 | form, that is based on (or derived from) the Work and for which the 50 | editorial revisions, annotations, elaborations, or other modifications 51 | represent, as a whole, an original work of authorship. For the purposes 52 | of this License, Derivative Works shall not include works that remain 53 | separable from, or merely link (or bind by name) to the interfaces of, 54 | the Work and Derivative Works thereof. 55 | 56 | "Contribution" shall mean any work of authorship, including 57 | the original version of the Work and any modifications or additions 58 | to that Work or Derivative Works thereof, that is intentionally 59 | submitted to Licensor for inclusion in the Work by the copyright owner 60 | or by an individual or Legal Entity authorized to submit on behalf of 61 | the copyright owner. For the purposes of this definition, "submitted" 62 | means any form of electronic, verbal, or written communication sent 63 | to the Licensor or its representatives, including but not limited to 64 | communication on electronic mailing lists, source code control systems, 65 | and issue tracking systems that are managed by, or on behalf of, the 66 | Licensor for the purpose of discussing and improving the Work, but 67 | excluding communication that is conspicuously marked or otherwise 68 | designated in writing by the copyright owner as "Not a Contribution." 69 | 70 | "Contributor" shall mean Licensor and any individual or Legal Entity 71 | on behalf of whom a Contribution has been received by Licensor and 72 | subsequently incorporated within the Work. 73 | 74 | 2. Grant of Copyright License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | copyright license to reproduce, prepare Derivative Works of, 78 | publicly display, publicly perform, sublicense, and distribute the 79 | Work and such Derivative Works in Source or Object form. 80 | 81 | 3. Grant of Patent License. Subject to the terms and conditions of 82 | this License, each Contributor hereby grants to You a perpetual, 83 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 84 | (except as stated in this section) patent license to make, have made, 85 | use, offer to sell, sell, import, and otherwise transfer the Work, 86 | where such license applies only to those patent claims licensable 87 | by such Contributor that are necessarily infringed by their 88 | Contribution(s) alone or by combination of their Contribution(s) 89 | with the Work to which such Contribution(s) was submitted. If You 90 | institute patent litigation against any entity (including a 91 | cross-claim or counterclaim in a lawsuit) alleging that the Work 92 | or a Contribution incorporated within the Work constitutes direct 93 | or contributory patent infringement, then any patent licenses 94 | granted to You under this License for that Work shall terminate 95 | as of the date such litigation is filed. 96 | 97 | 4. Redistribution. You may reproduce and distribute copies of the 98 | Work or Derivative Works thereof in any medium, with or without 99 | modifications, and in Source or Object form, provided that You 100 | meet the following conditions: 101 | 102 | (a) You must give any other recipients of the Work or 103 | Derivative Works a copy of this License; and 104 | 105 | (b) You must cause any modified files to carry prominent notices 106 | stating that You changed the files; and 107 | 108 | (c) You must retain, in the Source form of any Derivative Works 109 | that You distribute, all copyright, patent, trademark, and 110 | attribution notices from the Source form of the Work, 111 | excluding those notices that do not pertain to any part of 112 | the Derivative Works; and 113 | 114 | (d) If the Work includes a "NOTICE" text file as part of its 115 | distribution, then any Derivative Works that You distribute must 116 | include a readable copy of the attribution notices contained 117 | within such NOTICE file, excluding those notices that do not 118 | pertain to any part of the Derivative Works, in at least one 119 | of the following places: within a NOTICE text file distributed 120 | as part of the Derivative Works; within the Source form or 121 | documentation, if provided along with the Derivative Works; or, 122 | within a display generated by the Derivative Works, if and 123 | wherever such third-party notices normally appear. The contents 124 | of the NOTICE file are for informational purposes only and 125 | do not modify the License. You may add Your own attribution 126 | notices within Derivative Works that You distribute, alongside 127 | or as an addendum to the NOTICE text from the Work, provided 128 | that such additional attribution notices cannot be construed 129 | as modifying the License. 130 | 131 | You may add Your own copyright statement to Your modifications and 132 | may provide additional or different license terms and conditions 133 | for use, reproduction, or distribution of Your modifications, or 134 | for any such Derivative Works as a whole, provided Your use, 135 | reproduction, and distribution of the Work otherwise complies with 136 | the conditions stated in this License. 137 | 138 | 5. Submission of Contributions. Unless You explicitly state otherwise, 139 | any Contribution intentionally submitted for inclusion in the Work 140 | by You to the Licensor shall be under the terms and conditions of 141 | this License, without any additional terms or conditions. 142 | Notwithstanding the above, nothing herein shall supersede or modify 143 | the terms of any separate license agreement you may have executed 144 | with Licensor regarding such Contributions. 145 | 146 | 6. Trademarks. This License does not grant permission to use the trade 147 | names, trademarks, service marks, or product names of the Licensor, 148 | except as required for reasonable and customary use in describing the 149 | origin of the Work and reproducing the content of the NOTICE file. 150 | 151 | 7. Disclaimer of Warranty. Unless required by applicable law or 152 | agreed to in writing, Licensor provides the Work (and each 153 | Contributor provides its Contributions) on an "AS IS" BASIS, 154 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 155 | implied, including, without limitation, any warranties or conditions 156 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 157 | PARTICULAR PURPOSE. You are solely responsible for determining the 158 | appropriateness of using or redistributing the Work and assume any 159 | risks associated with Your exercise of permissions under this License. 160 | 161 | 8. Limitation of Liability. In no event and under no legal theory, 162 | whether in tort (including negligence), contract, or otherwise, 163 | unless required by applicable law (such as deliberate and grossly 164 | negligent acts) or agreed to in writing, shall any Contributor be 165 | liable to You for damages, including any direct, indirect, special, 166 | incidental, or consequential damages of any character arising as a 167 | result of this License or out of the use or inability to use the 168 | Work (including but not limited to damages for loss of goodwill, 169 | work stoppage, computer failure or malfunction, or any and all 170 | other commercial damages or losses), even if such Contributor 171 | has been advised of the possibility of such damages. 172 | 173 | 9. Accepting Warranty or Additional Liability. While redistributing 174 | the Work or Derivative Works thereof, You may choose to offer, 175 | and charge a fee for, acceptance of support, warranty, indemnity, 176 | or other liability obligations and/or rights consistent with this 177 | License. However, in accepting such obligations, You may act only 178 | on Your own behalf and on Your sole responsibility, not on behalf 179 | of any other Contributor, and only if You agree to indemnify, 180 | defend, and hold each Contributor harmless for any liability 181 | incurred by, or claims asserted against, such Contributor by reason 182 | of your accepting any such warranty or additional liability. 183 | 184 | END OF TERMS AND CONDITIONS 185 | 186 | APPENDIX: How to apply the Apache License to your work. 187 | 188 | To apply the Apache License to your work, attach the following 189 | boilerplate notice, with the fields enclosed by brackets "{}" 190 | replaced with your own identifying information. (Don't include 191 | the brackets!) The text should be enclosed in the appropriate 192 | comment syntax for the file format. We also recommend that a 193 | file or class name and description of purpose be included on the 194 | same "printed page" as the copyright notice for easier 195 | identification within third-party archives. 196 | 197 | Copyright 2016 Quick Team 198 | 199 | Licensed under the Apache License, Version 2.0 (the "License"); 200 | you may not use this file except in compliance with the License. 201 | You may obtain a copy of the License at 202 | 203 | http://www.apache.org/licenses/LICENSE-2.0 204 | 205 | Unless required by applicable law or agreed to in writing, software 206 | distributed under the License is distributed on an "AS IS" BASIS, 207 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 208 | See the License for the specific language governing permissions and 209 | limitations under the License. 210 | 211 | Type 212 | PSGroupSpecifier 213 | 214 | 215 | 216 | 217 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/Quick.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | Apache License 10 | Version 2.0, January 2004 11 | http://www.apache.org/licenses/ 12 | 13 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 14 | 15 | 1. Definitions. 16 | 17 | "License" shall mean the terms and conditions for use, reproduction, 18 | and distribution as defined by Sections 1 through 9 of this document. 19 | 20 | "Licensor" shall mean the copyright owner or entity authorized by 21 | the copyright owner that is granting the License. 22 | 23 | "Legal Entity" shall mean the union of the acting entity and all 24 | other entities that control, are controlled by, or are under common 25 | control with that entity. For the purposes of this definition, 26 | "control" means (i) the power, direct or indirect, to cause the 27 | direction or management of such entity, whether by contract or 28 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 29 | outstanding shares, or (iii) beneficial ownership of such entity. 30 | 31 | "You" (or "Your") shall mean an individual or Legal Entity 32 | exercising permissions granted by this License. 33 | 34 | "Source" form shall mean the preferred form for making modifications, 35 | including but not limited to software source code, documentation 36 | source, and configuration files. 37 | 38 | "Object" form shall mean any form resulting from mechanical 39 | transformation or translation of a Source form, including but 40 | not limited to compiled object code, generated documentation, 41 | and conversions to other media types. 42 | 43 | "Work" shall mean the work of authorship, whether in Source or 44 | Object form, made available under the License, as indicated by a 45 | copyright notice that is included in or attached to the work 46 | (an example is provided in the Appendix below). 47 | 48 | "Derivative Works" shall mean any work, whether in Source or Object 49 | form, that is based on (or derived from) the Work and for which the 50 | editorial revisions, annotations, elaborations, or other modifications 51 | represent, as a whole, an original work of authorship. For the purposes 52 | of this License, Derivative Works shall not include works that remain 53 | separable from, or merely link (or bind by name) to the interfaces of, 54 | the Work and Derivative Works thereof. 55 | 56 | "Contribution" shall mean any work of authorship, including 57 | the original version of the Work and any modifications or additions 58 | to that Work or Derivative Works thereof, that is intentionally 59 | submitted to Licensor for inclusion in the Work by the copyright owner 60 | or by an individual or Legal Entity authorized to submit on behalf of 61 | the copyright owner. For the purposes of this definition, "submitted" 62 | means any form of electronic, verbal, or written communication sent 63 | to the Licensor or its representatives, including but not limited to 64 | communication on electronic mailing lists, source code control systems, 65 | and issue tracking systems that are managed by, or on behalf of, the 66 | Licensor for the purpose of discussing and improving the Work, but 67 | excluding communication that is conspicuously marked or otherwise 68 | designated in writing by the copyright owner as "Not a Contribution." 69 | 70 | "Contributor" shall mean Licensor and any individual or Legal Entity 71 | on behalf of whom a Contribution has been received by Licensor and 72 | subsequently incorporated within the Work. 73 | 74 | 2. Grant of Copyright License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | copyright license to reproduce, prepare Derivative Works of, 78 | publicly display, publicly perform, sublicense, and distribute the 79 | Work and such Derivative Works in Source or Object form. 80 | 81 | 3. Grant of Patent License. Subject to the terms and conditions of 82 | this License, each Contributor hereby grants to You a perpetual, 83 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 84 | (except as stated in this section) patent license to make, have made, 85 | use, offer to sell, sell, import, and otherwise transfer the Work, 86 | where such license applies only to those patent claims licensable 87 | by such Contributor that are necessarily infringed by their 88 | Contribution(s) alone or by combination of their Contribution(s) 89 | with the Work to which such Contribution(s) was submitted. If You 90 | institute patent litigation against any entity (including a 91 | cross-claim or counterclaim in a lawsuit) alleging that the Work 92 | or a Contribution incorporated within the Work constitutes direct 93 | or contributory patent infringement, then any patent licenses 94 | granted to You under this License for that Work shall terminate 95 | as of the date such litigation is filed. 96 | 97 | 4. Redistribution. You may reproduce and distribute copies of the 98 | Work or Derivative Works thereof in any medium, with or without 99 | modifications, and in Source or Object form, provided that You 100 | meet the following conditions: 101 | 102 | (a) You must give any other recipients of the Work or 103 | Derivative Works a copy of this License; and 104 | 105 | (b) You must cause any modified files to carry prominent notices 106 | stating that You changed the files; and 107 | 108 | (c) You must retain, in the Source form of any Derivative Works 109 | that You distribute, all copyright, patent, trademark, and 110 | attribution notices from the Source form of the Work, 111 | excluding those notices that do not pertain to any part of 112 | the Derivative Works; and 113 | 114 | (d) If the Work includes a "NOTICE" text file as part of its 115 | distribution, then any Derivative Works that You distribute must 116 | include a readable copy of the attribution notices contained 117 | within such NOTICE file, excluding those notices that do not 118 | pertain to any part of the Derivative Works, in at least one 119 | of the following places: within a NOTICE text file distributed 120 | as part of the Derivative Works; within the Source form or 121 | documentation, if provided along with the Derivative Works; or, 122 | within a display generated by the Derivative Works, if and 123 | wherever such third-party notices normally appear. The contents 124 | of the NOTICE file are for informational purposes only and 125 | do not modify the License. You may add Your own attribution 126 | notices within Derivative Works that You distribute, alongside 127 | or as an addendum to the NOTICE text from the Work, provided 128 | that such additional attribution notices cannot be construed 129 | as modifying the License. 130 | 131 | You may add Your own copyright statement to Your modifications and 132 | may provide additional or different license terms and conditions 133 | for use, reproduction, or distribution of Your modifications, or 134 | for any such Derivative Works as a whole, provided Your use, 135 | reproduction, and distribution of the Work otherwise complies with 136 | the conditions stated in this License. 137 | 138 | 5. Submission of Contributions. Unless You explicitly state otherwise, 139 | any Contribution intentionally submitted for inclusion in the Work 140 | by You to the Licensor shall be under the terms and conditions of 141 | this License, without any additional terms or conditions. 142 | Notwithstanding the above, nothing herein shall supersede or modify 143 | the terms of any separate license agreement you may have executed 144 | with Licensor regarding such Contributions. 145 | 146 | 6. Trademarks. This License does not grant permission to use the trade 147 | names, trademarks, service marks, or product names of the Licensor, 148 | except as required for reasonable and customary use in describing the 149 | origin of the Work and reproducing the content of the NOTICE file. 150 | 151 | 7. Disclaimer of Warranty. Unless required by applicable law or 152 | agreed to in writing, Licensor provides the Work (and each 153 | Contributor provides its Contributions) on an "AS IS" BASIS, 154 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 155 | implied, including, without limitation, any warranties or conditions 156 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 157 | PARTICULAR PURPOSE. You are solely responsible for determining the 158 | appropriateness of using or redistributing the Work and assume any 159 | risks associated with Your exercise of permissions under this License. 160 | 161 | 8. Limitation of Liability. In no event and under no legal theory, 162 | whether in tort (including negligence), contract, or otherwise, 163 | unless required by applicable law (such as deliberate and grossly 164 | negligent acts) or agreed to in writing, shall any Contributor be 165 | liable to You for damages, including any direct, indirect, special, 166 | incidental, or consequential damages of any character arising as a 167 | result of this License or out of the use or inability to use the 168 | Work (including but not limited to damages for loss of goodwill, 169 | work stoppage, computer failure or malfunction, or any and all 170 | other commercial damages or losses), even if such Contributor 171 | has been advised of the possibility of such damages. 172 | 173 | 9. Accepting Warranty or Additional Liability. While redistributing 174 | the Work or Derivative Works thereof, You may choose to offer, 175 | and charge a fee for, acceptance of support, warranty, indemnity, 176 | or other liability obligations and/or rights consistent with this 177 | License. However, in accepting such obligations, You may act only 178 | on Your own behalf and on Your sole responsibility, not on behalf 179 | of any other Contributor, and only if You agree to indemnify, 180 | defend, and hold each Contributor harmless for any liability 181 | incurred by, or claims asserted against, such Contributor by reason 182 | of your accepting any such warranty or additional liability. 183 | 184 | END OF TERMS AND CONDITIONS 185 | 186 | APPENDIX: How to apply the Apache License to your work. 187 | 188 | To apply the Apache License to your work, attach the following 189 | boilerplate notice, with the fields enclosed by brackets "{}" 190 | replaced with your own identifying information. (Don't include 191 | the brackets!) The text should be enclosed in the appropriate 192 | comment syntax for the file format. We also recommend that a 193 | file or class name and description of purpose be included on the 194 | same "printed page" as the copyright notice for easier 195 | identification within third-party archives. 196 | 197 | Copyright 2014, Quick Team 198 | 199 | Licensed under the Apache License, Version 2.0 (the "License"); 200 | you may not use this file except in compliance with the License. 201 | You may obtain a copy of the License at 202 | 203 | http://www.apache.org/licenses/LICENSE-2.0 204 | 205 | Unless required by applicable law or agreed to in writing, software 206 | distributed under the License is distributed on an "AS IS" BASIS, 207 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 208 | See the License for the specific language governing permissions and 209 | limitations under the License. 210 | 211 | Type 212 | PSGroupSpecifier 213 | 214 | 215 | 216 | 217 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/ReactiveCocoa.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | **Copyright (c) 2012 - 2016, GitHub, Inc.** 10 | **All rights reserved.** 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy of 13 | this software and associated documentation files (the "Software"), to deal in 14 | the Software without restriction, including without limitation the rights to 15 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 16 | the Software, and to permit persons to whom the Software is furnished to do so, 17 | subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 24 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 25 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 26 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 27 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 28 | 29 | Type 30 | PSGroupSpecifier 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/ReactiveSwift.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | **Copyright (c) 2012 - 2016, GitHub, Inc.** 10 | **All rights reserved.** 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy of 13 | this software and associated documentation files (the "Software"), to deal in 14 | the Software without restriction, including without limitation the rights to 15 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 16 | the Software, and to permit persons to whom the Software is furnished to do so, 17 | subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 24 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 25 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 26 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 27 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 28 | 29 | Type 30 | PSGroupSpecifier 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/Result.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | The MIT License (MIT) 10 | 11 | Copyright (c) 2014 Rob Rix 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | Type 31 | PSGroupSpecifier 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/STRegex.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | The MIT License (MIT) 10 | 11 | Copyright (c) 2015 Adam Sharp 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | 31 | Type 32 | PSGroupSpecifier 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/Strongify.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | The MIT License (MIT) 10 | 11 | Copyright (c) 2017 Krzysztof Zablocki 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | 31 | 32 | Type 33 | PSGroupSpecifier 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/com.mono0926.LicensePlist/SwiftyJSON.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | The MIT License (MIT) 10 | 11 | Copyright (c) 2017 Ruoyu Fu 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in 21 | all copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 29 | THE SOFTWARE. 30 | 31 | Type 32 | PSGroupSpecifier 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /MajorInput/Settings.bundle/en.lproj/Root.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/MajorInput/Settings.bundle/en.lproj/Root.strings -------------------------------------------------------------------------------- /MajorInput/ShelfView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Anchorage 3 | 4 | final class ShelfView: UIView { 5 | 6 | let collection: UICollectionView 7 | let layout: UICollectionViewFlowLayout 8 | 9 | override init(frame: CGRect) { 10 | layout = UICollectionViewFlowLayout() 11 | collection = UICollectionView(frame: .zero, collectionViewLayout: layout) 12 | super.init(frame: frame) 13 | initialize() 14 | } 15 | 16 | required init?(coder aDecoder: NSCoder) { 17 | fatalError("init(coder:) has not been implemented") 18 | } 19 | } 20 | 21 | extension ShelfView { // ViewInitializing 22 | 23 | override func configure() { 24 | collection.backgroundColor = .white 25 | collection.contentInset = .zero 26 | 27 | layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize 28 | layout.minimumInteritemSpacing = 0 29 | layout.minimumLineSpacing = 0 30 | } 31 | 32 | override func buildUserInterface() { 33 | addSubview(collection) 34 | } 35 | 36 | override func activateDefaultLayout() { 37 | collection.edgeAnchors == safeAreaLayoutGuide.edgeAnchors 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /MajorInput/ShelfViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import ReactiveSwift 3 | import Strongify 4 | 5 | final class ShelfViewController: UIViewController, ViewDowncasting { 6 | 7 | typealias Dependencies = HasSessionsService & HasDownloadsService 8 | typealias DowncastView = ShelfView 9 | 10 | var onSelectSession: ((Session) -> Void)? 11 | 12 | fileprivate var sessions: [Session] = [] { 13 | didSet { 14 | guard isViewLoaded else { return } 15 | downcastView.collection.reloadData() 16 | downcastView.collection.layoutImmediately() 17 | } 18 | } 19 | 20 | fileprivate let sessionsService: SessionsService 21 | fileprivate let downloadsService: DownloadsService 22 | 23 | fileprivate let application = UIApplication.shared 24 | 25 | init(dependencies: Dependencies) { 26 | sessionsService = dependencies.sessionsService 27 | downloadsService = dependencies.downloadsService 28 | sessions = sessionsService.sessions 29 | super.init(nibName: nil, bundle: nil) 30 | } 31 | 32 | required init?(coder aDecoder: NSCoder) { 33 | fatalError("init(coder:) has not been implemented") 34 | } 35 | 36 | override func loadView() { 37 | view = ShelfView(frame: UIScreen.main.bounds) 38 | } 39 | 40 | override func viewDidLoad() { 41 | super.viewDidLoad() 42 | initialize() 43 | } 44 | } 45 | 46 | extension ShelfViewController { // ViewInitializing 47 | override func configure() { 48 | title = "MajorInput" 49 | 50 | downcastView.collection.dataSource = self 51 | downcastView.collection.delegate = self 52 | downcastView.collection.registerReusableCell(SessionCell.self) 53 | } 54 | } 55 | 56 | extension ShelfViewController: UICollectionViewDataSource { 57 | 58 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 59 | return sessions.count 60 | } 61 | 62 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 63 | let session = sessions[indexPath.item] 64 | let status = downloadsService.status(for: session) 65 | let cell: SessionCell = collectionView.dequeueReusableCell(indexPath: indexPath) 66 | cell.configure( 67 | with: session, 68 | captionsAvailable: sessionsService.canProvideCaptions(for: session), 69 | downloadStatus: status, 70 | onActionTap: strongify(weak: self) { `self` in 71 | switch status.value { 72 | case .remote: 73 | self.downloadsService.downloadVideo(for: session) 74 | case .downloading(_): 75 | self.downloadsService.cancelDownload(for: session) 76 | case .downloaded: 77 | self.downloadsService.deleteDownload(for: session) 78 | } 79 | }) 80 | return cell 81 | } 82 | } 83 | 84 | extension ShelfViewController: UICollectionViewDelegateFlowLayout { 85 | 86 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 87 | // inject width, leave height to each cell, but a good estimate here helps the scrollbar 88 | return CGSize(width: collectionView.bounds.size.width, height: 360) 89 | } 90 | 91 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 92 | let session = sessions[indexPath.item] 93 | let status = downloadsService.status(for: session) 94 | if case .remote = status.value { 95 | if sessionsService.canProvideCaptions(for: session) { 96 | downloadsService.downloadVideo(for: session) 97 | } else { 98 | showCaptionsUnavailableAlert() 99 | } 100 | } else if case .downloaded(_) = status.value { 101 | onSelectSession?(session) 102 | } 103 | } 104 | } 105 | 106 | fileprivate extension ShelfViewController { 107 | 108 | func showCaptionsUnavailableAlert() { 109 | let alert = UIAlertController( 110 | title: "Transcript Unavailable", 111 | message: "Perhaps the transcript is available in an app update. If not, then Apple probably has not yet captioned the video.", 112 | preferredStyle: .alert 113 | ) 114 | alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) 115 | alert.addAction(UIAlertAction(title: "Check Repo", style: .default, handler: strongify(weak: self) { `self`, _ in 116 | let repoUrl = URL(string: "https://github.com/rlwimi/major-input")! 117 | if self.application.canOpenURL(repoUrl) { 118 | self.application.open(repoUrl) 119 | } 120 | })) 121 | 122 | self.present(alert, animated: true, completion: nil) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /MajorInput/SortDescriptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | typealias SortDescriptor = (A, A) -> Bool 4 | 5 | func sortDescriptor(property: @escaping (Value) -> Property, ascending: Bool = true, comparator: @escaping (Property) -> (Property) -> ComparisonResult) -> SortDescriptor { 6 | return { value1, value2 in 7 | comparator(property(value1))(property(value2)) == (ascending ? .orderedAscending : .orderedDescending) 8 | } 9 | } 10 | 11 | func sortDescriptor(property: @escaping ((Value) -> Property), ascending: Bool = true) -> SortDescriptor where Property: Comparable { 12 | return { value1, value2 in 13 | if ascending { 14 | return property(value1) < property(value2) 15 | } else { 16 | return property(value1) > property(value2) 17 | } 18 | } 19 | } 20 | 21 | func combine(sortDescriptors: [SortDescriptor]) -> SortDescriptor { 22 | return { lhs, rhs in 23 | for descriptor in sortDescriptors { 24 | if descriptor(lhs,rhs) { return true } 25 | if descriptor(rhs,lhs) { return false } 26 | } 27 | return false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /MajorInput/StdLibExtensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection { 4 | 5 | subscript (safe index: Index) -> Iterator.Element? { 6 | return indices.contains(index) ? self[index] : nil 7 | } 8 | } 9 | 10 | extension String { 11 | /// - parameters: 12 | /// - substring: A string known to be contained within the instance. 13 | /// - returns: The position of the end of the substring within the instance, normalized from zero 14 | /// to one. 15 | /// - precondition: The instance contains `substring`. 16 | func progress(throughRangeOf substring: String) -> Double { 17 | let range = self.range(of: substring)! 18 | let substringDistance = distance(from: startIndex, to: range.upperBound) 19 | let totalDistance = distance(from: startIndex, to: endIndex) 20 | let progress = Double(substringDistance) / Double(totalDistance) 21 | return progress 22 | } 23 | } 24 | 25 | extension String { 26 | var endsInTerminalPoint: Bool { 27 | guard let last = self.trimmingCharacters(in: .whitespacesAndNewlines).last 28 | else { return false } 29 | return String(last).rangeOfCharacter(from: .terminalPunctuation) != nil 30 | } 31 | } 32 | 33 | extension String { 34 | var isBracketed: Bool { 35 | let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines) 36 | return trimmed.isEmpty == false && trimmed.first! == "[" && trimmed.last! == "]" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /MajorInput/TouchStencilingButton.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class TouchStencilingButton: UIButton { 4 | 5 | /// List of views to which the instance will pass through touches. 6 | var stenciledViews: [UIView] = [] 7 | 8 | override init(frame: CGRect) { 9 | super.init(frame: frame) 10 | backgroundColor = .clear 11 | } 12 | 13 | required init?(coder aDecoder: NSCoder) { 14 | fatalError("init(coder:) has not been implemented") 15 | } 16 | 17 | override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { 18 | guard super.point(inside: point, with: event) 19 | else { return false } 20 | let stenciled = stenciledViews.first { stenciledView in 21 | guard stenciledView.isHiddenInHierarchy == false else { return false } 22 | let convertedPoint = self.convert(point, to: stenciledView) 23 | let inside = stenciledView.point(inside: convertedPoint, with: event) 24 | return inside 25 | } 26 | let consumeTouch = (stenciled == nil) 27 | return consumeTouch 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /MajorInput/TransformerView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Anchorage 3 | 4 | final class TransformerView: UIView { 5 | 6 | enum AntennaStyle { 7 | case play 8 | case pause 9 | } 10 | 11 | let icon = TransformerIcon() 12 | 13 | fileprivate let body = UIView() 14 | 15 | fileprivate let indicator = CAShapeLayer() 16 | var style: AntennaStyle = .play { 17 | didSet { 18 | layer.setNeedsLayout() 19 | } 20 | } 21 | fileprivate var antenna: UIBezierPath { 22 | // A play or pause button. Play button is a triangle half of a 16pt square, a 12x24 area. 23 | let minY = body.frame.midY - 12 24 | let maxY = body.frame.midY + 12 25 | let maxX = body.frame.minX - 1 26 | let minX = maxX - 12 27 | let p = UIBezierPath() 28 | switch style { 29 | case .play: 30 | p.move(to: CGPoint(x: maxX, y: body.frame.midY)) 31 | // 16 / sqrt(2) ~= 12 32 | p.addLine(to: CGPoint(x: minX, y: minY)) 33 | p.addLine(to: CGPoint(x: minX, y: maxY)) 34 | p.close() 35 | case .pause: 36 | p.move(to: CGPoint(x: minX, y: minY)) 37 | p.addLine(to: CGPoint(x: minX, y: maxY)) 38 | p.addLine(to: CGPoint(x: minX + 3, y: maxY)) 39 | p.addLine(to: CGPoint(x: minX + 3, y: minY)) 40 | p.close() 41 | p.move(to: CGPoint(x: minX + 7, y: minY)) 42 | p.addLine(to: CGPoint(x: minX + 7, y: maxY)) 43 | p.addLine(to: CGPoint(x: minX + 10, y: maxY)) 44 | p.addLine(to: CGPoint(x: minX + 10, y: minY)) 45 | p.close() 46 | break 47 | } 48 | return p 49 | } 50 | 51 | override init(frame: CGRect) { 52 | super.init(frame: frame) 53 | initialize() 54 | } 55 | 56 | required init?(coder aDecoder: NSCoder) { 57 | fatalError("init(coder:) has not been implemented") 58 | } 59 | 60 | override func layoutSublayers(of layer: CALayer) { 61 | super.layoutSublayers(of: layer) 62 | if layer == self.layer { 63 | indicator.frame = layer.bounds 64 | indicator.path = antenna.cgPath 65 | } 66 | } 67 | } 68 | 69 | extension TransformerView { // ViewInitializing 70 | override func configure() { 71 | body.backgroundColor = .systemTintColor 72 | body.layer.masksToBounds = true 73 | body.layer.cornerRadius = 10 74 | 75 | indicator.lineWidth = 2 76 | indicator.strokeColor = UIColor.systemTintColor.cgColor 77 | indicator.fillColor = UIColor.systemTintColor.cgColor 78 | } 79 | 80 | override func buildUserInterface() { 81 | addSubview(body) 82 | addSubview(icon) 83 | layer.addSublayer(indicator) 84 | } 85 | 86 | override func activateDefaultLayout() { 87 | icon.centerYAnchor == self.centerYAnchor 88 | icon.trailingAnchor == self.trailingAnchor + 2 89 | 90 | body.edgeAnchors == icon.edgeAnchors - 10 91 | 92 | widthAnchor == 60 93 | heightAnchor == 80 94 | } 95 | } 96 | 97 | final class TransformerIcon: UIView { 98 | 99 | fileprivate let leftLayer = CAShapeLayer() 100 | fileprivate let rightLayer = CAShapeLayer() 101 | 102 | fileprivate var leftPath: UIBezierPath { 103 | let p = UIBezierPath() 104 | p.addArc(withCenter: CGPoint(x: 2, y: 2), radius: 2, startAngle: 0, endAngle: CGFloat(2 * Double.pi), clockwise: true) 105 | p.addLine(to: CGPoint(x: 12, y: 2)) 106 | p.addLine(to: CGPoint(x: 12, y: 12)) 107 | p.addArc(withCenter: CGPoint(x: 13, y: 14), radius: 2, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi / 2), clockwise: true) 108 | p.addLine(to: CGPoint(x: 12, y: 16)) 109 | p.addArc(withCenter: CGPoint(x: 13, y: 18), radius: 2, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi / 2), clockwise: true) 110 | p.addLine(to: CGPoint(x: 12, y: 20)) 111 | p.addArc(withCenter: CGPoint(x: 13, y: 22), radius: 2, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi / 2), clockwise: true) 112 | p.addLine(to: CGPoint(x: 12, y: 24)) 113 | p.addArc(withCenter: CGPoint(x: 13, y: 26), radius: 2, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi / 2), clockwise: true) 114 | p.addLine(to: CGPoint(x: 12, y: 28)) 115 | p.addLine(to: CGPoint(x: 12, y: 38)) 116 | p.addArc(withCenter: CGPoint(x: 2, y: 38), radius: 2, startAngle: 0, endAngle: CGFloat(2 * Double.pi), clockwise: true) 117 | p.move(to: CGPoint(x: 19, y: 12)) 118 | p.addLine(to: CGPoint(x: 19, y: 28)) 119 | return p 120 | } 121 | 122 | fileprivate var rightPath: UIBezierPath { 123 | let p = UIBezierPath() 124 | p.move(to: CGPoint(x: 40, y: 2)) 125 | p.addLine(to: CGPoint(x: 26, y: 2)) 126 | p.addLine(to: CGPoint(x: 26, y: 12)) 127 | p.addArc(withCenter: CGPoint(x: 25, y: 14), radius: 2, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi / 2), clockwise: false) 128 | p.addLine(to: CGPoint(x: 26, y: 16)) 129 | p.addArc(withCenter: CGPoint(x: 25, y: 18), radius: 2, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi / 2), clockwise: false) 130 | p.addLine(to: CGPoint(x: 26, y: 20)) 131 | p.addArc(withCenter: CGPoint(x: 25, y: 22), radius: 2, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi / 2), clockwise: false) 132 | p.addLine(to: CGPoint(x: 26, y: 24)) 133 | p.addArc(withCenter: CGPoint(x: 25, y: 26), radius: 2, startAngle: CGFloat(3 * Double.pi / 2), endAngle: CGFloat(Double.pi / 2), clockwise: false) 134 | p.addLine(to: CGPoint(x: 26, y: 28)) 135 | p.addLine(to: CGPoint(x: 26, y: 38)) 136 | p.addLine(to: CGPoint(x: 40, y: 38)) 137 | return p 138 | } 139 | 140 | override init(frame: CGRect) { 141 | super.init(frame: frame) 142 | initialize() 143 | } 144 | 145 | required init?(coder aDecoder: NSCoder) { 146 | fatalError("init(coder:) has not been implemented") 147 | } 148 | 149 | override func layoutSublayers(of layer: CALayer) { 150 | super.layoutSublayers(of: layer) 151 | if layer == self.layer { 152 | leftLayer.path = leftPath.cgPath 153 | rightLayer.path = rightPath.cgPath 154 | } 155 | } 156 | 157 | override var intrinsicContentSize: CGSize { 158 | return CGSize(width: 40, height: 40) 159 | } 160 | } 161 | 162 | extension TransformerIcon { // ViewInitializing 163 | 164 | override func configure() { 165 | backgroundColor = .clear 166 | configure(leftLayer, with: .white) 167 | configure(rightLayer, with: .white) 168 | } 169 | 170 | override func buildUserInterface() { 171 | layer.addSublayer(leftLayer) 172 | layer.addSublayer(rightLayer) 173 | } 174 | } 175 | 176 | fileprivate extension TransformerIcon { 177 | 178 | func configure(_ layer: CAShapeLayer, with color: UIColor) { 179 | layer.fillColor = UIColor.clear.cgColor 180 | layer.strokeColor = color.cgColor 181 | layer.lineWidth = 2 182 | layer.lineCap = .round 183 | layer.lineJoin = .round 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /MajorInput/UIKitExtensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | func afterSystemAnimation(do closure: @escaping () -> Void) { 4 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 5 | closure() 6 | } 7 | } 8 | 9 | protocol LayingOut { 10 | func setNeedsLayout() 11 | func layoutIfNeeded() 12 | } 13 | 14 | protocol LayoutForcing { 15 | func layoutImmediately() 16 | } 17 | 18 | extension LayoutForcing where Self: LayingOut { 19 | func layoutImmediately() { 20 | setNeedsLayout() 21 | layoutIfNeeded() 22 | } 23 | } 24 | 25 | extension UIView: LayingOut, LayoutForcing {} 26 | extension CALayer: LayingOut, LayoutForcing {} 27 | 28 | extension UICollectionView { 29 | func registerReusableCell(_: T.Type) { 30 | register(T.self, forCellWithReuseIdentifier: T.classString) 31 | } 32 | 33 | func registerHeader(_: T.Type) { 34 | register(T.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: T.classString) 35 | } 36 | 37 | func registerFooter(_: T.Type) { 38 | register(T.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: T.classString) 39 | } 40 | 41 | func dequeueReusableCell(indexPath: IndexPath) -> T { 42 | return dequeueReusableCell(withReuseIdentifier: T.classString, for: indexPath) as! T 43 | } 44 | 45 | func dequeueReusableSectionHeader(indexPath: IndexPath) -> T { 46 | return dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: T.classString, for: indexPath) as! T 47 | } 48 | 49 | func dequeueReusableSectionFooter(indexPath: IndexPath) -> T { 50 | return dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: T.classString, for: indexPath) as! T 51 | } 52 | } 53 | 54 | extension UICollectionView { 55 | /// Reload data and force layout. 56 | /// 57 | /// There is a window of time after calling `reloadData` in which layout has not yet occurred. 58 | /// During this window, methods like `layoutAttributesForItem(at:)` are executing against the 59 | /// data prior to the `reloadData` call. Forcing layout supports the use of layout attributes 60 | /// immediately after reloading data. 61 | func reloadDataImmediately() { 62 | reloadData() 63 | layoutImmediately() 64 | } 65 | } 66 | 67 | extension UIColor { 68 | @nonobjc private static let button: UIButton = { 69 | return UIButton(type: .system) 70 | }() 71 | 72 | @nonobjc static var systemTintColor: UIColor = { 73 | return UIColor.button.tintColor 74 | }() 75 | } 76 | 77 | extension UILayoutGuide { 78 | convenience init(identifier: String) { 79 | self.init() 80 | self.identifier = identifier 81 | } 82 | } 83 | 84 | extension UIView { 85 | func firstViewInHierarchy(where matcher: ((UIView) -> Bool)) -> UIView? { 86 | if matcher(self) { 87 | return self 88 | } 89 | for subview in subviews { 90 | if let first = subview.firstViewInHierarchy(where: matcher) { 91 | return first 92 | } 93 | } 94 | return nil 95 | } 96 | 97 | /// `true` if the instance or any of the views in its superview chain to its window are hidden. 98 | var isHiddenInHierarchy: Bool { 99 | guard isHidden == false else { return true } 100 | guard let superview = superview else { return false } 101 | return superview.isHiddenInHierarchy 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /MajorInput/ViewDowncasting.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Supports `UIViewController` subclasses overriding `loadView`. Conformance supports concise 4 | /// declaration that a `UIViewController` subclass's `view` is a given `UIView` subclass. Provides 5 | /// easy access to `view` as this type removing redundant casts. 6 | /// 7 | /// Conformance is simple: 8 | /// ```swift 9 | /// extension MyViewController: ViewDowncasting { 10 | /// typealias DowncastView: MyView 11 | /// } 12 | /// ``` 13 | protocol ViewDowncasting { 14 | 15 | associatedtype DowncastView 16 | 17 | /// Replace use of `view` property with this property. Avoids downcasting. 18 | /// - returns: `view` downcast as the view controller's associated subclass 19 | var downcastView: DowncastView { get } 20 | } 21 | 22 | extension ViewDowncasting where Self: UIViewController, Self.DowncastView: UIView { 23 | var downcastView: Self.DowncastView { 24 | return view as! Self.DowncastView 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /MajorInput/ViewInitializing.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Provides structure to the often ad hoc nature of `UIView` setup. Because Auto Layout requires 4 | /// `NSLayoutConstraint` items to share a common ancestor, it is critical that the view hierarchy is 5 | /// built before the layout is activated. Completing the construction of the view hierarchy before 6 | /// activating any layout constraints minimizes the fragility resulting from order-of-operations 7 | /// dependencies that arise when constraint activation is mixed with hierarchy construction. Strict 8 | /// separation is critical to supporting view subclassing. Other benefits include a standardization 9 | /// of common operations which go by any number of different names. 10 | /// 11 | /// These methods are intended to be called at init-time, in order: 12 | /// 13 | /// 1. configure 14 | /// 2. buildUserInterface 15 | /// 3. activateDefaultLayout 16 | /// 17 | /// This order of operations is encapsulated by a default implementation of `initialize`, an 18 | /// instance not to be confused with the Objective-C/`NSObject` class method (which will no longer 19 | /// be callable from Swift 4). 20 | /// 21 | /// Typically, these methods *should not* call super. 22 | /// 23 | /// Each class in a hierarchy should call `initialize` and take care of its own responsibilities. 24 | /// However, if a particular design, perhaps a template pattern where a subclass provides its 25 | /// "abstract" superclass with a particular subview, requires these methods to call super in or to 26 | /// build the complete view hierarchy spanning a class hierarchy before applying any layout, calling 27 | /// super first in these methods meets that need. If you encounter such a design consider whether 28 | /// you find dependency-injection via subclass to be a code smell, whether you reallyy want to deal 29 | /// with the complexity from interactions of merging two hierarchies (subview and subclass). 30 | @objc protocol ViewInitializing { 31 | 32 | /// Configure self and subviews. 33 | func configure() 34 | 35 | /// Construct subview and sublayer hierarchies here. 36 | func buildUserInterface() 37 | 38 | /// Layout should always be constrained down the hierarchy and not up. Views are not limited to 39 | /// a single layout, but layout of the instance's subview hierarchy should be fully constrained 40 | /// by the end of this method. 41 | func activateDefaultLayout() 42 | } 43 | 44 | extension ViewInitializing { 45 | func configure() {} 46 | func buildUserInterface() {} 47 | func activateDefaultLayout() {} 48 | 49 | /// Calls initialization operations in the appropriate order. 50 | func initialize() { 51 | configure() 52 | buildUserInterface() 53 | activateDefaultLayout() 54 | } 55 | } 56 | 57 | /// `UIView` and `UIViewController` are given seemingly repetitive empty implementations below. This 58 | /// supports subclasses implementing these methods as overrides without knowing whether the super- 59 | /// class implements the method. 60 | 61 | extension UIView: ViewInitializing { 62 | func configure() {} 63 | func buildUserInterface() {} 64 | func activateDefaultLayout() {} 65 | } 66 | 67 | extension UIViewController: ViewInitializing { 68 | func configure() {} 69 | func buildUserInterface() {} 70 | func activateDefaultLayout() {} 71 | } 72 | -------------------------------------------------------------------------------- /MajorInputTests/CaptionSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | @testable import MajorInput 4 | 5 | class CaptionSpec: QuickSpec { 6 | 7 | override func spec() { 8 | let session = Session(conference: .wwdc, 9 | description: "Media playback just got easier and more powerful with the introduction of AVKit on iOS. Hear how AVKit provides view-level services that give you access to the modern media capabilities of AV Foundation. Learn the best practices for playing audiovisual media on iOS and OS X.", 10 | downloadHD: URL(string: "http://devstreaming.apple.com/videos/wwdc/2014/503xx50xm4n63qe/503/503_sd_mastering_modern_media_playback.mov")!, 11 | downloadSD: URL(string: "http://devstreaming.apple.com/videos/wwdc/2014/503xx50xm4n63qe/503/503_hd_mastering_modern_media_playback.mov")!, 12 | duration: nil, 13 | focuses: [.macOS, .iOS], 14 | image: URL(string: "http://devstreaming.apple.com/videos/wwdc/thumbnails/d20ft1ql/2014/503/503_shelf.jpg"), 15 | number: "503", 16 | title: "Mastering Modern Media Playback", 17 | track: .media, 18 | year: "2014") 19 | 20 | describe("WebVTT parsing") { 21 | 22 | var captions: [Caption]! 23 | 24 | beforeEach { 25 | 26 | // WEBVTT 27 | // X-TIMESTAMP-MAP=MPEGTS:181083,LOCAL:00:00:00.000 28 | // 29 | // 00:00:11.616 --> 00:00:14.186 A:middle 30 | // >> Welcome to "Mastering 31 | // Modern Media Playback". 32 | // ... 33 | // 00:00:27.346 --> 00:00:29.846 A:middle 34 | // The goal of this session 35 | // is to show you how easy 36 | // ... 37 | // 00:45:37.516 --> 00:45:42.480 A:middle 38 | // [ Applause ] 39 | 40 | captions = SessionsService().captions(withContentsOf: session.localVttUrl) 41 | } 42 | 43 | it("provides captions") { 44 | 45 | expect(captions.count).to(equal(898)) 46 | 47 | let first = captions.first! 48 | expect(first.text).to(equal(">> Welcome to \"Mastering Modern Media Playback\".")) 49 | expect(first.start).to(equal(TimeInterval(11.616))) 50 | expect(first.end).to(equal(TimeInterval(14.186))) 51 | 52 | let caption = captions[6] 53 | 54 | expect(caption.text).to(equal("The goal of this session is to show you how easy")) 55 | expect(caption.start).to(equal(TimeInterval(27.346))) 56 | expect(caption.end).to(equal(TimeInterval(29.846))) 57 | 58 | let last = captions.last! 59 | 60 | expect(last.text).to(equal("[ Applause ]")) 61 | expect(last.start).to(equal(TimeInterval(45 * 60 + 37.516))) 62 | expect(last.end).to(equal(TimeInterval(45 * 60 + 42.48))) 63 | } 64 | } 65 | 66 | describe("merging time range") { 67 | 68 | var merged: Caption! 69 | 70 | beforeEach { 71 | let first = Caption(start: TimeInterval(0), end: TimeInterval(1), text: "This is a sentence,") 72 | let second = Caption(start: TimeInterval(1), end: TimeInterval(2), text: "broken for you.") 73 | merged = first.merging(second) 74 | } 75 | 76 | it("selects the earlier start") { 77 | expect(merged.start).to(equal(TimeInterval(0))) 78 | } 79 | 80 | it("selects the later end") { 81 | expect(merged.end).to(equal(TimeInterval(2))) 82 | } 83 | 84 | it("joins texts with a space") { 85 | expect(merged.text).to(equal("This is a sentence, broken for you.")) 86 | } 87 | } 88 | 89 | describe("sentencifying") { 90 | 91 | var captions: [Caption]! 92 | var sentences: [Caption]! 93 | 94 | beforeEach { 95 | captions = SessionsService().captions(withContentsOf: session.localVttUrl) 96 | sentences = captions.sentencifying 97 | } 98 | 99 | it("does not merge into square-bracketed texts") { 100 | 101 | // 00:21:49.516 --> 00:21:55.546 A:middle 102 | // [ Applause ] 103 | 104 | expect(sentences[170]).to(equal(captions[325])) 105 | } 106 | 107 | it("merges captions not ending in terminal point") { 108 | 109 | // 00:00:18.856 --> 00:00:21.686 A:middle 110 | // And if you are already using 111 | // or planning to adopt AVKit 112 | // 113 | // 00:00:21.686 --> 00:00:24.496 A:middle 114 | // or AVFoundation in your 115 | // iOS or OS X applications, 116 | // 117 | // 00:00:24.706 --> 00:00:25.916 A:middle 118 | // this is the right 119 | // session for you. 120 | 121 | let merged = Caption( 122 | start: TimeInterval(seconds: 18, milliseconds: 856), 123 | end: TimeInterval(seconds: 25, milliseconds: 916), 124 | text: "And if you are already using or planning to adopt AVKit or AVFoundation in your iOS or OS X applications, this is the right session for you." 125 | ) 126 | expect(sentences[3]).to(equal(merged)) 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /MajorInputTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /MajorInputTests/MajorInputSanityCheckTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import MajorInput 3 | 4 | class MajorInputTests: XCTestCase { 5 | 6 | func testSanity() { 7 | XCTAssert(true, "We're all mad here.") 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /MajorInputTests/QuickIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | 4 | class QuickTests: QuickSpec { 5 | 6 | override func spec() { 7 | 8 | describe("Quick") { 9 | 10 | var string: String! 11 | 12 | beforeEach { 13 | string = "asdf" 14 | } 15 | 16 | it("tests all the things") { 17 | expect(string).to(equal("asdf")) 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /MajorInputTests/StringExtensionsSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | @testable import MajorInput 4 | 5 | class StringExtensionsSpec: QuickSpec { 6 | 7 | override func spec() { 8 | 9 | describe("progress") { 10 | 11 | var string: String! 12 | 13 | beforeEach { 14 | string = "asdf" 15 | } 16 | 17 | it("maps prefix substrings to progress") { 18 | expect(string.progress(throughRangeOf: "a")).to(equal(0.25)) 19 | expect(string.progress(throughRangeOf: "as")).to(equal(0.5)) 20 | expect(string.progress(throughRangeOf: "asd")).to(equal(0.75)) 21 | expect(string.progress(throughRangeOf: "asdf")).to(equal(1)) 22 | } 23 | 24 | it("maps suffix substrings to progress") { 25 | expect(string.progress(throughRangeOf: "f")).to(equal(1)) 26 | expect(string.progress(throughRangeOf: "df")).to(equal(1)) 27 | expect(string.progress(throughRangeOf: "sdf")).to(equal(1)) 28 | expect(string.progress(throughRangeOf: "asdf")).to(equal(1)) 29 | } 30 | 31 | it("maps inner substrings to progress") { 32 | expect(string.progress(throughRangeOf: "sd")).to(equal(0.75)) 33 | } 34 | 35 | it("maps character substrings to progress") { 36 | expect(string.progress(throughRangeOf: "a")).to(equal(0.25)) 37 | expect(string.progress(throughRangeOf: "s")).to(equal(0.5)) 38 | expect(string.progress(throughRangeOf: "d")).to(equal(0.75)) 39 | expect(string.progress(throughRangeOf: "f")).to(equal(1)) 40 | } 41 | } 42 | 43 | describe("ends in terminal point") { 44 | 45 | it("recognizes the end of a question") { 46 | let question = "Would you tell me, please, which way I ought to go from here?" 47 | expect(question.endsInTerminalPoint).to(beTrue()) 48 | } 49 | 50 | it("recognizes the end of a statement") { 51 | let statement = "That depends a good deal on where you want to get to." 52 | expect(statement.endsInTerminalPoint).to(beTrue()) 53 | } 54 | 55 | it("recognizes a fragment") { 56 | let fragment = "I don't much care where," 57 | expect(fragment.endsInTerminalPoint).to(beFalse()) 58 | } 59 | 60 | it("recognizes the end of an exclamation") { 61 | let exclamation = "Then it doesn't matter which way you go!" 62 | expect(exclamation.endsInTerminalPoint).to(beTrue()) 63 | } 64 | 65 | it("ignores trailing whitespace") { 66 | let explanation = "so long as I get SOMEWHERE. \n" 67 | expect(explanation.endsInTerminalPoint).to(beTrue()) 68 | } 69 | } 70 | 71 | describe("is bracketed") { 72 | 73 | it("recognizes bracketed text") { 74 | let bracketed = "[ Applause ]" 75 | expect(bracketed.isBracketed).to(beTrue()) 76 | } 77 | 78 | it("recognizes partially bracketed text") { 79 | let prefixed = "[ Music" 80 | expect(prefixed.isBracketed).to(beFalse()) 81 | 82 | let suffixed = "Music ]" 83 | expect(suffixed.isBracketed).to(beFalse()) 84 | } 85 | 86 | it("recognizes unbracketed text") { 87 | let unbracketed = "Applause" 88 | expect(unbracketed.isBracketed).to(beFalse()) 89 | } 90 | 91 | it("ignores leading whitespace") { 92 | let leading = "\t[ Applause ]" 93 | expect(leading.isBracketed).to(beTrue()) 94 | } 95 | 96 | it("ignores trailing whitespace") { 97 | let trailing = "[ Applause ]\n" 98 | expect(trailing.isBracketed).to(beTrue()) 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /MajorInputTests/VideosJsonImportingSpec.swift: -------------------------------------------------------------------------------- 1 | import Quick 2 | import Nimble 3 | import SwiftyJSON 4 | @testable import MajorInput 5 | 6 | class VideosJsonImportingSpec: QuickSpec { 7 | 8 | override func spec() { 9 | 10 | describe("Sessions data") { 11 | 12 | var json: JSON! 13 | 14 | beforeEach { 15 | let url = Bundle.main.url(forResource: "sessions.json", withExtension: nil)! 16 | let data = try! Data(contentsOf: url) 17 | json = try! JSON(data: data) 18 | } 19 | 20 | it("contains 686 sessions") { 21 | let sessions = json.arrayValue 22 | expect(sessions.count).to(equal(687)) 23 | } 24 | 25 | it("deserializes sessions") { 26 | let sessions = json.arrayValue.compactMap(Session.init(json:)) 27 | expect(sessions.count).to(equal(575)) // all - 2012 sessions = 687 - 112 = 574 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | inhibit_all_warnings! 3 | ENV['COCOAPODS_DISABLE_STATS'] = "true" 4 | 5 | def app_pods 6 | pod 'Anchorage' 7 | pod 'HTMLEntities', :git => 'https://github.com/IBM-Swift/swift-html-entities.git' 8 | pod 'Kingfisher' 9 | pod 'ReactiveCocoa' 10 | pod 'ReactiveSwift' 11 | pod 'STRegex' 12 | pod 'Strongify' 13 | pod 'SwiftyJSON' 14 | end 15 | 16 | def testing_pods 17 | pod 'Quick' 18 | pod 'Nimble' 19 | end 20 | 21 | target 'MajorInput' do 22 | 23 | platform :ios, '11.0' 24 | 25 | app_pods 26 | end 27 | 28 | target 'MajorInputTests' do 29 | 30 | platform :ios, '11.0' 31 | 32 | inherit! :search_paths 33 | 34 | app_pods 35 | testing_pods 36 | end 37 | 38 | post_install do |installer| 39 | 40 | # Mark older-Swift-compatible dependencies as Swift 5, to prevent Xcode from 41 | # suggesting a Swift 5 migration is available because of these targets. 42 | installer.pods_project.targets.each do |target| 43 | next unless [ 44 | 'Nimble', 45 | 'ReactiveSwift', 46 | 'Result' 47 | ].include? target.name 48 | 49 | target.build_configurations.each do |config| 50 | config.build_settings['SWIFT_VERSION'] = '5.0' 51 | end 52 | end 53 | 54 | # Generate dependencies' license acknowledgements settings bundle 55 | system("sh Tools/Scripts/generate-acknowledgements.sh") 56 | end 57 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Anchorage (4.3) 3 | - HTMLEntities (3.0.12) 4 | - Kingfisher (5.3.1) 5 | - Nimble (8.0.1) 6 | - Quick (2.0.0) 7 | - ReactiveCocoa (9.0.0): 8 | - ReactiveSwift (~> 5.0) 9 | - ReactiveSwift (5.0.1): 10 | - Result (~> 4.1) 11 | - Result (4.1.0) 12 | - STRegex (2.0.0) 13 | - Strongify (1.2) 14 | - SwiftyJSON (4.3.0) 15 | 16 | DEPENDENCIES: 17 | - Anchorage 18 | - HTMLEntities (from `https://github.com/IBM-Swift/swift-html-entities.git`) 19 | - Kingfisher 20 | - Nimble 21 | - Quick 22 | - ReactiveCocoa 23 | - ReactiveSwift 24 | - STRegex 25 | - Strongify 26 | - SwiftyJSON 27 | 28 | SPEC REPOS: 29 | https://github.com/cocoapods/specs.git: 30 | - Anchorage 31 | - Kingfisher 32 | - Nimble 33 | - Quick 34 | - ReactiveCocoa 35 | - ReactiveSwift 36 | - Result 37 | - STRegex 38 | - Strongify 39 | - SwiftyJSON 40 | 41 | EXTERNAL SOURCES: 42 | HTMLEntities: 43 | :git: https://github.com/IBM-Swift/swift-html-entities.git 44 | 45 | CHECKOUT OPTIONS: 46 | HTMLEntities: 47 | :commit: 544e7e0e404edeb239d4bb86e5cbe5929c0809bf 48 | :git: https://github.com/IBM-Swift/swift-html-entities.git 49 | 50 | SPEC CHECKSUMS: 51 | Anchorage: 21a98675245188427f0bcea21404d42a824a766f 52 | HTMLEntities: b195103df85a07c6174ed52435d30ae90085fa44 53 | Kingfisher: d9e7e0b209b59b8f9873aa2f37654e81a7beea51 54 | Nimble: 45f786ae66faa9a709624227fae502db55a8bdd0 55 | Quick: ce1276c7c27ba2da3cb2fd0cde053c3648b3b22d 56 | ReactiveCocoa: 29709cf0cc7502ddd04a51220e10ee0336d8bec8 57 | ReactiveSwift: cac20a5bbe560c5806bd29c0fccf90d03b996ac1 58 | Result: bd966fac789cc6c1563440b348ab2598cc24d5c7 59 | STRegex: dc7c8082a77c521315a6339f86abf125b8c3feb8 60 | Strongify: b5286ad3c12ec804b3515625982ca6b52474eb2e 61 | SwiftyJSON: 6faa0040f8b59dead0ee07436cbf76b73c08fd08 62 | 63 | PODFILE CHECKSUM: ff1cb4905961f4625bf5dedfe3c4b02aefd1832e 64 | 65 | COCOAPODS: 1.6.1 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠️ Archived 2 | 3 | In 2019, Apple [added transcripts](https://developer.apple.com/news/?id=07102019a) to WWDC session video webpages, as well as the interaction that clicking text in the transcript scrubs to the corresponding time in the video. Apple released the [Developer.app](https://developer.apple.com/news/?id=11182019a) a few months later including the same functionality. 4 | 5 | While I do miss the one aspect of Major Input's UX design that Apple's is missing—that the video's time is locked in sync with the transcript's scroll offset—Apple's tap/click-based interaction is clearly and thoroughly ["good enough"](https://en.wikipedia.org/wiki/Principle_of_good_enough). I'm so happy to be living in the future. 6 | 7 | --- 8 | 9 | ![MajorInput icon](./MajorInput/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x-60@3x.png) 10 | 11 | # Major Input 12 | 13 | a novel iPad UI for reading WWDC session transcripts alongside the video/presentation context 14 | 15 | ![Tour GIF](Resources/screenshots/tour.gif) 16 | 17 | ## Getting Started 18 | 19 | Build Major Input yourself with Xcode 10.2 to your iPad running iOS 11 or higher, which of course requires use of your own developer account. Major Input will also run on the iPad simulator, but the UX is not designed for this purpose. 20 | 21 | Note that sessions and transcripts from 2017 and earlier are bundled in the build, and 2018 sessions will remain missing until the content is more dynamically sourced. 22 | 23 | 1. *Get the code* 24 | 25 | Clone the repo, or download a zip, depending on how you want to get future updates. 26 | 1. *Install dependencies* 27 | ``` 28 | cd <$SRCROOT> 29 | carthage bootstrap --no-build --no-use-binaries 30 | pod install 31 | ``` 32 | 1. *Configure code signing* 33 | 34 | Select the MajorInput project in the Project Navigator. For both Targets, MajorInput and MajorInputTests, select your own Development Team in the General tab's Signing section Team picker. 35 | 36 | 1. *Build and run* 37 | 38 | Build the MajorInput scheme. 39 | 40 | ## Usage 41 | 42 | Select a session, wait for the video to download if it hasn't already, and consume it. The blue transformer to the right of the text aligns the sync point between the transcript text and the video time, as does the small blue triangle in the filmstrip. 43 | 44 | #### Session selection: 45 | 46 | * Tap a session or its `DOWNLOAD` button to download the session. 47 | * Tap `CANCEL` during a download to cancel the download. 48 | * Tap `DELETE` to remove the downloaded video from the filesystem. 49 | * Tap a session with a downloaded video to consume the session. 50 | 51 | #### Session consumption: 52 | 53 | * Drag either the transformer, the transcript, or the filmstrip to scrub the video. 54 | * Tap a caption in the transcript to scroll that caption to the transformer. 55 | * After dragging the transformer, you'll see a blue line indicating the transformer's anchor point. Tap the transformer to scroll it and the transcript back to the anchor point. 56 | * Tap-and-drag the transformer to move its anchor point. 57 | * Double tap the transformer to play/pause the video. 58 | * Tap the video to show the linear video scrubber and back button. 59 | 60 | #### Putting it all together 61 | 62 | * Look at the filmstrip to quickly spot the video frame that provides the best presentation context for the transcript lines you're about to read. 63 | * Use the transformer to scrub the video to that frame, then read the transcript. 64 | * Tap the transformer to scroll things back to the anchor point (assuming it's still near the top). 65 | * Wash, rinse, repeat. 66 | -------------------------------------------------------------------------------- /Resources/screenshots/majorinput.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/Resources/screenshots/majorinput.png -------------------------------------------------------------------------------- /Resources/screenshots/shelf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/Resources/screenshots/shelf.png -------------------------------------------------------------------------------- /Resources/screenshots/tour.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/Resources/screenshots/tour.gif -------------------------------------------------------------------------------- /Tools/Scripts/generate-acknowledgements.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | Tools/bin/license-plist --output-path MajorInput/Settings.bundle --force --suppress-opening-directory --add-version-numbers 3 | -------------------------------------------------------------------------------- /Tools/bin/license-plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlwimi/major-input/7f8ad1cd87e37ed4ca5fe60aadc5dbcf383812f7/Tools/bin/license-plist -------------------------------------------------------------------------------- /Tools/bin/license-plist.LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Masayuki Ono 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /license_plist.yml: -------------------------------------------------------------------------------- 1 | exclude: 2 | - wwdc-session-transcripts 3 | --------------------------------------------------------------------------------