You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1137 lines
36 KiB
1137 lines
36 KiB
@JS()
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:core';
|
|
import 'dart:developer';
|
|
import 'dart:html';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
|
import 'package:js/js.dart';
|
|
import 'package:js/js_util.dart';
|
|
import 'package:synchronized/synchronized.dart' as synchronized;
|
|
|
|
import 'enums/repeat_mode_enum.dart';
|
|
import 'models/album.dart';
|
|
import 'models/artist.dart';
|
|
import 'models/connection_status.dart';
|
|
import 'models/image_uri.dart';
|
|
import 'models/player_context.dart';
|
|
import 'models/player_options.dart' as options;
|
|
import 'models/player_restrictions.dart';
|
|
import 'models/player_state.dart';
|
|
import 'models/track.dart';
|
|
import 'platform_channels.dart';
|
|
|
|
export 'package:spotify_sdk/enums/image_dimension_enum.dart';
|
|
export 'package:spotify_sdk/enums/repeat_mode_enum.dart';
|
|
export 'package:spotify_sdk/extensions/image_dimension_extension.dart';
|
|
|
|
///
|
|
/// [SpotifySdkPlugin] is the web implementation of the Spotify SDK plugin.
|
|
///
|
|
class SpotifySdkPlugin {
|
|
/// authentication token error id
|
|
static const String errorAuthenticationTokenError =
|
|
'authenticationTokenError';
|
|
|
|
/// spotify sdk url
|
|
static const String spotifySdkUrl = 'https://sdk.scdn.co/spotify-player.js';
|
|
|
|
/// Whether the Spotify SDK is loaded.
|
|
bool _sdkLoaded = false;
|
|
|
|
/// Future loading the Spotify SDK.
|
|
Future? _sdkLoadFuture;
|
|
|
|
/// Current Spotify SDK player instance.
|
|
Player? _currentPlayer;
|
|
|
|
/// Current Spotify auth token.
|
|
SpotifyToken? _spotifyToken;
|
|
|
|
/// player context event stream controller
|
|
final StreamController playerContextEventController;
|
|
|
|
/// player state event stream controller
|
|
final StreamController playerStateEventController;
|
|
|
|
/// player capabilities event stream controller
|
|
final StreamController playerCapabilitiesEventController;
|
|
|
|
/// user state event stream controller
|
|
final StreamController userStateEventController;
|
|
|
|
/// connection status event stream controller
|
|
final StreamController connectionStatusEventController;
|
|
|
|
/// Dio http client
|
|
final Dio _dio = Dio(BaseOptions(
|
|
baseUrl: 'https://api.spotify.com/v1/me/player',
|
|
));
|
|
final Dio _authDio = Dio(BaseOptions());
|
|
|
|
/// Lock for getting the token
|
|
final synchronized.Lock _getTokenLock = synchronized.Lock(reentrant: true);
|
|
|
|
/// Default scopes that are required for Web SDK to work
|
|
static const String defaultScopes =
|
|
'streaming user-read-email user-read-private';
|
|
|
|
static String? tokenSwapURL;
|
|
static String? tokenRefreshURL;
|
|
|
|
/// constructor
|
|
SpotifySdkPlugin(
|
|
this.playerContextEventController,
|
|
this.playerStateEventController,
|
|
this.playerCapabilitiesEventController,
|
|
this.userStateEventController,
|
|
this.connectionStatusEventController);
|
|
|
|
/// registers plugin method channels
|
|
static void registerWith(Registrar registrar) {
|
|
// method channel
|
|
final channel = MethodChannel(
|
|
MethodChannels.spotifySdk, const StandardMethodCodec(), registrar);
|
|
// event channels
|
|
const playerContextEventChannel =
|
|
PluginEventChannel(EventChannels.playerContext);
|
|
final playerContextEventController = StreamController.broadcast();
|
|
playerContextEventChannel.setController(playerContextEventController);
|
|
const playerStateEventChannel =
|
|
PluginEventChannel(EventChannels.playerState);
|
|
final playerStateEventController = StreamController.broadcast();
|
|
playerStateEventChannel.setController(playerStateEventController);
|
|
const playerCapabilitiesEventChannel =
|
|
PluginEventChannel(EventChannels.capabilities);
|
|
final playerCapabilitiesEventController = StreamController.broadcast();
|
|
playerCapabilitiesEventChannel
|
|
.setController(playerCapabilitiesEventController);
|
|
const userStatusEventChannel = PluginEventChannel(EventChannels.userStatus);
|
|
final userStatusEventController = StreamController.broadcast();
|
|
userStatusEventChannel.setController(userStatusEventController);
|
|
const connectionStatusEventChannel =
|
|
PluginEventChannel(EventChannels.connectionStatus);
|
|
final connectionStatusEventController = StreamController.broadcast();
|
|
connectionStatusEventChannel.setController(connectionStatusEventController);
|
|
|
|
final instance = SpotifySdkPlugin(
|
|
playerContextEventController,
|
|
playerStateEventController,
|
|
playerCapabilitiesEventController,
|
|
userStatusEventController,
|
|
connectionStatusEventController);
|
|
|
|
channel.setMethodCallHandler(instance.handleMethodCall);
|
|
}
|
|
|
|
/// handles method coming through the method channel
|
|
Future<dynamic> handleMethodCall(MethodCall call) async {
|
|
// check if spotify is loaded
|
|
if (_sdkLoaded == false) {
|
|
_sdkLoadFuture ??= _initializeSpotify();
|
|
await _sdkLoadFuture;
|
|
}
|
|
|
|
switch (call.method) {
|
|
case MethodNames.connectToSpotify:
|
|
if (_currentPlayer != null) {
|
|
return true;
|
|
}
|
|
log('Connecting to Spotify...');
|
|
var clientId = call.arguments[ParamNames.clientId] as String?;
|
|
var redirectUrl = call.arguments[ParamNames.redirectUrl] as String?;
|
|
var playerName = call.arguments[ParamNames.playerName] as String?;
|
|
var scopes =
|
|
call.arguments[ParamNames.scope] as String? ?? defaultScopes;
|
|
var accessToken = call.arguments[ParamNames.accessToken] as String?;
|
|
|
|
// ensure that required arguments are present
|
|
if (clientId == null ||
|
|
clientId.isEmpty ||
|
|
redirectUrl == null ||
|
|
redirectUrl.isEmpty) {
|
|
throw PlatformException(
|
|
message:
|
|
'Client id or redirectUrl are not set or have invalid format',
|
|
code: 'Authentication Error');
|
|
}
|
|
|
|
// get initial token if not supplied
|
|
if (accessToken == null || accessToken.isEmpty) {
|
|
await _authorizeSpotify(
|
|
clientId: clientId, redirectUrl: redirectUrl, scopes: scopes);
|
|
}
|
|
|
|
// create player
|
|
_currentPlayer = Player(PlayerOptions(
|
|
name: playerName,
|
|
getOAuthToken: allowInterop((Function callback, t) {
|
|
_getSpotifyAuthToken().then((value) {
|
|
callback(value);
|
|
});
|
|
})));
|
|
|
|
_registerPlayerEvents(_currentPlayer!);
|
|
var result = await promiseToFuture(_currentPlayer!.connect());
|
|
if (result == true) {
|
|
// wait for the confirmation
|
|
num time = 0;
|
|
while (_currentPlayer!.deviceID == null) {
|
|
await Future.delayed(const Duration(milliseconds: 200));
|
|
time += 200;
|
|
if (time > 10000) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
} else {
|
|
// disconnected
|
|
_onSpotifyDisconnected(
|
|
errorCode: 'Initialization Error',
|
|
errorDetails: 'Attempt to connect to the Spotify SDK failed');
|
|
return false;
|
|
}
|
|
case MethodNames.getAccessToken:
|
|
var clientId = call.arguments[ParamNames.clientId] as String?;
|
|
var redirectUrl = call.arguments[ParamNames.redirectUrl] as String?;
|
|
|
|
// ensure that required arguments are present
|
|
if (clientId == null ||
|
|
clientId.isEmpty ||
|
|
redirectUrl == null ||
|
|
redirectUrl.isEmpty) {
|
|
throw PlatformException(
|
|
message:
|
|
'Client id or redirectUrl are not set or have invalid format',
|
|
code: 'Authentication Error');
|
|
}
|
|
|
|
return await _authorizeSpotify(
|
|
clientId: clientId,
|
|
redirectUrl: redirectUrl,
|
|
scopes:
|
|
call.arguments[ParamNames.scope] as String? ?? defaultScopes);
|
|
case MethodNames.disconnectFromSpotify:
|
|
log('Disconnecting from Spotify...');
|
|
_spotifyToken = null;
|
|
if (_currentPlayer == null) {
|
|
return true;
|
|
} else {
|
|
_currentPlayer!.disconnect();
|
|
_onSpotifyDisconnected();
|
|
return true;
|
|
}
|
|
case MethodNames.play:
|
|
await _play(call.arguments[ParamNames.spotifyUri] as String?);
|
|
break;
|
|
case MethodNames.queueTrack:
|
|
await _queue(call.arguments[ParamNames.spotifyUri] as String?);
|
|
break;
|
|
case MethodNames.setShuffle:
|
|
await _setShuffle(call.arguments[ParamNames.shuffle] as bool?);
|
|
break;
|
|
case MethodNames.setRepeatMode:
|
|
await _setRepeatMode(
|
|
call.arguments[ParamNames.repeatMode] as RepeatMode?);
|
|
break;
|
|
case MethodNames.resume:
|
|
await promiseToFuture(_currentPlayer?.resume());
|
|
break;
|
|
case MethodNames.pause:
|
|
await promiseToFuture(_currentPlayer?.pause());
|
|
break;
|
|
case MethodNames.skipNext:
|
|
await promiseToFuture(_currentPlayer?.nextTrack());
|
|
break;
|
|
case MethodNames.skipPrevious:
|
|
await promiseToFuture(_currentPlayer?.previousTrack());
|
|
break;
|
|
case MethodNames.getPlayerState:
|
|
var stateRaw = await promiseToFuture(_currentPlayer?.getCurrentState())
|
|
as WebPlaybackState?;
|
|
if (stateRaw == null) return null;
|
|
return jsonEncode(toPlayerState(stateRaw)!.toJson());
|
|
default:
|
|
throw PlatformException(
|
|
code: 'Unimplemented',
|
|
details:
|
|
"Method '${call.method}' not implemented in web spotify_sdk");
|
|
}
|
|
}
|
|
|
|
/// Loads the Spotify SDK library.
|
|
Future _initializeSpotify() async {
|
|
if (_onSpotifyWebPlaybackSDKReady == null) {
|
|
log('Loading Spotify SDK...');
|
|
|
|
// link spotify ready function
|
|
_onSpotifyWebPlaybackSDKReady = allowInterop(_onSpotifyInitialized);
|
|
|
|
// load spotify sdk
|
|
querySelector('body')!.children.add(ScriptElement()..src = spotifySdkUrl);
|
|
|
|
// wait for initialization
|
|
while (_sdkLoaded == false) {
|
|
await Future.delayed(const Duration(milliseconds: 200));
|
|
}
|
|
|
|
log('Spotify SDK loaded!');
|
|
} else {
|
|
// spotify sdk already loaded
|
|
log('Reusing loaded Spotify SDK');
|
|
_sdkLoaded = true;
|
|
}
|
|
}
|
|
|
|
/// Registers Spotify event handlers.
|
|
void _registerPlayerEvents(Player player) {
|
|
// player state
|
|
player.addListener('player_state_changed',
|
|
allowInterop((WebPlaybackState? state) {
|
|
if (state == null) return;
|
|
playerStateEventController
|
|
.add(jsonEncode(toPlayerState(state)!.toJson()));
|
|
playerContextEventController
|
|
.add(jsonEncode(toPlayerContext(state)!.toJson()));
|
|
}));
|
|
|
|
// ready/not ready
|
|
player.addListener('ready', allowInterop((WebPlaybackPlayer player) {
|
|
log('Spotify SDK ready!');
|
|
_onSpotifyConnected(player.device_id);
|
|
}));
|
|
player.addListener('not_ready', allowInterop((event) {
|
|
_onSpotifyDisconnected(
|
|
errorCode: 'Spotify SDK not ready',
|
|
errorDetails: 'Spotify SDK is not ready to take requests');
|
|
}));
|
|
|
|
// errors
|
|
player.addListener('initialization_error',
|
|
allowInterop((WebPlaybackError error) {
|
|
_onSpotifyDisconnected(
|
|
errorCode: 'Initialization Error', errorDetails: error.message);
|
|
}));
|
|
player.addListener('authentication_error',
|
|
allowInterop((WebPlaybackError error) {
|
|
// If the error is due to browser security, don't disconnect.
|
|
// The user needs to interact with the SDK to trigger media activation.
|
|
// https://developer.spotify.com/documentation/web-playback-sdk/quick-start/#mobile-support
|
|
if (error.message.contains('Browser prevented autoplay')) {
|
|
log('authentication_error: ${error.message}');
|
|
return;
|
|
}
|
|
_onSpotifyDisconnected(
|
|
errorCode: 'Authentication Error', errorDetails: error.message);
|
|
}));
|
|
player.addListener('account_error', allowInterop((WebPlaybackError error) {
|
|
_onSpotifyDisconnected(
|
|
errorCode: 'Account Error', errorDetails: error.message);
|
|
}));
|
|
player.addListener('playback_error', allowInterop((WebPlaybackError error) {
|
|
log('playback_error: ${error.message}');
|
|
}));
|
|
}
|
|
|
|
/// Called when the Spotify SDK is first loaded.
|
|
void _onSpotifyInitialized() {
|
|
_sdkLoaded = true;
|
|
}
|
|
|
|
/// Called when the plugin successfully connects to the spotify web sdk.
|
|
void _onSpotifyConnected(String deviceId) {
|
|
_currentPlayer!.deviceID = deviceId;
|
|
|
|
// emit connected event
|
|
connectionStatusEventController.add(jsonEncode(ConnectionStatus(
|
|
'Spotify SDK connected',
|
|
'',
|
|
'',
|
|
connected: true,
|
|
).toJson()));
|
|
}
|
|
|
|
/// Called when the plugin disconnects from the spotify sdk.
|
|
void _onSpotifyDisconnected({String? errorCode, String? errorDetails}) {
|
|
_unregisterPlayerEvents(_currentPlayer!);
|
|
_currentPlayer = null;
|
|
|
|
if (errorCode != null) {
|
|
// disconnected due to error
|
|
log('$errorCode: $errorDetails');
|
|
}
|
|
|
|
// emit not connected event
|
|
connectionStatusEventController.add(jsonEncode(ConnectionStatus(
|
|
'Spotify SDK disconnected', errorCode ?? '', errorDetails ?? '',
|
|
connected: false)
|
|
.toJson()));
|
|
}
|
|
|
|
void _unregisterPlayerEvents(Player player) {
|
|
player.removeListener('player_state_changed');
|
|
player.removeListener('ready');
|
|
player.removeListener('not_ready');
|
|
player.removeListener('initialization_error');
|
|
player.removeListener('authentication_error');
|
|
player.removeListener('account_error');
|
|
player.removeListener('playback_error');
|
|
}
|
|
|
|
/// Gets the current Spotify token or
|
|
/// refreshes the token if it expired.
|
|
Future<String> _getSpotifyAuthToken() async {
|
|
return await _getTokenLock.synchronized<String>(() async {
|
|
if (_spotifyToken?.accessToken != null) {
|
|
// attempt to use the previously authorized credentials
|
|
if (_spotifyToken!.expiry >
|
|
DateTime.now().millisecondsSinceEpoch / 1000) {
|
|
// access token valid
|
|
return _spotifyToken!.accessToken;
|
|
} else {
|
|
// access token invalid, refresh it
|
|
var newToken = await _refreshSpotifyToken(
|
|
_spotifyToken!.clientId, _spotifyToken!.refreshToken);
|
|
_spotifyToken = SpotifyToken(
|
|
clientId: _spotifyToken!.clientId,
|
|
accessToken: newToken['access_token'] as String,
|
|
refreshToken: newToken['refresh_token'] as String,
|
|
expiry: (DateTime.now().millisecondsSinceEpoch / 1000).round() +
|
|
(newToken['expires_in'] as int));
|
|
return _spotifyToken!.accessToken;
|
|
}
|
|
} else {
|
|
throw PlatformException(
|
|
message: 'Spotify user not logged in!',
|
|
code: 'Authentication Error');
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Authenticates a new user with Spotify and stores access token.
|
|
Future<String> _authorizeSpotify(
|
|
{required String clientId,
|
|
required String redirectUrl,
|
|
required String? scopes}) async {
|
|
// creating auth uri
|
|
var codeVerifier = _createCodeVerifier();
|
|
var codeChallenge = _createCodeChallenge(codeVerifier);
|
|
var state = _createAuthState();
|
|
|
|
var params = {
|
|
'client_id': clientId,
|
|
'redirect_uri': redirectUrl,
|
|
'response_type': 'code',
|
|
'state': state,
|
|
'scope': scopes,
|
|
};
|
|
|
|
if (tokenSwapURL == null) {
|
|
params['code_challenge_method'] = 'S256';
|
|
params['code_challenge'] = codeChallenge;
|
|
}
|
|
|
|
final authorizationUri = Uri.https(
|
|
'accounts.spotify.com',
|
|
'authorize',
|
|
params,
|
|
);
|
|
|
|
// opening auth window
|
|
var authPopup = window.open(
|
|
authorizationUri.toString(),
|
|
'Spotify Authorization',
|
|
);
|
|
String? message;
|
|
var sub = window.onMessage.listen(allowInterop((event) {
|
|
message = event.data.toString();
|
|
// ensure the message contains auth code
|
|
if (!message!.startsWith('?code=')) {
|
|
message = null;
|
|
}
|
|
}));
|
|
|
|
// loop and wait for auth
|
|
while (authPopup.closed == false && message == null) {
|
|
// await response from the window
|
|
await Future.delayed(const Duration(milliseconds: 250));
|
|
}
|
|
|
|
// error if window closed by user
|
|
if (message == null) {
|
|
throw PlatformException(
|
|
message: 'User closed authentication window',
|
|
code: 'Authentication Error');
|
|
}
|
|
|
|
// parse the returned parameters
|
|
var parsedMessage = Uri.parse(message!);
|
|
|
|
// check if state is the same
|
|
if (state != parsedMessage.queryParameters['state']) {
|
|
throw PlatformException(
|
|
message: 'Invalid state', code: 'Authentication Error');
|
|
}
|
|
|
|
// check for error
|
|
if (parsedMessage.queryParameters['error'] != null ||
|
|
parsedMessage.queryParameters['code'] == null) {
|
|
throw PlatformException(
|
|
message: "${parsedMessage.queryParameters['error']}",
|
|
code: 'Authentication Error');
|
|
}
|
|
|
|
// close auth window
|
|
if (authPopup.closed == false) {
|
|
authPopup.close();
|
|
}
|
|
await sub.cancel();
|
|
|
|
// exchange auth code for access and refresh tokens
|
|
dynamic authResponse;
|
|
|
|
RequestOptions req;
|
|
|
|
if (tokenSwapURL == null) {
|
|
// build request to exchange auth code with PKCE for access and refresh tokens
|
|
req = RequestOptions(
|
|
path: 'https://accounts.spotify.com/api/token',
|
|
method: 'POST',
|
|
data: {
|
|
'client_id': clientId,
|
|
'grant_type': 'authorization_code',
|
|
'code': parsedMessage.queryParameters['code'],
|
|
'redirect_uri': redirectUrl,
|
|
'code_verifier': codeVerifier
|
|
},
|
|
contentType: Headers.formUrlEncodedContentType,
|
|
);
|
|
} else {
|
|
// or build request to exchange code with token swap
|
|
// https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/
|
|
req = RequestOptions(
|
|
path: tokenSwapURL!,
|
|
method: 'POST',
|
|
data: {
|
|
'code': parsedMessage.queryParameters['code'],
|
|
'redirect_uri': redirectUrl,
|
|
},
|
|
contentType: Headers.formUrlEncodedContentType,
|
|
);
|
|
}
|
|
|
|
try {
|
|
var res = await _authDio.fetch(req);
|
|
authResponse = res.data;
|
|
} on DioException catch (e) {
|
|
log('Spotify auth error: ${e.response?.data}');
|
|
rethrow;
|
|
}
|
|
|
|
_spotifyToken = SpotifyToken(
|
|
clientId: clientId,
|
|
accessToken: authResponse['access_token'] as String,
|
|
refreshToken: authResponse['refresh_token'] as String,
|
|
expiry: (DateTime.now().millisecondsSinceEpoch / 1000).round() +
|
|
(authResponse['expires_in'] as int));
|
|
return _spotifyToken!.accessToken;
|
|
}
|
|
|
|
/// Refreshes the Spotify access token using the refresh token.
|
|
Future<dynamic> _refreshSpotifyToken(
|
|
String? clientId, String? refreshToken) async {
|
|
RequestOptions req;
|
|
if (tokenRefreshURL == null) {
|
|
// build request to refresh PKCE for access and refresh tokens
|
|
req = RequestOptions(
|
|
path: 'https://accounts.spotify.com/api/token',
|
|
method: 'POST',
|
|
data: {
|
|
'grant_type': 'refresh_token',
|
|
'refresh_token': refreshToken,
|
|
'client_id': clientId,
|
|
},
|
|
contentType: Headers.formUrlEncodedContentType,
|
|
);
|
|
} else {
|
|
// or build request to refresh code with token swap
|
|
// https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/
|
|
req = RequestOptions(
|
|
path: tokenRefreshURL!,
|
|
method: 'POST',
|
|
data: {
|
|
'refresh_token': refreshToken,
|
|
},
|
|
contentType: Headers.formUrlEncodedContentType,
|
|
);
|
|
}
|
|
|
|
try {
|
|
var res = await _authDio.fetch(req);
|
|
var d = res.data;
|
|
d['refresh_token'] = refreshToken;
|
|
return d;
|
|
} on DioException catch (e) {
|
|
log('Token refresh error: ${e.response?.data}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Creates a code verifier as per
|
|
/// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
|
|
String _createCodeVerifier() {
|
|
return _createRandomString(127);
|
|
}
|
|
|
|
/// Creates a code challenge as per
|
|
/// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
|
|
String _createCodeChallenge(String codeVerifier) {
|
|
return base64Url
|
|
.encode(sha256.convert(ascii.encode(codeVerifier)).bytes)
|
|
.replaceAll('=', '');
|
|
}
|
|
|
|
/// Creates a random string unique to a given authentication session.
|
|
String _createAuthState() {
|
|
return _createRandomString(64);
|
|
}
|
|
|
|
/// Creates a cryptographically random string.
|
|
String _createRandomString(int length) {
|
|
const chars =
|
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
return List.generate(
|
|
128, (i) => chars[math.Random.secure().nextInt(chars.length)]).join();
|
|
}
|
|
|
|
/// Starts track playback on the device.
|
|
Future _play(String? uri) async {
|
|
if (_currentPlayer?.deviceID == null) {
|
|
throw PlatformException(
|
|
message: 'Spotify player not connected!', code: 'Playback Error');
|
|
}
|
|
|
|
await _dio.put(
|
|
'/play',
|
|
data: {
|
|
'uris': [uri]
|
|
},
|
|
queryParameters: {'device_id': _currentPlayer!.deviceID},
|
|
options: Options(
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ${await _getSpotifyAuthToken()}'
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Adds a given track to the playback queue.
|
|
Future _queue(String? uri) async {
|
|
if (_currentPlayer?.deviceID == null) {
|
|
throw PlatformException(
|
|
message: 'Spotify player not connected!', code: 'Playback Error');
|
|
}
|
|
|
|
await _dio.post(
|
|
'/queue',
|
|
queryParameters: {'uri': uri, 'device_id': _currentPlayer!.deviceID},
|
|
options: Options(
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ${await _getSpotifyAuthToken()}'
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Sets whether shuffle should be enabled.
|
|
Future _setShuffle(bool? shuffleEnabled) async {
|
|
if (_currentPlayer?.deviceID == null) {
|
|
throw PlatformException(
|
|
message: 'Spotify player not connected!', code: 'Set Shuffle Error');
|
|
}
|
|
|
|
await _dio.put(
|
|
'/shuffle',
|
|
queryParameters: {
|
|
'state': shuffleEnabled,
|
|
'device_id': _currentPlayer!.deviceID
|
|
},
|
|
options: Options(
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ${await _getSpotifyAuthToken()}'
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Sets the repeat mode.
|
|
Future _setRepeatMode(RepeatMode? repeatMode) async {
|
|
if (_currentPlayer?.deviceID == null) {
|
|
throw PlatformException(
|
|
message: 'Spotify player not connected!',
|
|
code: 'Set Repeat Mode Error');
|
|
}
|
|
|
|
await _dio.put(
|
|
'/repeat',
|
|
queryParameters: {
|
|
'state': repeatMode.toString().substring(11),
|
|
'device_id': _currentPlayer!.deviceID
|
|
},
|
|
options: Options(
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ${await _getSpotifyAuthToken()}'
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Toggles shuffle on the current player.
|
|
Future toggleShuffle({bool? state}) async {
|
|
if (_currentPlayer?.deviceID == null) {
|
|
throw PlatformException(
|
|
message: 'Spotify player not connected!', code: 'Playback Error');
|
|
}
|
|
|
|
await _dio.put(
|
|
'/shuffle',
|
|
queryParameters: {'state': state, 'device_id': _currentPlayer!.deviceID},
|
|
options: Options(
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ${await _getSpotifyAuthToken()}'
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Toggles repeat on the current player.
|
|
Future toggleRepeat({bool? state}) async {
|
|
if (_currentPlayer?.deviceID == null) {
|
|
throw PlatformException(
|
|
message: 'Spotify player not connected!', code: 'Playback Error');
|
|
}
|
|
|
|
await _dio.put(
|
|
'/repeat',
|
|
queryParameters: {'state': state, 'device_id': _currentPlayer!.deviceID},
|
|
options: Options(
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ${await _getSpotifyAuthToken()}'
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Converts a native WebPlaybackState to the library PlayerState
|
|
PlayerState? toPlayerState(WebPlaybackState? state) {
|
|
if (state == null) return null;
|
|
var trackRaw = state.track_window.current_track;
|
|
var albumRaw = trackRaw?.album;
|
|
var restrictionsRaw = state.disallows;
|
|
var artists = <Artist>[];
|
|
|
|
if (trackRaw != null) {
|
|
for (var artist in trackRaw.artists) {
|
|
artists.add(Artist(artist.name, artist.uri));
|
|
}
|
|
}
|
|
|
|
// getting repeat mode
|
|
options.RepeatMode repeatMode;
|
|
switch (state.repeat_mode) {
|
|
case 1:
|
|
repeatMode = options.RepeatMode.context;
|
|
break;
|
|
case 2:
|
|
repeatMode = options.RepeatMode.track;
|
|
break;
|
|
default:
|
|
repeatMode = options.RepeatMode.off;
|
|
break;
|
|
}
|
|
|
|
return PlayerState(
|
|
trackRaw != null
|
|
? Track(
|
|
Album(albumRaw!.name, albumRaw.uri),
|
|
artists[0],
|
|
artists,
|
|
-1,
|
|
ImageUri(albumRaw.images[0].url),
|
|
trackRaw.name,
|
|
trackRaw.uri,
|
|
trackRaw.linked_from.uri,
|
|
isEpisode: trackRaw.type == 'episode',
|
|
isPodcast: trackRaw.type == 'episode',
|
|
)
|
|
: null,
|
|
1.0,
|
|
state.position,
|
|
options.PlayerOptions(repeatMode, isShuffling: state.shuffle),
|
|
PlayerRestrictions(
|
|
canSkipNext: restrictionsRaw.skipping_next || true,
|
|
canSkipPrevious: restrictionsRaw.skipping_prev || true,
|
|
canSeek: restrictionsRaw.seeking || true,
|
|
canRepeatTrack: true,
|
|
canRepeatContext: true,
|
|
canToggleShuffle: true),
|
|
isPaused: state.paused,
|
|
);
|
|
}
|
|
|
|
/// Converts a native WebPlaybackState to the library PlayerContext
|
|
PlayerContext? toPlayerContext(WebPlaybackState? state) {
|
|
if (state == null) return null;
|
|
return PlayerContext(
|
|
state.context.metadata.title,
|
|
state.context.metadata.subtitle,
|
|
state.context.metadata.type,
|
|
state.context.uri);
|
|
}
|
|
}
|
|
|
|
/// Allows assigning the function onSpotifyWebPlaybackSDKReady
|
|
/// to be callable from `window.onSpotifyWebPlaybackSDKReady()`
|
|
@JS('onSpotifyWebPlaybackSDKReady')
|
|
external set _onSpotifyWebPlaybackSDKReady(void Function()? f);
|
|
|
|
/// Allows assigning the function onSpotifyWebPlaybackSDKReady
|
|
/// to be callable from `window.onSpotifyWebPlaybackSDKReady()`
|
|
@JS('onSpotifyWebPlaybackSDKReady')
|
|
external void Function()? get _onSpotifyWebPlaybackSDKReady;
|
|
|
|
/// Spotify Player Object
|
|
@JS('Spotify.Player')
|
|
class Player {
|
|
/// Device id of the player.
|
|
String? deviceID;
|
|
|
|
/// The main constructor for initializing the Web Playback SDK.
|
|
/// It should contain an object with the player name, volume and access token.
|
|
external Player(PlayerOptions options);
|
|
|
|
/// Connects Web Playback SDK instance to Spotify
|
|
/// with the credentials provided during initialization.
|
|
external dynamic connect();
|
|
|
|
/// Closes the current session that Web Playback SDK has with Spotify.
|
|
external void disconnect();
|
|
|
|
/// Create a new event listener in the Web Playback SDK.
|
|
external void addListener(String type, Function callback);
|
|
|
|
/// Remove an event listener in the Web Playback SDK.
|
|
// ignore: non_constant_identifier_names
|
|
external void removeListener(String event_name);
|
|
|
|
/// Collect metadata on local playback.
|
|
external dynamic getCurrentState();
|
|
|
|
/// Rename the Spotify Player device.
|
|
/// This is visible across all Spotify Connect devices.
|
|
external dynamic setName(String name);
|
|
|
|
/// Set the local volume for the Web Playback SDK.
|
|
external dynamic setVolume(double volume);
|
|
|
|
/// Pause the local playback.
|
|
external dynamic pause();
|
|
|
|
/// Resume the local playback.
|
|
external dynamic resume();
|
|
|
|
/// Resume/pause the local playback.
|
|
external dynamic togglePlay();
|
|
|
|
/// Seek to a position in the current track in local playback.
|
|
// ignore: non_constant_identifier_names
|
|
external dynamic seek(int position_ms);
|
|
|
|
/// Switch to the previous track in local playback.
|
|
external dynamic previousTrack();
|
|
|
|
/// Skip to the next track in local playback.
|
|
external dynamic nextTrack();
|
|
}
|
|
|
|
/// Spotify player options object
|
|
@JS()
|
|
@anonymous
|
|
class PlayerOptions {
|
|
// ignore: public_member_api_docs
|
|
external String get name;
|
|
// ignore: public_member_api_docs
|
|
external Function get getOAuthToken;
|
|
// ignore: public_member_api_docs
|
|
external double get volume;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory PlayerOptions(
|
|
{String? name, Function? getOAuthToken, double? volume});
|
|
}
|
|
|
|
/// Spotify playback object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlaybackPlayer {
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external String get device_id;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external factory WebPlaybackPlayer({String? device_id});
|
|
}
|
|
|
|
/// Spotify playback state object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlaybackState {
|
|
// ignore: public_member_api_docs
|
|
external WebPlayerContext get context;
|
|
// ignore: public_member_api_docs
|
|
external WebPlayerDisallows get disallows;
|
|
// ignore: public_member_api_docs
|
|
external bool get paused;
|
|
// ignore: public_member_api_docs
|
|
external int get position;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external int get repeat_mode;
|
|
// ignore: public_member_api_docs
|
|
external bool get shuffle;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external WebPlayerTrackWindow get track_window;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlaybackState(
|
|
{WebPlayerContext? context,
|
|
WebPlayerDisallows? disallows,
|
|
bool? paysed,
|
|
int? position,
|
|
// ignore: non_constant_identifier_names
|
|
int? repeat_mode,
|
|
bool? shuffle,
|
|
// ignore: non_constant_identifier_names
|
|
WebPlayerTrackWindow? track_window});
|
|
}
|
|
|
|
/// Spotify player context object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlayerContext {
|
|
// ignore: public_member_api_docs
|
|
external String get uri;
|
|
// ignore: public_member_api_docs
|
|
external WebPlayerContextMetadata get metadata;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlayerContext(
|
|
{String? uri, WebPlayerContextMetadata? metadata});
|
|
}
|
|
|
|
/// Spotify player context metadata object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlayerContextMetadata {
|
|
// ignore: public_member_api_docs
|
|
external String get title;
|
|
// ignore: public_member_api_docs
|
|
external String get subtitle;
|
|
// ignore: public_member_api_docs
|
|
external String get type;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlayerContextMetadata(
|
|
{String? title, String? subtitle, String? type});
|
|
}
|
|
|
|
/// Spotify player disallows object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlayerDisallows {
|
|
// ignore: public_member_api_docs
|
|
external bool get pausing;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external bool get peeking_next;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external bool get peeking_prev;
|
|
// ignore: public_member_api_docs
|
|
external bool get resuming;
|
|
// ignore: public_member_api_docs
|
|
external bool get seeking;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external bool get skipping_next;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external bool get skipping_prev;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlayerDisallows(
|
|
{bool? pausing,
|
|
// ignore: non_constant_identifier_names
|
|
bool? peeking_next,
|
|
// ignore: non_constant_identifier_names
|
|
bool? peeking_prev,
|
|
bool? resuming,
|
|
bool? seeking,
|
|
// ignore: non_constant_identifier_names
|
|
bool? skipping_next,
|
|
// ignore: non_constant_identifier_names
|
|
bool? skipping_prev});
|
|
}
|
|
|
|
/// Spotify player track window object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlayerTrackWindow {
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external WebPlaybackTrack? get current_track;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external List<WebPlaybackTrack>? get previous_tracks;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external List<WebPlaybackTrack>? get next_tracks;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlayerTrackWindow(
|
|
// ignore: non_constant_identifier_names
|
|
{WebPlaybackTrack? current_track,
|
|
// ignore: non_constant_identifier_names
|
|
List<WebPlaybackTrack>? previous_tracks,
|
|
// ignore: non_constant_identifier_names
|
|
List<WebPlaybackTrack>? next_tracks});
|
|
}
|
|
|
|
/// Spotify playback track object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlaybackTrack {
|
|
// ignore: public_member_api_docs
|
|
external String get uri;
|
|
// ignore: public_member_api_docs
|
|
external String get id;
|
|
// ignore: public_member_api_docs
|
|
external String get type;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external String get media_type;
|
|
// ignore: public_member_api_docs
|
|
external String get name;
|
|
// ignore: public_member_api_docs, non_constant_identifier_names
|
|
external bool get is_playable;
|
|
// ignore: public_member_api_docs
|
|
external WebPlaybackAlbum get album;
|
|
// ignore: public_member_api_docs
|
|
external List<WebPlaybackArtist> get artists;
|
|
// ignore: public_member_api_docs
|
|
// ignore: non_constant_identifier_names
|
|
external WebLinkedFrom get linked_from;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlaybackTrack(
|
|
{String? uri,
|
|
String? id,
|
|
String? type,
|
|
// ignore: non_constant_identifier_names
|
|
String? media_type,
|
|
String? name,
|
|
// ignore: non_constant_identifier_names
|
|
bool? is_playable,
|
|
WebPlaybackAlbum? album,
|
|
List<WebPlaybackArtist>? artists,
|
|
// ignore: non_constant_identifier_names
|
|
WebLinkedFrom? linked_from});
|
|
}
|
|
|
|
/// Spotify playback album object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlaybackAlbum {
|
|
// ignore: public_member_api_docs
|
|
external String get uri;
|
|
// ignore: public_member_api_docs
|
|
external String get name;
|
|
// ignore: public_member_api_docs
|
|
external List<WebPlaybackAlbumImage> get images;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlaybackAlbum(
|
|
{String? uri, String? name, List<WebPlaybackAlbumImage>? images});
|
|
}
|
|
|
|
/// Spotify playback album object
|
|
@JS()
|
|
@anonymous
|
|
class WebLinkedFrom {
|
|
// ignore: public_member_api_docs
|
|
external String get uri;
|
|
// ignore: public_member_api_docs
|
|
external String get id;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebLinkedFrom({String? uri, String? id});
|
|
}
|
|
|
|
/// Spotify artist object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlaybackArtist {
|
|
// ignore: public_member_api_docs
|
|
external String get uri;
|
|
// ignore: public_member_api_docs
|
|
external String get name;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlaybackArtist({String? uri, String? name});
|
|
}
|
|
|
|
/// Spotify album image object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlaybackAlbumImage {
|
|
// ignore: public_member_api_docs
|
|
external String get url;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlaybackAlbumImage({String? url});
|
|
}
|
|
|
|
/// Spotify playback error object
|
|
@JS()
|
|
@anonymous
|
|
class WebPlaybackError {
|
|
// ignore: public_member_api_docs
|
|
external String get message;
|
|
|
|
// ignore: public_member_api_docs
|
|
external factory WebPlaybackError({String? message});
|
|
}
|
|
|
|
/// Spotify token object.
|
|
class SpotifyToken {
|
|
/// Currently used client id.
|
|
final String clientId;
|
|
|
|
/// Access token data.
|
|
final String accessToken;
|
|
|
|
/// Refresh token data.
|
|
final String refreshToken;
|
|
|
|
/// Token expiry time in unix seconds.
|
|
final int expiry;
|
|
|
|
// ignore: public_member_api_docs
|
|
SpotifyToken(
|
|
{required this.clientId,
|
|
required this.accessToken,
|
|
required this.refreshToken,
|
|
required this.expiry});
|
|
}
|
|
|