├── VideoTimelineView ├── movie.mov ├── VideoTimelineView │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ ├── SceneDelegate.swift │ ├── ViewController.swift │ └── VideoTimelineView │ │ ├── CenterLine.swift │ │ ├── TimelineScroller.swift │ │ ├── TimelineMeasure.swift │ │ ├── VideoTimelineView.swift │ │ ├── FrameImagesView.swift │ │ ├── TimelineView.swift │ │ └── TrimView.swift ├── VideoTimelineView.xcodeproj │ ├── xcuserdata │ │ └── tom.xcuserdatad │ │ │ ├── xcdebugger │ │ │ └── Breakpoints_v2.xcbkptlist │ │ │ └── xcschemes │ │ │ └── xcschememanagement.plist │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ ├── xcuserdata │ │ │ └── tom.xcuserdatad │ │ │ │ └── UserInterfaceState.xcuserstate │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── project.pbxproj ├── VideoTimelineViewTests │ ├── Info.plist │ └── VideoTimelineViewTests.swift └── VideoTimelineViewUITests │ ├── Info.plist │ └── VideoTimelineViewUITests.swift └── README.md /VideoTimelineView/movie.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tomohiro-Yamashita/VideoTimelineView/HEAD/VideoTimelineView/movie.mov -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView.xcodeproj/xcuserdata/tom.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView.xcodeproj/project.xcworkspace/xcuserdata/tom.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tomohiro-Yamashita/VideoTimelineView/HEAD/VideoTimelineView/VideoTimelineView.xcodeproj/project.xcworkspace/xcuserdata/tom.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView.xcodeproj/xcuserdata/tom.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | VideoTimelineView.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineViewTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineViewUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineViewTests/VideoTimelineViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoTimelineViewTests.swift 3 | // VideoTimelineViewTests 4 | // 5 | // Created by Tom on 2020/03/27. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import VideoTimelineView 11 | 12 | class VideoTimelineViewTests: XCTestCase { 13 | 14 | override func setUp() { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | } 17 | 18 | override func tearDown() { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() { 23 | // This is an example of a functional test case. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testPerformanceExample() { 28 | // This is an example of a performance test case. 29 | self.measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // VideoTimelineView 4 | // 5 | // Created by Tomohiro Yamashita on 2020/03/27. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | 15 | 16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 17 | // Override point for customization after application launch. 18 | return true 19 | } 20 | 21 | // MARK: UISceneSession Lifecycle 22 | 23 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 24 | // Called when a new scene session is being created. 25 | // Use this method to select a configuration to create the new scene with. 26 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 27 | } 28 | 29 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 30 | // Called when the user discards a scene session. 31 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 32 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 33 | } 34 | 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineViewUITests/VideoTimelineViewUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoTimelineViewUITests.swift 3 | // VideoTimelineViewUITests 4 | // 5 | // Created by Tom on 2020/03/27. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class VideoTimelineViewUITests: XCTestCase { 12 | 13 | override func setUp() { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | 16 | // In UI tests it is usually best to stop immediately when a failure occurs. 17 | continueAfterFailure = false 18 | 19 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 20 | } 21 | 22 | override func tearDown() { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | } 25 | 26 | func testExample() { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use recording to get started writing UI tests. 32 | // Use XCTAssert and related functions to verify your tests produce the correct results. 33 | } 34 | 35 | func testLaunchPerformance() { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/Base.lproj/Main.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 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/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 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VideoTimelineView 2 | Video timeline UI for iOS Apps 3 | - Zoom in/out with pinch 4 | - Scrub with sound 5 | - Repeat playing in the trimmer 6 | 7 | ![screenshot](http://tomohiroyamashita.web.fc2.com/github/images/videotimelineview01.png) 8 | 9 | 10 | ## Usage 11 | Copy the VideoTimelineView folder in this project to yours 12 | 13 | 14 | - To setup 15 | ``` 16 | let videoTimelineView = VideoTimelineView() 17 | videoTimelineView.frame = timelineRect 18 | videoTimelineView.new(asset:AVAsset(url:videoURL)) 19 | view.addSubview(videoTimelineView) 20 | ``` 21 | 22 | 23 | - To get actions from VideoTimelineView 24 | Add TimelinePlayStatusReceiver protocol in your ViewController 25 | ``` 26 | class ViewController: UIViewController, TimelinePlayStatusReceiver { 27 | ``` 28 | And set viewController as receiver 29 | ``` 30 | videoTimelineView.playStatusReceiver = self 31 | ``` 32 | 33 | - Get actions 34 | Implement these functions in your viewController 35 | ``` 36 | func videoTimelineStopped() 37 | func videoTimelineMoved() 38 | func videoTimelineTrimChanged() 39 | ``` 40 | 41 | To get values of the trimmer 42 | ``` 43 | let trim = videoTimelineView.currentTrim() 44 | print("start time: \(trim.start)") 45 | print("end time: \(trim.end)") 46 | ``` 47 | 48 | 49 | - To control 50 | ``` 51 | //Repeat in the trimmer 52 | videoTimelineView.repeatOn = true 53 | 54 | //If set in false, the trimmer will be ignored 55 | videoTimelineView.setTrimIsEnabled(true) 56 | 57 | //Hide trimmer 58 | videoTimelineView.setTrimmerIsHidden(true) 59 | 60 | //Go to 0s with animation 61 | videoTimelineView.moveTo(0, animate:true) 62 | 63 | //Set trimmer from 5 to 10 with animation and move to 3 64 | videoTimelineView.setTrim(start:5, end:10, seek:3, animate:true) 65 | ``` 66 | ## Example Product 67 | The app with VideoTimeLlineView [on the AppStore](https://apps.apple.com/us/app/examplay/id1509013277?ls=1)(Free). 68 | 69 | ## License 70 | [MIT](https://choosealicense.com/licenses/mit/) 71 | 72 | ## Contact 73 | [E-mail](tomo_dev@sockettv.org), [twitter](https://twitter.com/DevYamashita), [Facebook](https://www.facebook.com/TomohiroYamashitaApps/) 74 | 75 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | UISceneStoryboardFile 37 | Main 38 | 39 | 40 | 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIMainStoryboardFile 45 | Main 46 | UIRequiredDeviceCapabilities 47 | 48 | armv7 49 | 50 | UISupportedInterfaceOrientations 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationLandscapeLeft 54 | UIInterfaceOrientationLandscapeRight 55 | 56 | UISupportedInterfaceOrientations~ipad 57 | 58 | UIInterfaceOrientationPortrait 59 | UIInterfaceOrientationPortraitUpsideDown 60 | UIInterfaceOrientationLandscapeLeft 61 | UIInterfaceOrientationLandscapeRight 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // VideoTimelineView 4 | // 5 | // Created by Tomohiro Yamashita on 2020/03/27. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 12 | 13 | var window: UIWindow? 14 | 15 | 16 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 17 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 18 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 19 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 20 | guard let _ = (scene as? UIWindowScene) else { return } 21 | } 22 | 23 | func sceneDidDisconnect(_ scene: UIScene) { 24 | // Called as the scene is being released by the system. 25 | // This occurs shortly after the scene enters the background, or when its session is discarded. 26 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 27 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 28 | } 29 | 30 | func sceneDidBecomeActive(_ scene: UIScene) { 31 | // Called when the scene has moved from an inactive state to an active state. 32 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 33 | } 34 | 35 | func sceneWillResignActive(_ scene: UIScene) { 36 | // Called when the scene will move from an active state to an inactive state. 37 | // This may occur due to temporary interruptions (ex. an incoming phone call). 38 | } 39 | 40 | func sceneWillEnterForeground(_ scene: UIScene) { 41 | // Called as the scene transitions from the background to the foreground. 42 | // Use this method to undo the changes made on entering the background. 43 | } 44 | 45 | func sceneDidEnterBackground(_ scene: UIScene) { 46 | // Called as the scene transitions from the foreground to the background. 47 | // Use this method to save data, release shared resources, and store enough scene-specific state information 48 | // to restore the scene back to its current state. 49 | } 50 | 51 | 52 | } 53 | 54 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // VideoTimelineView 4 | // 5 | // Created by Tomohiro Yamashita on 2020/03/27. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AVFoundation 11 | 12 | class ViewController: UIViewController, TimelinePlayStatusReceiver { 13 | 14 | var videoTimelineView:VideoTimelineView! 15 | let playerView = UIView() 16 | var playerLayer:AVPlayerLayer! 17 | let playButton = UIButton() 18 | 19 | 20 | override func viewDidLoad() { 21 | 22 | super.viewDidLoad() 23 | 24 | 25 | 26 | ///Prepare videoTimelineView 27 | let asset = AVAsset(url: URL(fileURLWithPath: Bundle.main.path(forResource: "movie", ofType:"mov")!)) 28 | 29 | videoTimelineView = VideoTimelineView() 30 | videoTimelineView.frame = layout().timeline 31 | videoTimelineView.new(asset:asset) 32 | videoTimelineView.playStatusReceiver = self 33 | 34 | videoTimelineView.repeatOn = true 35 | videoTimelineView.setTrimIsEnabled(true) 36 | videoTimelineView.setTrimmerIsHidden(false) 37 | view.addSubview(videoTimelineView) 38 | 39 | videoTimelineView.moveTo(0, animate:false) 40 | videoTimelineView.setTrim(start:5, end:10, seek:nil, animate:false) 41 | 42 | 43 | 44 | ///Prepare playerView 45 | let player = videoTimelineView.player!//You can also set another player like below 46 | //let player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) 47 | //videoTimelineView.player = player 48 | 49 | let playerFrame = layout().player 50 | playerLayer = AVPlayerLayer(player: player) 51 | playerLayer.frame.size = playerFrame.size 52 | playerLayer.videoGravity = AVLayerVideoGravity.resizeAspect 53 | player.actionAtItemEnd = AVPlayer.ActionAtItemEnd.none 54 | 55 | playerView.frame = playerFrame 56 | playerView.layer.addSublayer(playerLayer) 57 | view.addSubview(playerView) 58 | 59 | 60 | 61 | 62 | ///Prepare playButton 63 | playButton.frame = layout().button 64 | playButton.addTarget(self,action:#selector(self.playButtonAction), for:.touchUpInside) 65 | setPlayButtonImage() 66 | view.addSubview(playButton) 67 | 68 | } 69 | 70 | override func viewDidLayoutSubviews() { 71 | videoTimelineView.frame = layout().timeline 72 | playerView.frame = layout().player 73 | playerLayer.frame.size = playerView.frame.size 74 | playButton.frame = layout().button 75 | videoTimelineView.viewDidLayoutSubviews() 76 | } 77 | 78 | func layout() -> (timeline:CGRect, player:CGRect, button:CGRect) { 79 | let timeline = CGRect(x: 0,y:view.frame.size.height * 0.6, width:view.frame.size.width, height:view.frame.size.height / 6) 80 | let player = CGRect(x:0, y:40, width:view.frame.size.width, height:view.frame.size.height * 0.4) 81 | let button = CGRect(x:(view.frame.size.width - 60) / 2, y:view.frame.size.height - 60, width:60, height:60) 82 | return (timeline, player, button) 83 | } 84 | 85 | var playButtonStatus:Bool = false 86 | @objc func playButtonAction() { 87 | playButtonStatus = !playButtonStatus 88 | if playButtonStatus { 89 | videoTimelineView.play() 90 | } else { 91 | videoTimelineView.stop() 92 | } 93 | setPlayButtonImage() 94 | } 95 | 96 | func setPlayButtonImage() { 97 | if playButtonStatus { 98 | self.playButton.setImage(UIImage(systemName: "pause.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .light, scale: .medium)), for: .normal) 99 | } else { 100 | self.playButton.setImage(UIImage(systemName: "play.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .light, scale: .medium)), for: .normal) 101 | } 102 | } 103 | 104 | func videoTimelineStopped() { 105 | playButtonStatus = false 106 | setPlayButtonImage() 107 | } 108 | 109 | func videoTimelineMoved() { 110 | let time = videoTimelineView.currentTime 111 | print("time: \(time)") 112 | } 113 | 114 | func videoTimelineTrimChanged() { 115 | let trim = videoTimelineView.currentTrim() 116 | print("start time: \(trim.start)") 117 | print("end time: \(trim.end)") 118 | } 119 | 120 | 121 | } 122 | 123 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/VideoTimelineView/CenterLine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CenterLine.swift 3 | // Examplay 4 | // 5 | // Created by Tomohiro Yamashita on 2020/03/09. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AVFoundation 11 | 12 | class CenterLine: UIView { 13 | 14 | var mainView:VideoTimelineView! 15 | 16 | let timeLabel = UILabel() 17 | var parentView:TimelineView? = nil 18 | var duration:Float64 = 0 19 | var currentTime:Float64 = 0 20 | let margin:CGFloat = 6 21 | 22 | override init (frame: CGRect) { 23 | super.init(frame: frame) 24 | 25 | self.backgroundColor = .clear 26 | self.isUserInteractionEnabled = false 27 | } 28 | 29 | required init(coder aDecoder: NSCoder) { 30 | fatalError("CenterLine init(coder:) has not been implemented") 31 | } 32 | 33 | func configure(parent:TimelineView) { 34 | parentView = parent 35 | self.setNeedsDisplay() 36 | 37 | self.timeLabel.adjustsFontSizeToFitWidth = true 38 | self.timeLabel.textAlignment = .center 39 | 40 | self.timeLabel.text = String("00:00.00") 41 | 42 | 43 | self.addSubview(timeLabel) 44 | } 45 | 46 | func update() { 47 | self.setNeedsDisplay() 48 | let textMargin = margin + 3 49 | self.timeLabel.frame.size.width = self.bounds.size.width - textMargin * 2 50 | self.timeLabel.frame.origin = CGPoint(x:textMargin,y:0) 51 | self.timeLabel.textColor = UIColor(hue: 0.94, saturation:0.68, brightness:0.95, alpha: 0.94) 52 | setTimeText() 53 | timeLabel.font = UIFont(name:"HelveticaNeue-CondensedBold" ,size:timeLabel.frame.size.height * 0.9) 54 | 55 | } 56 | 57 | 58 | override func draw(_ rect: CGRect) { 59 | 60 | 61 | gradient() 62 | 63 | let context = UIGraphicsGetCurrentContext()! 64 | context.saveGState() 65 | context.setShadow(offset: CGSize(width: 0,height: 0), blur: 6, color: UIColor(hue: 0, saturation:0, brightness:0.0, alpha: 0.30).cgColor) 66 | 67 | UIColor(hue: 0, saturation:0, brightness:1, alpha: 0.92).setFill() 68 | 69 | let labelRect = CGRect(x: margin,y: 0.3,width: self.frame.size.width - margin * 2,height: timeLabel.frame.size.height + 1) 70 | let rectPath = UIBezierPath(roundedRect:labelRect, cornerRadius:timeLabel.frame.size.height) 71 | rectPath.fill() 72 | context.restoreGState() 73 | 74 | 75 | 76 | let path = UIBezierPath() 77 | path.move(to: CGPoint(x: self.frame.size.width / 2 , y:timeLabel.frame.size.height)) 78 | path.addLine(to: CGPoint(x:self.frame.size.width / 2 , y:self.frame.size.height)) 79 | path.lineWidth = 1.4 80 | UIColor(hue: 0, saturation:0, brightness:1.0, alpha: 0.7).setStroke() 81 | path.stroke() 82 | 83 | } 84 | 85 | 86 | func gradient() { 87 | let width = self.frame.size.width 88 | 89 | 90 | let context = UIGraphicsGetCurrentContext()! 91 | 92 | let startColor = UIColor(hue: 0, saturation:0, brightness:0.0, alpha: 0.06).cgColor 93 | let endColor = UIColor.clear.cgColor 94 | let colors = [startColor, endColor] as CFArray 95 | 96 | let locations = [0, 1] as [CGFloat] 97 | 98 | let space = CGColorSpaceCreateDeviceRGB() 99 | 100 | let gradient = CGGradient(colorsSpace: space, colors: colors, locations: locations)! 101 | context.drawLinearGradient(gradient, start: CGPoint(x:(width / 2) - 0.7, y:0), end: CGPoint(x: (width / 2) - 4, y: 0), options: []) 102 | context.drawLinearGradient(gradient, start: CGPoint(x:(width / 2) + 0.7, y:0), end: CGPoint(x: (width / 2) + 4, y: 0), options: []) 103 | } 104 | 105 | var ignoreSendScrollToParent = false 106 | func setScrollPoint(_ scrollPoint:CGFloat) { 107 | currentTime = Float64(scrollPoint) * duration 108 | setTimeText() 109 | if !ignoreSendScrollToParent { 110 | if let parent = parentView { 111 | parent.moved(currentTime) 112 | } 113 | } 114 | 115 | mainView!.currentTime = currentTime 116 | ignoreSendScrollToParent = false 117 | } 118 | 119 | func setTimeText() { 120 | let minute = Int(currentTime / 60) 121 | let second = (currentTime - Float64(minute) * 60) 122 | let milliSec = Int((second - Float64(Int(second))) * 100) 123 | let text = String(format: "%02d:%02d.%02d", minute, (Int(second)), milliSec) 124 | timeLabel.text = text 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/VideoTimelineView/TimelineScroller.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineScroller.swift 3 | // Examplay 4 | // 5 | // Created by Tomohiro Yamashita on 2020/03/01. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TimelineScroller: UIScrollView { 12 | 13 | var parentView:TimelineView? = nil 14 | let frameImagesView = FrameImagesView() 15 | let measure = TimelineMeasure() 16 | let trimView = TrimView() 17 | 18 | 19 | override init (frame: CGRect) { 20 | super.init(frame: frame) 21 | 22 | self.isScrollEnabled = true 23 | self.isDirectionalLockEnabled = true 24 | self.showsHorizontalScrollIndicator = false 25 | self.showsVerticalScrollIndicator = false 26 | self.bounces = false 27 | self.decelerationRate = .fast 28 | self.isMultipleTouchEnabled = true 29 | self.delaysContentTouches = false 30 | self.frameImagesView.parentScroller = self 31 | self.addSubview(frameImagesView) 32 | self.measure.parentScroller = self 33 | self.measure.frameImagesView = self.frameImagesView 34 | self.measure.backgroundColor = .clear 35 | } 36 | 37 | required init(coder aDecoder: NSCoder) { 38 | fatalError("TimelineScroller init(coder:) has not been implemented") 39 | } 40 | 41 | func configure(parent:TimelineView) { 42 | parentView = parent 43 | trimView.configure(parent, scroller:self) 44 | } 45 | 46 | func reset() { 47 | frameImagesView.reset() 48 | } 49 | 50 | var ignoreScrollViewDidScroll:Bool = false 51 | func setContentWidth(_ width:CGFloat) { 52 | setContentWidth(width, setOrigin:true) 53 | } 54 | 55 | func setContentWidth(_ width: CGFloat, setOrigin:Bool) { 56 | ignoreScrollViewDidScroll = true 57 | self.contentSize = CGSize(width:width + self.frame.size.width, height:self.frame.size.height) 58 | frameImagesView.frame.size.width = width 59 | //measure.frame.size.width = width 60 | 61 | if setOrigin { 62 | let halfVisibleWidth = self.frame.size.width / 2 63 | //frameImagesView.frame.size.height = self.frame.size.height 64 | frameImagesView.frame.origin.x = halfVisibleWidth 65 | } 66 | //measure.frame.origin.x = halfVisibleWidth 67 | measure.setNeedsDisplay() 68 | frameImagesView.displayFrames() 69 | } 70 | 71 | var measureHeight:CGFloat = 5 72 | func coordinate() { 73 | 74 | let measureMin:CGFloat = 10 75 | 76 | let wholeHeight = self.frame.size.height 77 | measureHeight = wholeHeight * 0.2 78 | if measureHeight < measureMin { 79 | measureHeight = measureMin 80 | } 81 | frameImagesView.frame.size.height = wholeHeight - measureHeight 82 | if frameImagesView.animating == false { 83 | frameImagesView.frame.origin = CGPoint(x: self.frame.size.width / 2,y: measureHeight) 84 | } 85 | measure.frame.size.height = measureHeight 86 | //frameImagesView.layout() 87 | 88 | trimView.frame = self.frame 89 | 90 | } 91 | 92 | func visibleRect() -> CGRect { 93 | var visibleRect = frame 94 | visibleRect.origin = contentOffset 95 | if contentSize.width < frame.size.width { 96 | visibleRect.size.width = contentSize.width 97 | } 98 | if contentSize.height < frame.size.height { 99 | visibleRect.size.height = contentSize.height 100 | } 101 | if zoomScale != 1 { 102 | let theScale = 1.0 / zoomScale; 103 | visibleRect.origin.x *= theScale; 104 | visibleRect.origin.y *= theScale; 105 | visibleRect.size.width *= theScale; 106 | visibleRect.size.height *= theScale; 107 | } 108 | return visibleRect 109 | } 110 | 111 | 112 | //MARK: - scroll 113 | func setScrollPoint(_ scrollPoint:CGFloat) { 114 | let offset = (scrollPoint * frameImagesView.frame.size.width) + (self.frame.size.width / 2) 115 | 116 | self.contentOffset.x = offset - (self.frame.size.width / 2) 117 | } 118 | 119 | 120 | //MARK: - Touch Events 121 | var allTouches = [UITouch]() 122 | 123 | override open func touchesBegan(_ touches: Set, with event: UIEvent?) 124 | { 125 | for touch in touches { 126 | if !allTouches.contains(touch) { 127 | allTouches += [touch] 128 | } 129 | if !parentView!.allTouches.contains(touch) { 130 | parentView!.allTouches += [touch] 131 | } 132 | } 133 | 134 | } 135 | 136 | override open func touchesMoved(_ touches: Set, with event: UIEvent?) 137 | { 138 | if parentView!.allTouches.count == 2 { 139 | if parentView!.pinching { 140 | parentView!.updatePinch() 141 | } else { 142 | parentView!.startPinch() 143 | } 144 | } 145 | } 146 | 147 | override open func touchesEnded(_ touches: Set, with event: UIEvent?) 148 | { 149 | for touch in touches { 150 | if let index = allTouches.firstIndex(of:touch) { 151 | allTouches.remove(at: index) 152 | } 153 | if let index = parentView!.allTouches.firstIndex(of:touch) { 154 | parentView!.allTouches.remove(at: index) 155 | } 156 | } 157 | if parentView!.pinching && parentView!.allTouches.count < 2 { 158 | parentView!.endPinch() 159 | } 160 | } 161 | 162 | override open func touchesCancelled(_ touches: Set, with event: UIEvent?) 163 | { 164 | for touch in touches { 165 | if let index = allTouches.firstIndex(of:touch) { 166 | allTouches.remove(at: index) 167 | } 168 | if let index = parentView!.allTouches.firstIndex(of:touch) { 169 | parentView!.allTouches.remove(at: index) 170 | } 171 | } 172 | 173 | if parentView!.pinching { 174 | parentView!.endPinch() 175 | } 176 | } 177 | } 178 | 179 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/VideoTimelineView/TimelineMeasure.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineMeasure.swift 3 | // Examplay 4 | // 5 | // Created by Tomohiro Yamashita on 2020/03/08. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TimelineMeasure: UIView { 12 | 13 | var unitSize:CGFloat = 100 14 | var frameImagesView:FrameImagesView? = nil 15 | var parentScroller:TimelineScroller? = nil 16 | var parentView:TimelineView? = nil 17 | var stringColor:UIColor = UIColor(hue: 0.0, saturation:0.0, brightness:0.35, alpha: 1) 18 | var animating = false 19 | 20 | override init (frame: CGRect) { 21 | super.init(frame: frame) 22 | 23 | self.isMultipleTouchEnabled = true 24 | self.isUserInteractionEnabled = true 25 | } 26 | 27 | required init(coder aDecoder: NSCoder) { 28 | fatalError("TimelineMeasure init(coder:) has not been implemented") 29 | } 30 | 31 | 32 | 33 | override func draw(_ rect: CGRect) { 34 | 35 | if frameImagesView == nil { 36 | return 37 | } 38 | 39 | let max = frameImagesView!.maxWidth 40 | var width = frameImagesView!.frame.size.width 41 | if animating { 42 | if let layer = frameImagesView!.layer.presentation() { 43 | width = layer.frame.size.width 44 | } 45 | } 46 | if width == 0 { 47 | return 48 | } 49 | var unit = (width / max) * unitSize 50 | 51 | let unitWidth = CGFloat(80) 52 | var division = 0 53 | while (unit <= unitWidth) { 54 | unit *= 2 55 | division += 1 56 | if division > 1000 { 57 | break 58 | } 59 | } 60 | let unitLength = pow(2,Double(division + 1)) / 2 61 | func index(_ position:CGFloat) -> Int { 62 | return Int(position / unit) 63 | } 64 | 65 | let paragraphStyle = NSMutableParagraphStyle() 66 | paragraphStyle.alignment = .center 67 | 68 | let attributes = [NSAttributedString.Key.font: UIFont(name: "HelveticaNeue", size: self.frame.size.height * 0.7)!, NSAttributedString.Key.paragraphStyle: paragraphStyle, NSAttributedString.Key.foregroundColor: stringColor] 69 | 70 | var string = "" 71 | 72 | var visibleRect = rect 73 | if let parent = parentScroller { 74 | 75 | if animating { 76 | if let layer = parent.layer.presentation() { 77 | let offset = layer.bounds.origin 78 | visibleRect.origin = offset 79 | visibleRect.size = parent.frame.size 80 | } else { 81 | visibleRect = parent.visibleRect() 82 | } 83 | } else { 84 | visibleRect = parent.visibleRect() 85 | } 86 | } 87 | let startIndex = index(visibleRect.origin.x) - 10 * (division + 1) 88 | let endIndex = index(visibleRect.origin.x + visibleRect.size.width) 89 | 90 | for index in startIndex ... endIndex { 91 | if index < 0 { 92 | continue 93 | } 94 | let position = CGFloat(index) * unit - visibleRect.origin.x + (visibleRect.size.width / 2) 95 | if division == 0 { 96 | let path = UIBezierPath() 97 | path.move(to: CGPoint(x: position + (unit / 2 ) - (unitWidth / 4) , y:(self.frame.size.height / 2))) 98 | path.addLine(to: CGPoint(x: position + (unit / 2) + (unitWidth / 4), y:(self.frame.size.height / 2))) 99 | path.lineWidth = 1 100 | stringColor.setStroke() 101 | path.stroke() 102 | } else { 103 | for i in 1 ... 3 { 104 | let point = position + ((unit / 4 ) * CGFloat(i)) 105 | let r:CGFloat = 0.8 106 | let pointRect = CGRect(x: point - r, y: (self.frame.size.height / 2) - r, width: r * 2,height: r * 2) 107 | let path = UIBezierPath(ovalIn:pointRect) 108 | stringColor.setFill() 109 | path.fill() 110 | } 111 | } 112 | 113 | let length = unitLength * Double(index) 114 | let minute = Int(length / 60) 115 | let second = Int(length - Double(minute * 60)) 116 | 117 | string = String(format: "%02d:%02d", minute, (Int(second))) 118 | 119 | string.draw(with: CGRect(x: position - (unitWidth / 2), y:0, width: unitWidth, height: self.frame.size.height), options: .usesLineFragmentOrigin, attributes: attributes, context: nil) 120 | 121 | 122 | } 123 | } 124 | 125 | //MARK: - timer for animation 126 | var animationTimer = Timer() 127 | func startAnimation() { 128 | animationTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(self.animate(_:)), userInfo: nil, repeats: true) 129 | RunLoop.main.add(animationTimer, forMode:RunLoop.Mode.common) 130 | animating = true 131 | } 132 | 133 | func stopAnimation() { 134 | animating = false 135 | self.setNeedsDisplay() 136 | animationTimer.invalidate() 137 | } 138 | 139 | @objc func animate(_ timer:Timer) { 140 | if animating == false { 141 | return 142 | } 143 | self.setNeedsDisplay() 144 | } 145 | 146 | 147 | 148 | //MARK: - Touch Events 149 | var allTouches = [UITouch]() 150 | 151 | override open func touchesBegan(_ touches: Set, with event: UIEvent?) 152 | { 153 | for touch in touches { 154 | if !allTouches.contains(touch) { 155 | allTouches += [touch] 156 | } 157 | if !parentView!.allTouches.contains(touch) { 158 | parentView!.allTouches += [touch] 159 | } 160 | } 161 | } 162 | 163 | 164 | override open func touchesMoved(_ touches: Set, with event: UIEvent?) 165 | { 166 | if parentView!.allTouches.count == 2 { 167 | if parentView!.pinching { 168 | parentView!.updatePinch() 169 | } else { 170 | parentView!.startPinch() 171 | } 172 | } 173 | } 174 | 175 | override open func touchesEnded(_ touches: Set, with event: UIEvent?) 176 | { 177 | for touch in touches { 178 | if let index = allTouches.firstIndex(of:touch) { 179 | allTouches.remove(at: index) 180 | } 181 | if let index = parentView!.allTouches.firstIndex(of:touch) { 182 | parentView!.allTouches.remove(at: index) 183 | } 184 | } 185 | if parentView!.pinching && parentView!.allTouches.count < 2 { 186 | parentView!.endPinch() 187 | } 188 | } 189 | 190 | override open func touchesCancelled(_ touches: Set, with event: UIEvent?) 191 | { 192 | for touch in touches { 193 | if let index = allTouches.firstIndex(of:touch) { 194 | allTouches.remove(at: index) 195 | } 196 | if let index = parentView!.allTouches.firstIndex(of:touch) { 197 | parentView!.allTouches.remove(at: index) 198 | } 199 | } 200 | 201 | if parentView!.pinching { 202 | parentView!.endPinch() 203 | } 204 | } 205 | } 206 | 207 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/VideoTimelineView/VideoTimelineView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoTimelineView.swift 3 | // VideoTimelineView 4 | // 5 | // Created by Tomohiro Yamashita on 2020/03/28. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AVFoundation 11 | 12 | protocol TimelinePlayStatusReceiver: class { 13 | func videoTimelineStopped() 14 | func videoTimelineMoved() 15 | func videoTimelineTrimChanged() 16 | } 17 | 18 | 19 | struct VideoTimelineTrim { 20 | var start:Float64 21 | var end:Float64 22 | } 23 | 24 | class VideoTimelineView: UIView { 25 | 26 | public private(set) var asset:AVAsset? = nil 27 | var player:AVPlayer? = nil 28 | 29 | weak var playStatusReceiver:TimelinePlayStatusReceiver? = nil 30 | 31 | var repeatOn:Bool = false 32 | 33 | public private(set) var trimEnabled:Bool = false 34 | 35 | 36 | 37 | var currentTime:Float64 = 0 38 | public private(set) var duration:Float64 = 0 39 | 40 | public private(set) var audioPlayer:AVPlayer! 41 | public private(set) var audioPlayer2:AVPlayer! 42 | 43 | let timelineView = TimelineView() 44 | 45 | override init (frame: CGRect) { 46 | super.init(frame: frame) 47 | timelineView.mainView = self 48 | timelineView.centerLine.mainView = self 49 | timelineView.scroller.frameImagesView.mainView = self 50 | timelineView.scroller.trimView.mainView = self 51 | 52 | self.addSubview(timelineView) 53 | } 54 | 55 | required init(coder aDecoder: NSCoder) { 56 | fatalError("MainView init(coder:) has not been implemented") 57 | } 58 | 59 | 60 | func viewDidLayoutSubviews() { 61 | coordinate() 62 | } 63 | 64 | func coordinate() { 65 | timelineView.coordinate() 66 | } 67 | 68 | func new(asset newAsset:AVAsset?) { 69 | if let new = newAsset { 70 | asset = new 71 | duration = CMTimeGetSeconds(new.duration) 72 | player = AVPlayer(playerItem: AVPlayerItem(asset: asset!)) 73 | audioPlayer = AVPlayer(playerItem: AVPlayerItem(asset: asset!)) 74 | audioPlayer.volume = 1.0 75 | audioPlayer2 = AVPlayer(playerItem: AVPlayerItem(asset: asset!)) 76 | audioPlayer2.volume = 1.0 77 | timelineView.newMovieSet() 78 | } 79 | } 80 | 81 | 82 | func setTrim(start:Float64, end:Float64, seek:Float64?, animate:Bool) { 83 | 84 | var seekTime = currentTime 85 | if let time = seek { 86 | seekTime = time 87 | } 88 | if animate { 89 | timelineView.setTrimWithAnimation(trim:VideoTimelineTrim(start:start, end:end), time:seekTime) 90 | } else { 91 | timelineView.setTrim(start:start, end:end) 92 | if seek != nil { 93 | moveTo(seek!, animate:animate) 94 | } 95 | } 96 | } 97 | 98 | func setTrimIsEnabled(_ enabled:Bool) { 99 | trimEnabled = enabled 100 | timelineView.setTrimmerStatus(enabled:enabled) 101 | } 102 | 103 | func setTrimmerIsHidden(_ hide:Bool) { 104 | timelineView.setTrimmerVisible(!hide) 105 | } 106 | 107 | func currentTrim() -> (start:Float64, end:Float64) { 108 | let trim = timelineView.currentTrim() 109 | return (trim.start,trim.end) 110 | } 111 | 112 | func moveTo(_ time:Float64, animate:Bool) { 113 | if animate { 114 | 115 | } else { 116 | accurateSeek(time, scrub:false) 117 | timelineView.setCurrentTime(time, force:true) 118 | } 119 | } 120 | 121 | //MARK: - seeking 122 | var previousSeektime:Float64 = 0 123 | func timelineIsMoved(_ currentTime:Float64, scrub:Bool) { 124 | let move = abs(currentTime - previousSeektime) 125 | let seekTolerance = CMTimeMakeWithSeconds(move, preferredTimescale:100) 126 | 127 | if player != nil { 128 | player!.seek(to:CMTimeMakeWithSeconds(currentTime , preferredTimescale:100), toleranceBefore:seekTolerance,toleranceAfter:seekTolerance) 129 | } 130 | previousSeektime = currentTime 131 | if scrub { 132 | audioScrub() 133 | } 134 | } 135 | 136 | func accurateSeek(_ currentTime:Float64, scrub:Bool) { 137 | previousSeektime = currentTime 138 | timelineIsMoved(currentTime, scrub:scrub) 139 | } 140 | 141 | var scrubed1 = Date() 142 | var scrubed2 = Date() 143 | var canScrub1 = true 144 | var canScrub2 = true 145 | func audioScrub() { 146 | if player == nil { 147 | return 148 | } 149 | if scrubed2.timeIntervalSinceNow < -0.16 && canScrub1 { 150 | canScrub1 = false 151 | self.scrubed1 = Date() 152 | DispatchQueue.main.async { 153 | if self.audioPlayer.timeControlStatus == .playing { 154 | self.audioPlayer.pause() 155 | self.canScrub1 = true 156 | } else { 157 | self.audioPlayer.seek(to: self.player!.currentTime()) 158 | self.audioPlayer.play() 159 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { 160 | self.audioPlayer.pause() 161 | self.audioPlayer.seek(to: self.player!.currentTime()) 162 | self.canScrub1 = true 163 | } 164 | } 165 | 166 | } 167 | } 168 | if scrubed1.timeIntervalSinceNow < -0.16 && canScrub2 { 169 | canScrub2 = false 170 | self.scrubed2 = Date() 171 | DispatchQueue.main.async { 172 | if self.audioPlayer2.timeControlStatus == .playing { 173 | self.audioPlayer2.pause() 174 | self.canScrub2 = true 175 | } else { 176 | self.audioPlayer2.seek(to: self.player!.currentTime()) 177 | self.audioPlayer2.play() 178 | 179 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { 180 | self.audioPlayer2.pause() 181 | self.audioPlayer2.seek(to: self.player!.currentTime()) 182 | self.canScrub2 = true 183 | } 184 | } 185 | } 186 | } 187 | } 188 | 189 | 190 | //MARK: - play 191 | var playerTimer = Timer() 192 | @objc dynamic var playing = false 193 | func play() { 194 | if asset == nil { 195 | return 196 | } 197 | let currentTime = timelineView.centerLine.currentTime 198 | let reached = timeReachesEnd(currentTime) 199 | 200 | if reached.trimEnd { 201 | accurateSeek(timelineView.currentTrim().start, scrub:false) 202 | timelineView.manualScrolledAfterEnd = false 203 | } else if reached.movieEnd { 204 | accurateSeek(0, scrub:false) 205 | timelineView.manualScrolledAfterEnd = false 206 | } 207 | if player != nil { 208 | player!.play() 209 | } 210 | playerTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(self.playerTimerAction(_:)), userInfo: nil, repeats: true) 211 | RunLoop.main.add(playerTimer, forMode:RunLoop.Mode.common) 212 | playing = true 213 | } 214 | 215 | func stop() { 216 | playing = false 217 | if asset == nil || player == nil { 218 | return 219 | } 220 | player!.pause() 221 | playerTimer.invalidate() 222 | 223 | if let receiver = playStatusReceiver { 224 | receiver.videoTimelineStopped() 225 | } 226 | } 227 | 228 | var reachFlg = false 229 | @objc func playerTimerAction(_ timer:Timer) { 230 | if player == nil { 231 | return 232 | } 233 | var currentPlayerTime = CMTimeGetSeconds(player!.currentTime()) 234 | 235 | let trim = timelineView.currentTrim() 236 | let reached = timeReachesEnd(currentPlayerTime) 237 | if timelineView.inAction() { 238 | if player!.timeControlStatus == .playing { 239 | player!.pause() 240 | } 241 | } else if reached.reached { 242 | if repeatOn && reached.trimEnd { 243 | 244 | if player!.timeControlStatus == .playing { 245 | player!.pause() 246 | } 247 | currentPlayerTime = trim.start 248 | accurateSeek(currentPlayerTime, scrub:false) 249 | reachFlg = true 250 | 251 | } else { 252 | stop() 253 | } 254 | timelineView.setCurrentTime(currentPlayerTime,force:false) 255 | timelineView.manualScrolledAfterEnd = false 256 | } else if timelineView.animating == false { 257 | timelineView.setCurrentTime(currentPlayerTime,force:false) 258 | if player!.timeControlStatus == .paused { 259 | player!.play() 260 | } 261 | if reachFlg { 262 | if let receiver = playStatusReceiver { 263 | receiver.videoTimelineMoved() 264 | } 265 | reachFlg = false 266 | } 267 | } 268 | } 269 | 270 | func timeReachesEnd(_ time:Float64) -> (reached:Bool, trimEnd:Bool, movieEnd:Bool) { 271 | var reached = false 272 | var trimEnd = false 273 | var movieEnd = false 274 | if asset != nil { 275 | let duration = CMTimeGetSeconds(asset!.duration) 276 | let trimTimeEnd = timelineView.currentTrim().end 277 | if (time >= trimTimeEnd && timelineView.manualScrolledAfterEnd == false && trimEnabled) { 278 | trimEnd = true 279 | reached = true 280 | } 281 | if time >= duration { 282 | if trimTimeEnd < duration { 283 | trimEnd = false 284 | } 285 | movieEnd = true 286 | reached = true 287 | } 288 | } 289 | return (reached, trimEnd, movieEnd) 290 | } 291 | 292 | 293 | //MARK: - 294 | func resizeHeightKeepRatio(_ size:CGSize, height:CGFloat) -> CGSize { 295 | var result = size 296 | let ratio = size.width / size.height 297 | result.height = height 298 | result.width = height * ratio 299 | return result 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/VideoTimelineView/FrameImagesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FrameImagesView.swift 3 | // Examplay 4 | // 5 | // Created by Tomohiro Yamashita on 2020/03/01. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import AVFoundation 12 | 13 | 14 | 15 | class FrameImage: UIImageView { 16 | var tolerance:Float64? = nil 17 | } 18 | 19 | 20 | // MARK: - FrameImagesView 21 | class FrameImagesView: UIScrollView { 22 | 23 | 24 | var mainView:VideoTimelineView! 25 | 26 | var frameImagesArray:[FrameImage] = [] 27 | 28 | 29 | var thumbnailFrameSize:CGSize = CGSize(width: 640,height: 480) 30 | let preferredTimescale:Int32 = 100 31 | var timeTolerance = CMTimeMakeWithSeconds(10 , preferredTimescale:100) 32 | 33 | var maxWidth:CGFloat = 0 34 | var minWidth:CGFloat = 0 35 | 36 | var parentScroller:TimelineScroller? = nil 37 | 38 | override init (frame: CGRect) { 39 | super.init(frame: frame) 40 | self.isScrollEnabled = false 41 | self.isUserInteractionEnabled = false 42 | self.backgroundColor = UIColor(hue: 0, saturation:0, brightness:0.0, alpha: 0.02) 43 | } 44 | 45 | required init(coder aDecoder: NSCoder) { 46 | fatalError("FrameImagesView init(coder:) has not been implemented") 47 | } 48 | 49 | 50 | 51 | 52 | func reset() { 53 | discardAllFrameImages() 54 | cancelImageGenerator() 55 | 56 | prepareFrameViews() 57 | layout() 58 | requestVisible(depth:0, wide:2, direction:0) 59 | } 60 | 61 | //MARK: - timer for animation 62 | var animationTimer = Timer() 63 | var animating = false 64 | func startAnimation() { 65 | animating = true 66 | displayFrames() 67 | animationTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(self.animate(_:)), userInfo: nil, repeats: true) 68 | RunLoop.main.add(animationTimer, forMode:RunLoop.Mode.common) 69 | 70 | } 71 | 72 | func stopAnimation() { 73 | animationTimer.invalidate() 74 | animating = false 75 | 76 | 77 | if let parent = parentScroller { 78 | frame.origin = CGPoint(x: parent.frame.size.width / 2, y: parent.measureHeight) 79 | parent.addSubview(self) 80 | } 81 | displayFrames() 82 | } 83 | 84 | @objc func animate(_ timer:Timer) { 85 | if animating == false { 86 | return 87 | } 88 | displayFrames() 89 | } 90 | 91 | //MARK: - layout 92 | 93 | 94 | var uponFrames = Set() 95 | var belowFrames = Set() 96 | var deepFrames = Set() 97 | var hiddenFrames = Set() 98 | 99 | 100 | func layout() { 101 | setThumnailFrameSize() 102 | let coordinated = coordinateFrames() 103 | uponFrames = coordinated.upon 104 | belowFrames = coordinated.below 105 | hiddenFrames = coordinated.hidden 106 | deepFrames = coordinated.deep 107 | displayFrames() 108 | } 109 | 110 | func displayFrames() { 111 | 112 | var baseView:UIView = self 113 | var offset:CGPoint = CGPoint.zero 114 | 115 | if let parent = parentScroller { 116 | let visibleHalf = parent.frame.size.width / 2 117 | 118 | if animating { 119 | if let layer = parent.layer.presentation() { 120 | offset.x = visibleHalf - layer.bounds.origin.x 121 | offset.y = parent.frame.origin.y + parent.measureHeight 122 | frame.origin = offset 123 | mainView!.timelineView.viewForAnimate.addSubview(self) 124 | } 125 | } 126 | } 127 | 128 | let visibleSide = indexOfVisibleSide() 129 | func locate(_ index:Int, visible:Bool) { 130 | if frameImagesArray.count > index && index >= 0 { 131 | let frameImg = frameImagesArray[index] 132 | if index >= visibleSide.left && index <= visibleSide.right { 133 | let position = positionWithIndex(index) 134 | frameImg.frame = CGRect(x: position, y:0, width: thumbnailFrameSize.width, height: thumbnailFrameSize.height) 135 | if visible { 136 | self.addSubview(frameImg) 137 | } else { 138 | frameImg.removeFromSuperview() 139 | } 140 | } else { 141 | frameImg.removeFromSuperview() 142 | } 143 | 144 | } 145 | } 146 | for element in belowFrames {//do addSubview under the upon 147 | locate(element, visible:true) 148 | } 149 | for element in uponFrames { 150 | locate(element, visible:true) 151 | } 152 | for element in hiddenFrames {// includes deep 153 | locate(element, visible:false) 154 | } 155 | 156 | } 157 | 158 | func positionWithIndex(_ index:Int) -> CGFloat { 159 | let position = frame.size.width * (CGFloat(index) / CGFloat(thumbnailCountFloat())) 160 | return position 161 | } 162 | 163 | func coordinateFrames() -> (upon:Set, below:Set, hidden:Set, deep:Set) { 164 | 165 | let keyDivision = Int(pow(2,Double(Int(log2(maxWidth * 2 / (self.frame.size.width + 1)))))) 166 | if keyDivision <= 0 { 167 | return (Set(), Set(), Set(), Set()) 168 | } 169 | let keyCount = ((frameImagesArray.count - 1) / keyDivision) + 1 170 | 171 | 172 | var visibleIndexes = Set() 173 | var uponElements = Set() 174 | for index in 0 ... (keyCount - 1) { 175 | let uponIndex = index * keyDivision 176 | uponElements.insert(uponIndex) 177 | visibleIndexes.insert(uponIndex) 178 | } 179 | var belowElements = Set() 180 | let belowDivision = keyDivision / 2 181 | if belowDivision >= 1 { 182 | for index in 0 ..< (keyCount - 1) { 183 | let belowIndex = (index * keyDivision) + (keyDivision / 2) 184 | belowElements.insert(belowIndex) 185 | visibleIndexes.insert(belowIndex) 186 | } 187 | } 188 | var deepElements = Set() 189 | let deepDivision = belowDivision / 2 190 | if deepDivision >= 1 { 191 | if let max = visibleIndexes.max() { 192 | for index in visibleIndexes { 193 | if max > index { 194 | deepElements.insert(index + deepDivision) 195 | } 196 | } 197 | } 198 | } 199 | var hiddenElements = Set() 200 | for index in 0 ..< frameImagesArray.count { 201 | if !visibleIndexes.contains(index) { 202 | let hiddenIndex = index 203 | hiddenElements.insert(hiddenIndex) 204 | } 205 | } 206 | return (uponElements,belowElements,hiddenElements, deepElements) 207 | } 208 | 209 | 210 | func setThumnailFrameSize() { 211 | if let asset = mainView!.asset { 212 | var frameSize = CGSize(width: 640,height: 480) 213 | let tracks:Array = asset.tracks(withMediaType:AVMediaType.video) 214 | if tracks.count > 0 { 215 | let track = tracks[0] 216 | frameSize = track.naturalSize.applying(track.preferredTransform) 217 | frameSize.width = abs(frameSize.width) 218 | frameSize.height = abs(frameSize.height) 219 | } 220 | thumbnailFrameSize = mainView!.resizeHeightKeepRatio(frameSize, height:self.frame.size.height) 221 | } 222 | } 223 | 224 | func assetDuration() -> Float64? { 225 | if let asset = mainView!.asset { 226 | return CMTimeGetSeconds(asset.duration) 227 | } 228 | return nil 229 | } 230 | 231 | func prepareFrameViews() { 232 | frameImagesArray = [] 233 | for _ in 0 ... Int(thumbnailCountFloat()) { 234 | let view = FrameImage() 235 | view.alpha = 0 236 | frameImagesArray += [view] 237 | } 238 | } 239 | 240 | 241 | 242 | func thumbnailCountFloat() -> CGFloat { 243 | return maxWidth / thumbnailFrameSize.width 244 | } 245 | 246 | 247 | func indexWithTime(_ time:Float64) -> Int? { 248 | if let assetDuration = assetDuration() { 249 | let value = time / (assetDuration / Float64(thumbnailCountFloat())) 250 | var intValue = Int(value) 251 | if value - Float64(intValue) >= 0.5 { 252 | intValue += 1 253 | } 254 | return intValue 255 | } 256 | return nil 257 | } 258 | 259 | func timeWithIndex(_ index:Int) -> Float64 { 260 | if let assetDuration = assetDuration() { 261 | return assetDuration * ((Float64(index)) / Float64(thumbnailCountFloat())) 262 | } 263 | return 0 264 | } 265 | 266 | 267 | 268 | 269 | //MARK: - frame images 270 | func cancelImageGenerator() { 271 | if let asset = mainView!.asset { 272 | let assetImgGenerate : AVAssetImageGenerator = AVAssetImageGenerator(asset: asset) 273 | assetImgGenerate.cancelAllCGImageGeneration() 274 | } 275 | } 276 | 277 | func discardAllFrameImages() { 278 | 279 | for index in 0 ..< frameImagesArray.count { 280 | let view = frameImagesArray[index] 281 | UIView.animate(withDuration: 0.5,delay:Double(0.0),options:UIView.AnimationOptions.curveEaseOut, animations: { () -> Void in 282 | 283 | view.alpha = 0 284 | 285 | },completion: { finished in 286 | 287 | view.image = nil 288 | view.removeFromSuperview() 289 | view.tolerance = nil 290 | }) 291 | } 292 | frameImagesArray = [] 293 | } 294 | 295 | 296 | 297 | func requestAll(){ 298 | 299 | var timesArray = [NSValue]() 300 | for index in 0 ..< frameImagesArray.count { 301 | timesArray += [NSValue(time:CMTimeMakeWithSeconds(timeWithIndex(index) , preferredTimescale:preferredTimescale))] 302 | } 303 | requestImageGeneration(timesArray:timesArray) 304 | } 305 | 306 | 307 | var requesting = Set() 308 | func requestVisible(depth:Int, wide:Float, direction:Float) { 309 | var timesArray = [NSValue]() 310 | func request(_ index:Int) { 311 | 312 | if self.requesting.count > 0 && self.requesting.contains(index) { 313 | return 314 | } 315 | 316 | if frameImagesArray.count > index { 317 | let imageView = frameImagesArray[index] 318 | var needsUpdate = false 319 | if let tolerance = imageView.tolerance { 320 | if tolerance > CMTimeGetSeconds(timeTolerance) * 1.2 { 321 | needsUpdate = true 322 | } 323 | } 324 | if imageView.image == nil || needsUpdate { 325 | timesArray += [NSValue(time:CMTimeMakeWithSeconds(timeWithIndex(index) , preferredTimescale:preferredTimescale))] 326 | self.requesting.insert(index) 327 | } 328 | } 329 | } 330 | let visibleSide = indexOfVisibleSide() 331 | let width = visibleSide.right - visibleSide.left 332 | var additionLeft = Int(Float(width) * wide) 333 | var additionRight = additionLeft 334 | if direction > 0 { 335 | additionLeft = Int(Float(-width) * direction) 336 | } else if direction < 0 { 337 | additionRight = Int(Float(width) * direction) 338 | } 339 | 340 | 341 | for index in uponFrames { 342 | if index >= visibleSide.left - additionLeft && index <= visibleSide.right + additionRight { 343 | request(index) 344 | } 345 | } 346 | let belowAddLeft = Int(Float(additionLeft) * 0.5) - Int(Float(width) * 0.7) 347 | let belowAddRight = Int(Float(additionRight) * 0.5) - Int(Float(width) * 0.7) 348 | if depth > 0 { 349 | for index in belowFrames { 350 | if index >= visibleSide.left - belowAddLeft && index <= visibleSide.right + belowAddRight { 351 | request(index) 352 | } 353 | } 354 | } 355 | let deepAddLeft = Int(Float(additionLeft) * 0.2) - Int(Float(width) * 0.4) 356 | let deepAddRight = Int(Float(additionRight) * 0.2) - Int(Float(width) * 0.4) 357 | if depth > 1 { 358 | for index in deepFrames { 359 | if index >= visibleSide.left - deepAddLeft && index <= visibleSide.right + deepAddRight { 360 | request(index) 361 | } 362 | } 363 | } 364 | if timesArray.count > 0 { 365 | requestImageGeneration(timesArray:timesArray) 366 | } 367 | } 368 | 369 | func indexOfVisibleSide() -> (left:Int, right:Int) { 370 | func indexWithPosition(_ position:CGFloat) -> Int{ 371 | let index = position * CGFloat(thumbnailCountFloat()) / frame.size.width 372 | var indexInt = Int(index) 373 | if index - CGFloat(indexInt) > 0.5 { 374 | indexInt += 1 375 | } 376 | return indexInt 377 | } 378 | var visibleLeft = indexWithPosition(-(parentScroller!.frame.width / 2) + parentScroller!.contentOffset.x - thumbnailFrameSize.width) - 1 379 | var visibleRight = indexWithPosition(parentScroller!.contentOffset.x + (parentScroller!.frame.size.width * 0.5) + thumbnailFrameSize.width) 380 | if visibleLeft < 0 { 381 | visibleLeft = 0 382 | } 383 | let max = frameImagesArray.count - 1 384 | if visibleRight > max { 385 | visibleRight = max 386 | } 387 | return (visibleLeft, visibleRight) 388 | } 389 | 390 | func updateTolerance() { 391 | if mainView!.asset == nil { 392 | return 393 | } 394 | let thumbDuration = Float64(thumbnailFrameSize.width / self.frame.size.width) * mainView!.duration * 2 395 | timeTolerance = CMTimeMakeWithSeconds(thumbDuration , preferredTimescale:100) 396 | 397 | 398 | } 399 | 400 | func requestImageGeneration(timesArray:[NSValue]) { 401 | 402 | if let asset = mainView!.asset { 403 | let assetImgGenerate : AVAssetImageGenerator = AVAssetImageGenerator(asset: asset) 404 | assetImgGenerate.appliesPreferredTrackTransform = true 405 | let maxsize = CGSize(width: thumbnailFrameSize.width * 1.5,height: thumbnailFrameSize.height * 1.5) 406 | assetImgGenerate.maximumSize = maxsize 407 | assetImgGenerate.requestedTimeToleranceAfter = timeTolerance 408 | assetImgGenerate.requestedTimeToleranceBefore = timeTolerance 409 | 410 | assetImgGenerate.generateCGImagesAsynchronously(forTimes: timesArray, 411 | completionHandler: 412 | { time,resultImage,actualTime,result,error in 413 | 414 | let timeValue = CMTimeGetSeconds(time) 415 | if let image = resultImage { 416 | DispatchQueue.main.async { 417 | self.setFrameImage(image:UIImage(cgImage:image), time:timeValue) 418 | } 419 | } 420 | if let index = self.indexWithTime(timeValue) { 421 | self.requesting.remove(index) 422 | } 423 | }) 424 | } 425 | } 426 | 427 | 428 | func setFrameImage(image:UIImage, time:Float64) { 429 | 430 | if let index = indexWithTime(time) { 431 | if frameImagesArray.count > index && index >= 0 { 432 | let imageView = frameImagesArray[index] 433 | 434 | imageView.image = image 435 | imageView.tolerance = CMTimeGetSeconds(timeTolerance) 436 | UIView.animate(withDuration: 0.2,delay:Double(0),options:UIView.AnimationOptions.curveEaseOut, animations: { () -> Void in 437 | 438 | imageView.alpha = 1 439 | 440 | },completion: { finished in 441 | 442 | 443 | }) 444 | imageView.alpha = 1 445 | imageView.backgroundColor = .red 446 | } 447 | } 448 | } 449 | } 450 | 451 | 452 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/VideoTimelineView/TimelineView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineView.swift 3 | // Examplay 4 | // 5 | // Created by Tomohiro Yamashita on 2020/02/18. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import AVFoundation 12 | 13 | class TimelineView: UIView, UIScrollViewDelegate { 14 | var mainView:VideoTimelineView? = nil 15 | let scroller = TimelineScroller() 16 | let centerLine = CenterLine() 17 | let viewForAnimate = UIScrollView() 18 | 19 | let durationPerHeight:Float64 = 0.35 20 | var animating = false 21 | 22 | override init (frame: CGRect) { 23 | 24 | super.init(frame: frame) 25 | 26 | self.backgroundColor = UIColor(hue: 0, saturation:0, brightness:0.0, alpha: 0.05) 27 | 28 | viewForAnimate.frame.origin = CGPoint.zero 29 | viewForAnimate.isScrollEnabled = false 30 | viewForAnimate.isUserInteractionEnabled = false 31 | self.addSubview(viewForAnimate) 32 | 33 | self.addSubview(scroller) 34 | scroller.delegate = self 35 | 36 | self.addSubview(scroller.measure) 37 | scroller.configure(parent: self) 38 | centerLine.configure(parent:self) 39 | coordinate() 40 | 41 | self.addSubview(scroller.trimView) 42 | self.addSubview(centerLine) 43 | 44 | scroller.measure.parentView = self 45 | } 46 | 47 | required init(coder aDecoder: NSCoder) { 48 | fatalError("TimelineView init(coder:) has not been implemented") 49 | } 50 | 51 | 52 | //MARK: - coordinate 53 | func coordinate() { 54 | if mainView == nil { 55 | return 56 | } 57 | frame = mainView!.bounds 58 | viewForAnimate.frame.size = self.frame.size 59 | 60 | scroller.frame = self.bounds 61 | scroller.frameImagesView.frame.size.height = scroller.frame.size.height 62 | scroller.measure.frame = scroller.frame 63 | scroller.measure.frame.size.height = 20 64 | scroller.coordinate() 65 | 66 | centerLine.timeLabel.frame.size.height = scroller.measure.frame.size.height - 2 67 | let centerLineWidth:CGFloat = scroller.measure.frame.size.height * 5 68 | centerLine.frame = CGRect(x: (self.frame.size.width - centerLineWidth) / 2,y: 0,width: centerLineWidth,height: self.frame.size.height) 69 | 70 | centerLine.update() 71 | 72 | guard let view = (mainView) else { return } 73 | guard let _ = (view.asset) else { return } 74 | if scroller.frameImagesView.frame.size.width <= 0 { 75 | return 76 | } 77 | let previousThumbSize = scroller.frameImagesView.thumbnailFrameSize 78 | scroller.frameImagesView.setThumnailFrameSize() 79 | let thumbSize = scroller.frameImagesView.thumbnailFrameSize 80 | let unit = (thumbSize.height / CGFloat(durationPerHeight)) 81 | scroller.measure.unitSize = unit 82 | let contentMaxWidth = (unit * CGFloat(centerLine.duration)) 83 | scroller.frameImagesView.maxWidth = contentMaxWidth 84 | let defineMin = scroller.frame.size.width * 0.8 85 | var contentMinWidth:CGFloat 86 | 87 | if scroller.frameImagesView.maxWidth <= defineMin { 88 | contentMinWidth = scroller.frameImagesView.maxWidth 89 | } else { 90 | contentMinWidth = snapWidth(defineMin, max:scroller.frameImagesView.maxWidth) 91 | } 92 | 93 | scroller.frameImagesView.minWidth = contentMinWidth 94 | 95 | var currentWidth = snapWidth((scroller.frameImagesView.thumbnailFrameSize.width / previousThumbSize.width) * scroller.frameImagesView.frame.size.width, max:scroller.frameImagesView.maxWidth) 96 | if currentWidth < scroller.frameImagesView.minWidth { 97 | currentWidth = scroller.frameImagesView.minWidth 98 | } else if currentWidth > scroller.frameImagesView.maxWidth { 99 | currentWidth = scroller.frameImagesView.maxWidth 100 | } 101 | 102 | scroller.setContentWidth(currentWidth) 103 | 104 | scroller.reset() 105 | 106 | scroller.trimView.layout() 107 | if view.currentTime <= view.duration { 108 | setCurrentTime(view.currentTime, force:false) 109 | } 110 | } 111 | 112 | func snapWidth(_ width:CGFloat, max:CGFloat) -> CGFloat { 113 | let n = log2((2 * max) / width) 114 | var intN = CGFloat(Int(n)) 115 | if n - intN >= 0.5 { 116 | intN += 1 117 | } 118 | let result = (2 * max) / (pow(2,intN)) 119 | return result 120 | } 121 | 122 | func scrollPoint() -> CGFloat { 123 | return scroller.contentOffset.x / scroller.frameImagesView.frame.size.width 124 | } 125 | 126 | 127 | 128 | //MARK: new movie set 129 | func newMovieSet() { 130 | 131 | coordinate() 132 | if let asset = mainView!.asset{ 133 | scroller.frameImagesView.setThumnailFrameSize() 134 | 135 | let duration = asset.duration 136 | let durationFloat = CMTimeGetSeconds(duration) 137 | centerLine.duration = durationFloat 138 | 139 | let detailThumbSize = scroller.frameImagesView.thumbnailFrameSize 140 | 141 | let unit = (detailThumbSize.height / CGFloat(durationPerHeight)) 142 | scroller.measure.unitSize = unit 143 | 144 | 145 | let contentMaxWidth = (unit * CGFloat(centerLine.duration)) 146 | scroller.frameImagesView.maxWidth = contentMaxWidth 147 | 148 | 149 | let defineMin = scroller.frame.size.width * 0.8 150 | var contentMinWidth:CGFloat 151 | 152 | if scroller.frameImagesView.maxWidth <= defineMin { 153 | contentMinWidth = scroller.frameImagesView.maxWidth 154 | } else { 155 | contentMinWidth = snapWidth(defineMin, max:scroller.frameImagesView.maxWidth) 156 | } 157 | scroller.frameImagesView.minWidth = contentMinWidth 158 | scroller.setContentWidth(scroller.frameImagesView.minWidth) 159 | 160 | scroller.reset() 161 | scroller.trimView.reset(duration:durationFloat) 162 | } 163 | } 164 | 165 | //MARK: - currentTime 166 | func setCurrentTime(_ currentTime:Float64, force:Bool) { 167 | if inAction() && force == false { 168 | return 169 | } 170 | if mainView!.asset == nil { 171 | return 172 | } 173 | var scrollPoint:CGFloat = 0 174 | scrollPoint = CGFloat(currentTime / mainView!.duration) 175 | 176 | centerLine.ignoreSendScrollToParent = true 177 | centerLine.setScrollPoint(scrollPoint) 178 | scroller.ignoreScrollViewDidScroll = true 179 | scroller.setScrollPoint(scrollPoint) 180 | 181 | scroller.frameImagesView.requestVisible(depth:0, wide:0, direction:0) 182 | 183 | scroller.frameImagesView.displayFrames() 184 | scroller.measure.setNeedsDisplay() 185 | } 186 | 187 | func moved(_ currentTime:Float64) { 188 | mainView!.timelineIsMoved(currentTime, scrub:true) 189 | } 190 | 191 | 192 | //MARK: - TrimViews 193 | func setTrimmerStatus(enabled:Bool) { 194 | if enabled { 195 | scroller.trimView.alpha = 1 196 | scroller.trimView.startKnob.isUserInteractionEnabled = true 197 | scroller.trimView.alpha = 1 198 | scroller.trimView.endKnob.isUserInteractionEnabled = true 199 | 200 | } else { 201 | scroller.trimView.alpha = 0.5 202 | scroller.trimView.startKnob.isUserInteractionEnabled = false 203 | scroller.trimView.alpha = 0.5 204 | scroller.trimView.endKnob.isUserInteractionEnabled = false 205 | } 206 | } 207 | 208 | func setTrimmerVisible(_ visible:Bool) { 209 | scroller.trimView.isHidden = !visible 210 | } 211 | 212 | 213 | func setTrim(start:Float64?, end:Float64?) { 214 | var changed = false 215 | if start != nil { 216 | scroller.trimView.startKnob.knobTimePoint = start! 217 | changed = true 218 | } 219 | if end != nil { 220 | scroller.trimView.endKnob.knobTimePoint = end! 221 | changed = true 222 | } 223 | if changed { 224 | scroller.trimView.layout() 225 | } 226 | } 227 | 228 | func setTrimWithAnimation(trim:VideoTimelineTrim, time:Float64) { 229 | scroller.trimView.moveToTimeAndTrimWithAnimation(time, trim:trim) 230 | } 231 | 232 | 233 | var manualScrolledAfterEnd = false 234 | func setTrimViewInteraction(_ active:Bool) { 235 | if mainView!.trimEnabled == false && active { 236 | return 237 | } 238 | 239 | scroller.trimView.startKnob.isUserInteractionEnabled = active 240 | scroller.trimView.endKnob.isUserInteractionEnabled = active 241 | 242 | if active { 243 | setManualScrolledAfterEnd() 244 | } 245 | } 246 | 247 | func setManualScrolledAfterEnd() { 248 | let trim = currentTrim() 249 | if mainView!.asset != nil { 250 | let currentTime = mainView!.currentTime 251 | if currentTime >= trim.end { 252 | manualScrolledAfterEnd = true 253 | } else { 254 | manualScrolledAfterEnd = false 255 | } 256 | } 257 | } 258 | 259 | func currentTrim() -> (start:Float64, end:Float64) { 260 | var start = scroller.trimView.startKnob.knobTimePoint 261 | var end = scroller.trimView.endKnob.knobTimePoint 262 | if mainView!.asset != nil { 263 | if end > mainView!.duration { 264 | end = mainView!.duration 265 | } 266 | if start < 0 { 267 | start = 0 268 | } 269 | } 270 | return (start, end) 271 | } 272 | 273 | 274 | func swapTrimKnobs() { 275 | let knob = scroller.trimView.endKnob 276 | scroller.trimView.endKnob = scroller.trimView.startKnob 277 | scroller.trimView.startKnob = knob 278 | } 279 | 280 | //MARK: - animation 281 | func startAnimation() { 282 | scroller.frameImagesView.startAnimation() 283 | scroller.measure.startAnimation() 284 | scroller.trimView.startAnimation() 285 | } 286 | 287 | func stopAnimation() { 288 | scroller.frameImagesView.stopAnimation() 289 | scroller.measure.stopAnimation() 290 | scroller.trimView.stopAnimation() 291 | } 292 | 293 | 294 | 295 | 296 | 297 | //MARK: - Gestures 298 | func inAction() -> Bool { 299 | if allTouches.count > 0 || scroller.isTracking || scroller.isDecelerating { 300 | return true 301 | } else { 302 | return false 303 | } 304 | } 305 | 306 | //MARK: - Scrolling 307 | 308 | var allTouches = [UITouch]() 309 | var pinching:Bool = false 310 | 311 | func scrollViewDidScroll(_ scrollView:UIScrollView) { 312 | scroller.trimView.layout() 313 | if scroller.ignoreScrollViewDidScroll { 314 | scroller.ignoreScrollViewDidScroll = false 315 | return 316 | } 317 | scroller.measure.setNeedsDisplay() 318 | scroller.frameImagesView.displayFrames() 319 | setTrimViewInteraction(false) 320 | let scrollPoint = scroller.contentOffset.x / scroller.frameImagesView.frame.size.width 321 | self.centerLine.setScrollPoint(scrollPoint) 322 | 323 | guard let mView = (mainView) else { return } 324 | if let receiver = mView.playStatusReceiver { 325 | receiver.videoTimelineMoved() 326 | } 327 | } 328 | 329 | func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { 330 | scroller.frameImagesView.requestVisible(depth:0, wide:0, direction:0) 331 | setTrimViewInteraction(true) 332 | let scrollPoint = scroller.contentOffset.x / scroller.frameImagesView.frame.size.width 333 | self.centerLine.setScrollPoint(scrollPoint) 334 | } 335 | 336 | func scrollViewDidEndDragging(_ scrollView: UIScrollView, 337 | willDecelerate decelerate: Bool) { 338 | scroller.frameImagesView.requestVisible(depth:0, wide:0, direction:0) 339 | if decelerate == false { 340 | setTrimViewInteraction(true) 341 | } 342 | let scrollPoint = scroller.contentOffset.x / scroller.frameImagesView.frame.size.width 343 | self.centerLine.setScrollPoint(scrollPoint) 344 | } 345 | 346 | 347 | 348 | //MARK: - Zooming 349 | 350 | var pinchCenterInContent:CGFloat = 0 351 | var pinchStartDistance:CGFloat = 0 352 | var pinchStartContent:(x:CGFloat,width:CGFloat) = (0,0) 353 | func pinchCenter(_ pointA:CGPoint, pointB:CGPoint) -> CGPoint { 354 | return CGPoint(x: (pointA.x + pointB.x) / 2, y: (pointA.y + pointB.y) / 2) 355 | } 356 | func pinchDistance(_ pointA:CGPoint, pointB:CGPoint) -> CGFloat { 357 | return sqrt(pow((pointA.x - pointB.x),2) + pow((pointA.y - pointB.y),2)); 358 | } 359 | func startPinch() { 360 | pinching = true 361 | scroller.isScrollEnabled = false 362 | 363 | let touch1 = allTouches[0] 364 | let touch2 = allTouches[1] 365 | let center = pinchCenter(touch1.location(in: self),pointB: touch2.location(in: self)) 366 | 367 | pinchStartDistance = pinchDistance(touch1.location(in: self),pointB: touch2.location(in: self)) 368 | let framewidth = scroller.frame.size.width 369 | pinchStartContent = ((framewidth / 2) - scroller.contentOffset.x,scroller.contentSize.width - framewidth) 370 | pinchCenterInContent = (center.x - pinchStartContent.x) / pinchStartContent.width 371 | } 372 | 373 | func updatePinch() { 374 | let touch1 = allTouches[0] 375 | let touch2 = allTouches[1] 376 | let center = pinchCenter(touch1.location(in: self), pointB:touch2.location(in: self)) 377 | var sizeChange = (1 * pinchDistance(touch1.location(in: self), pointB: touch2.location(in: self))) / pinchStartDistance 378 | 379 | var contentWidth = pinchStartContent.width * sizeChange 380 | 381 | let sizeMin = scroller.frameImagesView.minWidth 382 | let sizeMax = scroller.frameImagesView.maxWidth 383 | 384 | if contentWidth < sizeMin { 385 | let sizeUnit = sizeMin / pinchStartContent.width 386 | sizeChange = ((pow(sizeChange/sizeUnit,2)/4) + 0.75) * sizeUnit 387 | contentWidth = pinchStartContent.width * sizeChange 388 | contentWidth = sizeMin 389 | } else if contentWidth > sizeMax { 390 | sizeChange = sizeMax 391 | contentWidth = sizeMax 392 | } else { 393 | let startRatio = pinchStartContent.width / sizeMax 394 | let currentRatio = startRatio * sizeChange 395 | let effect = ((sin(CGFloat.pi * 2 * log2(2/currentRatio)) * 0.108) - (sin(CGFloat.pi * 6 * log2(2/currentRatio)) * 0.009)) * currentRatio 396 | let resultWidth = sizeMax * (currentRatio + effect) 397 | contentWidth = resultWidth 398 | } 399 | let contentOrigin = center.x - (contentWidth * pinchCenterInContent) 400 | scroller.contentOffset.x = (scroller.frame.size.width / 2) - contentOrigin 401 | scroller.setContentWidth(contentWidth) 402 | scroller.frameImagesView.layout() 403 | 404 | scroller.frameImagesView.requestVisible(depth:2, wide:0, direction:0) 405 | self.centerLine.setScrollPoint(scroller.contentOffset.x / scroller.frameImagesView.frame.size.width) 406 | scroller.trimView.layout() 407 | } 408 | 409 | func endPinch() { 410 | scroller.frameImagesView.requestVisible(depth:0, wide:1, direction:0) 411 | 412 | let width = snapWidth(scroller.frameImagesView.frame.size.width, max:scroller.frameImagesView.maxWidth) 413 | 414 | let offset = self.resizedPositionWithKeepOrigin(width:scroller.frameImagesView.frame.size.width, origin:scroller.contentOffset.x, destinationWidth:width) 415 | //startAnimation() 416 | 417 | UIView.animate(withDuration: 0.1,delay:Double(0.0),options:UIView.AnimationOptions.curveEaseOut, animations: { () -> Void in 418 | 419 | self.scroller.setContentWidth(width, setOrigin:false) 420 | self.scroller.contentOffset.x = offset 421 | self.scroller.frameImagesView.layout() 422 | self.scroller.trimView.layout() 423 | },completion: { finished in 424 | self.pinching = false 425 | self.scroller.isScrollEnabled = true 426 | }) 427 | 428 | self.scroller.frameImagesView.updateTolerance() 429 | 430 | self.centerLine.setScrollPoint(scroller.contentOffset.x / scroller.frameImagesView.frame.size.width) 431 | 432 | setTrimViewInteraction(true) 433 | 434 | guard let mView = (mainView) else { return } 435 | if let receiver = mView.playStatusReceiver { 436 | receiver.videoTimelineMoved() 437 | } 438 | } 439 | 440 | func resizedPositionWithKeepOrigin(width:CGFloat, origin:CGFloat, destinationWidth:CGFloat) -> CGFloat { 441 | let originPoint = origin / width 442 | let result = originPoint * destinationWidth 443 | return result 444 | } 445 | 446 | 447 | } 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | E29DE0E9242F19CF0046D62C /* VideoTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29DE0E8242F19CF0046D62C /* VideoTimelineView.swift */; }; 11 | E2C43F1A24346FDF00C03ED5 /* movie.mov in Resources */ = {isa = PBXBuildFile; fileRef = E2C43F1924346FDF00C03ED5 /* movie.mov */; }; 12 | E2D72532242E1E4F00C31BAD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72531242E1E4F00C31BAD /* AppDelegate.swift */; }; 13 | E2D72534242E1E4F00C31BAD /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72533242E1E4F00C31BAD /* SceneDelegate.swift */; }; 14 | E2D72536242E1E4F00C31BAD /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72535242E1E4F00C31BAD /* ViewController.swift */; }; 15 | E2D72539242E1E4F00C31BAD /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E2D72537242E1E4F00C31BAD /* Main.storyboard */; }; 16 | E2D7253B242E1E5A00C31BAD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2D7253A242E1E5A00C31BAD /* Assets.xcassets */; }; 17 | E2D7253E242E1E5A00C31BAD /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E2D7253C242E1E5A00C31BAD /* LaunchScreen.storyboard */; }; 18 | E2D72549242E1E5A00C31BAD /* VideoTimelineViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72548242E1E5A00C31BAD /* VideoTimelineViewTests.swift */; }; 19 | E2D72554242E1E5A00C31BAD /* VideoTimelineViewUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72553242E1E5A00C31BAD /* VideoTimelineViewUITests.swift */; }; 20 | E2D72565242E21EC00C31BAD /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72564242E21EC00C31BAD /* TimelineView.swift */; }; 21 | E2D7256B242E223E00C31BAD /* CenterLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72566242E223D00C31BAD /* CenterLine.swift */; }; 22 | E2D7256C242E223E00C31BAD /* TimelineMeasure.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72567242E223E00C31BAD /* TimelineMeasure.swift */; }; 23 | E2D7256D242E223E00C31BAD /* TimelineScroller.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72568242E223E00C31BAD /* TimelineScroller.swift */; }; 24 | E2D7256E242E223E00C31BAD /* TrimView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D72569242E223E00C31BAD /* TrimView.swift */; }; 25 | E2D7256F242E223E00C31BAD /* FrameImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D7256A242E223E00C31BAD /* FrameImagesView.swift */; }; 26 | /* End PBXBuildFile section */ 27 | 28 | /* Begin PBXContainerItemProxy section */ 29 | E2D72545242E1E5A00C31BAD /* PBXContainerItemProxy */ = { 30 | isa = PBXContainerItemProxy; 31 | containerPortal = E2D72526242E1E4F00C31BAD /* Project object */; 32 | proxyType = 1; 33 | remoteGlobalIDString = E2D7252D242E1E4F00C31BAD; 34 | remoteInfo = VideoTimelineView; 35 | }; 36 | E2D72550242E1E5A00C31BAD /* PBXContainerItemProxy */ = { 37 | isa = PBXContainerItemProxy; 38 | containerPortal = E2D72526242E1E4F00C31BAD /* Project object */; 39 | proxyType = 1; 40 | remoteGlobalIDString = E2D7252D242E1E4F00C31BAD; 41 | remoteInfo = VideoTimelineView; 42 | }; 43 | /* End PBXContainerItemProxy section */ 44 | 45 | /* Begin PBXFileReference section */ 46 | E29DE0E8242F19CF0046D62C /* VideoTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoTimelineView.swift; sourceTree = ""; }; 47 | E2C43F1924346FDF00C03ED5 /* movie.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = movie.mov; sourceTree = ""; }; 48 | E2D7252E242E1E4F00C31BAD /* VideoTimelineView.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VideoTimelineView.app; sourceTree = BUILT_PRODUCTS_DIR; }; 49 | E2D72531242E1E4F00C31BAD /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 50 | E2D72533242E1E4F00C31BAD /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 51 | E2D72535242E1E4F00C31BAD /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 52 | E2D72538242E1E4F00C31BAD /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 53 | E2D7253A242E1E5A00C31BAD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 54 | E2D7253D242E1E5A00C31BAD /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 55 | E2D7253F242E1E5A00C31BAD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 56 | E2D72544242E1E5A00C31BAD /* VideoTimelineViewTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VideoTimelineViewTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 57 | E2D72548242E1E5A00C31BAD /* VideoTimelineViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoTimelineViewTests.swift; sourceTree = ""; }; 58 | E2D7254A242E1E5A00C31BAD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 59 | E2D7254F242E1E5A00C31BAD /* VideoTimelineViewUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VideoTimelineViewUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 60 | E2D72553242E1E5A00C31BAD /* VideoTimelineViewUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoTimelineViewUITests.swift; sourceTree = ""; }; 61 | E2D72555242E1E5A00C31BAD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 62 | E2D72564242E21EC00C31BAD /* TimelineView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; 63 | E2D72566242E223D00C31BAD /* CenterLine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CenterLine.swift; sourceTree = ""; }; 64 | E2D72567242E223E00C31BAD /* TimelineMeasure.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineMeasure.swift; sourceTree = ""; }; 65 | E2D72568242E223E00C31BAD /* TimelineScroller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineScroller.swift; sourceTree = ""; }; 66 | E2D72569242E223E00C31BAD /* TrimView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrimView.swift; sourceTree = ""; }; 67 | E2D7256A242E223E00C31BAD /* FrameImagesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameImagesView.swift; sourceTree = ""; }; 68 | /* End PBXFileReference section */ 69 | 70 | /* Begin PBXFrameworksBuildPhase section */ 71 | E2D7252B242E1E4F00C31BAD /* Frameworks */ = { 72 | isa = PBXFrameworksBuildPhase; 73 | buildActionMask = 2147483647; 74 | files = ( 75 | ); 76 | runOnlyForDeploymentPostprocessing = 0; 77 | }; 78 | E2D72541242E1E5A00C31BAD /* Frameworks */ = { 79 | isa = PBXFrameworksBuildPhase; 80 | buildActionMask = 2147483647; 81 | files = ( 82 | ); 83 | runOnlyForDeploymentPostprocessing = 0; 84 | }; 85 | E2D7254C242E1E5A00C31BAD /* Frameworks */ = { 86 | isa = PBXFrameworksBuildPhase; 87 | buildActionMask = 2147483647; 88 | files = ( 89 | ); 90 | runOnlyForDeploymentPostprocessing = 0; 91 | }; 92 | /* End PBXFrameworksBuildPhase section */ 93 | 94 | /* Begin PBXGroup section */ 95 | E2D72525242E1E4F00C31BAD = { 96 | isa = PBXGroup; 97 | children = ( 98 | E2C43F1924346FDF00C03ED5 /* movie.mov */, 99 | E2D72530242E1E4F00C31BAD /* VideoTimelineView */, 100 | E2D72547242E1E5A00C31BAD /* VideoTimelineViewTests */, 101 | E2D72552242E1E5A00C31BAD /* VideoTimelineViewUITests */, 102 | E2D7252F242E1E4F00C31BAD /* Products */, 103 | ); 104 | sourceTree = ""; 105 | }; 106 | E2D7252F242E1E4F00C31BAD /* Products */ = { 107 | isa = PBXGroup; 108 | children = ( 109 | E2D7252E242E1E4F00C31BAD /* VideoTimelineView.app */, 110 | E2D72544242E1E5A00C31BAD /* VideoTimelineViewTests.xctest */, 111 | E2D7254F242E1E5A00C31BAD /* VideoTimelineViewUITests.xctest */, 112 | ); 113 | name = Products; 114 | sourceTree = ""; 115 | }; 116 | E2D72530242E1E4F00C31BAD /* VideoTimelineView */ = { 117 | isa = PBXGroup; 118 | children = ( 119 | E2D72531242E1E4F00C31BAD /* AppDelegate.swift */, 120 | E2D72533242E1E4F00C31BAD /* SceneDelegate.swift */, 121 | E2D72535242E1E4F00C31BAD /* ViewController.swift */, 122 | E2D72561242E211F00C31BAD /* VideoTimelineView */, 123 | E2D72537242E1E4F00C31BAD /* Main.storyboard */, 124 | E2D7253A242E1E5A00C31BAD /* Assets.xcassets */, 125 | E2D7253C242E1E5A00C31BAD /* LaunchScreen.storyboard */, 126 | E2D7253F242E1E5A00C31BAD /* Info.plist */, 127 | ); 128 | path = VideoTimelineView; 129 | sourceTree = ""; 130 | }; 131 | E2D72547242E1E5A00C31BAD /* VideoTimelineViewTests */ = { 132 | isa = PBXGroup; 133 | children = ( 134 | E2D72548242E1E5A00C31BAD /* VideoTimelineViewTests.swift */, 135 | E2D7254A242E1E5A00C31BAD /* Info.plist */, 136 | ); 137 | path = VideoTimelineViewTests; 138 | sourceTree = ""; 139 | }; 140 | E2D72552242E1E5A00C31BAD /* VideoTimelineViewUITests */ = { 141 | isa = PBXGroup; 142 | children = ( 143 | E2D72553242E1E5A00C31BAD /* VideoTimelineViewUITests.swift */, 144 | E2D72555242E1E5A00C31BAD /* Info.plist */, 145 | ); 146 | path = VideoTimelineViewUITests; 147 | sourceTree = ""; 148 | }; 149 | E2D72561242E211F00C31BAD /* VideoTimelineView */ = { 150 | isa = PBXGroup; 151 | children = ( 152 | E29DE0E8242F19CF0046D62C /* VideoTimelineView.swift */, 153 | E2D72564242E21EC00C31BAD /* TimelineView.swift */, 154 | E2D72568242E223E00C31BAD /* TimelineScroller.swift */, 155 | E2D72566242E223D00C31BAD /* CenterLine.swift */, 156 | E2D7256A242E223E00C31BAD /* FrameImagesView.swift */, 157 | E2D72567242E223E00C31BAD /* TimelineMeasure.swift */, 158 | E2D72569242E223E00C31BAD /* TrimView.swift */, 159 | ); 160 | path = VideoTimelineView; 161 | sourceTree = ""; 162 | }; 163 | /* End PBXGroup section */ 164 | 165 | /* Begin PBXNativeTarget section */ 166 | E2D7252D242E1E4F00C31BAD /* VideoTimelineView */ = { 167 | isa = PBXNativeTarget; 168 | buildConfigurationList = E2D72558242E1E5B00C31BAD /* Build configuration list for PBXNativeTarget "VideoTimelineView" */; 169 | buildPhases = ( 170 | E2D7252A242E1E4F00C31BAD /* Sources */, 171 | E2D7252B242E1E4F00C31BAD /* Frameworks */, 172 | E2D7252C242E1E4F00C31BAD /* Resources */, 173 | ); 174 | buildRules = ( 175 | ); 176 | dependencies = ( 177 | ); 178 | name = VideoTimelineView; 179 | productName = VideoTimelineView; 180 | productReference = E2D7252E242E1E4F00C31BAD /* VideoTimelineView.app */; 181 | productType = "com.apple.product-type.application"; 182 | }; 183 | E2D72543242E1E5A00C31BAD /* VideoTimelineViewTests */ = { 184 | isa = PBXNativeTarget; 185 | buildConfigurationList = E2D7255B242E1E5B00C31BAD /* Build configuration list for PBXNativeTarget "VideoTimelineViewTests" */; 186 | buildPhases = ( 187 | E2D72540242E1E5A00C31BAD /* Sources */, 188 | E2D72541242E1E5A00C31BAD /* Frameworks */, 189 | E2D72542242E1E5A00C31BAD /* Resources */, 190 | ); 191 | buildRules = ( 192 | ); 193 | dependencies = ( 194 | E2D72546242E1E5A00C31BAD /* PBXTargetDependency */, 195 | ); 196 | name = VideoTimelineViewTests; 197 | productName = VideoTimelineViewTests; 198 | productReference = E2D72544242E1E5A00C31BAD /* VideoTimelineViewTests.xctest */; 199 | productType = "com.apple.product-type.bundle.unit-test"; 200 | }; 201 | E2D7254E242E1E5A00C31BAD /* VideoTimelineViewUITests */ = { 202 | isa = PBXNativeTarget; 203 | buildConfigurationList = E2D7255E242E1E5B00C31BAD /* Build configuration list for PBXNativeTarget "VideoTimelineViewUITests" */; 204 | buildPhases = ( 205 | E2D7254B242E1E5A00C31BAD /* Sources */, 206 | E2D7254C242E1E5A00C31BAD /* Frameworks */, 207 | E2D7254D242E1E5A00C31BAD /* Resources */, 208 | ); 209 | buildRules = ( 210 | ); 211 | dependencies = ( 212 | E2D72551242E1E5A00C31BAD /* PBXTargetDependency */, 213 | ); 214 | name = VideoTimelineViewUITests; 215 | productName = VideoTimelineViewUITests; 216 | productReference = E2D7254F242E1E5A00C31BAD /* VideoTimelineViewUITests.xctest */; 217 | productType = "com.apple.product-type.bundle.ui-testing"; 218 | }; 219 | /* End PBXNativeTarget section */ 220 | 221 | /* Begin PBXProject section */ 222 | E2D72526242E1E4F00C31BAD /* Project object */ = { 223 | isa = PBXProject; 224 | attributes = { 225 | LastSwiftUpdateCheck = 1130; 226 | LastUpgradeCheck = 1130; 227 | ORGANIZATIONNAME = Tom; 228 | TargetAttributes = { 229 | E2D7252D242E1E4F00C31BAD = { 230 | CreatedOnToolsVersion = 11.3.1; 231 | }; 232 | E2D72543242E1E5A00C31BAD = { 233 | CreatedOnToolsVersion = 11.3.1; 234 | TestTargetID = E2D7252D242E1E4F00C31BAD; 235 | }; 236 | E2D7254E242E1E5A00C31BAD = { 237 | CreatedOnToolsVersion = 11.3.1; 238 | TestTargetID = E2D7252D242E1E4F00C31BAD; 239 | }; 240 | }; 241 | }; 242 | buildConfigurationList = E2D72529242E1E4F00C31BAD /* Build configuration list for PBXProject "VideoTimelineView" */; 243 | compatibilityVersion = "Xcode 9.3"; 244 | developmentRegion = en; 245 | hasScannedForEncodings = 0; 246 | knownRegions = ( 247 | en, 248 | Base, 249 | ); 250 | mainGroup = E2D72525242E1E4F00C31BAD; 251 | productRefGroup = E2D7252F242E1E4F00C31BAD /* Products */; 252 | projectDirPath = ""; 253 | projectRoot = ""; 254 | targets = ( 255 | E2D7252D242E1E4F00C31BAD /* VideoTimelineView */, 256 | E2D72543242E1E5A00C31BAD /* VideoTimelineViewTests */, 257 | E2D7254E242E1E5A00C31BAD /* VideoTimelineViewUITests */, 258 | ); 259 | }; 260 | /* End PBXProject section */ 261 | 262 | /* Begin PBXResourcesBuildPhase section */ 263 | E2D7252C242E1E4F00C31BAD /* Resources */ = { 264 | isa = PBXResourcesBuildPhase; 265 | buildActionMask = 2147483647; 266 | files = ( 267 | E2D7253E242E1E5A00C31BAD /* LaunchScreen.storyboard in Resources */, 268 | E2C43F1A24346FDF00C03ED5 /* movie.mov in Resources */, 269 | E2D7253B242E1E5A00C31BAD /* Assets.xcassets in Resources */, 270 | E2D72539242E1E4F00C31BAD /* Main.storyboard in Resources */, 271 | ); 272 | runOnlyForDeploymentPostprocessing = 0; 273 | }; 274 | E2D72542242E1E5A00C31BAD /* Resources */ = { 275 | isa = PBXResourcesBuildPhase; 276 | buildActionMask = 2147483647; 277 | files = ( 278 | ); 279 | runOnlyForDeploymentPostprocessing = 0; 280 | }; 281 | E2D7254D242E1E5A00C31BAD /* Resources */ = { 282 | isa = PBXResourcesBuildPhase; 283 | buildActionMask = 2147483647; 284 | files = ( 285 | ); 286 | runOnlyForDeploymentPostprocessing = 0; 287 | }; 288 | /* End PBXResourcesBuildPhase section */ 289 | 290 | /* Begin PBXSourcesBuildPhase section */ 291 | E2D7252A242E1E4F00C31BAD /* Sources */ = { 292 | isa = PBXSourcesBuildPhase; 293 | buildActionMask = 2147483647; 294 | files = ( 295 | E2D72536242E1E4F00C31BAD /* ViewController.swift in Sources */, 296 | E2D7256E242E223E00C31BAD /* TrimView.swift in Sources */, 297 | E2D7256F242E223E00C31BAD /* FrameImagesView.swift in Sources */, 298 | E2D7256B242E223E00C31BAD /* CenterLine.swift in Sources */, 299 | E2D72532242E1E4F00C31BAD /* AppDelegate.swift in Sources */, 300 | E2D7256C242E223E00C31BAD /* TimelineMeasure.swift in Sources */, 301 | E2D72565242E21EC00C31BAD /* TimelineView.swift in Sources */, 302 | E2D7256D242E223E00C31BAD /* TimelineScroller.swift in Sources */, 303 | E2D72534242E1E4F00C31BAD /* SceneDelegate.swift in Sources */, 304 | E29DE0E9242F19CF0046D62C /* VideoTimelineView.swift in Sources */, 305 | ); 306 | runOnlyForDeploymentPostprocessing = 0; 307 | }; 308 | E2D72540242E1E5A00C31BAD /* Sources */ = { 309 | isa = PBXSourcesBuildPhase; 310 | buildActionMask = 2147483647; 311 | files = ( 312 | E2D72549242E1E5A00C31BAD /* VideoTimelineViewTests.swift in Sources */, 313 | ); 314 | runOnlyForDeploymentPostprocessing = 0; 315 | }; 316 | E2D7254B242E1E5A00C31BAD /* Sources */ = { 317 | isa = PBXSourcesBuildPhase; 318 | buildActionMask = 2147483647; 319 | files = ( 320 | E2D72554242E1E5A00C31BAD /* VideoTimelineViewUITests.swift in Sources */, 321 | ); 322 | runOnlyForDeploymentPostprocessing = 0; 323 | }; 324 | /* End PBXSourcesBuildPhase section */ 325 | 326 | /* Begin PBXTargetDependency section */ 327 | E2D72546242E1E5A00C31BAD /* PBXTargetDependency */ = { 328 | isa = PBXTargetDependency; 329 | target = E2D7252D242E1E4F00C31BAD /* VideoTimelineView */; 330 | targetProxy = E2D72545242E1E5A00C31BAD /* PBXContainerItemProxy */; 331 | }; 332 | E2D72551242E1E5A00C31BAD /* PBXTargetDependency */ = { 333 | isa = PBXTargetDependency; 334 | target = E2D7252D242E1E4F00C31BAD /* VideoTimelineView */; 335 | targetProxy = E2D72550242E1E5A00C31BAD /* PBXContainerItemProxy */; 336 | }; 337 | /* End PBXTargetDependency section */ 338 | 339 | /* Begin PBXVariantGroup section */ 340 | E2D72537242E1E4F00C31BAD /* Main.storyboard */ = { 341 | isa = PBXVariantGroup; 342 | children = ( 343 | E2D72538242E1E4F00C31BAD /* Base */, 344 | ); 345 | name = Main.storyboard; 346 | sourceTree = ""; 347 | }; 348 | E2D7253C242E1E5A00C31BAD /* LaunchScreen.storyboard */ = { 349 | isa = PBXVariantGroup; 350 | children = ( 351 | E2D7253D242E1E5A00C31BAD /* Base */, 352 | ); 353 | name = LaunchScreen.storyboard; 354 | sourceTree = ""; 355 | }; 356 | /* End PBXVariantGroup section */ 357 | 358 | /* Begin XCBuildConfiguration section */ 359 | E2D72556242E1E5A00C31BAD /* Debug */ = { 360 | isa = XCBuildConfiguration; 361 | buildSettings = { 362 | ALWAYS_SEARCH_USER_PATHS = NO; 363 | CLANG_ANALYZER_NONNULL = YES; 364 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 365 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 366 | CLANG_CXX_LIBRARY = "libc++"; 367 | CLANG_ENABLE_MODULES = YES; 368 | CLANG_ENABLE_OBJC_ARC = YES; 369 | CLANG_ENABLE_OBJC_WEAK = YES; 370 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 371 | CLANG_WARN_BOOL_CONVERSION = YES; 372 | CLANG_WARN_COMMA = YES; 373 | CLANG_WARN_CONSTANT_CONVERSION = YES; 374 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 375 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 376 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 377 | CLANG_WARN_EMPTY_BODY = YES; 378 | CLANG_WARN_ENUM_CONVERSION = YES; 379 | CLANG_WARN_INFINITE_RECURSION = YES; 380 | CLANG_WARN_INT_CONVERSION = YES; 381 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 382 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 383 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 384 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 385 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 386 | CLANG_WARN_STRICT_PROTOTYPES = YES; 387 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 388 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 389 | CLANG_WARN_UNREACHABLE_CODE = YES; 390 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 391 | COPY_PHASE_STRIP = NO; 392 | DEBUG_INFORMATION_FORMAT = dwarf; 393 | ENABLE_STRICT_OBJC_MSGSEND = YES; 394 | ENABLE_TESTABILITY = YES; 395 | GCC_C_LANGUAGE_STANDARD = gnu11; 396 | GCC_DYNAMIC_NO_PIC = NO; 397 | GCC_NO_COMMON_BLOCKS = YES; 398 | GCC_OPTIMIZATION_LEVEL = 0; 399 | GCC_PREPROCESSOR_DEFINITIONS = ( 400 | "DEBUG=1", 401 | "$(inherited)", 402 | ); 403 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 404 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 405 | GCC_WARN_UNDECLARED_SELECTOR = YES; 406 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 407 | GCC_WARN_UNUSED_FUNCTION = YES; 408 | GCC_WARN_UNUSED_VARIABLE = YES; 409 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 410 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 411 | MTL_FAST_MATH = YES; 412 | ONLY_ACTIVE_ARCH = YES; 413 | SDKROOT = iphoneos; 414 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 415 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 416 | }; 417 | name = Debug; 418 | }; 419 | E2D72557242E1E5A00C31BAD /* Release */ = { 420 | isa = XCBuildConfiguration; 421 | buildSettings = { 422 | ALWAYS_SEARCH_USER_PATHS = NO; 423 | CLANG_ANALYZER_NONNULL = YES; 424 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 425 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 426 | CLANG_CXX_LIBRARY = "libc++"; 427 | CLANG_ENABLE_MODULES = YES; 428 | CLANG_ENABLE_OBJC_ARC = YES; 429 | CLANG_ENABLE_OBJC_WEAK = YES; 430 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 431 | CLANG_WARN_BOOL_CONVERSION = YES; 432 | CLANG_WARN_COMMA = YES; 433 | CLANG_WARN_CONSTANT_CONVERSION = YES; 434 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 435 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 436 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 437 | CLANG_WARN_EMPTY_BODY = YES; 438 | CLANG_WARN_ENUM_CONVERSION = YES; 439 | CLANG_WARN_INFINITE_RECURSION = YES; 440 | CLANG_WARN_INT_CONVERSION = YES; 441 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 442 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 443 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 444 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 445 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 446 | CLANG_WARN_STRICT_PROTOTYPES = YES; 447 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 448 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 449 | CLANG_WARN_UNREACHABLE_CODE = YES; 450 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 451 | COPY_PHASE_STRIP = NO; 452 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 453 | ENABLE_NS_ASSERTIONS = NO; 454 | ENABLE_STRICT_OBJC_MSGSEND = YES; 455 | GCC_C_LANGUAGE_STANDARD = gnu11; 456 | GCC_NO_COMMON_BLOCKS = YES; 457 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 458 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 459 | GCC_WARN_UNDECLARED_SELECTOR = YES; 460 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 461 | GCC_WARN_UNUSED_FUNCTION = YES; 462 | GCC_WARN_UNUSED_VARIABLE = YES; 463 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 464 | MTL_ENABLE_DEBUG_INFO = NO; 465 | MTL_FAST_MATH = YES; 466 | SDKROOT = iphoneos; 467 | SWIFT_COMPILATION_MODE = wholemodule; 468 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 469 | VALIDATE_PRODUCT = YES; 470 | }; 471 | name = Release; 472 | }; 473 | E2D72559242E1E5B00C31BAD /* Debug */ = { 474 | isa = XCBuildConfiguration; 475 | buildSettings = { 476 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 477 | CODE_SIGN_STYLE = Automatic; 478 | DEVELOPMENT_TEAM = TS6L5G84J8; 479 | INFOPLIST_FILE = VideoTimelineView/Info.plist; 480 | LD_RUNPATH_SEARCH_PATHS = ( 481 | "$(inherited)", 482 | "@executable_path/Frameworks", 483 | ); 484 | PRODUCT_BUNDLE_IDENTIFIER = com.tomo.VideoTimelineView; 485 | PRODUCT_NAME = "$(TARGET_NAME)"; 486 | SWIFT_VERSION = 5.0; 487 | TARGETED_DEVICE_FAMILY = "1,2"; 488 | }; 489 | name = Debug; 490 | }; 491 | E2D7255A242E1E5B00C31BAD /* Release */ = { 492 | isa = XCBuildConfiguration; 493 | buildSettings = { 494 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 495 | CODE_SIGN_STYLE = Automatic; 496 | DEVELOPMENT_TEAM = TS6L5G84J8; 497 | INFOPLIST_FILE = VideoTimelineView/Info.plist; 498 | LD_RUNPATH_SEARCH_PATHS = ( 499 | "$(inherited)", 500 | "@executable_path/Frameworks", 501 | ); 502 | PRODUCT_BUNDLE_IDENTIFIER = com.tomo.VideoTimelineView; 503 | PRODUCT_NAME = "$(TARGET_NAME)"; 504 | SWIFT_VERSION = 5.0; 505 | TARGETED_DEVICE_FAMILY = "1,2"; 506 | }; 507 | name = Release; 508 | }; 509 | E2D7255C242E1E5B00C31BAD /* Debug */ = { 510 | isa = XCBuildConfiguration; 511 | buildSettings = { 512 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 513 | BUNDLE_LOADER = "$(TEST_HOST)"; 514 | CODE_SIGN_STYLE = Automatic; 515 | DEVELOPMENT_TEAM = TS6L5G84J8; 516 | INFOPLIST_FILE = VideoTimelineViewTests/Info.plist; 517 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 518 | LD_RUNPATH_SEARCH_PATHS = ( 519 | "$(inherited)", 520 | "@executable_path/Frameworks", 521 | "@loader_path/Frameworks", 522 | ); 523 | PRODUCT_BUNDLE_IDENTIFIER = com.tomo.VideoTimelineViewTests; 524 | PRODUCT_NAME = "$(TARGET_NAME)"; 525 | SWIFT_VERSION = 5.0; 526 | TARGETED_DEVICE_FAMILY = "1,2"; 527 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VideoTimelineView.app/VideoTimelineView"; 528 | }; 529 | name = Debug; 530 | }; 531 | E2D7255D242E1E5B00C31BAD /* Release */ = { 532 | isa = XCBuildConfiguration; 533 | buildSettings = { 534 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 535 | BUNDLE_LOADER = "$(TEST_HOST)"; 536 | CODE_SIGN_STYLE = Automatic; 537 | DEVELOPMENT_TEAM = TS6L5G84J8; 538 | INFOPLIST_FILE = VideoTimelineViewTests/Info.plist; 539 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 540 | LD_RUNPATH_SEARCH_PATHS = ( 541 | "$(inherited)", 542 | "@executable_path/Frameworks", 543 | "@loader_path/Frameworks", 544 | ); 545 | PRODUCT_BUNDLE_IDENTIFIER = com.tomo.VideoTimelineViewTests; 546 | PRODUCT_NAME = "$(TARGET_NAME)"; 547 | SWIFT_VERSION = 5.0; 548 | TARGETED_DEVICE_FAMILY = "1,2"; 549 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VideoTimelineView.app/VideoTimelineView"; 550 | }; 551 | name = Release; 552 | }; 553 | E2D7255F242E1E5B00C31BAD /* Debug */ = { 554 | isa = XCBuildConfiguration; 555 | buildSettings = { 556 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 557 | CODE_SIGN_STYLE = Automatic; 558 | DEVELOPMENT_TEAM = TS6L5G84J8; 559 | INFOPLIST_FILE = VideoTimelineViewUITests/Info.plist; 560 | LD_RUNPATH_SEARCH_PATHS = ( 561 | "$(inherited)", 562 | "@executable_path/Frameworks", 563 | "@loader_path/Frameworks", 564 | ); 565 | PRODUCT_BUNDLE_IDENTIFIER = com.tomo.VideoTimelineViewUITests; 566 | PRODUCT_NAME = "$(TARGET_NAME)"; 567 | SWIFT_VERSION = 5.0; 568 | TARGETED_DEVICE_FAMILY = "1,2"; 569 | TEST_TARGET_NAME = VideoTimelineView; 570 | }; 571 | name = Debug; 572 | }; 573 | E2D72560242E1E5B00C31BAD /* Release */ = { 574 | isa = XCBuildConfiguration; 575 | buildSettings = { 576 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 577 | CODE_SIGN_STYLE = Automatic; 578 | DEVELOPMENT_TEAM = TS6L5G84J8; 579 | INFOPLIST_FILE = VideoTimelineViewUITests/Info.plist; 580 | LD_RUNPATH_SEARCH_PATHS = ( 581 | "$(inherited)", 582 | "@executable_path/Frameworks", 583 | "@loader_path/Frameworks", 584 | ); 585 | PRODUCT_BUNDLE_IDENTIFIER = com.tomo.VideoTimelineViewUITests; 586 | PRODUCT_NAME = "$(TARGET_NAME)"; 587 | SWIFT_VERSION = 5.0; 588 | TARGETED_DEVICE_FAMILY = "1,2"; 589 | TEST_TARGET_NAME = VideoTimelineView; 590 | }; 591 | name = Release; 592 | }; 593 | /* End XCBuildConfiguration section */ 594 | 595 | /* Begin XCConfigurationList section */ 596 | E2D72529242E1E4F00C31BAD /* Build configuration list for PBXProject "VideoTimelineView" */ = { 597 | isa = XCConfigurationList; 598 | buildConfigurations = ( 599 | E2D72556242E1E5A00C31BAD /* Debug */, 600 | E2D72557242E1E5A00C31BAD /* Release */, 601 | ); 602 | defaultConfigurationIsVisible = 0; 603 | defaultConfigurationName = Release; 604 | }; 605 | E2D72558242E1E5B00C31BAD /* Build configuration list for PBXNativeTarget "VideoTimelineView" */ = { 606 | isa = XCConfigurationList; 607 | buildConfigurations = ( 608 | E2D72559242E1E5B00C31BAD /* Debug */, 609 | E2D7255A242E1E5B00C31BAD /* Release */, 610 | ); 611 | defaultConfigurationIsVisible = 0; 612 | defaultConfigurationName = Release; 613 | }; 614 | E2D7255B242E1E5B00C31BAD /* Build configuration list for PBXNativeTarget "VideoTimelineViewTests" */ = { 615 | isa = XCConfigurationList; 616 | buildConfigurations = ( 617 | E2D7255C242E1E5B00C31BAD /* Debug */, 618 | E2D7255D242E1E5B00C31BAD /* Release */, 619 | ); 620 | defaultConfigurationIsVisible = 0; 621 | defaultConfigurationName = Release; 622 | }; 623 | E2D7255E242E1E5B00C31BAD /* Build configuration list for PBXNativeTarget "VideoTimelineViewUITests" */ = { 624 | isa = XCConfigurationList; 625 | buildConfigurations = ( 626 | E2D7255F242E1E5B00C31BAD /* Debug */, 627 | E2D72560242E1E5B00C31BAD /* Release */, 628 | ); 629 | defaultConfigurationIsVisible = 0; 630 | defaultConfigurationName = Release; 631 | }; 632 | /* End XCConfigurationList section */ 633 | }; 634 | rootObject = E2D72526242E1E4F00C31BAD /* Project object */; 635 | } 636 | -------------------------------------------------------------------------------- /VideoTimelineView/VideoTimelineView/VideoTimelineView/TrimView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrimView.swift 3 | // Examplay 4 | // 5 | // Created by Tomohiro Yamashita on 2020/03/12. 6 | // Copyright © 2020 Tom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TrimView: UIView { 12 | var mainView:VideoTimelineView! 13 | 14 | var timelineView:TimelineView! 15 | var parentScroller:TimelineScroller! 16 | var startKnob = TrimKnob() 17 | var endKnob = TrimKnob() 18 | var movieDuration:Float64 = 0 19 | 20 | let canPassThroughEachKnobs = true 21 | 22 | override init (frame: CGRect) { 23 | super.init(frame: frame) 24 | self.isUserInteractionEnabled = false 25 | self.backgroundColor = .clear 26 | 27 | } 28 | 29 | required init(coder aDecoder: NSCoder) { 30 | fatalError("TrimView init(coder:) has not been implemented") 31 | } 32 | 33 | func configure(_ timeline:TimelineView, scroller:TimelineScroller) { 34 | timelineView = timeline 35 | parentScroller = scroller 36 | self.frame = timeline.frame 37 | startKnob.configure(timeline, trimmer:self) 38 | endKnob.configure(timeline, trimmer:self) 39 | 40 | timelineView.addSubview(startKnob) 41 | timelineView.addSubview(endKnob) 42 | } 43 | 44 | 45 | func reset(duration:Float64) { 46 | movieDuration = duration 47 | startKnob.knobTimePoint = 0 48 | endKnob.knobTimePoint = 3 49 | if duration < endKnob.knobTimePoint { 50 | endKnob.knobTimePoint = duration 51 | } 52 | layout() 53 | } 54 | 55 | 56 | 57 | let knobWidth:CGFloat = 20 58 | let knobWidthExtend:CGFloat = 5 59 | func layout() { 60 | if self.isHidden { 61 | return 62 | } 63 | 64 | swapKnobs() 65 | 66 | let knobPositions = knobPositionsAsVisible() 67 | let startPosition = knobPositions.start 68 | let endPosition = knobPositions.end 69 | 70 | startKnob.knobPositionOnScreen = startPosition 71 | endKnob.knobPositionOnScreen = endPosition 72 | startKnob.isOutOfScreen = knobPositions.startFixed 73 | endKnob.isOutOfScreen = knobPositions.endFixed 74 | 75 | startKnob.frame = CGRect(x:startPosition - knobWidth - knobWidthExtend, y:self.frame.origin.y, width:knobWidth + knobWidthExtend * 2, height:self.frame.size.height) 76 | endKnob.frame = CGRect(x:endPosition - knobWidthExtend, y:self.frame.origin.y, width:knobWidth + knobWidthExtend * 2, height:self.frame.size.height) 77 | 78 | self.setNeedsDisplay() 79 | } 80 | 81 | //MARK: - draw 82 | 83 | override func draw(_ rect: CGRect) { 84 | 85 | 86 | var startRect = startKnob.frame 87 | var endRect = endKnob.frame 88 | if startRect.size.height <= 0 { 89 | return 90 | } 91 | if animating { 92 | if let layer = startKnob.layer.presentation() { 93 | startRect = layer.frame 94 | } 95 | if let layer = endKnob.layer.presentation() { 96 | endRect = layer.frame 97 | } 98 | } 99 | startRect.origin.x += knobWidthExtend 100 | startRect.size.width -= knobWidthExtend * 2 101 | endRect.origin.x += knobWidthExtend 102 | endRect.size.width -= knobWidthExtend * 2 103 | if startRect.origin.x > endRect.origin.x + endRect.size.width { 104 | let swapRect = startRect 105 | startRect = endRect 106 | endRect = swapRect 107 | } 108 | 109 | 110 | let beamWidth:CGFloat = 3 111 | var outerRect = CGRect(x: startRect.origin.x,y: 0,width: endRect.origin.x + endRect.size.width - startRect.origin.x,height:startRect.size.height) 112 | var innerRect = CGRect(x:startRect.origin.x + startRect.size.width,y:beamWidth,width: endRect.origin.x - startRect.origin.x - startRect.size.width,height:startRect.size.height - (beamWidth * 2)) 113 | 114 | let screenLeft = cgToTime(screenToTimelinePosition(0)) 115 | let screenRight = cgToTime(screenToTimelinePosition(timelineView.frame.size.width)) 116 | var color = UIColor(hue: 0.1, saturation:0.8, brightness:1, alpha: 1) 117 | let outColor = UIColor(hue: 0.1, saturation:0.8, brightness:1, alpha: 0.3) 118 | if (endKnob.knobTimePoint < screenLeft || startKnob.knobTimePoint > screenRight) { 119 | color = outColor 120 | } else { 121 | let addition = knobWidth + 10 122 | let knobWidthTime = cgToTime(knobWidth) 123 | if endKnob.knobTimePoint + knobWidthTime * 0.9 > screenRight { 124 | outerRect.size.width += addition 125 | innerRect.size.width += addition 126 | let outRect = CGRect(x: timelineView.frame.size.width - knobWidth, y: beamWidth,width: knobWidth, height: endRect.size.height - beamWidth * 2) 127 | let path = UIBezierPath(rect:outRect) 128 | outColor.setFill() 129 | path.fill() 130 | } 131 | if startKnob.knobTimePoint - knobWidthTime * 0.9 < screenLeft { 132 | outerRect.origin.x -= addition 133 | innerRect.origin.x -= addition 134 | outerRect.size.width += addition 135 | innerRect.size.width += addition 136 | let outRect = CGRect(x: 0, y: beamWidth,width: knobWidth, height: endRect.size.height - beamWidth * 2) 137 | let path = UIBezierPath(rect:outRect) 138 | outColor.setFill() 139 | path.fill() 140 | } 141 | } 142 | 143 | let path = UIBezierPath(roundedRect:outerRect, cornerRadius:5) 144 | path.usesEvenOddFillRule = true 145 | let innerPath = UIBezierPath(roundedRect:innerRect, cornerRadius:2) 146 | path.append(innerPath) 147 | color.setFill() 148 | path.fill() 149 | } 150 | 151 | 152 | 153 | //MARK: - timer for animation 154 | var animationTimer = Timer() 155 | var animating = false 156 | func startAnimation() { 157 | animationTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(self.animate(_:)), userInfo: nil, repeats: true) 158 | RunLoop.main.add(animationTimer, forMode:RunLoop.Mode.common) 159 | animating = true 160 | } 161 | 162 | func stopAnimation() { 163 | animating = false 164 | self.setNeedsDisplay() 165 | animationTimer.invalidate() 166 | } 167 | 168 | @objc func animate(_ timer:Timer) { 169 | if animating == false { 170 | return 171 | } 172 | self.setNeedsDisplay() 173 | } 174 | 175 | 176 | //MARK: - positioning 177 | func swapKnobs() { 178 | if startKnob.knobTimePoint > endKnob.knobTimePoint { 179 | if canPassThroughEachKnobs { 180 | //timelineView.swapTrimKnobs() 181 | } else { 182 | let start = endKnob.knobTimePoint 183 | endKnob.knobTimePoint = startKnob.knobTimePoint 184 | startKnob.knobTimePoint = start 185 | } 186 | } 187 | } 188 | 189 | func anotherKnob(_ knob:TrimKnob) -> TrimKnob { 190 | if knob == startKnob { 191 | return endKnob 192 | } 193 | return startKnob 194 | } 195 | 196 | func knobOnScreen(_ knob:TrimKnob) -> CGFloat { 197 | let maxWidth = scrollMaxWidth() 198 | let offset = scrollOffset() 199 | let position = CGFloat(knob.knobTimePoint / movieDuration) * maxWidth 200 | return position - offset + (timelineView.frame.size.width / 2) 201 | } 202 | 203 | func knobsMinDistanceTime() -> Float64 { 204 | return Float64(0.1) 205 | } 206 | 207 | func knobsMinDistanceFloat() -> CGFloat { 208 | return timeToCG(knobsMinDistanceTime()) 209 | } 210 | 211 | func timeToCG(_ time:Float64) -> CGFloat { 212 | return CGFloat(time / movieDuration) * scrollMaxWidth() 213 | } 214 | 215 | func cgToTime(_ cgFloat:CGFloat) -> Float64 { 216 | return Float64(cgFloat / scrollMaxWidth()) * movieDuration 217 | } 218 | 219 | func screenToTimelinePosition(_ onScreen:CGFloat) -> CGFloat { 220 | return onScreen + scrollOffset() - (timelineView.frame.size.width / 2) 221 | } 222 | 223 | func timelineToScreenPosition(_ position:CGFloat) -> CGFloat { 224 | return position - scrollOffset() + (timelineView.frame.size.width / 2) 225 | } 226 | 227 | func knobPositionsAsVisible() -> (start:CGFloat, end:CGFloat, startFixed:Bool, endFixed:Bool) { 228 | 229 | let positionStart = knobOnScreen(startKnob) 230 | let positionEnd = knobOnScreen(endKnob) 231 | var resultStart = positionStart 232 | var resultEnd = positionEnd 233 | var startFixed:Bool = false 234 | var endFixed:Bool = false 235 | let screenRight = timelineView.frame.size.width// + offset 236 | let minDistance = knobsMinDistanceFloat() 237 | 238 | if positionStart < knobWidth { 239 | if (positionEnd - minDistance - knobWidth) < 0 { 240 | resultStart = positionEnd - minDistance 241 | } else { 242 | resultStart = knobWidth 243 | } 244 | startFixed = true 245 | } else if positionStart > screenRight { 246 | resultStart = screenRight 247 | startFixed = true 248 | } 249 | if positionEnd < 0 { 250 | resultEnd = 0 251 | endFixed = true 252 | } else if positionEnd + knobWidth > screenRight { 253 | if positionStart + minDistance + knobWidth > screenRight { 254 | resultEnd = positionStart + minDistance 255 | } else { 256 | resultEnd = screenRight - knobWidth 257 | } 258 | } 259 | if true { 260 | let distance = abs(resultStart - resultEnd) 261 | if distance < minDistance { 262 | let value = (minDistance - distance) / 2 263 | resultStart -= value 264 | resultEnd += value 265 | startFixed = true 266 | endFixed = true 267 | } 268 | } 269 | return (resultStart, resultEnd, startFixed, endFixed) 270 | } 271 | 272 | func scrollOffset() -> CGFloat { 273 | return parentScroller.contentOffset.x 274 | } 275 | 276 | func scrollMaxWidth() -> CGFloat { 277 | return parentScroller.frameImagesView.frame.size.width 278 | } 279 | 280 | func knobTimeOnScreen(_ knob:TrimKnob) -> Float64 { 281 | let offset = scrollOffset() 282 | let position = offset + knob.knobPositionOnScreen - (timelineView.frame.size.width / 2) 283 | let maxWidth = scrollMaxWidth() 284 | let time = Float64(position / maxWidth) * movieDuration 285 | return time 286 | } 287 | 288 | 289 | func knobMoveRange(_ knob:TrimKnob) -> (min:Float64, max:Float64) { 290 | var min:Float64 = 0 291 | var max:Float64 = movieDuration 292 | let minDistance = knobsMinDistanceTime() 293 | if canPassThroughEachKnobs { 294 | if knob == startKnob { 295 | max -= minDistance 296 | } else if knob == endKnob { 297 | min += minDistance 298 | } 299 | } else { 300 | if knob == startKnob { 301 | max = endKnob.knobTimePoint - minDistance 302 | } else if knob == endKnob { 303 | min = startKnob.knobTimePoint + minDistance 304 | } 305 | } 306 | return (min, max) 307 | } 308 | 309 | func visibleKnobMoveLimit(_ knob:TrimKnob, margin:CGFloat) -> (min:Bool, max:Bool) { 310 | let range = knobMoveRange(knob) 311 | let minOnScreen = timelineToScreenPosition(timeToCG(range.min)) 312 | let maxOnScreen = timelineToScreenPosition(timeToCG(range.max)) 313 | var resultMin = false 314 | var resultMax = false 315 | if minOnScreen >= margin && minOnScreen <= timelineView.frame.width - margin { 316 | resultMin = true 317 | } 318 | if maxOnScreen >= margin && maxOnScreen <= timelineView.frame.width - margin { 319 | resultMax = true 320 | } 321 | return (resultMin , resultMax ) 322 | } 323 | 324 | func directionReachesEnd(_ knob:TrimKnob, direction:CGFloat) -> Bool { 325 | let visibleLimit = visibleKnobMoveLimit(knob, margin:knobWidth) 326 | if direction > 0 { 327 | if visibleLimit.max { 328 | return true 329 | } 330 | } else if direction < 0 { 331 | if visibleLimit.min { 332 | return true 333 | } 334 | } 335 | return false 336 | } 337 | 338 | func fixKnobPoint(_ knob:TrimKnob, move:CGFloat, startKnobPoint:Float64) -> (knobPoint:Float64, fixed:Bool) { 339 | 340 | var timePoint = startKnobPoint + cgToTime(move) 341 | 342 | let moveRange = knobMoveRange(knob) 343 | var fixed = false 344 | if timePoint < moveRange.min { 345 | timePoint = moveRange.min 346 | fixed = true 347 | } 348 | if timePoint > moveRange.max { 349 | timePoint = moveRange.max 350 | fixed = true 351 | } 352 | return (timePoint, fixed) 353 | } 354 | 355 | func updateKnob(_ knob:TrimKnob, timePoint:Float64) { 356 | knob.knobTimePoint = timePoint 357 | layout() 358 | 359 | timelineView.moved(knob.knobTimePoint) 360 | } 361 | 362 | func resetSeek(_ time:Float64) { 363 | if edgeScrolled { 364 | moveToTimeWithAnimation(time) 365 | } else { 366 | mainView!.accurateSeek(time, scrub:true) 367 | } 368 | edgeScrolled = false 369 | } 370 | 371 | func moveToTimeWithAnimation(_ time:Float64) { 372 | moveToTimeAndTrimWithAnimation(time, trim:nil) 373 | } 374 | 375 | func moveToTimeAndTrimWithAnimation(_ time:Float64, trim:VideoTimelineTrim?) { 376 | let player = mainView!.player 377 | if player == nil { 378 | return 379 | } 380 | if player!.timeControlStatus == .playing { 381 | player!.pause() 382 | } 383 | timelineView.animating = true 384 | mainView!.isUserInteractionEnabled = false 385 | timelineView.startAnimation() 386 | 387 | UIView.animate(withDuration: 0.2,delay:Double(0.0),options:UIView.AnimationOptions.curveEaseOut, animations: { () -> Void in 388 | if let pinnedTrim = trim { 389 | self.timelineView.setTrim(start:pinnedTrim.start,end:pinnedTrim.end) 390 | } 391 | self.timelineView.setCurrentTime(time,force:false) 392 | },completion: { finished in 393 | 394 | self.mainView!.isUserInteractionEnabled = true 395 | if self.mainView!.playing { 396 | self.mainView!.accurateSeek(time, scrub:false) 397 | player!.play() 398 | } else { 399 | self.mainView!.accurateSeek(time, scrub:true) 400 | } 401 | self.timelineView.animating = false 402 | self.timelineView.setManualScrolledAfterEnd() 403 | self.timelineView.stopAnimation() 404 | if let receiver = self.mainView!.playStatusReceiver { 405 | receiver.videoTimelineMoved() 406 | } 407 | }) 408 | } 409 | 410 | //MARK: - edgeScroll 411 | var edgeScrollTimer = Timer() 412 | var edgeScrolled = false 413 | var edgeScrollingKnob:TrimKnob? = nil 414 | var edgeScrollStrength:CGFloat = 0 415 | var edgeScrollingKnobPosition:CGFloat = 0 416 | var edgeScrollLastChangedTime:Date = Date() 417 | var edgeScrollLastChangedPosition:CGFloat = 0 418 | 419 | 420 | func updateEdgeScroll(_ knob:TrimKnob, strength:CGFloat, position:CGFloat) { 421 | var changed = false 422 | if edgeScrollStrength != strength { 423 | edgeScrollStrength = strength 424 | changed = true 425 | } 426 | if edgeScrolling() == false { 427 | startEdgeScroll(knob) 428 | edgeScrolled = true 429 | } else if changed { 430 | edgeScrollLastChangedPosition = scrollOffset() 431 | edgeScrollLastChangedTime = Date() 432 | } 433 | edgeScrollingKnobPosition = position 434 | edgeScrollingKnob = knob 435 | } 436 | 437 | func startEdgeScroll(_ knob:TrimKnob) { 438 | edgeScrollTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(self.edgeScrollTimer(_:)), userInfo: nil, repeats: true) 439 | RunLoop.main.add(edgeScrollTimer, forMode:RunLoop.Mode.common) 440 | edgeScrollLastChangedTime = Date() 441 | edgeScrollLastChangedPosition = scrollOffset() 442 | } 443 | 444 | @objc func edgeScrollTimer(_ timer:Timer) { 445 | if let knob = edgeScrollingKnob { 446 | let movedPosition = currentEdgeScrollMovedPosition() 447 | let destination = currentEdgeScrollPosition(movedPosition) 448 | let moveRange = knobMoveRange(knob) 449 | var knobPoint = cgToTime(edgeScrollingKnobPosition - (timelineView.frame.size.width / 2) + destination) 450 | 451 | var overLimit:Float64 = 0 452 | if knobPoint > moveRange.max { 453 | overLimit = knobPoint - moveRange.max 454 | knobPoint = moveRange.max 455 | 456 | } else if knobPoint < moveRange.min { 457 | overLimit = knobPoint - moveRange.min 458 | knobPoint = moveRange.min 459 | 460 | } 461 | updateKnob(knob, timePoint:knobPoint) 462 | if (knob == startKnob && knobPoint > endKnob.knobTimePoint) || (knob == endKnob && knobPoint < startKnob.knobTimePoint) { 463 | timelineView.swapTrimKnobs() 464 | } 465 | timelineView.setCurrentTime(cgToTime(destination) - overLimit,force:true) 466 | } 467 | } 468 | 469 | func stopEdgeScrollTimer() { 470 | edgeScrollTimer.invalidate() 471 | edgeScrollStrength = 0 472 | edgeScrollingKnob = nil 473 | } 474 | 475 | func edgeScrolling() -> Bool { 476 | return edgeScrollTimer.isValid 477 | } 478 | 479 | func currentEdgeScrollPosition(_ moved:CGFloat) -> CGFloat { 480 | var result:CGFloat = 0 481 | 482 | let maxWidth = scrollMaxWidth() 483 | result = edgeScrollLastChangedPosition + moved 484 | if result < 0 { 485 | result = 0 486 | } else if result > maxWidth { 487 | result = maxWidth 488 | } 489 | return result 490 | } 491 | 492 | func currentEdgeScrollMovedPosition() -> CGFloat { 493 | let pastTime = -edgeScrollLastChangedTime.timeIntervalSinceNow 494 | return CGFloat(pastTime) * edgeScrollStrength * 5 495 | } 496 | } 497 | 498 | 499 | //MARK: - TrimKnob 500 | class TrimKnob:UIView { 501 | var timelineView:TimelineView! 502 | var knobPositionOnScreen:CGFloat = 0 503 | var trimView:TrimView! 504 | var knobTimePoint:Float64 = 0 505 | var isOutOfScreen:Bool = false 506 | 507 | 508 | override init (frame: CGRect) { 509 | super.init(frame: frame) 510 | 511 | self.isMultipleTouchEnabled = true 512 | self.isUserInteractionEnabled = true 513 | 514 | } 515 | 516 | required init(coder aDecoder: NSCoder) { 517 | fatalError("TrimKnob init(coder:) has not been implemented") 518 | } 519 | 520 | func configure(_ timeline:TimelineView, trimmer:TrimView) { 521 | timelineView = timeline 522 | trimView = trimmer 523 | } 524 | 525 | //MARK: - Touch Events 526 | var allTouches = [UITouch]() 527 | 528 | override open func touchesBegan(_ touches: Set, with event: UIEvent?) 529 | { 530 | for touch in touches { 531 | if !allTouches.contains(touch) { 532 | allTouches += [touch] 533 | } 534 | if !timelineView!.allTouches.contains(touch) { 535 | timelineView!.allTouches += [touch] 536 | } 537 | } 538 | if timelineView!.allTouches.count == 1 && allTouches.count == 1 { 539 | if dragging == false { 540 | startDrag() 541 | } 542 | evaluateTap = true 543 | } else { 544 | evaluateTap = false 545 | } 546 | } 547 | 548 | 549 | override open func touchesMoved(_ touches: Set, with event: UIEvent?) 550 | { 551 | if timelineView!.allTouches.count > 1 { 552 | if dragging { 553 | cancelDrag() 554 | } 555 | } 556 | if timelineView!.allTouches.count == 2 { 557 | if timelineView!.pinching { 558 | timelineView!.updatePinch() 559 | } else { 560 | timelineView!.startPinch() 561 | } 562 | } 563 | if dragging && timelineView!.allTouches.count == 1 && allTouches.count == 1 { 564 | updateDrag() 565 | } 566 | evaluateTap = false 567 | } 568 | 569 | override open func touchesEnded(_ touches: Set, with event: UIEvent?) 570 | { 571 | for touch in touches { 572 | if let index = allTouches.firstIndex(of:touch) { 573 | allTouches.remove(at: index) 574 | } 575 | if let index = timelineView!.allTouches.firstIndex(of:touch) { 576 | timelineView!.allTouches.remove(at: index) 577 | } 578 | } 579 | if timelineView!.pinching && timelineView!.allTouches.count < 2 { 580 | timelineView!.endPinch() 581 | } 582 | if dragging { 583 | endDrag() 584 | } 585 | 586 | if evaluateTap && timelineView!.allTouches.count == 0 { 587 | tapped() 588 | } 589 | evaluateTap = false 590 | } 591 | 592 | override open func touchesCancelled(_ touches: Set, with event: UIEvent?) 593 | { 594 | for touch in touches { 595 | if let index = allTouches.firstIndex(of:touch) { 596 | allTouches.remove(at: index) 597 | } 598 | if let index = timelineView!.allTouches.firstIndex(of:touch) { 599 | timelineView!.allTouches.remove(at: index) 600 | } 601 | } 602 | 603 | if timelineView!.pinching { 604 | timelineView!.endPinch() 605 | } 606 | if dragging { 607 | endDrag() 608 | } 609 | evaluateTap = false 610 | } 611 | 612 | 613 | //MARK: - actions 614 | 615 | var dragging:Bool = false 616 | var dragStartPoint = CGPoint.zero 617 | var startKnobTimePoint:Float64 = 0 618 | var dragStartOffset:CGFloat = 0 619 | var startTimeOutOfScreen:Float64 = 0 620 | var scrolling = false 621 | var startCurrentTime:Float64 = 0 622 | var evaluateTap:Bool = false 623 | var ignoreEdgeScroll = false 624 | 625 | func startDrag() { 626 | 627 | dragging = true 628 | let touch = allTouches[0] 629 | dragStartPoint = touch.location(in: timelineView) 630 | startKnobTimePoint = knobTimePoint 631 | dragStartOffset = trimView.scrollOffset() 632 | startTimeOutOfScreen = trimView.knobTimeOnScreen(self) - startKnobTimePoint 633 | 634 | 635 | startCurrentTime = timelineView.mainView!.currentTime 636 | 637 | if edgeScrollStrength(dragStartPoint.x) != 0 { 638 | ignoreEdgeScroll = true 639 | } else { 640 | ignoreEdgeScroll = false 641 | } 642 | } 643 | 644 | func updateDrag() { 645 | let touch = allTouches[0] 646 | let currentPoint = touch.location(in: timelineView) 647 | let scrolled = trimView.scrollOffset() - dragStartOffset 648 | let move = currentPoint.x - dragStartPoint.x + scrolled 649 | let startKnobPoint = startKnobTimePoint + startTimeOutOfScreen 650 | 651 | let timePoint = startKnobPoint + trimView.cgToTime(move) 652 | let dragPoint = trimView.cgToTime(trimView.screenToTimelinePosition(currentPoint.x)) 653 | let onKnob = trimView.timeToCG(timePoint - dragPoint) 654 | let anotherKnob = trimView.anotherKnob(self) 655 | let anotherTimePoint = anotherKnob.knobTimePoint 656 | let knobWidth = trimView.knobWidth 657 | var swapping:CGFloat = 0 658 | 659 | 660 | if anotherTimePoint < timePoint && startKnobPoint < anotherTimePoint 661 | { 662 | swapping = trimView.timeToCG(anotherTimePoint - timePoint) 663 | if -swapping > knobWidth { 664 | swapping = -knobWidth 665 | } 666 | } else if anotherTimePoint > timePoint && startKnobPoint > anotherTimePoint 667 | { 668 | swapping = trimView.timeToCG(anotherTimePoint - timePoint) 669 | if swapping > knobWidth { 670 | swapping = knobWidth 671 | } 672 | } 673 | var rangeOut = false 674 | let range = trimView.knobMoveRange(self) 675 | if timePoint > range.max || timePoint < range.min { 676 | rangeOut = true 677 | } 678 | 679 | if rangeOut == false && ((self == trimView.startKnob && dragPoint > anotherTimePoint) || (self == trimView.endKnob && dragPoint < anotherTimePoint)) { 680 | timelineView.swapTrimKnobs() 681 | } 682 | 683 | 684 | let strength = edgeScrollStrength(currentPoint.x) 685 | let reachedEnd = trimView.directionReachesEnd(self, direction:strength) 686 | if strength != 0 && ignoreEdgeScroll == false && reachedEnd == false { 687 | trimView.updateEdgeScroll(self, strength:strength, position:currentPoint.x + onKnob + swapping) 688 | } else { 689 | let fixedKnobPoint = trimView.fixKnobPoint(self, move:move + swapping, startKnobPoint:startKnobPoint) 690 | 691 | trimView.updateKnob(self, timePoint:fixedKnobPoint.knobPoint) 692 | if strength == 0 { 693 | ignoreEdgeScroll = false 694 | 695 | } 696 | if strength == 0 || reachedEnd { 697 | if trimView.edgeScrolling() { 698 | trimView.stopEdgeScrollTimer() 699 | } 700 | } 701 | } 702 | 703 | guard let mainView = (timelineView.mainView) else { return } 704 | if let receiver = mainView.playStatusReceiver { 705 | receiver.videoTimelineTrimChanged() 706 | } 707 | } 708 | 709 | func endDrag() { 710 | let anotherKnob = trimView.anotherKnob(self) 711 | let distance = abs(anotherKnob.knobTimePoint - knobTimePoint) 712 | let minDistance = trimView.knobsMinDistanceTime() 713 | if distance < minDistance { 714 | if self == trimView.startKnob { 715 | knobTimePoint -= (minDistance - distance) 716 | } else if self == trimView.endKnob { 717 | knobTimePoint += (minDistance - distance) 718 | } 719 | 720 | trimView.timelineView.animating = true 721 | trimView.startAnimation() 722 | UIView.animate(withDuration: 0.2,delay:Double(0.0),options:UIView.AnimationOptions.curveEaseOut, animations: { () -> Void in 723 | 724 | self.trimView.layout() 725 | 726 | },completion: { finished in 727 | self.trimView.stopAnimation() 728 | self.timelineView.animating = false 729 | }) 730 | } 731 | timelineView.setManualScrolledAfterEnd() 732 | trimView.resetSeek(startCurrentTime) 733 | dragging = false 734 | trimView.stopEdgeScrollTimer() 735 | 736 | if evaluateTap == false { 737 | guard let mainView = (timelineView.mainView) else { return } 738 | if let receiver = mainView.playStatusReceiver { 739 | receiver.videoTimelineTrimChanged() 740 | } 741 | } 742 | } 743 | 744 | func cancelDrag() { 745 | knobTimePoint = startKnobTimePoint 746 | 747 | timelineView.setManualScrolledAfterEnd() 748 | dragging = false 749 | trimView.layout() 750 | trimView.resetSeek(startCurrentTime) 751 | trimView.stopEdgeScrollTimer() 752 | } 753 | 754 | func tapped() { 755 | trimView.moveToTimeWithAnimation(knobTimePoint) 756 | timelineView.setManualScrolledAfterEnd() 757 | } 758 | 759 | 760 | func edgeScrollStrength(_ position:CGFloat) -> CGFloat { 761 | var strength:CGFloat = 0 762 | let edgeWidth:CGFloat = 40 763 | if position >= timelineView.frame.size.width - edgeWidth { 764 | strength = position + edgeWidth - timelineView.frame.size.width 765 | } else if position <= edgeWidth { 766 | strength = position - edgeWidth 767 | } 768 | return strength 769 | } 770 | 771 | 772 | 773 | } 774 | --------------------------------------------------------------------------------