135 lines
5.7 KiB
Objective-C
135 lines
5.7 KiB
Objective-C
//
|
|
// WQMediaStream.m
|
|
// WQ Files
|
|
//
|
|
|
|
#import "WQMediaStream.h"
|
|
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
|
#import <objc/runtime.h>
|
|
|
|
static NSString *const kWQMediaScheme = @"wqmedia";
|
|
static const NSUInteger kWQMediaChunk = 512 * 1024;
|
|
static char kWQMediaStreamKey;
|
|
|
|
@interface WQMediaStream ()
|
|
@property (nonatomic, copy) NSString *contentType;
|
|
@property (nonatomic) unsigned long long size;
|
|
@property (nonatomic, copy) WQMediaReadRange read;
|
|
@property (nonatomic, copy) void (^onFailure)(NSString *reason);
|
|
@property (nonatomic, weak) AVPlayerItem *item;
|
|
@property (nonatomic) BOOL failed;
|
|
@end
|
|
|
|
@implementation WQMediaStream {
|
|
dispatch_queue_t _readQueue;
|
|
}
|
|
|
|
+ (NSString *)contentTypeForFileName:(NSString *)name
|
|
{
|
|
NSString *ext = name.pathExtension.lowercaseString;
|
|
// Playlists reference other files; those can't be served from here.
|
|
if (!ext.length || [ext isEqualToString:@"m3u8"]) return nil;
|
|
UTType *type = [UTType typeWithFilenameExtension:ext];
|
|
if (!type || !([type conformsToType:UTTypeAudiovisualContent])) return nil;
|
|
return type.identifier;
|
|
}
|
|
|
|
+ (AVPlayerItem *)playerItemForName:(NSString *)name size:(unsigned long long)size
|
|
read:(WQMediaReadRange)read onFailure:(void (^)(NSString *))onFailure
|
|
{
|
|
NSString *contentType = [self contentTypeForFileName:name];
|
|
if (!contentType || size == 0) return nil;
|
|
WQMediaStream *stream = [WQMediaStream new];
|
|
stream.contentType = contentType;
|
|
stream.size = size;
|
|
stream.read = read;
|
|
stream.onFailure = onFailure;
|
|
// The extension stays on the URL: AVFoundation also looks at it.
|
|
NSString *escaped = [name.lastPathComponent stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet];
|
|
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@://media/%@", kWQMediaScheme, escaped ?: @"media"]];
|
|
if (!url) return nil;
|
|
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
|
|
[asset.resourceLoader setDelegate:stream queue:dispatch_queue_create("wq.media.loader", DISPATCH_QUEUE_SERIAL)];
|
|
AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:asset];
|
|
stream.item = item;
|
|
// The resource loader holds its delegate weakly: the item keeps it.
|
|
objc_setAssociatedObject(item, &kWQMediaStreamKey, stream, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
|
[item addObserver:stream forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:&kWQMediaStreamKey];
|
|
return item;
|
|
}
|
|
|
|
- (instancetype)init
|
|
{
|
|
if ((self = [super init])) _readQueue = dispatch_queue_create("wq.media.read", DISPATCH_QUEUE_SERIAL);
|
|
return self;
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
@try { [_item removeObserver:self forKeyPath:@"status" context:&kWQMediaStreamKey]; } @catch (NSException *__unused e) {}
|
|
}
|
|
|
|
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
|
|
{
|
|
if (context != &kWQMediaStreamKey) { [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; return; }
|
|
AVPlayerItem *item = object;
|
|
if (item.status != AVPlayerItemStatusFailed) return;
|
|
NSString *reason = item.error.localizedDescription ?: @"the player failed";
|
|
NSError *underlying = item.error.userInfo[NSUnderlyingErrorKey];
|
|
if (underlying) reason = [reason stringByAppendingFormat:@" (%@ %ld)", underlying.domain, (long)underlying.code];
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
if (self.failed) return;
|
|
self.failed = YES;
|
|
if (self.onFailure) self.onFailure(reason);
|
|
});
|
|
}
|
|
|
|
- (NSData *)dataForOffset:(unsigned long long)offset length:(NSUInteger)length error:(NSString **)error
|
|
{
|
|
if (offset >= _size) return [NSData data];
|
|
unsigned long long left = _size - offset;
|
|
NSUInteger want = (NSUInteger)MIN((unsigned long long)length, left);
|
|
return _read(offset, want, error);
|
|
}
|
|
|
|
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader
|
|
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)request
|
|
{
|
|
if (![request.request.URL.scheme isEqualToString:kWQMediaScheme]) return NO;
|
|
AVAssetResourceLoadingContentInformationRequest *info = request.contentInformationRequest;
|
|
if (info) {
|
|
info.contentType = _contentType;
|
|
info.contentLength = (long long)_size;
|
|
info.byteRangeAccessSupported = YES;
|
|
}
|
|
AVAssetResourceLoadingDataRequest *data = request.dataRequest;
|
|
if (!data) { [request finishLoading]; return YES; }
|
|
// Reads happen off the loader queue, in chunks, so a seek can cancel a
|
|
// long request between chunks.
|
|
dispatch_async(_readQueue, ^{
|
|
long long start = data.currentOffset;
|
|
long long end = data.requestsAllDataToEndOfResource ? (long long)self->_size
|
|
: data.requestedOffset + (long long)data.requestedLength;
|
|
end = MIN(end, (long long)self->_size);
|
|
long long offset = start;
|
|
while (offset < end) {
|
|
if (request.isCancelled || request.isFinished) return;
|
|
NSUInteger length = (NSUInteger)MIN((long long)kWQMediaChunk, end - offset);
|
|
NSString *error = nil;
|
|
NSData *chunk = [self dataForOffset:(unsigned long long)offset length:length error:&error];
|
|
if (!chunk.length) {
|
|
if (request.isCancelled || request.isFinished) return;
|
|
[request finishLoadingWithError:[NSError errorWithDomain:@"WQMediaStream" code:1
|
|
userInfo:@{NSLocalizedDescriptionKey: error ?: @"The file could not be read"}]];
|
|
return;
|
|
}
|
|
[data respondWithData:chunk];
|
|
offset += (long long)chunk.length;
|
|
}
|
|
if (!request.isCancelled && !request.isFinished) [request finishLoading];
|
|
});
|
|
return YES;
|
|
}
|
|
|
|
@end
|