Last active
December 13, 2018 13:26
-
-
Save inorganik/16df9c18cd2cd9aabf94f9712ce70b88 to your computer and use it in GitHub Desktop.
Player code for Tung - a social podcast player for iOS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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