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 invokeMethod(String method, [dynamic arguments]) => channel.invokeMethod(method, arguments); Future 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 invokeMethod(String method, [dynamic arguments]) => channel.invokeMethod(method, arguments); Future 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 started(MethodCall call) async => true; Future ready(MethodCall call) async => { 'fastForwardInterval': plugin.fastForwardInterval ?? 30000, 'rewindInterval': plugin.rewindInterval ?? 30000, 'params': plugin.params }; Future stopped(MethodCall call) async { final session = html.window.navigator.mediaSession; session.metadata = null; plugin.started = false; mediaItem = null; plugin.clientHandler.invokeMethod('onStopped'); } Future setState(MethodCall call) async { final session = html.window.navigator.mediaSession; final List args = call.arguments; final List controls = call.arguments[0] .map((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 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 setQueue(MethodCall call) async { plugin.clientHandler.invokeMethod('onQueueChanged', [call.arguments]); } }