Building Periscope fast rewind control for iOS

Written by gontovnik | Published 2016/01/04
Tech Story Tags: ios | swift | periscope

TLDRvia the TL;DR App

Christmas time is finished. New Year’s Eve is finished as well. I hope that everyone had a great time and you are ready to start working hard and learning new things!

I want to start this year with a wonderful article-tutorial on how to create similar to Periscope fast rewind control.

Requirements

  1. Xcode 7+
  2. iOS 8+
  3. At least basic Swift knowledge
  4. Passion to iOS and development

High-level explanation

To build this component we will have to use video player — AVPlayer. We will add UILongPressGestureRecognizer to begin rewind and calculate finger translation — it will help us to change rewind speed and calculate time. While rewinding is in progress we will use UIVisualEffectView to blur video, and AVAssetImageGenerator to generate preview thumbnails.

OK. Let’s stop talking, just do it.

Create single view application and create new class VideoViewController — subclass of UIViewController. Import AVFoundation and put this code inside class declaration:

Everything we did here is quite easy:

  1. Declared all variables related to AVPlayer which we are going to use in the future;
  2. Created custom initializer with videoURL where we set URL and initialize all — AVURLAsset, AVPlayerItem, AVPlayer and AVPlayerLayer;
  3. In loadView we add player to view sublayers;
  4. In viewDidLoad we simply start playing video;
  5. In prefersStatusBarHidden we return true — it hides status bar if our status bar is based on view controller (it can be changed in Info.plist file).
  6. Simply set frame of playerLayer to view.bounds — we support both portrait and landscape orientations.

OK. Our video view controller is ready for early stage test. In order to test it we have to do two simple steps:

  1. Download this ZIP file, extract and add Resources folder to your project (do not forget to select required targets);
  2. Open ViewController.swift file and put this code inside:

Now run project on your device or simulator and make sure that video view controller is presented, video is playing, and all orientations are supported.

Our next stage is to recognize touches and blur content.

Declare these two variables in VideoViewController:

Paste this code to the end of loadView method:

Add this code to the end of viewWillLayoutSubviews method:

Implement this function:

The purpose of this function is to pause video and fade in visual effect view when touches began, and resume video and fade out visual effect view when touches cancelled, ended or failed. In the future we will add more logic to this method.

Run project and test long press.

That’s how it works on my device:

Next step is to create timeline view and implement rewind functionality. But let’s have a very quick look at Periscope UI to understand what exactly our timeline should have and how it should work.

As you can see — timeline goes from the left side to the right side of the view controller. It has a white dot which indicates where video was stopped when user started rewinding. If you compare screenshots you will notice, that interval width is different — it is because rewind speed (we will call it zoom in our project) values are not equal. And the last thing to notice is that current time is always in the middle of the timeline.

Create TimelineView as a subclass of UIView and declare these variables:

The purpose of each variable is obvious, but let’s make sure everyone understands:

  1. duration — total duration of a video in seconds;
  2. initialTime — seconds, when rewind began;
  3. currentTime — current time in seconds;
  4. _zoom — private variable which stores zoom value;
  5. zoom — wrap on top of _zoom variable, so when new value is going to be assigned we can check whether it is in an acceptable range;
  6. minZoom and maxZoom — both variables define acceptable range for zoom;
  7. intervalWidth — width of a line representing a specific time interval on a timeline. If zoom is not equal 1, then actual interval width equals to intervalWidth * zoom. Value will be used during rewind for calculations — for example, if zoom is 1, intervalWidth is 30 and intervalDuration is 15, then when user moves 10pixels left or right we will rewind by +5 or -5 seconds;
  8. intervalDuration — duration of an interval in seconds. If video is 55 seconds and interval is 15 seconds — then we will have 3 full intervals and one not full interval. Value will be used during rewind for calculations.

minZoom, maxZoom, intervalWidth and intervalDuration could be constants, but I decided to make them vars — if you want to reuse this view controller with different videos you might want to adjust these values.

Implement these functions:

  1. currentIntervalWidth — calculates interval width depending on the zoom value;
  2. timeIntervalFromDistance — calculates time interval in seconds from passed width. Will be used to rewind by distance;
  3. distanceFromTimeInterval — calculates distance from given time interval. Will be used to calculate elapsed interval width;
  4. rewindByDistance — takes distance, calculates time interval from and adds this value to the current time.

Implement draw rect:

Do not be scared. Everything here is simple :) I will explain only those things, which I think might need explanation:

  1. As you probably noticed before — when we rewind in Periscope timeline moves left-right. To have that movement we calculate originX value and apply to all our drawings;
  2. We draw first line which indicates full timeline;
  3. We draw second line which indicates elapsed time;
  4. We draw dot which indicates initial time;
  5. And last, we draw separators between intervals.

And final bit on timeline view for now — implement new initializer and set opaque to false:

I feel that our timeline is finished. Next stage is to add it to view controller and make it work!

Add these variables:

First variable is a content view, where all rewind related views/controls will be added. We will fade in and fade out this view when rewinding begins and finishes. Second variable is our actual timeline view which we implemented in a previous stage. And the last variable will help us to calculate how far finger has moved and how far we should rewind.

Put this code to the end of loadView method:

It adds all new subviews to their superviews and sets duration of the timeline to the duration of the asset.

Update longPressed function with this code:

  1. We have added code to get current location of the finger;
  2. We calculate zoom value depending on the finger location. You can play with this calculation, but I prefer this calculation;
  3. We added logic to set initial time to the timeline when rewind begins;
  4. We call rewindByDistance method when gesture state is equal to .Changed;
  5. We fade in and fade out our newly created content view;
  6. We update previous location x if it is not equal to the current.

Last stage before we try it is to layout our newly created views. Put this code to the end of viewWillLayoutSubviews:

Run project on your device or simulator and check it out!

That’s how it looks on my device:

I really hope that you managed to do everything and it works exactly the same. If not — quickly go threw tutorial again.

As you might have noticed it does not rewind when we release finger. In order to do that add update longPressed function with this code:

We added 2 new lines — line 17 and line 18. With the first line we calculate new time, and with the second line we seek to that time.

Run your project again.. and enjoy!

It works now!

Lazy people can close tutorial at this stage (hope you are not lazy), because core stuff is built and next stage is mainly improvements and some nice-to-have features.

Our goal is to add two new views:

  1. Preview image view which will show thumbnail of the video at current rewind time;
  2. Label, which will show current rewind time in format minutes:seconds.

In order to update these views I decided to add closure to timeline view which will be triggered every time when currentTime value changes.

Add this variable to TimelineView:

Update currentTime variable implementation:

Add these variables to the VideoViewController class:

Add these two lines to the end of init(videoURL: NSURL):

Maximum size specifies the maximum dimensions for generated image. We want maximum height to be rewindPreviewMaxHeight. Generated image is never scaled up. But in most of the cases video size is much higher than the expected thumbnail size.

Add these lines to the end of loadView method:

Update viewWillLayoutSubviews function with this code:

Feel free to change verticalSpacing value. It indicates gap height between preview image view, current time label and timeline view.

And the last step in this section is to implement currentTimeDidChange closure. Add this code just before adding timeline view to it’s superview:

  1. We generate current time string and assign to rewindCurrentTimeLabel.text;
  2. We initialize requested time with current time and required timescale;
  3. We generate CGImages asynchronously. On completion we try to generate UIImage from CGImage, switch to main thread, set image to preview image view and ask to call layout subviews if needed.

Run you project and enjoy! It looks fantastic!

Let’s do the final touch! In Periscope they have a nice shadow behind preview image view. We will add it as well!

Add this variable to VideoViewController:

Add this block of code to the loadView function just before setting up and adding rewindPreviewImageView to subviews (if you add it after rewindPreviewImageView, then it will be higher in a layer hierarchy and will be shown on top of imageView):

For those who does not understand the purpose of line number 6 I would suggest to read about iOS implicit animations.

Update viewWillLayoutSubviews:

Run and check it out.

Everything is great. But.. wait.. our preview image is pixelated!

It is because AVAssetImageGenerator.maximumSize expects us to provide pixels instead of points. Documentation on how points are different from pixels can be found here.

Let’s fix this minor issue.

Update this line all over the class:

with this:

Now run project again.

What’s going on.. Where is our shadow?! Where is our corner radius?!

We forgot one little thing — AVAssetImageGenerator returns us CGImage and we initialize UIImage with CGImage without providing required scale. In our case our needed scale is UIScreen.mainScreen().scale.

Find this line:

And update with this:

Ruuun again!

Wonderful! Everything is back and it works great! Woop woop!!!

Thank you for going threw this tutorial. If you liked it please press heart button and share it with your friends & colleagues.

Source code from this tutorial with some improvements and more features can be downloaded here.

See you in my next articles-tutorials! Bye!


Published by HackerNoon on 2016/01/04