Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save JoeyBodnar/475bd7a6e29552f183faa79eecf7b1f3 to your computer and use it in GitHub Desktop.
Save JoeyBodnar/475bd7a6e29552f183faa79eecf7b1f3 to your computer and use it in GitHub Desktop.
PART 1
So basically, for me the trick to getting this done is to move the video playing outside the cell, meaning outside cellForRowAtIndexPath. For my video, i had it auto play once the table view stopped scrolling, but if you want it to auto play while scrolling, I think that is possible as well, and I'll discuss how a little later. But first some code.
For the time being, I will assume the videos are not coming from a server (in my case they did, and I can cover that too if you want, just you didn't specify in the beginning so I won't immediately get complicated).
First, you will need a preview image--an image of the first frame of the video. This will be set in cellForRowAtIndexPath:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
VideoCell *cell = (VideoCell *)[[self.tableView dequeueReusableCellWithIdentifier:@"cellReuseId"]]
// asynchronously download and set image:
YourObject *image = self.yourDataSourceArray[indexPath.row].image
cell.previewimageView.image = image
}
So that's all that will be done in cell for row at index path. The rest will be done in scroll view delegate methods. You will need a variable declared at the top of your class, isScrolling. The scrollView delegate methods:
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
self.isScrolling = YES;
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
[NSObject cancelPreviousPerformRequestsWithTarget:self];
[self performSelector:@selector(scrollViewDidEndScrollingAnimation:) withObject:nil afterDelay:0.3];
}
-(void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
[NSObject cancelPreviousPerformRequestsWithTarget:self];
}
What those three methods do: scrollViewWillBeginDraging changes self.isScrolling to YES. The other 2 methods combine to tell you when the scrollView has stopped scrolling. Now, as soon as the tableview is done scrolling and you stop, whatever code is inside scrollViewDidEndScrollingAnimation will run. So, that is where the code to play the video will go.
-(void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
[NSObject cancelPreviousPerformRequestsWithTarget:self];
// change self.isScrolling to NO
self.isScrolling = NO;
// return if there are no visible index paths, because then no video to play
if([self.tableView indexPathsForVisibleRows].count == 0) {
return;
}
// only do this if the contentOffset.y is greater than 0, otherwise will get a crash (i.e., pull to refresh)
if (self.tableView.contentOffset.y >= 0) {
// get the index path at the top of the screen
NSIndexPath *topIndexPath = [[self.tableView indexPathsForVisibleRows] objectAtIndex:0];
// now we need to get the overall frame of the top visible index path within the visible section of the UITableView
// we do this because the top visible row can have only a few pixels of it actually visible
// and we only want to play the video if the majority of the cell is visible on screen
// not if the majority is offscreen and cell only partially visible
CGRect topCellTotalrect = [self.tableView rectForRowAtIndexPath:topIndexPath];
CGRect topCellScreenRect = CGRectOffset(topCellTotalrect, -self.tableView.contentOffset.x, -self.tableView.contentOffset.y);
CGFloat topCellY = topCellScreenRect.origin.y;
CGFloat height = <height of your tableView cell that plays the video>
if ((topCellY > -(height * 0.15)) || (topCellY == 0)) {
// this plays the video when 85% of cell is visible on screen
// to change it, i.e. 90% visible onscreen, change 0.15 to 0.10, etc.
// now get a reference to the cell at the top index path below:
VideoCell *videoCell = (VideoCell *)[self.tableView cellForRowAtIndexPath:topIndexPath];
if (videoCell.playerLayer == nil) {
[self playVideoFromDirectory:videoCell];
}
}
}
Above, we only play the video in the cell if the playerLayer is nil. That is because it is possible that you scroll, stop scrolling, then start scrolling again, and land on the same cell, Essentially causing this whole process to happen over again. If that happens, then the cell will be playing 2 videos, and cell reuse will cause the top view to get recycled and play in the wrong cell when you scroll.
Another thing to consider--the code above handles only the top index path (the first visible row), but of course it is possible also for 2 or more cells to be visible on screen at one time. In this case, just do the exact same as above for each visible cell.
// function to play video in cell from documents directory (my app cached video in documents directory)
- (void)playVideoFromDirectory(VideoCell *)videoCell {
videoCell.contentView.backgroundColor = [UIColor clearColor];
videoCell.redView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, videoCell.frame.size.width, [self heightForVideoCell])];
[videoCell.contentView insertSubview:videoCell.redView aboveSubview:videoCell.blueView];
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documentsDirectory = [paths objectAtIndex:0];
NSString *mov = @".mov";
NSString *fullComponent = [NSString stringWithFormat: @"%@%@", self.videoChallengeID, mov];
NSString *fullPath = [documentsDirectory stringByAppendingPathComponent:fullComponent];
NSURL *url = [NSURL fileURLWithPath:fullPath];
videoCell.item = [AVPlayerItem playerItemWithURL:url];
videoCell.player = [[AVPlayer alloc] initWithPlayerItem:videoCell.item];
[videoCell.player setMuted:YES];
videoCell.player.actionAtItemEnd = AVPlayerActionAtItemEndNone;
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(videoFinished:) name:AVPlayerItemDidPlayToEndTimeNotification object:[videoCell.player currentItem]];
videoCell.playerLayer = [AVPlayerLayer playerLayerWithPlayer:videoCell.player];
videoCell.playerLayer.frame = CGRectMake(0, 0, videoCell.frame.size.width, [self heightForVideoCell]);
videoCell.playerLayer.backgroundColor = [UIColor clearColor].CGColor;
videoCell.playerLayer.videoGravity = AVLayerVideoGravityResize;
[videoCell.redView.layer addSublayer:videoCell.playerLayer];
[videoCell.player play];
}
So the above function will probably be different for you, as I don't know where your videos are coming from--documents directory, server, Application Bundle, etc., or how you are saving them (I saved mine as .mov files to the documents directory) but the main takeaway here is: 1) the AVPlayer, AVPlayerItem, and AVPlayerLayer all reside in the cell, not in the view controller. and 2) You need to very delicately handle your subviews.
Here is what my videoCell.h file looked like:
@property (strong, nonatomic)AVPlayerItem *item;
@property (strong, nonatomic)AVPlayer *player;
@property (weak, nonatomic)AVPlayerLayer *playerLayer;
@property (strong, nonatomic) UIView *blueView;
@property (strong, nonatomic) UIView *redView;
and the videoCell.m file:
- (void)awakeFromNib {
self.blueView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.frame.size.width, [self heightForVideoCell])];
self.blueView.backgroundColor = [UIColor clearColor];
[self.contentView addSubView: self.blueView];
self.previewImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, [self widthForPreviewImageView], [self heightForVideoCell])];
[self.contentView insertSubview: self.previewImageView belowSubview:self.blueView];
}
Ok, so looking back, I'm not actually 100% sure that I need the blueView
in order to make all this work, but how I have it set up does indeed work
for for me, and I was confused when I was originally making this,
and once I got it working didn't want to mess with it anymore and possibly ruin it,
so I never took it out. But basically, the view hierarchy is very important.
First, there is the cell. Then, there is the cell contentView. The contentView has a subview,
the blueView, which is ON TOP of the previewImageView--the previewImageView is another contentView subview.
So as you are casually scrolling through the tableView (without stopping)
and seeing the previewImages in the cells, that view hierarchy consists of the blueView and the previewImageView.
Then, you stop scrolling. Ok, so the AVPlayerLayer needs its own UIView, because we are going to add the AVPlayerLayer to the layer of that UIView. That UIView that belongs specially to the AVPlayerLayer is the redView. Ok, so you were scrolling, and now you stopped. As soon as you stop, the scrollview delegate methods above go into action. Finally, it comes to the function (void)playVideoFromDirectory(VideoCell *)videoCell. First thing is the redView is added as a subView. But, the background color is kept as clear, so that this is invisible. Then, once the AVPlayer and AVPlayerItem are initialized, the AVPlayerLayer is added as a sublayer to the redView.layer. Here, the AVPlayerLayer background color is also kept to clear, so that this step is invisible as well. If you don't do this: videoCell.playerLayer.backgroundColor = [UIColor clearColor].CGColor; then whatever color you choose will make a small flash on screen before the video starts playing. Keep it clear, and then there will be no change on the screen until the video actually starts playing. Then it will look like the previewImage that was there just started moving and became a video, which is exactly what you want.
View hierarchy is very important also because of cell reuse. If you don't clear the cell of all video stuff (redView, AVPlayerLayer) when it disappears, then when those cells are reused you will see the AVPlayerLayer on another cell playing the wrong video, and things get messy very fast. So last but not least, the didEndDisplayingCell tableView delegate method:
- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
if ([cell isKindOfClass:[VideoCell class]]) {
VideoCell *videoCell = (VideoCell *)[self.tableView cellForRowAtIndexPath:indexPath];
if (videoCell.redView != nil) {
[videoCell.redView removeFromSuperview];
[videoCell.playerLayer removeFromSuperlayer];
}
}
}
And lastly--this code plays video when you stop scrolling in the tableview. If you want them to play as people scroll, my suggestion is to find a way to calculate scroll view speed (this is possible, I did it originally but later deleted that code and switched to this model), and then perform these calculations not in scrollViewDidEndScrollingAnimation, but instead in scrollViewDidScroll, when the scrollView is scrolling sufficiently slow (I assume you don't want to play videos when the user is scrolling very fast). There might be some other things to worry about with that, but I'm pretty sure that is a good start.
Hope this helps some. If you need any more help let me know, and if your videos are coming from a server and you need to download/cache at the same time then I can help with that too.
AvAssetResourceLoader is used to simultaneously download/cache video (or audio) at the same time. Basically, if you have an incoming URL to an AVAsset item, and the URL is not readable by the AVAsset (basically, not a http protocol), then it defaults to AVAssetResourceLoader to take control of the download process. Most of my code concerning AVAssetResourceLoader comes from here: https://gist.github.com/anonymous/83a93746d1ea52e9d23f There is some discussion there to read as well.
At the top of your class you will need some variables:
@property (strong, nonatomic)NSMutableArray *pendingRequests;
@property NSURLConnection *connection;
@property (nonatomic, strong) NSMutableData *videoData;
@property (nonatomic, strong) NSHTTPURLResponse *response;
@property BOOL isLoadingComplete;
@property (strong, nonatomic) NSURL *videoURL;
@property BOOL downloadInProgress;
So going back to the code from my previous set of messages, in the scrollViewDidEndScrollingAnimation, there was the line where we play the video:
if (videoCell.playerLayer == nil) {
[self playVideoFromDirectory:videoCell];
}
now, instead of playing from the directory, we'll call another function:
[self playStreamingVideoInCell:videoCell withObject: object];
Here "Object" is a class in Parse, and object is an object in that class (i.e., with an objectId, etc)
So as the user scrolls, they then stop, and the scrollViewDidEndScrollingAnimation is called, and eventually comes to the code to play the streaming video. Then the [self playStreamingVideoInCell:videoCell withId: id]; is called, and this code starts to run:
- (void)playStreamingVideoInCell:(VideoCell *)videoCell withObject:(Object *)object {
// Configure Video Cell Here:
// first, get the URL out of the PFFile from the Parse Object.
PFFile *file = object.media;
NSString *urlString = file.url;
self.videoURL = [NSURL URLWithString:urlString];
videoCell.backgroundColor = [UIColor clearColor];
// now comes into play the AVAssetResourceLoader. we init the AVURLAsset with a custom URL
// we do this by modifying the url we got out of the parse object, and passing
// it to the method videoURLWithCustomScheme (that method is listed below)
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[self videoURLWithCustomScheme:@"streaming"] options:nil];
// we set the delegate
[asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];
videoCell.item = [AVPlayerItem playerItemWithAsset:asset];
dispatch_queue_t queue;
videoCell.contentView.backgroundColor = [UIColor clearColor];
queue = dispatch_queue_create("videoLoading", NULL);
// performing on separate queues isn't important here. It's left over form previous code
dispatch_async(queue, ^{
self.pendingRequests = [NSMutableArray array];
dispatch_sync(dispatch_get_main_queue(), ^{
// here begins code that sets up the views properly again
videoCell.redView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, videoCell.frame.size.width, [self heightForVideoCell])];
[videoCell.contentView insertSubview:videoCell.redView aboveSubview:videoCell.blueView];
videoCell.blueView.layer.backgroundColor = [UIColor clearColor].CGColor;
videoCell.player = [[AVPlayer alloc] initWithPlayerItem:videoCell.item];
[videoCell.player setMuted:YES];
videoCell.player.actionAtItemEnd = AVPlayerActionAtItemEndNone;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(videoFinished:) name: AVPlayerItemDidPlayToEndTimeNotification object:[videoCell.player currentItem]];
videoCell.playerLayer = [AVPlayerLayer playerLayerWithPlayer:videoCell.player];
videoCell.playerLayer.frame = CGRectMake(0, 0, videoCell.contentView.frame.size.width, [self heightForVideoCell]);
videoCell.playerLayer.videoGravity = AVLayerVideoGravityResize;
videoCell.playerLayer.backgroundColor = [UIColor clearColor].CGColor;
[videoCell.redView.layer addSublayer:videoCell.playerLayer];
[videoCell.player play];
});
});
}
- (NSURL *)videoURLWithCustomScheme:(NSString *)scheme {
NSURLComponents *components = [[NSURLComponents alloc] initWithURL:[self videoURL] resolvingAgainstBaseURL:NO];
components.scheme = scheme;
return [components URL];
}
Below is just the entire chunk of code I used. I'll leave some comments to explain some things:
#pragma mark - Video Processing
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
self.videoData = [NSMutableData data];
self.response = (NSHTTPURLResponse *)response;
}
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
// once the download starts, you append the data here. at the end, process all pending requests
[self.videoData appendData:data];
NSLog(@"Size of End Image(bytes):%lu",(unsigned long)[self.videoData length]);
[self processPendingRequests];
}
// this is the method that is called when the connection has finished loading. Here, I take the object id
// of the video to be saved (self.videoChallengeID) and save it to the documents directory with the ID
// at the end of the dress, so that I can later retrieve it by its exact same objectId when playing from cache.
// this code my be different for you depending on how you handle the data once it's done.
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
[self processPendingRequests];
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documentsDirectory = [paths objectAtIndex:0];
NSString *dataPath = [documentsDirectory stringByAppendingPathComponent:@"BlackberryVideo"];
if (![[NSFileManager defaultManager] fileExistsAtPath:dataPath])
[[NSFileManager defaultManager] createDirectoryAtPath:dataPath withIntermediateDirectories:NO attributes:nil error:nil];
NSString *mov = @".mov";
NSString *finalPath = [NSString stringWithFormat: @"/%@%@", self.videoChallengeID, mov];
NSString *videoPath = [documentsDirectory stringByAppendingString:finalPath];
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
[defaults setObject:videoPath forKey:self.videoChallengeID];
BOOL success = [self.videoData writeToFile:videoPath atomically:NO];
NSLog(@"Successs:::: %@", success ? @"YES" : @"NO");
NSLog(@"video path --> %@",videoPath);
self.connection = nil;
self.videoData = nil;
self.downloadInProgress = NO;
}
- (void)processPendingRequests {
NSMutableArray *requestsCompleted = [NSMutableArray array];
for (AVAssetResourceLoadingRequest *loadingRequest in self.pendingRequests) {
[self fillInContentInformation:loadingRequest.contentInformationRequest];
BOOL didRespondCompletely = [self respondWithDataForRequest:loadingRequest.dataRequest];
if (didRespondCompletely) {
[requestsCompleted addObject:loadingRequest];
[loadingRequest finishLoading];
}
}
[self.pendingRequests removeObjectsInArray:requestsCompleted];
}
- (void)fillInContentInformation:(AVAssetResourceLoadingContentInformationRequest *)contentInformationRequest {
if (contentInformationRequest == nil || self.response == nil) {
return;
}
NSString *mimeType = [self.response MIMEType];
CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, (__bridge CFStringRef)(mimeType), NULL);
contentInformationRequest.byteRangeAccessSupported = YES;
contentInformationRequest.contentType = CFBridgingRelease(contentType);
contentInformationRequest.contentLength = [self.response expectedContentLength];
}
- (BOOL)respondWithDataForRequest:(AVAssetResourceLoadingDataRequest *)dataRequest {
long long startOffset = dataRequest.requestedOffset;
if (dataRequest.currentOffset != 0) {
startOffset = dataRequest.currentOffset;
}
// Don't have any data at all for this request
if (self.videoData.length < startOffset) {
return NO;
}
// This is the total data we have from startOffset to whatever has been downloaded so far
NSUInteger unreadBytes = self.videoData.length - (NSUInteger)startOffset;
// Respond with whatever is available if we can't satisfy the request fully yet
NSUInteger numberOfBytesToRespondWith = MIN((NSUInteger)dataRequest.requestedLength, unreadBytes);
[dataRequest respondWithData:[self.videoData subdataWithRange:NSMakeRange((NSUInteger)startOffset, numberOfBytesToRespondWith)]];
long long endOffset = startOffset + dataRequest.requestedLength;
BOOL didRespondFully = self.videoData.length >= endOffset;
return didRespondFully;
}
// when you first pass the AVURLAsset the custom scheme, this is the delegate method that is called
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {
if(self.isLoadingComplete == YES) {
//NSLog(@"LOADING WAS COMPLETE");
[self.pendingRequests addObject:loadingRequest];
[self processPendingRequests];
return YES;
}
if (self.connection == nil) {
NSURL *interceptedURL = [loadingRequest.request URL];
NSURLComponents *actualURLComponents = [[NSURLComponents alloc] initWithURL:interceptedURL resolvingAgainstBaseURL:NO];
actualURLComponents.scheme = @"http";
NSURLRequest *request = [NSURLRequest requestWithURL:[actualURLComponents URL]];
self.connection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO];
[self.connection setDelegateQueue:[NSOperationQueue mainQueue]];
self.isLoadingComplete = NO;
[self.connection start];
}
[self.pendingRequests addObject:loadingRequest];
[self processPendingRequests];
return YES;
}
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
[self.pendingRequests removeObject:loadingRequest];
}
So honestly, most of the video download part is sort of "magic", and I have a general sense of what's happening, and when certain delegate methods get called, but the process is hard to take control over. Once a download starts, it is vey difficult to pause it or change anything about the process, aside from completely canceling it.
Canceling the download is actually very important. As I said, once a download starts, as far as I know, it is really difficult to simply pause it or do anything to change it. So if you scroll, then stop, and then go down to another video, what happens is both may begin downloading, and the player gets confused. For me what happened is the player would save the wrong video to the wrong cell sometimes. So part1 the solution was this function:
- (void)cancelDownloadRequest {
[self.timer invalidate];
self.videoData = nil;
[self.connection cancel];
self.connection = nil;
self.response = nil;
[self.pendingRequests removeAllObjects];
self.downloadInProgress = NO;
self.videoChallengeID = @"";
}
Put this in didEndDisplayingCell, and it will completely cancel and reset all variables associated to downloading when the cell goes offscreen. part 3 to come in next message
Part 3 is handling multiple downloads at once. I read that somehow AVAssetResourceLoader could do this, but I never could figure it out, so I just kinda hacked away a custom solution that works for me using NSTimer.
so it looks kind of like this. Going back to the scrollViewDidEndScrollingAnimation method, we have the function called that plays video:
if (videoCell.playerLayer == nil) {
self.videoChallengeID = firstActivity.objectId;
[self playVideoFromCache:videoCell];
}
Now, whenever you start downloading a video, set self.downloadInProgress = YES;. Change the scrollViewDidEndScrollingAnimation code to call the function to this:
if (self.downloadInProgress) {
NSDictionary *downloadParameters = @{@"id": firstActivity.objectId, @"cell": videoCell, @"activity": firstActivity};
self.timer = [NSTimer scheduledTimerWithTimeInterval:0.4 target:self selector:@selector(downloadNextVideo:) userInfo:downloadParameters repeats:YES];
}
so what this does, is if you are currently downloading a video, and then scroll down a little bit and it tries to start downloading a new video--it will not begin downloading it, but will instead set off a timer that checks every 0.4 seconds to see if the current video downloading has finished or not. When it has, then it will start playing/downloading the video. Below is the downloadNextVideo method that does this:
- (void)downloadNextVideo:(NSTimer *)timer {
if (self.downloadInProgress == NO) {
NSString *videoID = [[timer userInfo] objectForKey:@"id"];
VideoCell *videoCell = [[timer userInfo] objectForKey:@"cell"];
Activity *activity = [[timer userInfo] objectForKey:@"activity"];
self.videoChallengeID = videoID;
[self.timer invalidate];
self.downloadInProgress = YES;
[self playStreamingVideoInCell:videoCell withActivity:activity];
}
}
So as you can see, the method above is called every 0.4 seconds. As soon as self.downloadInProgress = NO, then it will start the process of streaming the new video in the cell.
This covers the case of when you start downloading a video, but then don't completely scroll that cell off screen (so didEndDisplayingCell is not called to cancel the download), but you are displaying enough of another cell to warrant starting the download process.
The timer as a solution to the simultaneous download problem seems kind of tacky and convoluted I know, but as you said, the documentation for AVAssetResourceLoader is almost nonexistent and it was the only way I could figure it out. But if you have another more elegant solution I'd love to hear it :) I've only been programming for 1 year now so there is probably some way that is obvious that I'm just missing lol.
anyways hope this solution helps, I am going to sleep soon but will try to answer any more questions. and yes I do have a twitter, here: https://twitter.com/courir321
as for parse server, I think it has potential but I don't plan on using it in the future. I know a decent amount of rails api stuff, and that is what I am using to build my own app that I am working on (separate app than the one I built this video stuff for). I really like the complete flexibility to be able to completely manage the front and backend myself. It is a lot more work but I'm on my 3rd rails API now and it's going faster each time.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment