From c29822ad850a50542a53326baadeedd781214320 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Fri, 13 Mar 2026 09:48:49 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20Flutter=20Web=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E4=BB=A5=E4=BD=BF=E7=94=A8=20js=5Finterop=20?= =?UTF-8?q?=E5=92=8C=20web=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移 Spotify SDK Web 插件从 dart:html 和 js 包到 dart:js_interop 和 web 包,以适配 Dart 3.7 的 JS 互操作改进。主要更改包括: - 更新 pubspec.yaml 中的 SDK 约束和依赖项 - 使用 extension types 重写 JS 类型定义 - 用新的互操作方法替换 allowInterop 和 promiseToFuture - 将 DOM 操作迁移到 web 包 API - 修复类型转换和事件处理逻辑 --- .../spotify_sdk/lib/spotify_sdk_web.dart | 243 +++++++++--------- local_plugins/spotify_sdk/pubspec.yaml | 4 +- 2 files changed, 119 insertions(+), 128 deletions(-) diff --git a/local_plugins/spotify_sdk/lib/spotify_sdk_web.dart b/local_plugins/spotify_sdk/lib/spotify_sdk_web.dart index 44bcb6eb5..75b8a1d6b 100644 --- a/local_plugins/spotify_sdk/lib/spotify_sdk_web.dart +++ b/local_plugins/spotify_sdk/lib/spotify_sdk_web.dart @@ -5,16 +5,15 @@ import 'dart:async'; import 'dart:convert'; import 'dart:core'; import 'dart:developer'; -import 'dart:html'; +import 'dart:js_interop'; 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 'package:web/web.dart' as web; import 'enums/repeat_mode_enum.dart'; import 'models/album.dart'; @@ -52,6 +51,9 @@ class SpotifySdkPlugin { /// Current Spotify SDK player instance. Player? _currentPlayer; + /// Current device ID (存储在 Dart 侧,因为 extension type 不支持实例字段) + String? _currentDeviceID; + /// Current Spotify auth token. SpotifyToken? _spotifyToken; @@ -172,18 +174,19 @@ class SpotifySdkPlugin { // create player _currentPlayer = Player(PlayerOptions( name: playerName, - getOAuthToken: allowInterop((Function callback, t) { + getOAuthToken: ((JSAny? callback, JSAny? t) { _getSpotifyAuthToken().then((value) { - callback(value); + (callback as JSFunction).callAsFunction(null, value.toJS); }); - }))); + }).toJS)); _registerPlayerEvents(_currentPlayer!); - var result = await promiseToFuture(_currentPlayer!.connect()); + var result = + (await _currentPlayer!.connect().toDart).toDart; if (result == true) { // wait for the confirmation num time = 0; - while (_currentPlayer!.deviceID == null) { + while (_currentDeviceID == null) { await Future.delayed(const Duration(milliseconds: 200)); time += 200; if (time > 10000) { @@ -242,19 +245,19 @@ class SpotifySdkPlugin { call.arguments[ParamNames.repeatMode] as RepeatMode?); break; case MethodNames.resume: - await promiseToFuture(_currentPlayer?.resume()); + await _currentPlayer!.resume().toDart; break; case MethodNames.pause: - await promiseToFuture(_currentPlayer?.pause()); + await _currentPlayer!.pause().toDart; break; case MethodNames.skipNext: - await promiseToFuture(_currentPlayer?.nextTrack()); + await _currentPlayer!.nextTrack().toDart; break; case MethodNames.skipPrevious: - await promiseToFuture(_currentPlayer?.previousTrack()); + await _currentPlayer!.previousTrack().toDart; break; case MethodNames.getPlayerState: - var stateRaw = await promiseToFuture(_currentPlayer?.getCurrentState()) + var stateRaw = (await _currentPlayer!.getCurrentState().toDart) as WebPlaybackState?; if (stateRaw == null) return null; return jsonEncode(toPlayerState(stateRaw)!.toJson()); @@ -272,10 +275,13 @@ class SpotifySdkPlugin { log('Loading Spotify SDK...'); // link spotify ready function - _onSpotifyWebPlaybackSDKReady = allowInterop(_onSpotifyInitialized); + _onSpotifyWebPlaybackSDKReady = _onSpotifyInitialized.toJS; // load spotify sdk - querySelector('body')!.children.add(ScriptElement()..src = spotifySdkUrl); + final script = + web.document.createElement('script') as web.HTMLScriptElement; + script.src = spotifySdkUrl; + web.document.body!.appendChild(script); // wait for initialization while (_sdkLoaded == false) { @@ -294,33 +300,37 @@ class SpotifySdkPlugin { void _registerPlayerEvents(Player player) { // player state player.addListener('player_state_changed', - allowInterop((WebPlaybackState? state) { - if (state == null) return; + ((JSAny? stateObj) { + if (stateObj == null) return; + var state = stateObj as WebPlaybackState; playerStateEventController .add(jsonEncode(toPlayerState(state)!.toJson())); playerContextEventController .add(jsonEncode(toPlayerContext(state)!.toJson())); - })); + }).toJS); // ready/not ready - player.addListener('ready', allowInterop((WebPlaybackPlayer player) { + player.addListener('ready', ((JSAny? playerObj) { + var p = playerObj as WebPlaybackPlayer; log('Spotify SDK ready!'); - _onSpotifyConnected(player.device_id); - })); - player.addListener('not_ready', allowInterop((event) { + _onSpotifyConnected(p.device_id); + }).toJS); + player.addListener('not_ready', ((JSAny? event) { _onSpotifyDisconnected( errorCode: 'Spotify SDK not ready', errorDetails: 'Spotify SDK is not ready to take requests'); - })); + }).toJS); // errors player.addListener('initialization_error', - allowInterop((WebPlaybackError error) { + ((JSAny? errorObj) { + var error = errorObj as WebPlaybackError; _onSpotifyDisconnected( errorCode: 'Initialization Error', errorDetails: error.message); - })); + }).toJS); player.addListener('authentication_error', - allowInterop((WebPlaybackError error) { + ((JSAny? errorObj) { + var error = errorObj as WebPlaybackError; // 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 @@ -330,14 +340,16 @@ class SpotifySdkPlugin { } _onSpotifyDisconnected( errorCode: 'Authentication Error', errorDetails: error.message); - })); - player.addListener('account_error', allowInterop((WebPlaybackError error) { + }).toJS); + player.addListener('account_error', ((JSAny? errorObj) { + var error = errorObj as WebPlaybackError; _onSpotifyDisconnected( errorCode: 'Account Error', errorDetails: error.message); - })); - player.addListener('playback_error', allowInterop((WebPlaybackError error) { + }).toJS); + player.addListener('playback_error', ((JSAny? errorObj) { + var error = errorObj as WebPlaybackError; log('playback_error: ${error.message}'); - })); + }).toJS); } /// Called when the Spotify SDK is first loaded. @@ -347,7 +359,7 @@ class SpotifySdkPlugin { /// Called when the plugin successfully connects to the spotify web sdk. void _onSpotifyConnected(String deviceId) { - _currentPlayer!.deviceID = deviceId; + _currentDeviceID = deviceId; // emit connected event connectionStatusEventController.add(jsonEncode(ConnectionStatus( @@ -362,6 +374,7 @@ class SpotifySdkPlugin { void _onSpotifyDisconnected({String? errorCode, String? errorDetails}) { _unregisterPlayerEvents(_currentPlayer!); _currentPlayer = null; + _currentDeviceID = null; if (errorCode != null) { // disconnected due to error @@ -445,27 +458,32 @@ class SpotifySdkPlugin { ); // opening auth window - var authPopup = window.open( + var authPopup = web.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; + final messageHandler = ((JSAny? e) { + final event = e as web.MessageEvent; + if (event.data != null && event.data!.isA()) { + final str = (event.data as JSString).toDart; + // ensure the message contains auth code + if (str.startsWith('?code=')) { + message = str; + } } - })); + }).toJS; + web.window.addEventListener('message', messageHandler); // loop and wait for auth - while (authPopup.closed == false && message == null) { + 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) { + web.window.removeEventListener('message', messageHandler); throw PlatformException( message: 'User closed authentication window', code: 'Authentication Error'); @@ -476,6 +494,7 @@ class SpotifySdkPlugin { // check if state is the same if (state != parsedMessage.queryParameters['state']) { + web.window.removeEventListener('message', messageHandler); throw PlatformException( message: 'Invalid state', code: 'Authentication Error'); } @@ -483,16 +502,17 @@ class SpotifySdkPlugin { // check for error if (parsedMessage.queryParameters['error'] != null || parsedMessage.queryParameters['code'] == null) { + web.window.removeEventListener('message', messageHandler); throw PlatformException( message: "${parsedMessage.queryParameters['error']}", code: 'Authentication Error'); } // close auth window - if (authPopup.closed == false) { - authPopup.close(); + if (authPopup?.closed == false) { + authPopup?.close(); } - await sub.cancel(); + web.window.removeEventListener('message', messageHandler); // exchange auth code for access and refresh tokens dynamic authResponse; @@ -613,7 +633,7 @@ class SpotifySdkPlugin { /// Starts track playback on the device. Future _play(String? uri) async { - if (_currentPlayer?.deviceID == null) { + if (_currentDeviceID == null) { throw PlatformException( message: 'Spotify player not connected!', code: 'Playback Error'); } @@ -623,7 +643,7 @@ class SpotifySdkPlugin { data: { 'uris': [uri] }, - queryParameters: {'device_id': _currentPlayer!.deviceID}, + queryParameters: {'device_id': _currentDeviceID}, options: Options( headers: { 'Content-Type': 'application/json', @@ -635,14 +655,14 @@ class SpotifySdkPlugin { /// Adds a given track to the playback queue. Future _queue(String? uri) async { - if (_currentPlayer?.deviceID == null) { + if (_currentDeviceID == null) { throw PlatformException( message: 'Spotify player not connected!', code: 'Playback Error'); } await _dio.post( '/queue', - queryParameters: {'uri': uri, 'device_id': _currentPlayer!.deviceID}, + queryParameters: {'uri': uri, 'device_id': _currentDeviceID}, options: Options( headers: { 'Content-Type': 'application/json', @@ -654,7 +674,7 @@ class SpotifySdkPlugin { /// Sets whether shuffle should be enabled. Future _setShuffle(bool? shuffleEnabled) async { - if (_currentPlayer?.deviceID == null) { + if (_currentDeviceID == null) { throw PlatformException( message: 'Spotify player not connected!', code: 'Set Shuffle Error'); } @@ -663,7 +683,7 @@ class SpotifySdkPlugin { '/shuffle', queryParameters: { 'state': shuffleEnabled, - 'device_id': _currentPlayer!.deviceID + 'device_id': _currentDeviceID }, options: Options( headers: { @@ -676,7 +696,7 @@ class SpotifySdkPlugin { /// Sets the repeat mode. Future _setRepeatMode(RepeatMode? repeatMode) async { - if (_currentPlayer?.deviceID == null) { + if (_currentDeviceID == null) { throw PlatformException( message: 'Spotify player not connected!', code: 'Set Repeat Mode Error'); @@ -686,7 +706,7 @@ class SpotifySdkPlugin { '/repeat', queryParameters: { 'state': repeatMode.toString().substring(11), - 'device_id': _currentPlayer!.deviceID + 'device_id': _currentDeviceID }, options: Options( headers: { @@ -699,14 +719,14 @@ class SpotifySdkPlugin { /// Toggles shuffle on the current player. Future toggleShuffle({bool? state}) async { - if (_currentPlayer?.deviceID == null) { + if (_currentDeviceID == null) { throw PlatformException( message: 'Spotify player not connected!', code: 'Playback Error'); } await _dio.put( '/shuffle', - queryParameters: {'state': state, 'device_id': _currentPlayer!.deviceID}, + queryParameters: {'state': state, 'device_id': _currentDeviceID}, options: Options( headers: { 'Content-Type': 'application/json', @@ -718,14 +738,14 @@ class SpotifySdkPlugin { /// Toggles repeat on the current player. Future toggleRepeat({bool? state}) async { - if (_currentPlayer?.deviceID == null) { + if (_currentDeviceID == null) { throw PlatformException( message: 'Spotify player not connected!', code: 'Playback Error'); } await _dio.put( '/repeat', - queryParameters: {'state': state, 'device_id': _currentPlayer!.deviceID}, + queryParameters: {'state': state, 'device_id': _currentDeviceID}, options: Options( headers: { 'Content-Type': 'application/json', @@ -744,7 +764,8 @@ class SpotifySdkPlugin { var artists = []; if (trackRaw != null) { - for (var artist in trackRaw.artists) { + for (var i = 0; i < trackRaw.artists.length; i++) { + var artist = trackRaw.artists[i] as WebPlaybackArtist; artists.add(Artist(artist.name, artist.uri)); } } @@ -770,7 +791,7 @@ class SpotifySdkPlugin { artists[0], artists, -1, - ImageUri(albumRaw.images[0].url), + ImageUri((albumRaw.images[0] as WebPlaybackAlbumImage).url), trackRaw.name, trackRaw.uri, trackRaw.linked_from.uri, @@ -806,87 +827,79 @@ class SpotifySdkPlugin { /// Allows assigning the function onSpotifyWebPlaybackSDKReady /// to be callable from `window.onSpotifyWebPlaybackSDKReady()` @JS('onSpotifyWebPlaybackSDKReady') -external set _onSpotifyWebPlaybackSDKReady(void Function()? f); +external set _onSpotifyWebPlaybackSDKReady(JSFunction? f); -/// Allows assigning the function onSpotifyWebPlaybackSDKReady -/// to be callable from `window.onSpotifyWebPlaybackSDKReady()` +/// Allows checking if onSpotifyWebPlaybackSDKReady has been set @JS('onSpotifyWebPlaybackSDKReady') -external void Function()? get _onSpotifyWebPlaybackSDKReady; +external JSFunction? get _onSpotifyWebPlaybackSDKReady; /// Spotify Player Object @JS('Spotify.Player') -class Player { - /// Device id of the player. - String? deviceID; - +extension type Player._(JSObject _) implements JSObject { /// 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(); + external JSPromise 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); + external void addListener(String type, JSFunction 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(); + external JSPromise getCurrentState(); /// Rename the Spotify Player device. /// This is visible across all Spotify Connect devices. - external dynamic setName(String name); + external JSPromise setName(String name); /// Set the local volume for the Web Playback SDK. - external dynamic setVolume(double volume); + external JSPromise setVolume(double volume); /// Pause the local playback. - external dynamic pause(); + external JSPromise pause(); /// Resume the local playback. - external dynamic resume(); + external JSPromise resume(); /// Resume/pause the local playback. - external dynamic togglePlay(); + external JSPromise togglePlay(); /// Seek to a position in the current track in local playback. // ignore: non_constant_identifier_names - external dynamic seek(int position_ms); + external JSPromise seek(int position_ms); /// Switch to the previous track in local playback. - external dynamic previousTrack(); + external JSPromise previousTrack(); /// Skip to the next track in local playback. - external dynamic nextTrack(); + external JSPromise nextTrack(); } /// Spotify player options object -@JS() -@anonymous -class PlayerOptions { +extension type PlayerOptions._(JSObject _) implements JSObject { // ignore: public_member_api_docs external String get name; // ignore: public_member_api_docs - external Function get getOAuthToken; + external JSFunction 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}); + {String? name, JSFunction? getOAuthToken, double? volume}); } /// Spotify playback object -@JS() -@anonymous -class WebPlaybackPlayer { +extension type WebPlaybackPlayer._(JSObject _) implements JSObject { // ignore: public_member_api_docs, non_constant_identifier_names external String get device_id; // ignore: public_member_api_docs, non_constant_identifier_names @@ -894,9 +907,7 @@ class WebPlaybackPlayer { } /// Spotify playback state object -@JS() -@anonymous -class WebPlaybackState { +extension type WebPlaybackState._(JSObject _) implements JSObject { // ignore: public_member_api_docs external WebPlayerContext get context; // ignore: public_member_api_docs @@ -916,7 +927,7 @@ class WebPlaybackState { external factory WebPlaybackState( {WebPlayerContext? context, WebPlayerDisallows? disallows, - bool? paysed, + bool? paused, int? position, // ignore: non_constant_identifier_names int? repeat_mode, @@ -926,9 +937,7 @@ class WebPlaybackState { } /// Spotify player context object -@JS() -@anonymous -class WebPlayerContext { +extension type WebPlayerContext._(JSObject _) implements JSObject { // ignore: public_member_api_docs external String get uri; // ignore: public_member_api_docs @@ -940,9 +949,7 @@ class WebPlayerContext { } /// Spotify player context metadata object -@JS() -@anonymous -class WebPlayerContextMetadata { +extension type WebPlayerContextMetadata._(JSObject _) implements JSObject { // ignore: public_member_api_docs external String get title; // ignore: public_member_api_docs @@ -956,9 +963,7 @@ class WebPlayerContextMetadata { } /// Spotify player disallows object -@JS() -@anonymous -class WebPlayerDisallows { +extension type WebPlayerDisallows._(JSObject _) implements JSObject { // ignore: public_member_api_docs external bool get pausing; // ignore: public_member_api_docs, non_constant_identifier_names @@ -990,30 +995,26 @@ class WebPlayerDisallows { } /// Spotify player track window object -@JS() -@anonymous -class WebPlayerTrackWindow { +extension type WebPlayerTrackWindow._(JSObject _) implements JSObject { // 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? get previous_tracks; + external JSArray? get previous_tracks; // ignore: public_member_api_docs, non_constant_identifier_names - external List? get next_tracks; + external JSArray? 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? previous_tracks, + JSArray? previous_tracks, // ignore: non_constant_identifier_names - List? next_tracks}); + JSArray? next_tracks}); } /// Spotify playback track object -@JS() -@anonymous -class WebPlaybackTrack { +extension type WebPlaybackTrack._(JSObject _) implements JSObject { // ignore: public_member_api_docs external String get uri; // ignore: public_member_api_docs @@ -1029,7 +1030,7 @@ class WebPlaybackTrack { // ignore: public_member_api_docs external WebPlaybackAlbum get album; // ignore: public_member_api_docs - external List get artists; + external JSArray get artists; // ignore: public_member_api_docs // ignore: non_constant_identifier_names external WebLinkedFrom get linked_from; @@ -1045,31 +1046,27 @@ class WebPlaybackTrack { // ignore: non_constant_identifier_names bool? is_playable, WebPlaybackAlbum? album, - List? artists, + JSArray? artists, // ignore: non_constant_identifier_names WebLinkedFrom? linked_from}); } /// Spotify playback album object -@JS() -@anonymous -class WebPlaybackAlbum { +extension type WebPlaybackAlbum._(JSObject _) implements JSObject { // 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 get images; + external JSArray get images; // ignore: public_member_api_docs external factory WebPlaybackAlbum( - {String? uri, String? name, List? images}); + {String? uri, String? name, JSArray? images}); } /// Spotify playback album object -@JS() -@anonymous -class WebLinkedFrom { +extension type WebLinkedFrom._(JSObject _) implements JSObject { // ignore: public_member_api_docs external String get uri; // ignore: public_member_api_docs @@ -1080,9 +1077,7 @@ class WebLinkedFrom { } /// Spotify artist object -@JS() -@anonymous -class WebPlaybackArtist { +extension type WebPlaybackArtist._(JSObject _) implements JSObject { // ignore: public_member_api_docs external String get uri; // ignore: public_member_api_docs @@ -1093,9 +1088,7 @@ class WebPlaybackArtist { } /// Spotify album image object -@JS() -@anonymous -class WebPlaybackAlbumImage { +extension type WebPlaybackAlbumImage._(JSObject _) implements JSObject { // ignore: public_member_api_docs external String get url; @@ -1104,9 +1097,7 @@ class WebPlaybackAlbumImage { } /// Spotify playback error object -@JS() -@anonymous -class WebPlaybackError { +extension type WebPlaybackError._(JSObject _) implements JSObject { // ignore: public_member_api_docs external String get message; diff --git a/local_plugins/spotify_sdk/pubspec.yaml b/local_plugins/spotify_sdk/pubspec.yaml index 0a14ec222..46c687adc 100644 --- a/local_plugins/spotify_sdk/pubspec.yaml +++ b/local_plugins/spotify_sdk/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://github.com/brim-borium/spotify_sdk issue_tracker: https://github.com/brim-borium/spotify_sdk/issues environment: - sdk: ^3.5.0 + sdk: ^3.7.0 flutter: '>=3.3.0' dependencies: @@ -16,7 +16,7 @@ dependencies: logger: ^2.4.0 dio: ^5.7.0 json_annotation: ^4.9.0 - js: ">=0.6.7 <0.8.0" + web: ^1.1.0 crypto: ^3.0.5 synchronized: ^3.3.0+3 http: ^1.2.2