Skip to content

Instantly share code, notes, and snippets.

@inorganik
Last active December 13, 2018 13:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save inorganik/16df9c18cd2cd9aabf94f9712ce70b88 to your computer and use it in GitHub Desktop.
Save inorganik/16df9c18cd2cd9aabf94f9712ce70b88 to your computer and use it in GitHub Desktop.
Player code for Tung - a social podcast player for iOS
/* These are methods extracted from a singleton class i used called
"TungCommonObjects". I didn't include the properties just
the methods concerned with playing and caching audio.
This file acted as NSURLConnectionDataDelegate, AVAssetResourceLoaderDelegate.
It is broken into the following parts, separated by pragma marks.
- Player Instance methods
- caching/saving episodes
- NSURLConnection delegate
- AVURLAsset resource loading delegate
Playing a podcast was initiated by calling [self playQueuedPodcast]. Inside
that method, getStreamUrlForEpisodeEntity was called which would determine
whether to use a custom scheme. The custom scheme allows you to use AVURLAsset
resource loading delegate methods in concert with NSURLConnection delegate methods
to allow you to save the streamed file.
*/
#pragma mark - Player instance methods
- (BOOL) isPlaying {
JPLog(@"is playing at rate: %f", _player.rate);
return (_player && _player.rate > 0.0f);
}
- (void) playerPlay {
if (_player && _playQueue.count > 0) {
_shouldStayPaused = NO;
if (_npEpisodeEntity.trackPosition.floatValue == 1) {
// start over
[self seekToTime:CMTimeMake(0, 100)];
} else {
[_player play];
}
[self setControlButtonStateToPause];
} else {
[self playQueuedPodcast];
}
}
- (void) playerPause {
if ([self isPlaying]) {
//float currentSecs = CMTimeGetSeconds(_player.currentTime);
//NSLog(@"currentSecs: %f, total secs: %f", currentSecs, _totalSeconds);
[_player pause];
_shouldStayPaused = YES;
[self setControlButtonStateToPlay];
[self savePositionForNowPlayingAndSync:YES];
// see if file is cached yet, so player can switch to local file
/* may be causing issues, disabling for now
if (_fileIsStreaming && _fileIsLocal) {
[self reestablishPlayerItemAndReplace];
} */
}
}
- (void) seekBack {
float currentTimeSecs = CMTimeGetSeconds(_player.currentTime);
if (currentTimeSecs < 3) {
[self playNextOlderEpisodeInFeed];
} else {
CMTime time = CMTimeMake(0, 1);
[self seekToTime:time];
}
}
- (void) skipAhead15 {
if (_player && _totalSeconds) {
float secs = CMTimeGetSeconds(_player.currentTime);
secs += 15;
secs = MIN(_totalSeconds - 1, secs - 1);
[self seekToTime:(CMTimeMake((secs * 100), 100))];
}
}
- (void) skipBack15 {
if (_player && _totalSeconds) {
float secs = CMTimeGetSeconds(_player.currentTime);
secs -= 15;
secs = MAX(0, secs);
[self seekToTime:(CMTimeMake((secs * 100), 100))];
}
}
- (void) determineTotalSeconds {
_totalSeconds = CMTimeGetSeconds(_player.currentItem.asset.duration);
[_trackInfo setObject:[NSNumber numberWithFloat:_totalSeconds] forKey:MPMediaItemPropertyPlaybackDuration];
[[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:_trackInfo];
//JPLog(@"determined total seconds: %f (%@)", _totalSeconds, [TungCommonObjects convertSecondsToTimeString:_totalSeconds]);
}
// PLAYER OBSERVING
- (void) addPlayerObserversForItem:(AVPlayerItem *)playerItem {
// player notifications
[_player addObserver:self forKeyPath:@"status" options:0 context:nil];
[_player addObserver:self forKeyPath:@"currentItem.playbackLikelyToKeepUp" options:NSKeyValueObservingOptionNew context:nil];
[_player addObserver:self forKeyPath:@"currentItem.duration" options:0 context:nil];
//[_player addObserver:self forKeyPath:@"currentItem.playbackBufferEmpty" options:NSKeyValueObservingOptionNew context:nil];
//[_player addObserver:self forKeyPath:@"currentItem.loadedTimeRanges" options:NSKeyValueObservingOptionNew context:nil];
// Subscribe to AVPlayerItem's notifications
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(completedPlayback) name:AVPlayerItemDidPlayToEndTimeNotification object:playerItem];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(playerError:) name:AVPlayerItemPlaybackStalledNotification object:playerItem];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(playerError:) name:AVPlayerItemFailedToPlayToEndTimeNotification object:playerItem];
}
- (void) removePlayerObservers {
[_player removeObserver:self forKeyPath:@"status"];
[_player removeObserver:self forKeyPath:@"currentItem.playbackLikelyToKeepUp"];
[_player removeObserver:self forKeyPath:@"currentItem.duration"];
//[_player removeObserver:self forKeyPath:@"currentItem.playbackBufferEmpty"];
//[_player removeObserver:self forKeyPath:@"currentItem.loadedTimeRanges"];
[[NSNotificationCenter defaultCenter] removeObserver:self name:AVPlayerItemDidPlayToEndTimeNotification object:_player.currentItem];
[[NSNotificationCenter defaultCenter] removeObserver:self name:AVPlayerItemPlaybackStalledNotification object:_player.currentItem];
[[NSNotificationCenter defaultCenter] removeObserver:self name:AVPlayerItemFailedToPlayToEndTimeNotification object:_player.currentItem];
}
- (void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
//JPLog(@"observe value for key path: %@", keyPath);
if (object == _player && [keyPath isEqualToString:@"status"]) {
switch (_player.status) {
case AVPlayerStatusFailed:
JPLog(@"-- AVPlayer status: Failed");
[self ejectCurrentEpisode];
[self setControlButtonStateToFauxDisabled];
break;
case AVPlayerStatusReadyToPlay:
JPLog(@"-- AVPlayer status: ready to play");
// check for track progress
float secs = 0;
CMTime time;
if (_playFromTimestamp) {
secs = [TungCommonObjects convertTimestampToSeconds:_playFromTimestamp];
time = CMTimeMake((secs * 100), 100);
}
else if (_npEpisodeEntity.trackProgress.floatValue > 0 && _npEpisodeEntity.trackPosition.floatValue < 1) {
secs = _npEpisodeEntity.trackProgress.floatValue;
time = CMTimeMake((secs * 100), 100);
}
// play
if (secs > 0) {
//JPLog(@"seeking to time: %f (progress: %f)", secs, _npEpisodeEntity.trackProgress.floatValue);
[_trackInfo setObject:[NSNumber numberWithFloat:secs] forKey:MPNowPlayingInfoPropertyElapsedPlaybackTime];
[_player seekToTime:time completionHandler:^(BOOL finished) {}];
} else {
[_trackInfo setObject:[NSNumber numberWithFloat:0] forKey:MPNowPlayingInfoPropertyElapsedPlaybackTime];
if (![self isPlaying]) {
//JPLog(@"play from beginning - with preroll");
[_player prerollAtRate:1.0 completionHandler:nil];
}
}
break;
case AVPlayerItemStatusUnknown:
JPLog(@"-- AVPlayer status: Unknown");
break;
default:
break;
}
}
if (object == _player && [keyPath isEqualToString:@"currentItem.playbackLikelyToKeepUp"]) {
if (_player.currentItem.playbackLikelyToKeepUp) {
//JPLog(@"-- player likely to keep up");
[_spinnerCheckTimer invalidate];
if (_totalSeconds > 0) {
float currentSecs = CMTimeGetSeconds(_player.currentTime);
if (round(currentSecs) >= floor(_totalSeconds)) {
JPLog(@"detected completed playback");
[self completedPlayback];
return;
}
}
if ([self isPlaying]) {
[self setControlButtonStateToPause];
}
else if (!_shouldStayPaused && ![self isPlaying]) {
[self playerPlay];
}
} else {
//JPLog(@"-- player NOT likely to keep up");
if (!_shouldStayPaused) [self setControlButtonStateToBuffering];
[_spinnerCheckTimer invalidate];
_spinnerCheckTimer = [NSTimer scheduledTimerWithTimeInterval:3.0 target:self selector:@selector(checkSpinner) userInfo:nil repeats:NO];
}
}
if (object == _player && [keyPath isEqualToString:@"currentItem.duration"]) {
if (_totalSeconds == 0) [self determineTotalSeconds];
//if (!_shouldStayPaused) [self setControlButtonStateToPause];
}
if (object == _player && [keyPath isEqualToString:@"currentItem.loadedTimeRanges"]) {
NSArray *timeRanges = (NSArray *)[change objectForKey:NSKeyValueChangeNewKey];
if (timeRanges && [timeRanges count]) {
CMTimeRange timerange = [[timeRanges objectAtIndex:0] CMTimeRangeValue];
JPLog(@" . . . %.3f, %@", CMTimeGetSeconds(CMTimeAdd(timerange.start, timerange.duration)), ([self isPlaying]) ? @"playing" : @"not playing");
/*
Even if you call play, player will NOT play until it's status is playbackLikelyToKeepUp
if (CMTimeGetSeconds(timerange.duration) >= 10) {
JPLog(@"got 10 secs, ready to play");
[_player play];
}
*/
}
}
if (object == _player && [keyPath isEqualToString:@"currentItem.playbackBufferEmpty"]) {
if (_player.currentItem.playbackBufferEmpty) {
JPLog(@"-- playback buffer empty");
[self setControlButtonStateToBuffering];
}
}
}
/* spinner can get stuck because playback not likely to keep up
gets called even when the player is playing
*/
- (void) checkSpinner {
if ([self isPlaying]) {
if (_shouldStayPaused) {
[self setControlButtonStateToPlay];
} else {
[self setControlButtonStateToPause];
}
}
}
- (void) seekToTime:(CMTime)time {
/* may be causing issues, disabling for now
if (_fileIsStreaming && _fileIsLocal) {
[self reestablishPlayerItemAndReplace];
}*/
[_trackInfo setObject:[NSNumber numberWithFloat:CMTimeGetSeconds(time)] forKey:MPNowPlayingInfoPropertyElapsedPlaybackTime];
[[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:_trackInfo];
//[_player seekToTime:time];
[_player seekToTime:time completionHandler:^(BOOL finished) {
if (!_shouldStayPaused) {
// avoid endless loop, do not use [self playerPlay];
[_player play];
}
}];
}
- (void) queueAndPlaySelectedEpisode:(NSString *)urlString fromTimestamp:(NSString *)timestamp {
if (!urlString || urlString.length == 0) {
[TungCommonObjects showNoAudioAlert];
return;
}
// url and file
NSURL *url = [TungCommonObjects urlFromString:urlString];
NSString *fileName = [url lastPathComponent];
NSString *fileType = [fileName pathExtension];
//JPLog(@"play file of type: %@", fileType);
// avoid videos
if ([fileType isEqualToString:@"mp4"] || [fileType isEqualToString:@"m4v"]) {
UIAlertController *videoAlert = [UIAlertController alertControllerWithTitle:@"Video podcast" message:@"Tung does not currently support video podcasts." preferredStyle:UIAlertControllerStyleAlert];
[videoAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]];
[_viewController presentViewController:videoAlert animated:YES completion:nil];
}
else {
// make sure it isn't playing
if (_playQueue.count > 0) {
// it's new, but something else is loaded
NSString *queuedItem = [TungCommonObjects stringFromUrl:[_playQueue objectAtIndex:0]];
if (![queuedItem isEqualToString:urlString]) {
[self ejectCurrentEpisode];
if (timestamp) {
_playFromTimestamp = timestamp;
}
[_playQueue insertObject:urlString atIndex:0];
[self playQueuedPodcast];
}
// trying to queue playing episode
else {
if (!!_player.currentItem) {
if ([self isPlaying]) [self playerPause];
else [self playerPlay];
}
}
} else {
[_playQueue insertObject:urlString atIndex:0];
[self playQueuedPodcast];
}
}
}
- (void) playUrl:(NSString *)urlString fromTimestamp:(NSString *)timestamp {
_playFromTimestamp = timestamp;
NSString *queuedItem = [TungCommonObjects stringFromUrl:[_playQueue objectAtIndex:0]];
if (_playQueue.count > 0 && [queuedItem isEqualToString:urlString]) {
// already listening
float secs = [TungCommonObjects convertTimestampToSeconds:timestamp];
CMTime time = CMTimeMake((secs * 100), 100);
if (_player) {
[self playerPlay];
[_player seekToTime:time];
} else {
[self playQueuedPodcast];
}
}
else {
// different episode
// play
[self queueAndPlaySelectedEpisode:urlString fromTimestamp:timestamp];
}
}
- (void) playQueuedPodcast {
if (_playQueue.count > 0) {
NSString *urlString = [TungCommonObjects stringFromUrl:[_playQueue objectAtIndex:0]];
if (urlString.length) {
//JPLog(@"play queued podcast: %@", urlString);
// assign now playing entity
AppDelegate *appDelegate = (AppDelegate *)[[UIApplication sharedApplication] delegate];
NSError *error = nil;
NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"EpisodeEntity"];
NSPredicate *predicate = [NSPredicate predicateWithFormat: @"url == %@", urlString];
[request setPredicate:predicate];
NSArray *episodeResult = [appDelegate.managedObjectContext executeFetchRequest:request error:&error];
if (episodeResult.count > 0) {
//JPLog(@"found and assigned now playing entity");
_npEpisodeEntity = [episodeResult lastObject];
} else {
/* create entity - case is next episode in feed is played. Episode entity may not have been
created yet, but podcast entity would, so we get it from np episode entity. */
// look up podcast entity
//JPLog(@"creating new entity for now playing entity");
NSDictionary *episodeDict = [_currentFeed objectAtIndex:_currentFeedIndex];
PodcastEntity *npPodcastEntity = _npEpisodeEntity.podcast;
_npEpisodeEntity = [TungCommonObjects getEntityForEpisode:episodeDict withPodcastEntity:npPodcastEntity save:NO];
}
// increment listen count if it isn't already playing
if (!_npEpisodeEntity.isNowPlaying.boolValue) {
_incPlayCountTimer = [NSTimer scheduledTimerWithTimeInterval:10.0 target:self selector:@selector(incrementPlayCountForNowPlaying) userInfo:nil repeats:NO];
}
//NSLog(@"sanitized guid: %@", [TungCommonObjects santizeGUID:_npEpisodeEntity.guid]);
_npEpisodeEntity.isNowPlaying = [NSNumber numberWithBool:YES];
[TungCommonObjects saveContextWithReason:@"now playing changed"];
// find index of episode in current feed for prev/next track fns
_currentFeed = [self getFeedOfNowPlayingEpisodeAndSetCurrentFeedIndex];
//NSLog(@"now playing episode: %@", [TungCommonObjects entityToDict:_npEpisodeEntity]);
// set now playing info center info
[TungImages retrievePodcastArtDataForEntity:_npEpisodeEntity.podcast defaultSize:NO callback:^(NSData *imageData) {
UIImage *artImage = [[UIImage alloc] initWithData:imageData];
MPMediaItemArtwork *albumArt = [[MPMediaItemArtwork alloc] initWithImage:artImage];
[_trackInfo setObject:albumArt forKey:MPMediaItemPropertyArtwork];
}];
[_trackInfo setObject:_npEpisodeEntity.title forKey:MPMediaItemPropertyTitle];
[_trackInfo setObject:_npEpisodeEntity.podcast.collectionName forKey:MPMediaItemPropertyArtist];
//[_trackInfo setObject:_npEpisodeEntity.podcast.collectionName forKey:MPMediaItemPropertyPodcastTitle];
//[_trackInfo setObject:_npEpisodeEntity.podcast.artistName forKey:MPMediaItemPropertyAlbumTitle];
[_trackInfo setObject:[NSNumber numberWithFloat:1.0] forKey:MPNowPlayingInfoPropertyPlaybackRate];
[_trackInfo setObject:_npEpisodeEntity.pubDate forKey:MPMediaItemPropertyReleaseDate];
// not used: MPMediaItemPropertyAssetURL
// set up new player item and player, observers
NSURL *urlToPlay = [self getStreamUrlForEpisodeEntity:_npEpisodeEntity];
if (urlToPlay) {
// set local notif. for deleting cached audio
NSInteger days = DAYS_TO_KEEP_CACHED;
[TungCommonObjects createLocalNotifToDeleteAudioForEntity:_npEpisodeEntity inDays:days forCached:YES];
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:urlToPlay options:nil];
[asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];
AVPlayerItem *playerItem = [AVPlayerItem playerItemWithAsset:asset];
if ([TungCommonObjects iOSVersionFloat] >= 10.0) {
playerItem.preferredForwardBufferDuration = 10.0; // required X seconds to be loaded for playback to be ready
}
_player = [[AVPlayer alloc] initWithPlayerItem:playerItem];
if ([TungCommonObjects iOSVersionFloat] >= 10.0) {
_player.automaticallyWaitsToMinimizeStalling = NO;
}
[self addPlayerObserversForItem:playerItem];
[self setControlButtonStateToBuffering];
NSNotification *nowPlayingDidChangeNotif = [NSNotification notificationWithName:@"nowPlayingDidChange" object:nil userInfo:nil];
[[NSNotificationCenter defaultCenter] postNotification:nowPlayingDidChangeNotif];
}
// else error handled in getStreamUrlForEpisodeEntity method
}
else {
JPLog(@"Error: Empty url string passed to playQueuedPodcast");
[TungCommonObjects simpleErrorAlertWithMessage:@"Could not play - empty url"];
}
}
//JPLog(@"play queue: %@", _playQueue);
}
- (NSArray *) getFeedOfNowPlayingEpisodeAndSetCurrentFeedIndex {
NSDictionary *feedDict = [TungPodcast retrieveAndCacheFeedForPodcastEntity:_npEpisodeEntity.podcast forceNewest:NO reachable:_connectionAvailable.boolValue];
NSError *feedError;
NSArray *feed = [TungPodcast extractFeedArrayFromFeedDict:feedDict error:&feedError];
_currentFeedIndex = 0; // default
if (!feedError) {
_currentFeedIndex = [TungCommonObjects getIndexOfEpisodeWithGUID:_npEpisodeEntity.guid inFeed:feed];
return feed;
}
else {
NSString *errorString = [NSString stringWithFormat:@"Error with Now Playing episode's feed: %@", feedError.localizedDescription];
[TungCommonObjects simpleErrorAlertWithMessage:errorString];
JPLog(@"now playing feed error: %@", errorString);
return @[];
}
}
// gets new episodes from subscribed podcasts and plays a random one
- (void) playRandomEpisode {
NSArray *result = [TungCommonObjects getAllSubscribedPodcasts];
if (result.count > 0) {
// build list of new episodes
NSMutableArray *newEpisodes = [NSMutableArray array];
for (int i = 0; i < result.count; i++) {
PodcastEntity *podEntity = [result objectAtIndex:i];
NSDictionary *feedDict = [TungPodcast retrieveCachedFeedForPodcastEntity:podEntity];
NSError *feedError;
NSArray *episodes = [TungPodcast extractFeedArrayFromFeedDict:feedDict error:&feedError];
if (!feedError) {
// only check the 10 most recent, or less
NSInteger max = MIN(10, episodes.count);
for (NSInteger j = 0; j < max; j++) {
NSDictionary *episodeDict = [episodes objectAtIndex:j];
EpisodeEntity *epEntity = [TungCommonObjects getEntityForEpisode:episodeDict withPodcastEntity:podEntity save:NO];
if (epEntity.trackProgress.floatValue > 0.0) {
continue;
}
else {
[newEpisodes addObject:@{
@"episode": episodeDict,
@"podcast": [TungCommonObjects entityToDict:podEntity]
}];
}
}
}
}
if (newEpisodes.count > 0) {
JPLog(@"found %lu new episodes. drawing random number...", (unsigned long)newEpisodes.count);
NSInteger i = arc4random_uniform((uint32_t) newEpisodes.count);
JPLog(@"drew %d", i);
NSDictionary *chosenDict = [newEpisodes objectAtIndex:i];
PodcastEntity *podEntity = [TungCommonObjects getEntityForPodcast:[chosenDict objectForKey:@"podcast"] save:NO];
EpisodeEntity *episodeEntity = [TungCommonObjects getEntityForEpisode:[chosenDict objectForKey:@"episode"] withPodcastEntity:podEntity save:YES];
[self queueAndPlaySelectedEpisode:episodeEntity.url fromTimestamp:nil];
}
else {
// inbox zero for subscribed podcasts
UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:@"Just wow." message:@"You've achieved inbox zero for subscribed podcasts. No new episodes to listen to!" preferredStyle:UIAlertControllerStyleAlert];
[errorAlert addAction:[UIAlertAction actionWithTitle:@"Check out the feed" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
AppDelegate *appDelegate = (AppDelegate *)[[UIApplication sharedApplication] delegate];
[appDelegate switchTabBarSelectionToTabIndex:0];
}]];
[errorAlert addAction:[UIAlertAction actionWithTitle:@"Dismiss" style:UIAlertActionStyleCancel handler:nil]];
[[TungCommonObjects activeViewController] presentViewController:errorAlert animated:YES completion:nil];
}
}
else {
// no subscribed podcasts
UIAlertController *noSubscribesAlert = [UIAlertController alertControllerWithTitle:@"No subscribed podcasts" message:@"Discover some great new episodes in the feed, or you could import your podcast subscriptions." preferredStyle:UIAlertControllerStyleAlert];
[noSubscribesAlert addAction:[UIAlertAction actionWithTitle:@"Dismiss" style:UIAlertActionStyleCancel handler:nil]];
[noSubscribesAlert addAction:[UIAlertAction actionWithTitle:@"Check out the feed" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
AppDelegate *appDelegate = (AppDelegate *)[[UIApplication sharedApplication] delegate];
[appDelegate switchTabBarSelectionToTabIndex:0];
}]];
[noSubscribesAlert addAction:[UIAlertAction actionWithTitle:@"Import podcast subsciptions" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
AppDelegate *appDelegate = (AppDelegate *)[[UIApplication sharedApplication] delegate];
[appDelegate switchTabBarSelectionToTabIndex:2];
[self promptAndRequestMediaLibraryAccess];
}]];
[[TungCommonObjects activeViewController] presentViewController:noSubscribesAlert animated:YES completion:nil];
}
}
// removes observers, releases player related properties
- (void) resetPlayer {
//JPLog(@"reset player ///////////////");
_npViewSetupForCurrentEpisode = NO;
_shouldStayPaused = NO;
_totalSeconds = 0;
[_incPlayCountTimer invalidate];
// remove old player and observers
if (_player) {
[_player cancelPendingPrerolls];
[_player.currentItem cancelPendingSeeks];
[self removePlayerObservers];
_player = nil;
}
_trackData = [NSMutableData data];
// clear leftover connection data
if (_trackDataConnection) {
//JPLog(@"clear connection data");
[_trackDataConnection cancel];
_trackDataConnection = nil;
_trackData = [NSMutableData data];
self.response = nil;
}
self.pendingRequests = [NSMutableArray array];
}
- (void) savePositionForNowPlayingAndSync:(BOOL)sync {
if (_totalSeconds > 0) {
float secs = CMTimeGetSeconds(_player.currentTime);
_npEpisodeEntity.trackProgress = [NSNumber numberWithFloat:secs];
float pos = secs / _totalSeconds;
if (round(secs) >= floor(_totalSeconds)) {
pos = 1.0;
}
_npEpisodeEntity.trackPosition = [NSNumber numberWithFloat:pos];
[TungCommonObjects saveContextWithReason:[NSString stringWithFormat:@"saving track position: %f", pos]];
// sync with server after delay
if (sync && _connectionAvailable.boolValue) {
[_syncProgressTimer invalidate];
_syncProgressTimer = [NSTimer scheduledTimerWithTimeInterval:10.0 target:self selector:@selector(syncProgressFromTimer:) userInfo:_npEpisodeEntity repeats:NO];
}
}
}
- (void) completedPlayback {
float currentTimeSecs = CMTimeGetSeconds(_player.currentTime);
JPLog(@"completed playback? current secs: %f, total secs: %f", currentTimeSecs, _totalSeconds);
// called prematurely
if (_totalSeconds == 0) {
JPLog(@"completed playback called prematurely. totalSeconds not set");
return;
}
if (round(currentTimeSecs) < floor(_totalSeconds)) {
JPLog(@"completed playback called prematurely.");
if (_fileIsStreaming && _fileIsLocal) {
[self reestablishPlayerItemAndReplace];
}
else {
JPLog(@"- attempt to reload episode");
// do not need timestamp bc eject current episode saves position
NSString *urlString = _npEpisodeEntity.url;
[self ejectCurrentEpisode];
[self queueAndPlaySelectedEpisode:urlString fromTimestamp:nil];
}
return;
}
//[TungCommonObjects showBannerAlertForText:[NSString stringWithFormat:@"completed playback. current secs: %f, total secs: %f", currentTimeSecs, _totalSeconds]];
SettingsEntity *settings = [TungCommonObjects settings];
if (settings.autoplayNextEpisode.boolValue) {
[self playNextEpisode]; // ejects current episode
}
else {
[self savePositionForNowPlayingAndSync:YES];
[self resetPlayer];
NSNotification *nowPlayingDidChangeNotif = [NSNotification notificationWithName:@"nowPlayingDidChange" object:nil userInfo:nil];
[[NSNotificationCenter defaultCenter] postNotification:nowPlayingDidChangeNotif];
[self setControlButtonStateToPlay];
}
}
- (void) ejectCurrentEpisode {
//NSLog(@"ejecting current episode");
if (_playQueue.count > 0) {
if ([self isPlaying]) [_player pause];
//_npEpisodeEntity.isNowPlaying = [NSNumber numberWithBool:NO];
[self savePositionForNowPlayingAndSync:YES];
[_playQueue removeObjectAtIndex:0];
[self removeNowPlayingStatusFromAllEpisodes];
[self resetPlayer];
_playFromTimestamp = nil;
}
}
- (void) removeNowPlayingStatusFromAllEpisodes {
// find playing episodes
AppDelegate *appDelegate = (AppDelegate *)[[UIApplication sharedApplication] delegate];
NSFetchRequest *npRequest = [[NSFetchRequest alloc] initWithEntityName:@"EpisodeEntity"];
NSPredicate *predicate = [NSPredicate predicateWithFormat: @"isNowPlaying == YES"];
[npRequest setPredicate:predicate];
NSError *error = nil;
NSArray *npResult = [appDelegate.managedObjectContext executeFetchRequest:npRequest error:&error];
if (npResult.count > 0) {
for (int i = 0; i < npResult.count; i++) {
EpisodeEntity *episodeEntity = [npResult objectAtIndex:i];
episodeEntity.isNowPlaying = [NSNumber numberWithBool:NO];
}
}
_npEpisodeEntity = nil;
//[TungCommonObjects saveContextWithReason:@"remove now playing status from all episodes"];
}
// uses some logic to play next unplayed episode
- (void) playNextEpisode {
// play manually queued episode
if (_playQueue.count > 1) {
[self ejectCurrentEpisode];
//AudioServicesPlaySystemSound(1103); // play beep
JPLog(@"play next episode");
[self playQueuedPodcast];
}
// play next episode in feed
else {
if (!_currentFeed) {
_currentFeed = [self getFeedOfNowPlayingEpisodeAndSetCurrentFeedIndex];
}
// first see if there is a newer one and if it has been listened to yet
if (_currentFeedIndex - 1 > -1) {
NSDictionary *epDict = [_currentFeed objectAtIndex:_currentFeedIndex - 1];
EpisodeEntity *epEntity = [TungCommonObjects getEntityForEpisode:epDict withPodcastEntity:_npEpisodeEntity.podcast save:NO];
if (epEntity.trackPosition.floatValue == 0) {
//JPLog(@"newer episode hasn't been listened to yet, queue and play");
[self ejectCurrentEpisode];
_currentFeedIndex--;
[_playQueue insertObject:epEntity.url atIndex:0];
[self playQueuedPodcast];
return;
}
}
// if method hasn't returned, try to play the next older episode in feed
[self playNextOlderEpisodeInFeed];
}
}
- (void) playNextOlderEpisodeInFeed {
if (!_currentFeed) {
_currentFeed = [self getFeedOfNowPlayingEpisodeAndSetCurrentFeedIndex];
}
if (_currentFeedIndex + 1 < _currentFeed.count) {
JPLog(@"play previous episode in feed");
[self ejectCurrentEpisode];
_currentFeedIndex++;
NSDictionary *episodeDict = [_currentFeed objectAtIndex:_currentFeedIndex];
NSString *urlString = [TungCommonObjects getUrlStringFromEpisodeDict:episodeDict];
if (urlString) {
[_playQueue insertObject:urlString atIndex:0];
[self playQueuedPodcast];
}
} else {
[self savePositionForNowPlayingAndSync:YES];
[self setControlButtonStateToPlay];
}
}
- (void) playNextNewerEpisodeInFeed {
if (!_currentFeed) {
_currentFeed = [self getFeedOfNowPlayingEpisodeAndSetCurrentFeedIndex];
}
if (_currentFeedIndex - 1 >= 0) {
JPLog(@"play previous episode in feed");
[self ejectCurrentEpisode];
_currentFeedIndex--;
NSDictionary *episodeDict = [_currentFeed objectAtIndex:_currentFeedIndex];
NSString *urlString = [TungCommonObjects getUrlStringFromEpisodeDict:episodeDict];
if (urlString) {
[_playQueue insertObject:urlString atIndex:0];
[self playQueuedPodcast];
}
} else {
[self savePositionForNowPlayingAndSync:YES];
[self setControlButtonStateToPlay];
}
}
- (void) playerError:(NSNotification *)notification {
if (notification != nil) {
JPLog(@"PLAYER ERROR: %@ ...attempting to recover playback", notification);
}
// re-queue now playing
[self savePositionForNowPlayingAndSync:NO];
[self resetPlayer];
[self queueAndPlaySelectedEpisode:_npEpisodeEntity.url fromTimestamp:nil];
UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:@"Player Error" message:@"Attempting to recover playback." preferredStyle:UIAlertControllerStyleAlert];
[errorAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]];
[[TungCommonObjects activeViewController] presentViewController:errorAlert animated:YES completion:nil];
}
// looks for local file, else returns url with custom scheme
- (NSURL *) getStreamUrlForEpisodeEntity:(EpisodeEntity *)epEntity {
// first look for file in episode temp dir
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *cachedEpisodeFilepath = [TungCommonObjects getCachedFilepathForEpisodeEntity:epEntity];
//NSLog(@"check for cached episode with filepath: %@", cachedEpisodeFilepath);
if ([fileManager fileExistsAtPath:cachedEpisodeFilepath]) {
//JPLog(@"^^^ will use local file in TEMP dir");
_fileIsLocal = YES;
_fileIsStreaming = NO;
_fileWillBeCached = YES;
return [NSURL fileURLWithPath:cachedEpisodeFilepath];
}
else {
// look for file in saved episodes directory
NSString *savedEpisodeFilepath = [TungCommonObjects getSavedFilepathForEpisodeEntity:epEntity];
//NSLog(@"check for saved episode with filepath: %@", savedEpisodeFilepath);
if ([fileManager fileExistsAtPath:savedEpisodeFilepath]) {
//JPLog(@"^^^ will use local file in SAVED dir");
_fileIsLocal = YES;
_fileIsStreaming = NO;
_fileWillBeCached = YES;
return [NSURL fileURLWithPath:savedEpisodeFilepath];
}
else {
// fuck it, we'll do it live!
_fileIsLocal = NO;
_fileIsStreaming = YES;
NSURL *url = [NSURL URLWithString:epEntity.url];
NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO];
// if episode has track position > 0.1, we do not use custom scheme,
// because this way AVPlayer will start streaming from the timestamp
// instead of downloading from the start as with a custom scheme
if (_npEpisodeEntity.trackPosition.floatValue > 0.1 && _npEpisodeEntity.trackPosition.floatValue < 1.0 && !_trackData.length) {
// no caching
_fileWillBeCached = NO;
//JPLog(@"^^^ will STREAM from url with NO caching");
}
else {
// return url with custom scheme
components.scheme = @"tungstream";
_fileWillBeCached = YES;
//JPLog(@"^^^ will STREAM from url with custom scheme");
}
if (_connectionAvailable.boolValue) {
return [components URL];
}
else {
JPLog(@"Error: Can't play because resource needs to be streamed and there is no connection");
[TungCommonObjects showNoConnectionAlert];
return nil;
}
}
}
}
// make sure player item is fetching from the available location
- (void) reestablishPlayerItemAndReplace {
JPLog(@"reestablish player item");
[self savePositionForNowPlayingAndSync:NO];
// clear leftover connection data
_trackDataConnection = nil;
_trackData = [NSMutableData data];
self.response = nil;
self.pendingRequests = [NSMutableArray array];
if (_player) {
[self removePlayerObservers];
[_player cancelPendingPrerolls];
}
CMTime currentTime = _player.currentTime;
NSURL *urlToPlay = [self getStreamUrlForEpisodeEntity:_npEpisodeEntity];
if (urlToPlay) {
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:urlToPlay options:nil];
[asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];
AVPlayerItem *playerItem = [[AVPlayerItem alloc] initWithAsset:asset];
[_player replaceCurrentItemWithPlayerItem:playerItem];
[self addPlayerObserversForItem:playerItem];
[_player seekToTime:currentTime completionHandler:^(BOOL finished) {
if (!_shouldStayPaused) {
[_player play];
}
}];
}
}
/*
Play Queue saving and retrieving
Does not seem to be a reliable way to recall what was playing when app becomes active
NOT USED
*/
- (NSString *) getPlayQueuePath {
NSFileManager *fileManager = [[NSFileManager alloc] init];
NSArray *folders = [fileManager URLsForDirectory:NSLibraryDirectory inDomains:NSUserDomainMask];
//NSArray *folders = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *appPath = [NSString stringWithFormat:@"%@/Application Support", [folders objectAtIndex:0]];
NSError *writeError;
[[NSFileManager defaultManager] createDirectoryAtPath:appPath withIntermediateDirectories:NO attributes:nil error:&writeError];
return [appPath stringByAppendingPathComponent:@"playQueue.txt"];
}
- (void) savePlayQueue {
NSString *playQueuePath = [self getPlayQueuePath];
// delete file if exists.
if ([[NSFileManager defaultManager] fileExistsAtPath:playQueuePath]) {
[[NSFileManager defaultManager] removeItemAtPath:playQueuePath error:nil];
}
//[fileURL setResourceValue:[NSNumber numberWithBool:YES] forKey:NSURLIsExcludedFromBackupKey error:&error];
[_playQueue writeToFile:playQueuePath atomically:YES];
JPLog(@"saved play queue %@ to path: %@", _playQueue, playQueuePath);
}
- (void) readPlayQueueFromDisk {
NSString *playQueuePath = [self getPlayQueuePath];
JPLog(@"read play queue from path: %@", playQueuePath);
NSArray *queue = [NSArray arrayWithContentsOfFile:playQueuePath];
if (queue) {
JPLog(@"found saved play queue: %@", _playQueue);
_playQueue = [queue mutableCopy];
} else {
JPLog(@"no saved play queue. create new");
_playQueue = [NSMutableArray array];
}
}
#pragma mark - caching/saving episodes
static NSString *episodeDirName = @"episodes";
+ (NSString *) getSavedEpisodesDirectoryPath {
NSFileManager *fileManager = [NSFileManager defaultManager];
NSArray *folders = [fileManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask];
NSURL *documentsDir = [folders objectAtIndex:0];
NSError *error;
BOOL success = [documentsDir setResourceValue:[NSNumber numberWithBool:YES] forKey:NSURLIsExcludedFromBackupKey error:&error];
if (success) {
NSString *episodesDir = [documentsDir.path stringByAppendingPathComponent:episodeDirName];
[fileManager createDirectoryAtPath:episodesDir withIntermediateDirectories:YES attributes:nil error:&error];
return episodesDir;
}
else {
JPLog(@"error making folder excluded from backup: %@", error.localizedDescription);
return nil;
}
}
+ (NSString *) getCachedEpisodesDirectoryPath {
NSFileManager *fileManager = [[NSFileManager alloc] init];
NSString *episodesDir = [NSTemporaryDirectory() stringByAppendingPathComponent:episodeDirName];
NSError *error;
[fileManager createDirectoryAtPath:episodesDir withIntermediateDirectories:YES attributes:nil error:&error];
return episodesDir;
}
+ (NSString *) santizeGUID:(NSString *)guid {
guid = [guid stringByRemovingPercentEncoding];
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"([0-9A-Za-z\\-]+)" options:0 error:nil];
NSMutableArray *components = [NSMutableArray array];
[regex enumerateMatchesInString:guid options:0 range:NSMakeRange(0, guid.length) usingBlock:^(NSTextCheckingResult * _Nullable result, NSMatchingFlags flags, BOOL * _Nonnull stop) {
[components addObject:[guid substringWithRange:result.range]];
}];
NSString *result = [components componentsJoinedByString:@""];
return result;
}
// removes query string, percent encoding
+ (NSString *) getEpisodeFilenameForEntity:(EpisodeEntity *)epEntity {
NSURL *url = [TungCommonObjects urlFromString:epEntity.url];
NSURLComponents *urlComponents = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO];
urlComponents.query = nil;
NSString *urlStr = [urlComponents.string stringByRemovingPercentEncoding];
NSString *filename = [urlStr lastPathComponent];
NSString *extension = [filename pathExtension];
NSString *episodeFilename = [NSString stringWithFormat:@"%@-%@.%@", epEntity.collectionId, [TungCommonObjects santizeGUID:epEntity.guid], extension];
//NSLog(@"filename result: %@", episodeFilename);
return episodeFilename;
}
+ (NSString *) getSavedFilepathForEpisodeEntity:(EpisodeEntity *)epEntity {
NSString *episodeFilename = [TungCommonObjects getEpisodeFilenameForEntity:epEntity];
NSString *savedEpisodesDir = [TungCommonObjects getSavedEpisodesDirectoryPath];
NSString *savedEpisodeFilepath = [savedEpisodesDir stringByAppendingPathComponent:episodeFilename];
return savedEpisodeFilepath;
}
+ (NSString *) getCachedFilepathForEpisodeEntity:(EpisodeEntity *)epEntity {
NSString *episodeFilename = [TungCommonObjects getEpisodeFilenameForEntity:epEntity];
NSString *savedEpisodesDir = [TungCommonObjects getCachedEpisodesDirectoryPath];
NSString *savedEpisodeFilepath = [savedEpisodesDir stringByAppendingPathComponent:episodeFilename];
return savedEpisodeFilepath;
}
NSTimer *debounceSaveStatusTimer;
// meant to minimize notification duplication, bc of unavoidable cases where multiple notifs get fired
+ (void) queueSaveStatusDidChangeNotification {
//JPLog(@"queue saveStatusDidChange notification");
[debounceSaveStatusTimer invalidate];
debounceSaveStatusTimer = [NSTimer scheduledTimerWithTimeInterval:0.05 target:self selector:@selector(postSavedStatusDidChangeNotification) userInfo:nil repeats:NO];
}
+ (void) postSavedStatusDidChangeNotification {
//JPLog(@"POST saveStatusDidChange notification");
NSNotification *saveStatusChangedNotif = [NSNotification notificationWithName:@"saveStatusDidChange" object:nil userInfo:nil];
[[NSNotificationCenter defaultCenter] postNotification:saveStatusChangedNotif];
}
- (void) cacheNowPlayingEpisodeAndMoveToSaved:(BOOL)moveToSaved {
// we use the _trackDataConnection because this is specifically for now playing,
// if it's to ultimately save the track, user will be able to see d/l progress
_fileWillBeCached = YES;
_saveOnDownloadComplete = moveToSaved;
NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:_npEpisodeEntity.url]];
_trackDataConnection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO];
[_trackDataConnection setDelegateQueue:[NSOperationQueue mainQueue]];
[_trackDataConnection start];
}
- (void) queueEpisodeForDownload:(EpisodeEntity *)episodeEntity {
//JPLog(@"queue episode for saving: %@", episodeEntity.title);
// check if there is enough disk space
CGFloat freeDiskSpace = [ALDisk freeDiskSpaceInBytes];
_bytesToSave += episodeEntity.dataLength.doubleValue;
NSString *freeSpace = [TungCommonObjects formatBytes:[NSNumber numberWithFloat:freeDiskSpace]];
NSString *spaceNeeded = [TungCommonObjects formatBytes:[NSNumber numberWithFloat:_bytesToSave]];
if (freeDiskSpace <= _bytesToSave) {
JPLog(@"Error queueing episode for save: not enough storage. free space: %@, space needed: %@", freeSpace, spaceNeeded);
UIAlertController *notEnoughDiskAlert = [UIAlertController alertControllerWithTitle:@"Not enough storage" message:[NSString stringWithFormat:@"The episode(s) you're trying to save require %@ but you only have %@ available. Try removing some other saved episodes or delete all saved episodes from settings.", spaceNeeded, freeSpace] preferredStyle:UIAlertControllerStyleAlert];
[notEnoughDiskAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]];
[_viewController presentViewController:notEnoughDiskAlert animated:YES completion:nil];
return;
}
// if not yet notified, notify of episode expiration
SettingsEntity *settings = [TungCommonObjects settings];
if (!settings.hasSeenEpisodeExpirationAlert.boolValue) {
NSInteger days = DAYS_TO_KEEP_SAVED;
NSString *expirationTitle = [NSString stringWithFormat:@"Saved episodes will be kept for %ld days.", (long)days];
UIAlertController *episodeExpirationAlert = [UIAlertController alertControllerWithTitle:expirationTitle message:@"After that, they will be automatically deleted." preferredStyle:UIAlertControllerStyleAlert];
[episodeExpirationAlert addAction:[UIAlertAction actionWithTitle:@"Don't show again" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
settings.hasSeenEpisodeExpirationAlert = [NSNumber numberWithBool:YES];
[TungCommonObjects saveContextWithReason:@"settings changed"];
}]];
[episodeExpirationAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
if ([TungCommonObjects iOSVersionFloat] >= 9.0) {
episodeExpirationAlert.preferredAction = [episodeExpirationAlert.actions objectAtIndex:1];
}
[_viewController presentViewController:episodeExpirationAlert animated:YES completion:nil];
}
[_episodeSaveQueue addObject:episodeEntity.url];
episodeEntity.isQueuedForSave = [NSNumber numberWithBool:YES];
// if nothing's downloading, start download
if (!_saveTrackConnection) {
[self downloadEpisode:episodeEntity];
} else {
[TungCommonObjects saveContextWithReason:@"queued episode for save"];
[TungCommonObjects queueSaveStatusDidChangeNotification];
}
}
- (void) cancelDownloadForEpisode:(EpisodeEntity *)episodeEntity {
//JPLog(@"cancel save for episode: %@", episodeEntity.title);
// deduct bytes to save
_bytesToSave -= episodeEntity.dataLength.doubleValue;
[_episodeSaveQueue removeObject:episodeEntity.url];
episodeEntity.isQueuedForSave = [NSNumber numberWithBool:NO];
episodeEntity.isDownloadingForSave = [NSNumber numberWithBool:NO];
[TungCommonObjects saveContextWithReason:@"episode cancelled saving"];
[TungCommonObjects queueSaveStatusDidChangeNotification];
// cancel download
if ([episodeEntity.url isEqualToString:_episodeToSaveEntity.url]) {
[_saveTrackConnection cancel];
_saveTrackConnection = nil;
_saveTrackData = nil;
[self downloadNextEpisodeInQueue];
}
}
- (void) downloadNextEpisodeInQueue {
if (_episodeSaveQueue.count > 0) {
// lookup entity by guid
NSString *urlString = [_episodeSaveQueue objectAtIndex:0];
EpisodeEntity *epEntity = [TungCommonObjects getEpisodeEntityFromUrlString:urlString];
if (epEntity) {
[self downloadEpisode:epEntity];
}
}
}
- (void) downloadEpisode:(EpisodeEntity *)episodeEntity {
//JPLog(@"start download of episode: %@", episodeEntity.title);
_episodeToSaveEntity = episodeEntity;
episodeEntity.isQueuedForSave = [NSNumber numberWithBool:YES];
episodeEntity.isDownloadingForSave = [NSNumber numberWithBool:YES];
[TungCommonObjects saveContextWithReason:@"new episode downloading"];
[TungCommonObjects queueSaveStatusDidChangeNotification];
NSURL *url = [NSURL URLWithString:episodeEntity.url];
//NSLog(@"init download connection with url: %@", url);
NSURLRequest *request = [NSURLRequest requestWithURL:url];
_saveTrackConnection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO];
[_saveTrackConnection setDelegateQueue:[NSOperationQueue mainQueue]];
[_saveTrackConnection start];
}
+ (void) deleteSavedEpisode:(EpisodeEntity *)epEntity confirm:(BOOL)confirm {
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *episodeFilepath = [TungCommonObjects getSavedFilepathForEpisodeEntity:epEntity];
NSError *error;
BOOL success = NO;
if ([fileManager fileExistsAtPath:episodeFilepath]) {
success = [fileManager removeItemAtPath:episodeFilepath error:&error];
if (success) {
JPLog(@"successfully removed episode from saved");
} else {
JPLog(@"failed to remove episode: %@", error);
}
}
if (success) {
// update entity
epEntity.isSaved = [NSNumber numberWithBool:NO];
[TungCommonObjects saveContextWithReason:@"deleted saved episode file"];
[TungCommonObjects queueSaveStatusDidChangeNotification];
//JPLog(@"deleted episode with url: %@", urlString);
if (confirm) {
[TungCommonObjects showBannerAlertForText:@"Your saved copy of this episode has been deleted."];
}
// safe to remove feed from saved? (are other episodes saved?)
BOOL safeToRemoveFeed = YES;
for (EpisodeEntity *ep in epEntity.podcast.episodes) {
if (ep.isSaved.boolValue) {
safeToRemoveFeed = NO;
break;
}
}
if (safeToRemoveFeed) {
[TungPodcast unsaveFeedForEntity:epEntity.podcast];
if (!epEntity.podcast.isSubscribed.boolValue) {
[TungImages unsavePodcastArtForEntity:epEntity.podcast];
}
}
}
}
+ (void) deleteAllSavedEpisodes {
//JPLog(@"delete all saved episodes");
// remove "isSaved" status from all entities
AppDelegate *appDelegate = (AppDelegate *)[[UIApplication sharedApplication] delegate];
NSError *error = nil;
NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"EpisodeEntity"];
NSPredicate *predicate = [NSPredicate predicateWithFormat: @"isSaved == YES"];
[request setPredicate:predicate];
NSArray *episodeResult = [appDelegate.managedObjectContext executeFetchRequest:request error:&error];
// collect entities
NSMutableArray *collectionIds = [NSMutableArray array];
NSMutableArray *podcastEntities = [NSMutableArray array];
if (episodeResult.count > 0) {
for (int i = 0; i < episodeResult.count; i++) {
EpisodeEntity *epEntity = [episodeResult objectAtIndex:i];
epEntity.isSaved = [NSNumber numberWithBool:NO];
if (![collectionIds containsObject:epEntity.collectionId]) {
[collectionIds addObject:epEntity.collectionId];
[podcastEntities addObject:epEntity.podcast];
}
}
[TungCommonObjects saveContextWithReason:@"removed saved status from episodes"];
[TungCommonObjects queueSaveStatusDidChangeNotification];
}
error = nil;
// remove saved files
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *episodesDir = [TungCommonObjects getSavedEpisodesDirectoryPath];
NSArray *episodesDirContents = [fileManager contentsOfDirectoryAtPath:episodesDir error:&error];
if (episodesDirContents.count > 0 && error == nil) {
for (NSString *item in episodesDirContents) {
if ([fileManager removeItemAtPath:[episodesDir stringByAppendingPathComponent:item] error:NULL]) {
JPLog(@"- removed item: %@", item);
};
}
}
// loop through podcast entities to un-save feeds, art
for (int i = 0; i < podcastEntities.count; i++) {
PodcastEntity *podEntity = [podcastEntities objectAtIndex:i];
if (!podEntity.isSubscribed.boolValue) {
[TungPodcast unsaveFeedForEntity:podEntity];
[TungImages unsavePodcastArtForEntity:podEntity];
}
}
}
+ (BOOL) deleteCachedEpisode:(EpisodeEntity *)epEntity {
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *episodeFilepath = [TungCommonObjects getCachedFilepathForEpisodeEntity:epEntity];
NSError *error;
if ([fileManager fileExistsAtPath:episodeFilepath]) {
if (!epEntity.isNowPlaying.boolValue) {
BOOL success = [fileManager removeItemAtPath:episodeFilepath error:&error];
return success;
}
else {
// episode is still cached and playing, don't delete but renew cache time
// set local notif. for deleting cached audio
NSInteger days = DAYS_TO_KEEP_CACHED;
[self createLocalNotifToDeleteAudioForEntity:epEntity inDays:days forCached:YES];
return NO;
}
}
return NO;
}
+ (void) deleteAllCachedEpisodes {
//JPLog(@"delete all cached episodes");
// remove saved files
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *episodesDir = [TungCommonObjects getCachedEpisodesDirectoryPath];
NSError *error;
NSArray *episodesDirContents = [fileManager contentsOfDirectoryAtPath:episodesDir error:&error];
if (episodesDirContents.count > 0 && error == nil) {
for (NSString *item in episodesDirContents) {
if ([fileManager removeItemAtPath:[episodesDir stringByAppendingPathComponent:item] error:NULL]) {
//JPLog(@"- removed item: %@", item);
};
}
}
}
// clears everyting in temp folder except cached episodes and MediaCache
+ (void) deleteCachedData {
NSError *error = nil;
NSArray *tmpFolderContents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:NSTemporaryDirectory() error:&error];
if ([tmpFolderContents count] > 0 && error == nil) {
for (NSString *item in tmpFolderContents) {
if (![item isEqualToString:@"MediaCache"] && ![item isEqualToString:@"episodes"]) {
NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:item];
[[NSFileManager defaultManager] removeItemAtPath:path error:&error];
if (error) {
JPLog(@"error removing item at path: %@ - %@", path, error.localizedDescription);
error = nil;
}
}
}
}
}
- (void) showSavedInfoAlertForEpisode:(EpisodeEntity *)episodeEntity {
// tell user when episode will be auto deleted
NSString *formattedDate = [NSDateFormatter localizedStringFromDate:episodeEntity.savedUntilDate dateStyle:NSDateFormatterLongStyle timeStyle:NSDateFormatterNoStyle];
UIAlertController *episodeSavedInfoAlert = [UIAlertController alertControllerWithTitle:@"Saved" message:[NSString stringWithFormat:@"This episode will be saved until\n%@", formattedDate] preferredStyle:UIAlertControllerStyleAlert];
[episodeSavedInfoAlert addAction:[UIAlertAction actionWithTitle:@"Remove" style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) {
[TungCommonObjects deleteSavedEpisode:episodeEntity confirm:YES];
}]];
UIAlertAction *keepAction = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil];
[episodeSavedInfoAlert addAction:keepAction];
if ([TungCommonObjects iOSVersionFloat] >= 9.0) {
episodeSavedInfoAlert.preferredAction = keepAction;
}
[_viewController presentViewController:episodeSavedInfoAlert animated:YES completion:nil];
}
/* move episode from temp dir to saved dir
if it's not in temp, queues episode for download */
- (BOOL) moveToSavedOrQueueDownloadForEpisode:(EpisodeEntity *)episodeEntity {
// find in temp
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *cachedEpisodeFilepath = [TungCommonObjects getCachedFilepathForEpisodeEntity:episodeEntity];
//NSLog(@"move episode - path: %@", cachedEpisodeFilepath);
BOOL result = NO;
if ([fileManager fileExistsAtPath:cachedEpisodeFilepath]) {
// save in docs directory
NSString *savedEpisodeFilepath = [TungCommonObjects getSavedFilepathForEpisodeEntity:episodeEntity];
NSError *error;
// if somehow it was already saved, remove it or it will error
[fileManager removeItemAtPath:savedEpisodeFilepath error:&error];
error = nil;
result = [fileManager moveItemAtPath:cachedEpisodeFilepath toPath:savedEpisodeFilepath error:&error];
//JPLog(@"moved episode to saved from temp: %@", (result) ? @"Success" : @"Failed");
if (result) {
episodeEntity.isSaved = [NSNumber numberWithBool:YES];
NSDate *todayPlusThirtyDays = [[NSCalendar currentCalendar] dateByAddingUnit:NSCalendarUnitDay value:30 toDate:[NSDate date] options:0];
episodeEntity.savedUntilDate = todayPlusThirtyDays;
[TungCommonObjects saveContextWithReason:@"moved episode to saved"];
[TungCommonObjects queueSaveStatusDidChangeNotification];
} else {
JPLog(@"Error moving episode: %@", error);
episodeEntity.isSaved = [NSNumber numberWithBool:NO];
}
} else {
// file does not exist in temp path
episodeEntity.isSaved = [NSNumber numberWithBool:NO];
[self queueEpisodeForDownload:episodeEntity];
}
return result;
}
+ (NSDate *) createLocalNotifToDeleteAudioForEntity:(EpisodeEntity *)epEntity inDays:(NSInteger)days forCached:(BOOL)cached {
// delate saved or cached episode?
NSString *saveType = (cached) ? @"deleteCachedEpisodeWithUrl" : @"deleteEpisodeWithUrl";
NSDate *todayPlusXDays = [[NSCalendar currentCalendar] dateByAddingUnit:NSCalendarUnitDay value:days toDate:[NSDate date] options:0];
UILocalNotification *expiredEpisodeNotif = [[UILocalNotification alloc] init];
expiredEpisodeNotif.fireDate = todayPlusXDays;
expiredEpisodeNotif.timeZone = [[NSCalendar currentCalendar] timeZone];
expiredEpisodeNotif.hasAction = NO;
expiredEpisodeNotif.userInfo = @{saveType: epEntity.url};
[[UIApplication sharedApplication] scheduleLocalNotification:expiredEpisodeNotif];
return todayPlusXDays;
}
#pragma mark - NSURLConnection delegate
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
{
//PLog(@"[NSURLConnectionDataDelegate] connection did receive response");
if (connection == _trackDataConnection) {
//NSLog(@"connection response: %@", response);
// get data length from response header
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *) response;
if ([[httpResponse allHeaderFields] objectForKey:@"Content-Length"]) {
NSNumber *dataLength = [NSNumber numberWithDouble:[[[httpResponse allHeaderFields] objectForKey:@"Content-Length"] doubleValue]];
_npEpisodeEntity.dataLength = dataLength;
//NSLog(@"episode size: %@", [TungCommonObjects formatBytes:dataLength]);
}
_response = (NSHTTPURLResponse *)response;
[self processPendingRequests];
}
else if (connection == _saveTrackConnection) {
// get data length from response header
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *) response;
if ([[httpResponse allHeaderFields] objectForKey:@"Content-Length"]) {
NSNumber *dataLength = [NSNumber numberWithDouble:[[[httpResponse allHeaderFields] objectForKey:@"Content-Length"] doubleValue]];
_episodeToSaveEntity.dataLength = dataLength;
}
_saveTrackData = [NSMutableData data];
}
}
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
{
//JPLog(@"[NSURLConnectionDataDelegate] connection did receive data: %d", data.length);
if (connection == _trackDataConnection) {
[_trackData appendData:data];
[self processPendingRequests];
}
else if (connection == _saveTrackConnection) {
[_saveTrackData appendData:data];
}
}
- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
if (connection == _trackDataConnection) {
//JPLog(@"[NSURLConnectionDataDelegate] connection did finish loading");
[self processPendingRequests];
NSString *cachedEpisodeFilepath = [TungCommonObjects getCachedFilepathForEpisodeEntity:_npEpisodeEntity];
NSError *error;
if ([_trackData writeToFile:cachedEpisodeFilepath options:0 error:&error]) {
_fileIsLocal = YES;
//JPLog(@"-- saved podcast track in temp episode dir: %@", episodeFilepath);
//_trackData = nil;
// move to saved?
if (_saveOnDownloadComplete) {
[self moveToSavedOrQueueDownloadForEpisode:_npEpisodeEntity];
_saveOnDownloadComplete = NO; // reset
}
}
else {
JPLog(@"ERROR: track did not get cached: %@", error);
_fileIsLocal = NO;
}
}
else if (connection == _saveTrackConnection) {
// deduct bytes to save
_bytesToSave -= _episodeToSaveEntity.dataLength.doubleValue;
// save in docs directory
NSString *savedEpisodeFilepath = [TungCommonObjects getSavedFilepathForEpisodeEntity:_episodeToSaveEntity];
NSError *error;
if ([_saveTrackData writeToFile:savedEpisodeFilepath options:0 error:&error]) {
JPLog(@"-- saved podcast track");
// save feed and art
[TungImages savePodcastArtForEntity:_episodeToSaveEntity.podcast];
[TungPodcast saveFeedForEntity:_episodeToSaveEntity.podcast];
_saveTrackData = nil;
_saveTrackConnection = nil;
// update entity
_episodeToSaveEntity.isQueuedForSave = [NSNumber numberWithBool:NO];
_episodeToSaveEntity.isDownloadingForSave = [NSNumber numberWithBool:NO];
_episodeToSaveEntity.isSaved = [NSNumber numberWithBool:YES];
// set date and local notif. for deletion
NSInteger days = DAYS_TO_KEEP_SAVED;
NSDate *todayPlusThirtyDays = [TungCommonObjects createLocalNotifToDeleteAudioForEntity:_episodeToSaveEntity inDays:days forCached:NO];
_episodeToSaveEntity.savedUntilDate = todayPlusThirtyDays;
[TungCommonObjects saveContextWithReason:@"episode finished saving"];
// next?
[_episodeSaveQueue removeObjectAtIndex:0];
if (_episodeSaveQueue.count > 0) {
[self downloadNextEpisodeInQueue];
} else {
[TungCommonObjects queueSaveStatusDidChangeNotification];
}
}
else {
JPLog(@"Error saving track: %@", error);
_saveTrackData = nil;
_saveTrackConnection = nil;
UIAlertController *noSaveAlert = [UIAlertController alertControllerWithTitle:@"Error saving episode" message:[NSString stringWithFormat:@"%@", [error localizedDescription]] preferredStyle:UIAlertControllerStyleAlert];
[noSaveAlert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]];
[_viewController presentViewController:noSaveAlert animated:YES completion:nil];
// update entity
_episodeToSaveEntity.isQueuedForSave = [NSNumber numberWithBool:NO];
_episodeToSaveEntity.isDownloadingForSave = [NSNumber numberWithBool:NO];
_episodeToSaveEntity.isSaved = [NSNumber numberWithBool:NO];
[TungCommonObjects saveContextWithReason:@"episode did not save"];
[TungCommonObjects queueSaveStatusDidChangeNotification];
}
}
}
- (void)connection:(NSURLConnection*)connection didFailWithError:(NSError*)error {
JPLog(@"connection lost");
[self reestablishPlayerItemAndReplace];
}
#pragma mark - AVURLAsset resource loading
- (void)processPendingRequests
{
//JPLog(@"[AVAssetResourceLoaderDelegate] process pending requests");
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;
}
//JPLog(@"[AVAssetResourceLoaderDelegate] fill in content information");
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
{
//JPLog(@"[AVAssetResourceLoaderDelegate] respond with data for request");
long long startOffset = dataRequest.requestedOffset;
if (dataRequest.currentOffset != 0)
{
startOffset = dataRequest.currentOffset;
}
// Don't have any data at all for this request
if (_trackData.length < startOffset)
{
return NO;
}
// This is the total data we have from startOffset to whatever has been downloaded so far
NSUInteger unreadBytes = _trackData.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:[_trackData subdataWithRange:NSMakeRange((NSUInteger)startOffset, numberOfBytesToRespondWith)]];
long long endOffset = startOffset + dataRequest.requestedLength;
BOOL didRespondFully = _trackData.length >= endOffset;
return didRespondFully;
}
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
{
//JPLog(@"[AVAssetResourceLoaderDelegate] should wait for loading of requested resource");
// implemented fix for stalling playback: http://stackoverflow.com/a/29977243/591487
if (_fileIsLocal) {
[self.pendingRequests addObject:loadingRequest];
[self processPendingRequests];
return YES;
}
// initiate connection only if we haven't already downloaded the file
else if (_trackDataConnection == nil)
{
[self initiateAVAssetDownload];
}
[self.pendingRequests addObject:loadingRequest];
return YES;
}
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
//JPLog(@"[AVAssetResourceLoaderDelegate] did cancel loading request");
[self.pendingRequests removeObject:loadingRequest];
}
- (void) initiateAVAssetDownload {
NSURL *url = [NSURL URLWithString:_npEpisodeEntity.url];
JPLog(@"init track data connection with url: %@", url);
NSURLRequest *request = [NSURLRequest requestWithURL:url];
_trackDataConnection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO];
[_trackDataConnection setDelegateQueue:[NSOperationQueue mainQueue]];
[_trackDataConnection start];
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment