├── .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 | 
10 |
11 | # Major Input
12 |
13 | a novel iPad UI for reading WWDC session transcripts alongside the video/presentation context
14 |
15 | 
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 |
--------------------------------------------------------------------------------