Стриминг аудио в iOS на примере Яндекс.Диск

в 12:10, , рубрики: audio streaming, AVAssetResourceLoader, ios development, objective-c, Yandex API, yandex disk, разработка под iOS, Яндекс API

Во время работы над проектом по стримингу аудио необходимо было добавить поддержку новых сервисов, таких как Яндекс.Диск. Работа с аудио в приложении реализована через AVPlayer, который проигрывает файлы по url и поддерживает стандартные схемы, такие как file, http, https. Все работает отлично для сервисов, в которых токен авторизации передается в url запроса, среди них DropBox, Box, Google Drive. Для таких сервисов, как Яндекс.Диск, токен авторизации передается в заголовке запроса и к нему AVPlayer доступ не предоставляет.

Поиск решения этой проблемы среди имеющегося API привели к использованию объекта resourceLoader в AVURLAsset. С его помощью мы предоставляем доступ к файлу, размещенному на удаленном ресурсе, для AVPlayer. Работает это по принципу локального HTTP прокси но с максимальным упрощением для использования.

Нужно понимать что AVPlayer использует resourceLoader в тех случаях когда сам не знает как загрузить файл. Поэтому мы создаем url c кастумной схемой и инициализируем плеер с этим url. AVPlayer не зная как загрузить ресурс передает управление resourceLoader`y.

AVAssetResourceLoader работает через AVAssetResourceLoaderDelegate для которого нужно реализовать два метода:

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest;

- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest;

Первый вызывается когда AVAssetResourceLoader начинает загрузку ресурса и передает нам AVAssetResourceLoadingRequest. В этом случае мы запоминаем запрос и начинаем загрузку данных. Если запрос уже не актуальный то AVAssetResourceLoader вызывает второй метод и мы отменяем загрузку данных.

Для начала создадим AVPlayer, используя url с кастумной схемой, назначим AVAssetResourceLoaderDelegate и очередь на которой будут вызываться методы делегата:

AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[NSURL URLWithString:@"customscheme://host/myfile.mp3"] options:nil];
[asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];

AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:asset];
[self addObserversForPlayerItem:item];

self.player = [AVPlayer playerWithPlayerItem:playerItem];
[self addObserversForPlayer];

Заниматься загрузкой ресурса будет некий класс LSFilePlayerResourceLoader. Он инициализируется с url загружаемого ресурса и сессией YDSession, которая и будет непосредственно загружать файл с сервера. Хранить объекты LSFilePlayerResourceLoader мы будем в NSDictionary, а ключем будет url ресурса.

При загрузке ресурса с неизвестного источника AVAssetResourceLoader вызовет методы делегата.

AVAssetResourceLoaderDelegate

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest{
    NSURL *resourceURL = [loadingRequest.request URL];
    if([resourceURL.scheme isEqualToString:@"customscheme"]){
        LSFilePlayerResourceLoader *loader = [self resourceLoaderForRequest:loadingRequest];
        if(loader==nil){
            loader = [[LSFilePlayerResourceLoader alloc] initWithResourceURL:resourceURL session:self.session];
            loader.delegate = self;
            [self.resourceLoaders setObject:loader forKey:[self keyForResourceLoaderWithURL:resourceURL]];
        }
        [loader addRequest:loadingRequest];
        return YES;
    }
    return NO;
}

- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest{
    LSFilePlayerResourceLoader *loader = [self resourceLoaderForRequest:loadingRequest];
    [loader removeRequest:loadingRequest];
}

В начале метода загрузки мы проверяем что схема соответствует нашей. Далее берем LSFilePlayerResourceLoader из кеша или создаем новый и добавляем к нему запрос на загрузку ресурса.

Интерфейс нашего LSFilePlayerResourceLoader выглядит так:

LSFilePlayerResourceLoader


@interface LSFilePlayerResourceLoader : NSObject

@property (nonatomic,readonly,strong)NSURL *resourceURL;
@property (nonatomic,readonly)NSArray *requests;
@property (nonatomic,readonly,strong)YDSession *session;
@property (nonatomic,readonly,assign)BOOL isCancelled;
@property (nonatomic,weak)id<LSFilePlayerResourceLoaderDelegate> delegate;

- (instancetype)initWithResourceURL:(NSURL *)url session:(YDSession *)session;
- (void)addRequest:(AVAssetResourceLoadingRequest *)loadingRequest;
- (void)removeRequest:(AVAssetResourceLoadingRequest *)loadingRequest;
- (void)cancel;

@end

@protocol LSFilePlayerResourceLoaderDelegate <NSObject>

@optional
- (void)filePlayerResourceLoader:(LSFilePlayerResourceLoader *)resourceLoader didFailWithError:(NSError *)error;
- (void)filePlayerResourceLoader:(LSFilePlayerResourceLoader *)resourceLoader didLoadResource:(NSURL *)resourceURL;

@end

Он содержит методы для добавления/удаления запроса в очередь и метод для отмены всех запросов. LSFilePlayerResourceLoaderDelegate сообщит когда ресурс полностью загружен или возникла ошибка при загрузке.

При добавлении запроса в очередь, вызовом addRequest, мы запоминаем его в pendingRequests и стартуем операцию загрузки данных:

Добавление запроса

- (void)addRequest:(AVAssetResourceLoadingRequest *)loadingRequest{
    if(self.isCancelled==NO){
        NSURL *interceptedURL = [loadingRequest.request URL];
        [self startOperationFromOffset:loadingRequest.dataRequest.requestedOffset length:loadingRequest.dataRequest.requestedLength];
        [self.pendingRequests addObject:loadingRequest];
    }
    else{
        if(loadingRequest.isFinished==NO){
            [loadingRequest finishLoadingWithError:[self loaderCancelledError]];
        }
    }
}

В начале мы создавали новую операцию загрузки данных для каждого поступающего запроса. В итоге получалось что файл загружался в три-четыре потока при этом данные пересекались. Но потом выяснили, что как только AVAssetResourceLoader начинает новый запрос предыдущие для него уже не актуальны. Это дает нам возможность смело отменять все выполняющиеся операции загрузки данных как только мы стартуем новую, что экономит трафик.

Операция загрузки данных с сервера разбита на две. Первая (contentInfoOperation) получает информацию о размере и типе файла. Вторая (dataOperation) — получает данные файла со смещением. Смещение и размер запрашиваемых данных мы вычитываем из объекта класса AVAssetResourceLoadingDataRequest.

Операция загрузки данных

- (void)startOperationFromOffset:(unsigned long long)requestedOffset
                          length:(unsigned long long)requestedLength{
    
    [self cancelAllPendingRequests];
    [self cancelOperations];
    
    __weak typeof (self) weakSelf = self;
    
    void(^failureBlock)(NSError *error) = ^(NSError *error) {
        [weakSelf performBlockOnMainThreadSync:^{
            if(weakSelf && weakSelf.isCancelled==NO){
                [weakSelf completeWithError:error];
            }
        }];
    };
    
    void(^loadDataBlock)(unsigned long long off, unsigned long long len) = ^(unsigned long long offset,unsigned long long length){
        [weakSelf performBlockOnMainThreadSync:^{
            NSString *bytesString = [NSString stringWithFormat:@"bytes=%lld-%lld",offset,(offset+length-1)];
            NSDictionary *params = @{@"Range":bytesString};
            id<YDSessionRequest> req =
            [weakSelf.session partialContentForFileAtPath:weakSelf.path withParams:params response:nil
                data:^(UInt64 recDataLength, UInt64 totDataLength, NSData *recData) {
                     [weakSelf performBlockOnMainThreadSync:^{
                         if(weakSelf && weakSelf.isCancelled==NO){
                             LSDataResonse *dataResponse = [LSDataResonse responseWithRequestedOffset:offset
                                                                                      requestedLength:length
                                                                                   receivedDataLength:recDataLength
                                                                                                 data:recData];
                             [weakSelf didReceiveDataResponse:dataResponse];
                         }
                     }];
                 }
                completion:^(NSError *err) {
                   if(err){
                       failureBlock(err);
                   }
                }];
           weakSelf.dataOperation = req;
        }];
    };
    
    if(self.contentInformation==nil){
        self.contentInfoOperation = [self.session fetchStatusForPath:self.path completion:^(NSError *err, YDItemStat *item) {
            if(weakSelf && weakSelf.isCancelled==NO){
                if(err==nil){
                    NSString *mimeType = item.path.mimeTypeForPathExtension;
                    CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType,(__bridge CFStringRef)(mimeType),NULL);
                    unsigned long long contentLength = item.size;
                    weakSelf.contentInformation = [[LSContentInformation alloc] init];
                    weakSelf.contentInformation.byteRangeAccessSupported = YES;
                    weakSelf.contentInformation.contentType = CFBridgingRelease(contentType);
                    weakSelf.contentInformation.contentLength = contentLength;
                    [weakSelf prepareDataCache];
                    loadDataBlock(requestedOffset,requestedLength);
                    weakSelf.contentInfoOperation = nil; 
                }
                else{
                    failureBlock(err);
                }
            }
        }];
    }
    else{
        loadDataBlock(requestedOffset,requestedLength);
    }
}

После получения информации о файле на сервере мы создаем временный файл, в который будем записывать данные из сети и считывать их по мере надобности.

Инициализация дискового кеша

- (void)prepareDataCache{
    
    self.cachedFilePath = [[self class] pathForTemporaryFile];

    NSError *error = nil;
    if ([[NSFileManager defaultManager] fileExistsAtPath:self.cachedFilePath] == YES){
        [[NSFileManager defaultManager] removeItemAtPath:self.cachedFilePath error:&error];
    }
    
    if (error == nil && [[NSFileManager defaultManager] fileExistsAtPath:self.cachedFilePath] == NO) {
        NSString *dirPath = [self.cachedFilePath stringByDeletingLastPathComponent];
        [[NSFileManager defaultManager] createDirectoryAtPath:dirPath
                                  withIntermediateDirectories:YES
                                                   attributes:nil
                                                        error:&error];
        
        if (error == nil) {
            [[NSFileManager defaultManager] createFileAtPath:self.cachedFilePath
                                                    contents:nil
                                                  attributes:nil];
            
            self.writingFileHandle = [NSFileHandle fileHandleForWritingAtPath:self.cachedFilePath];
            
            @try {
                [self.writingFileHandle truncateFileAtOffset:self.contentInformation.contentLength];
                [self.writingFileHandle synchronizeFile];
            }
            @catch (NSException *exception) {
                NSError *error = [[NSError alloc] initWithDomain:LSFilePlayerResourceLoaderErrorDomain
                                                            code:-1
                                                        userInfo:@{NSLocalizedDescriptionKey:@"can not write to file"}];
                [self completeWithError:error];
                return;
            }
            self.readingFileHandle = [NSFileHandle fileHandleForReadingAtPath:self.cachedFilePath];
        }
    }
    
    if (error != nil) {
        [self completeWithError:error];
    }
}

После получения пакета данных мы сначала кешируем его на диск и обновляем размер полученных данных, хранимый в переменной receivedDataLength. В конце оповещаем запросы находящиеся в очереди о новой порции данных.

Получение пакета данных

- (void)didReceiveDataResponse:(LSDataResonse *)dataResponse{
    [self cacheDataResponse:dataResponse];
    self.receivedDataLength=dataResponse.currentOffset;
    [self processPendingRequests];
}

Метод кеширования записывает данные в файл с нужным смещением.

Кеширование данных

- (void)cacheDataResponse:(LSDataResonse *)dataResponse{
    unsigned long long offset = dataResponse.dataOffset;
    @try {
        [self.writingFileHandle seekToFileOffset:offset];
        [self.writingFileHandle writeData:dataResponse.data];
        [self.writingFileHandle synchronizeFile];
    }
    @catch (NSException *exception) {
        NSError *error = [[NSError alloc] initWithDomain:LSFilePlayerResourceLoaderErrorDomain
                                                    code:-1
                                                userInfo:@{NSLocalizedDescriptionKey:@"can not write to file"}];
        [self completeWithError:error];
    }
}

Метод чтения делает обратную операцию.

Чтение данных из кеша

- (NSData *)readCachedData:(unsigned long long)startOffset length:(unsigned long long)numberOfBytesToRespondWith{
    @try {
        [self.readingFileHandle seekToFileOffset:startOffset];
        NSData *data = [self.readingFileHandle readDataOfLength:numberOfBytesToRespondWith];
        return data;
    }
    @catch (NSException *exception) {}
    return nil;
}

Для оповещения запросов находящихся в очереди о новой порции данных мы сначала записываем информацию о контенте, а затем данные из кеша. Если все данные для запроса были записаны, то мы удаляем его из очереди.

Оповещение запросов

- (void)processPendingRequests{
    NSMutableArray *requestsCompleted = [[NSMutableArray alloc] init];
    for (AVAssetResourceLoadingRequest *loadingRequest in self.pendingRequests){
        [self fillInContentInformation:loadingRequest.contentInformationRequest];
        BOOL didRespondCompletely = [self respondWithDataForRequest:loadingRequest.dataRequest];
        if (didRespondCompletely){
            [loadingRequest finishLoading];
            [requestsCompleted addObject:loadingRequest];
        }
    }
    [self.pendingRequests removeObjectsInArray:requestsCompleted];
}

В методе заполнения информации о контенте мы устанавливаем размер, тип, флаг доступа к произвольному диапазону данных.

Заполнение информации о контенте

- (void)fillInContentInformation:(AVAssetResourceLoadingContentInformationRequest *)contentInformationRequest{
    if (contentInformationRequest == nil || self.contentInformation == nil){
        return;
    }
    contentInformationRequest.byteRangeAccessSupported = self.contentInformation.byteRangeAccessSupported;
    contentInformationRequest.contentType = self.contentInformation.contentType;
    contentInformationRequest.contentLength = self.contentInformation.contentLength;
}

И основной метод, в котором мы считываем данные из кеша и передаем их запросам из очереди.

Заполнение данных

- (BOOL)respondWithDataForRequest:(AVAssetResourceLoadingDataRequest *)dataRequest{
    
    long long startOffset = dataRequest.requestedOffset;
    if (dataRequest.currentOffset != 0){
        startOffset = dataRequest.currentOffset;
    }
    
    // Don't have any data at all for this request
    if (self.receivedDataLength < startOffset){
        return NO;
    }
    
    // This is the total data we have from startOffset to whatever has been downloaded so far
    NSUInteger unreadBytes = self.receivedDataLength - startOffset;
    
    // Respond with whatever is available if we can't satisfy the request fully yet
    NSUInteger numberOfBytesToRespondWith = MIN(dataRequest.requestedLength, unreadBytes);
    
    BOOL didRespondFully = NO;

    NSData *data = [self readCachedData:startOffset length:numberOfBytesToRespondWith];

    if(data){
        [dataRequest respondWithData:data];
        long long endOffset = startOffset + dataRequest.requestedLength;
        didRespondFully = self.receivedDataLength >= endOffset;
    }

    return didRespondFully;
}

На этом работа с загрузчиком закончена. Осталось немного изменить SDK Яндекс.Диска, для того чтобы мы могли загружать данные произвольного диапазона из файла на сервере. Изменений всего три.

Первое — нужно добавить для каждого запроса в YDSession возможность отмены. Для этого добавляем новый протокол YDSessionRequest и устанавливаем его в качестве возвращаемого значения в запросах.

YDSession.h

@protocol YDSessionRequest <NSObject>
- (void)cancel;
@end

- (id<YDSessionRequest>)fetchDirectoryContentsAtPath:(NSString *)path completion:(YDFetchDirectoryHandler)block;
- (id<YDSessionRequest>)fetchStatusForPath:(NSString *)path completion:(YDFetchStatusHandler)block;

Второе — добавляем метод загрузки данных произвольного диапазона из файла на сервере.

YDSession.h

- (id<YDSessionRequest>)partialContentForFileAtPath:(NSString *)srcRemotePath
                                         withParams:(NSDictionary *)params
                                           response:(YDDidReceiveResponseHandler)response
                                               data:(YDPartialDataHandler)data
                                         completion:(YDHandler)completion;

YDSession.m

- (id<YDSessionRequest>)partialContentForFileAtPath:(NSString *)srcRemotePath
                                         withParams:(NSDictionary *)params
                                           response:(YDDidReceiveResponseHandler)response
                                               data:(YDPartialDataHandler)data
                                         completion:(YDHandler)completion{
    return [self downloadFileFromPath:srcRemotePath toFile:nil withParams:params response:response data:data progress:nil completion:completion];
}

- (id<YDSessionRequest>)downloadFileFromPath:(NSString *)path
                                      toFile:(NSString *)aFilePath
                                  withParams:(NSDictionary *)params
                                    response:(YDDidReceiveResponseHandler)responseBlock
                                        data:(YDPartialDataHandler)dataBlock
                                    progress:(YDProgressHandler)progressBlock
                                  completion:(YDHandler)completionBlock{
    
    NSURL *url = [YDSession urlForDiskPath:path];
    if (!url) {
        completionBlock([NSError errorWithDomain:kYDSessionBadArgumentErrorDomain
                                  code:0
                              userInfo:@{@"getPath": path}]);
        return nil;
    }
    
    BOOL skipReceivedData = NO;
    
    if(aFilePath==nil){
        aFilePath = [[self class] pathForTemporaryFile];
        skipReceivedData = YES;
    }
    
    NSURL *filePath = [YDSession urlForLocalPath:aFilePath];
    if (!filePath) {
        completionBlock([NSError errorWithDomain:kYDSessionBadArgumentErrorDomain
                                  code:1
                              userInfo:@{@"toFile": aFilePath}]);
        return nil;
    }
    
    YDDiskRequest *request = [[YDDiskRequest alloc] initWithURL:url];
    request.fileURL = filePath;
    request.params = params;
    request.skipReceivedData = skipReceivedData;
    [self prepareRequest:request];
    
    NSURL *requestURL = [request.URL copy];
    
    request.callbackQueue = _callBackQueue;
    
    request.didReceiveResponseBlock = ^(NSURLResponse *response, BOOL *accept) {
        if(responseBlock){
            responseBlock(response);
        }
    };
    
    request.didGetPartialDataBlock = ^(UInt64 receivedDataLength, UInt64 expectedDataLength, NSData *data){
        if(progressBlock){
            progressBlock(receivedDataLength,expectedDataLength);
        }
        if(dataBlock){
            dataBlock(receivedDataLength,expectedDataLength,data);
        }
    };
    
    request.didFinishLoadingBlock = ^(NSData *receivedData) {
        
        if(skipReceivedData){
            [[self class] removeTemporaryFileAtPath:aFilePath];
        }
        
        NSDictionary *userInfo = @{@"URL": requestURL,
                                   @"receivedDataLength": @(receivedData.length)};
        [[NSNotificationCenter defaultCenter] postNotificationInMainQueueWithName:kYDSessionDidDownloadFileNotification
                                                                           object:self
                                                                         userInfo:userInfo];
        completionBlock(nil);
    };
    
    request.didFailBlock = ^(NSError *error) {
        
        if(skipReceivedData){
            [[self class] removeTemporaryFileAtPath:aFilePath];
        }
        
        NSDictionary *userInfo = @{@"URL": requestURL};
        [[NSNotificationCenter defaultCenter] postNotificationInMainQueueWithName:kYDSessionDidFailToDownloadFileNotification
                                                                           object:self
                                                                         userInfo:userInfo];
        
        completionBlock([NSError errorWithDomain:error.domain code:error.code userInfo:userInfo]);
    };
    
    [request start];
    
    NSDictionary *userInfo = @{@"URL": request.URL};
    [[NSNotificationCenter defaultCenter] postNotificationInMainQueueWithName:kYDSessionDidStartDownloadFileNotification
                                                                       object:self
                                                                     userInfo:userInfo];
    return (id<YDSessionRequest>)request;
}

И третье, что нужно исправить — это изменить очередь колбеков с параллельной на последовательную, иначе блоки данных будут приходить не в том порядке, в котором мы запрашивали, и пользователь будет слышать рывки при проигрывании музыки.

YDSession.m

- (instancetype)initWithDelegate:(id<YDSessionDelegate>)delegate callBackQueue:(dispatch_queue_t)queue{
    self = [super init];
    if (self) {
        _delegate = delegate;
        _callBackQueue = queue;
    }
    return self;
}

 YDDiskRequest *request = [[YDDiskRequest alloc] initWithURL:url];
 request.fileURL = filePath;
 request.params = params;
 [self prepareRequest:request];
 request.callbackQueue = _callBackQueue;

Исходный код примера на GitHub.

Автор: leshko

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js