audio_service/lib/audio_service_web.dart
2020-09-18 19:25:00 +02:00

355 lines
12 KiB
Dart

import 'dart:async';
import 'dart:html' as html;
import 'dart:js' as js;
import 'package:audio_service/js/media_metadata.dart';
import 'js/media_session_web.dart';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
const String _CUSTOM_PREFIX = 'custom_';
class Art {
String src;
String type;
String sizes;
Art({this.src, this.type, this.sizes});
}
class AudioServicePlugin {
int fastForwardInterval;
int rewindInterval;
Map params;
bool started = false;
ClientHandler clientHandler;
BackgroundHandler backgroundHandler;
static void registerWith(Registrar registrar) {
AudioServicePlugin(registrar);
}
AudioServicePlugin(Registrar registrar) {
clientHandler = ClientHandler(this, registrar);
backgroundHandler = BackgroundHandler(this, registrar);
}
}
class ClientHandler {
final AudioServicePlugin plugin;
final MethodChannel channel;
ClientHandler(this.plugin, Registrar registrar)
: channel = MethodChannel(
'ryanheise.com/audioService',
const StandardMethodCodec(),
registrar.messenger,
) {
channel.setMethodCallHandler(handleServiceMethodCall);
}
Future<T> invokeMethod<T>(String method, [dynamic arguments]) =>
channel.invokeMethod(method, arguments);
Future<dynamic> handleServiceMethodCall(MethodCall call) async {
switch (call.method) {
case 'start':
plugin.fastForwardInterval = call.arguments['fastForwardInterval'];
plugin.rewindInterval = call.arguments['rewindInterval'];
plugin.params = call.arguments['params'];
plugin.started = true;
return plugin.started;
case 'connect':
// No-op not really anything for us to do with connect on the web, the
// streams should all be hydrated
break;
case 'disconnect':
// No-op not really anything for us to do with disconnect on the web,
// the streams should stay hydrated because everything is static and we
// aren't working with isolates
break;
case 'isRunning':
return plugin.started;
case 'rewind':
return plugin.backgroundHandler.invokeMethod('onRewind');
case 'fastForward':
return plugin.backgroundHandler.invokeMethod('onFastForward');
case 'skipToPrevious':
return plugin.backgroundHandler.invokeMethod('onSkipToPrevious');
case 'skipToNext':
return plugin.backgroundHandler.invokeMethod('onSkipToNext');
case 'play':
return plugin.backgroundHandler.invokeMethod('onPlay');
case 'pause':
return plugin.backgroundHandler.invokeMethod('onPause');
case 'stop':
return plugin.backgroundHandler.invokeMethod('onStop');
case 'seekTo':
return plugin.backgroundHandler
.invokeMethod('onSeekTo', [call.arguments]);
case 'prepareFromMediaId':
return plugin.backgroundHandler
.invokeMethod('onPrepareFromMediaId', [call.arguments]);
case 'playFromMediaId':
return plugin.backgroundHandler
.invokeMethod('onPlayFromMediaId', [call.arguments]);
case 'setBrowseMediaParent':
return plugin.backgroundHandler
.invokeMethod('onLoadChildren', [call.arguments]);
case 'onClick':
// No-op we don't really have the idea of a bluetooth button click on
// the web
break;
case 'addQueueItem':
return plugin.backgroundHandler
.invokeMethod('onAddQueueItem', [call.arguments]);
case 'addQueueItemAt':
return plugin.backgroundHandler
.invokeMethod('onQueueItemAt', call.arguments);
case 'removeQueueItem':
return plugin.backgroundHandler
.invokeMethod('onRemoveQueueItem', [call.arguments]);
case 'updateQueue':
return plugin.backgroundHandler
.invokeMethod('onUpdateQueue', [call.arguments]);
case 'updateMediaItem':
return plugin.backgroundHandler
.invokeMethod('onUpdateMediaItem', [call.arguments]);
case 'prepare':
return plugin.backgroundHandler.invokeMethod('onPrepare');
case 'playMediaItem':
return plugin.backgroundHandler
.invokeMethod('onPlayMediaItem', [call.arguments]);
case 'skipToQueueItem':
return plugin.backgroundHandler
.invokeMethod('onSkipToMediaItem', [call.arguments]);
case 'setRepeatMode':
return plugin.backgroundHandler
.invokeMethod('onSetRepeatMode', [call.arguments]);
case 'setShuffleMode':
return plugin.backgroundHandler
.invokeMethod('onSetShuffleMode', [call.arguments]);
case 'setRating':
return plugin.backgroundHandler.invokeMethod('onSetRating',
[call.arguments['rating'], call.arguments['extras']]);
case 'setSpeed':
return plugin.backgroundHandler
.invokeMethod('onSetSpeed', [call.arguments]);
default:
if (call.method.startsWith(_CUSTOM_PREFIX)) {
final result = await plugin.backgroundHandler
.invokeMethod(call.method, call.arguments);
return result;
}
throw PlatformException(
code: 'Unimplemented',
details: "The audio Service plugin for web doesn't implement "
"the method '${call.method}'");
}
}
}
class BackgroundHandler {
final AudioServicePlugin plugin;
final MethodChannel channel;
MediaItem mediaItem;
BackgroundHandler(this.plugin, Registrar registrar)
: channel = MethodChannel(
'ryanheise.com/audioServiceBackground',
const StandardMethodCodec(),
registrar.messenger,
) {
channel.setMethodCallHandler(handleBackgroundMethodCall);
}
Future<T> invokeMethod<T>(String method, [dynamic arguments]) =>
channel.invokeMethod(method, arguments);
Future<dynamic> handleBackgroundMethodCall(MethodCall call) async {
switch (call.method) {
case 'started':
return started(call);
case 'ready':
return ready(call);
case 'stopped':
return stopped(call);
case 'setState':
return setState(call);
case 'setMediaItem':
return setMediaItem(call);
case 'setQueue':
return setQueue(call);
case 'androidForceEnableMediaButtons':
//no-op
break;
default:
throw PlatformException(
code: 'Unimplemented',
details:
"The audio service background plugin for web doesn't implement "
"the method '${call.method}'");
}
}
Future<bool> started(MethodCall call) async => true;
Future<dynamic> ready(MethodCall call) async => {
'fastForwardInterval': plugin.fastForwardInterval ?? 30000,
'rewindInterval': plugin.rewindInterval ?? 30000,
'params': plugin.params
};
Future<void> stopped(MethodCall call) async {
final session = html.window.navigator.mediaSession;
session.metadata = null;
plugin.started = false;
mediaItem = null;
plugin.clientHandler.invokeMethod('onStopped');
}
Future<void> setState(MethodCall call) async {
final session = html.window.navigator.mediaSession;
final List args = call.arguments;
final List<MediaControl> controls = call.arguments[0]
.map<MediaControl>((element) => MediaControl(
action: MediaAction.values[element['action']],
androidIcon: element['androidIcon'],
label: element['label']))
.toList();
// Reset the handlers
// TODO: Make this better... Like only change ones that have been changed
try {
session.setActionHandler('play', null);
session.setActionHandler('pause', null);
session.setActionHandler('previoustrack', null);
session.setActionHandler('nexttrack', null);
session.setActionHandler('seekbackward', null);
session.setActionHandler('seekforward', null);
session.setActionHandler('stop', null);
} catch (e) {}
int actionBits = 0;
for (final control in controls) {
try {
switch (control.action) {
case MediaAction.play:
session.setActionHandler('play', AudioService.play);
break;
case MediaAction.pause:
session.setActionHandler('pause', AudioService.pause);
break;
case MediaAction.skipToPrevious:
session.setActionHandler(
'previoustrack', AudioService.skipToPrevious);
break;
case MediaAction.skipToNext:
session.setActionHandler('nexttrack', AudioService.skipToNext);
break;
// The naming convention here is a bit odd but seekbackward seems more
// analagous to rewind than seekBackward
case MediaAction.rewind:
session.setActionHandler('seekbackward', AudioService.rewind);
break;
case MediaAction.fastForward:
session.setActionHandler('seekforward', AudioService.fastForward);
break;
case MediaAction.stop:
session.setActionHandler('stop', AudioService.stop);
break;
default:
// no-op
break;
}
} catch (e) {}
int actionCode = 1 << control.action.index;
actionBits |= actionCode;
}
for (int rawSystemAction in call.arguments[1]) {
MediaAction action = MediaAction.values[rawSystemAction];
switch (action) {
case MediaAction.seekTo:
try {
setActionHandler('seekto', js.allowInterop((ActionResult ev) {
print(ev.action);
print(ev.seekTime);
// Chrome uses seconds for whatever reason
AudioService.seekTo(Duration(
milliseconds: (ev.seekTime * 1000).round(),
));
}));
} catch (e) {}
break;
default:
// no-op
break;
}
int actionCode = 1 << rawSystemAction;
actionBits |= actionCode;
}
try {
// Dart also doesn't expose setPositionState
if (mediaItem != null) {
print(
'Setting positionState Duration(${mediaItem.duration.inSeconds}), PlaybackRate(${args[6] ?? 1.0}), Position(${Duration(milliseconds: args[4]).inSeconds})');
// Chrome looks for seconds for some reason
setPositionState(PositionState(
duration: (mediaItem.duration?.inMilliseconds ?? 0) / 1000,
playbackRate: args[6] ?? 1.0,
position: (args[4] ?? 0) / 1000,
));
}
} catch (e) {
print(e);
}
plugin.clientHandler.invokeMethod('onPlaybackStateChanged', [
args[2], // Processing state
args[3], // Playing
actionBits, // Action bits
args[4], // Position
args[5], // bufferedPosition
args[6] ?? 1.0, // speed
args[7] ?? DateTime.now().millisecondsSinceEpoch, // updateTime
args[9], // repeatMode
args[10], // shuffleMode
]);
}
Future<void> setMediaItem(MethodCall call) async {
mediaItem = MediaItem.fromJson(call.arguments);
// This would be how we could pull images out of the cache... But nothing is actually cached on web
final artUri = /* mediaItem.extras['artCacheFile'] ?? */
mediaItem.artUri;
try {
metadata = MediaMetadata(MetadataLiteral(
album: mediaItem.album,
title: mediaItem.title,
artist: mediaItem.artist,
artwork: [
MetadataArtwork(
src: artUri,
sizes: '512x512',
)
],
));
} catch (e) {
print('Metadata failed $e');
}
plugin.clientHandler.invokeMethod('onMediaChanged', [mediaItem.toJson()]);
}
Future<void> setQueue(MethodCall call) async {
plugin.clientHandler.invokeMethod('onQueueChanged', [call.arguments]);
}
}