diff --git a/lib/assets/streamelements/seLogo.png b/lib/assets/streamelements/seLogo.png new file mode 100644 index 00000000..abcc665e Binary files /dev/null and b/lib/assets/streamelements/seLogo.png differ diff --git a/lib/src/bindings/home_bindings.dart b/lib/src/bindings/home_bindings.dart index acae1d67..9f166488 100644 --- a/lib/src/bindings/home_bindings.dart +++ b/lib/src/bindings/home_bindings.dart @@ -29,6 +29,9 @@ class HomeBindings extends Bindings { settingsUseCase: SettingsUseCase( settingsRepository: SettingsRepositoryImpl(), ), + streamelementsUseCase: StreamelementsUseCase( + streamelementsRepository: StreamelementsRepositoryImpl(), + ), ), ), ); @@ -48,6 +51,9 @@ class HomeBindings extends Bindings { settingsUseCase: SettingsUseCase( settingsRepository: SettingsRepositoryImpl(), ), + streamelementsUseCase: StreamelementsUseCase( + streamelementsRepository: StreamelementsRepositoryImpl(), + ), ), )); @@ -72,6 +78,9 @@ class HomeBindings extends Bindings { settingsUseCase: SettingsUseCase( settingsRepository: SettingsRepositoryImpl(), ), + streamelementsUseCase: StreamelementsUseCase( + streamelementsRepository: StreamelementsRepositoryImpl(), + ), ), )); Get.find(); diff --git a/lib/src/bindings/settings_bindings.dart b/lib/src/bindings/settings_bindings.dart index 737c01fd..5ad944a3 100644 --- a/lib/src/bindings/settings_bindings.dart +++ b/lib/src/bindings/settings_bindings.dart @@ -2,26 +2,36 @@ import 'package:get/get_core/src/get_main.dart'; import 'package:get/get_instance/src/bindings_interface.dart'; import 'package:get/get_instance/src/extension_instance.dart'; import 'package:irllink/src/data/repositories/settings_repository_impl.dart'; +import 'package:irllink/src/data/repositories/streamelements_repository_impl.dart'; import 'package:irllink/src/data/repositories/twitch_repository_impl.dart'; import 'package:irllink/src/domain/usecases/settings_usecase.dart'; +import 'package:irllink/src/domain/usecases/streamelements_usecase.dart'; import 'package:irllink/src/domain/usecases/twitch_usecase.dart'; import 'package:irllink/src/presentation/controllers/settings_view_controller.dart'; import 'package:irllink/src/presentation/events/settings_events.dart'; +import 'package:irllink/src/presentation/events/streamelements_events.dart'; class SettingsBindings extends Bindings { @override void dependencies() { Get.lazyPut( () => SettingsViewController( - settingsEvents: SettingsEvents( - settingsUseCase: SettingsUseCase( - settingsRepository: SettingsRepositoryImpl(), + settingsEvents: SettingsEvents( + settingsUseCase: SettingsUseCase( + settingsRepository: SettingsRepositoryImpl(), + ), + twitchUseCase: TwitchUseCase( + twitchRepository: TwitchRepositoryImpl(), + ), ), - twitchUseCase: TwitchUseCase( - twitchRepository: TwitchRepositoryImpl(), - ), - ), - ), + streamelementsEvents: StreamelementsEvents( + streamelementsUseCase: StreamelementsUseCase( + streamelementsRepository: StreamelementsRepositoryImpl(), + ), + settingsUseCase: SettingsUseCase( + settingsRepository: SettingsRepositoryImpl(), + ), + )), ); } } diff --git a/lib/src/core/params/streamelements_auth_params.dart b/lib/src/core/params/streamelements_auth_params.dart index 69a41f79..9c15a7e1 100644 --- a/lib/src/core/params/streamelements_auth_params.dart +++ b/lib/src/core/params/streamelements_auth_params.dart @@ -8,8 +8,8 @@ class StreamelementsAuthParams { const StreamelementsAuthParams({ this.clientId = kStreamelementsAuthClientId, - this.redirectUri = 'https://irllink.com/streamelements/auth', + this.redirectUri = 'https://www.irllink.com/api/streamelements/auth', this.responseType = 'code', - this.scopes = 'activities:read activities:write tips:read', + this.scopes = 'channel:read tips:read activities:read overlays:read', }); } diff --git a/lib/src/core/resources/data_state.dart b/lib/src/core/resources/data_state.dart index 6442d66e..f9261a9d 100644 --- a/lib/src/core/resources/data_state.dart +++ b/lib/src/core/resources/data_state.dart @@ -1,17 +1,23 @@ +import 'package:irllink/src/core/utils/globals.dart' as globals; + abstract class DataState { final T? data; final String? error; - const DataState({ + DataState({ this.data, this.error, - }); + }) { + if(error != null) { + globals.talker?.error(error); + } + } } class DataSuccess extends DataState { - const DataSuccess(T data) : super(data: data); + DataSuccess(T data) : super(data: data); } class DataFailed extends DataState { - const DataFailed(String error) : super(error: error); + DataFailed(String error) : super(error: error); } diff --git a/lib/src/core/utils/constants.dart b/lib/src/core/utils/constants.dart index 3c05599d..2b31226e 100644 --- a/lib/src/core/utils/constants.dart +++ b/lib/src/core/utils/constants.dart @@ -4,6 +4,6 @@ const kTwitchAuthUrlPath = '/oauth2/authorize'; const kRedirectScheme = 'dev.lezd.www.irllink'; -const kStreamelementsAuthClientId = ''; +const kStreamelementsAuthClientId = '77746eebf069856d'; const kStreamelementsUrlBase = 'api.streamelements.com'; const kStreamelementsAuthPath = '/oauth2/authorize'; diff --git a/lib/src/core/utils/init_dio.dart b/lib/src/core/utils/init_dio.dart index 970eb610..5e8148af 100644 --- a/lib/src/core/utils/init_dio.dart +++ b/lib/src/core/utils/init_dio.dart @@ -1,5 +1,4 @@ import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart'; import 'package:talker_dio_logger/talker_dio_logger_settings.dart'; import 'package:irllink/src/core/utils/globals.dart' as globals; @@ -11,8 +10,9 @@ Dio initDio() { TalkerDioLogger( talker: talker, settings: TalkerDioLoggerSettings( - requestFilter: (RequestOptions options) => kDebugMode ? true : !options.path.contains('api.twitch.tv'), - responseFilter: (response) => kDebugMode ? true : ![200, 202].contains(response.statusCode), + requestFilter: (RequestOptions options) => !options.path.contains('api.twitch.tv'), + printRequestHeaders: true, + // responseFilter: (response) => ![200, 202].contains(response.statusCode), ), ), ); diff --git a/lib/src/core/utils/twitch_event_sub.dart b/lib/src/core/utils/twitch_event_sub.dart index 0a0c7ade..4661d882 100644 --- a/lib/src/core/utils/twitch_event_sub.dart +++ b/lib/src/core/utils/twitch_event_sub.dart @@ -139,7 +139,7 @@ class TwitchEventSub { } void _onDone() { - globals.talker?.debug("Twitch Sub Event: Connection closed"); + globals.talker?.info("Twitch Sub Event: Connection closed"); close(); } diff --git a/lib/src/data/entities/settings/stream_elements_settings_dto.dart b/lib/src/data/entities/settings/stream_elements_settings_dto.dart index 082e45a2..872e648e 100644 --- a/lib/src/data/entities/settings/stream_elements_settings_dto.dart +++ b/lib/src/data/entities/settings/stream_elements_settings_dto.dart @@ -10,6 +10,9 @@ class StreamElementsSettingsDTO extends StreamElementsSettings { required super.showRaidActivity, required super.showHostActivity, required super.showMerchActivity, + required super.jwt, + required super.overlayToken, + required super.mutedOverlays, }); @override @@ -21,6 +24,9 @@ class StreamElementsSettingsDTO extends StreamElementsSettings { 'showRaidActivity': showRaidActivity, 'showHostActivity': showHostActivity, 'showMerchActivity': showMerchActivity, + 'jwt': jwt, + 'overlayToken': overlayToken, + 'mutedOverlays': mutedOverlays, }; factory StreamElementsSettingsDTO.fromJson(Map map) { @@ -53,6 +59,14 @@ class StreamElementsSettingsDTO extends StreamElementsSettings { const Settings.defaultSettings() .streamElementsSettings! .showMerchActivity, + jwt: map['jwt'] ?? + const Settings.defaultSettings().streamElementsSettings!.jwt, + overlayToken: map['overlayToken'] ?? + const Settings.defaultSettings().streamElementsSettings!.overlayToken, + mutedOverlays: (List.from(map['mutedOverlays'] ?? + const Settings.defaultSettings() + .streamElementsSettings! + .mutedOverlays)), ); } } diff --git a/lib/src/data/entities/settings_dto.dart b/lib/src/data/entities/settings_dto.dart index fdca98ce..e194201f 100644 --- a/lib/src/data/entities/settings_dto.dart +++ b/lib/src/data/entities/settings_dto.dart @@ -29,7 +29,6 @@ class SettingsDTO extends Settings { required bool isObsConnected, required String obsWebsocketUrl, required String obsWebsocketPassword, - required String streamElementsAccessToken, required List browserTabs, required List obsConnectionsHistory, required StreamElementsSettings streamElementsSettings, @@ -50,7 +49,6 @@ class SettingsDTO extends Settings { obsWebsocketUrl: obsWebsocketUrl, obsWebsocketPassword: obsWebsocketPassword, isObsConnected: isObsConnected, - streamElementsAccessToken: streamElementsAccessToken, browserTabs: browserTabs, obsConnectionsHistory: obsConnectionsHistory, streamElementsSettings: streamElementsSettings, @@ -73,7 +71,6 @@ class SettingsDTO extends Settings { 'isObsConnected': isObsConnected, 'obsWebsocketUrl': obsWebsocketUrl, 'obsWebsocketPassword': obsWebsocketPassword, - 'streamElementsAccessToken': streamElementsAccessToken, 'browserTabs': browserTabs, 'obsConnectionsHistory': obsConnectionsHistory, 'streamElementsSettings': streamElementsSettings?.toJson(), @@ -119,9 +116,6 @@ class SettingsDTO extends Settings { obsWebsocketPassword: map['obsWebsocketPassword'] != null ? map['obsWebsocketPassword'] as String : const Settings.defaultSettings().obsWebsocketPassword!, - streamElementsAccessToken: map['streamElementsAccessToken'] != null - ? map['streamElementsAccessToken'] as String - : const Settings.defaultSettings().streamElementsAccessToken!, browserTabs: map['browserTabs'] != null ? map['browserTabs'] as List : const Settings.defaultSettings().browserTabs!, diff --git a/lib/src/data/entities/stream_elements/se_credentials_dto.dart b/lib/src/data/entities/stream_elements/se_credentials_dto.dart new file mode 100644 index 00000000..31404e7a --- /dev/null +++ b/lib/src/data/entities/stream_elements/se_credentials_dto.dart @@ -0,0 +1,27 @@ +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; + +class SeCredentialsDTO extends SeCredentials { + const SeCredentialsDTO({ + required super.accessToken, + required super.refreshToken, + required super.expiresIn, + required super.scopes, + }); + + @override + Map toJson() => { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'expiresIn': expiresIn, + 'scopes': scopes, + }; + + factory SeCredentialsDTO.fromJson(Map map) { + return SeCredentialsDTO( + accessToken: map['accessToken'] as String, + refreshToken: map['refreshToken'] as String, + expiresIn: map['expiresIn'] as int, + scopes: map['scopes'] as String, + ); + } +} diff --git a/lib/src/data/repositories/settings_repository_impl.dart b/lib/src/data/repositories/settings_repository_impl.dart index 2d255cd3..f83ce852 100644 --- a/lib/src/data/repositories/settings_repository_impl.dart +++ b/lib/src/data/repositories/settings_repository_impl.dart @@ -15,7 +15,7 @@ class SettingsRepositoryImpl extends SettingsRepository { SettingsDTO settings = SettingsDTO.fromJson(settingsJson); return DataSuccess(settings); } - return const DataSuccess(Settings.defaultSettings()); + return DataSuccess(const Settings.defaultSettings()); } @override diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index 73b132fa..32000aca 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -1,58 +1,152 @@ -// import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'dart:convert'; + import 'package:dio/dio.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'package:get_storage/get_storage.dart'; import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; +import 'package:irllink/src/core/utils/constants.dart'; import 'package:irllink/src/core/utils/init_dio.dart'; import 'package:irllink/src/data/entities/stream_elements/se_activity_dto.dart'; +import 'package:irllink/src/data/entities/stream_elements/se_credentials_dto.dart'; import 'package:irllink/src/data/entities/stream_elements/se_me_dto.dart'; import 'package:irllink/src/data/entities/stream_elements/se_overlay_dto.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; -// import 'package:irllink/src/core/utils/constants.dart'; +import 'package:irllink/src/core/utils/globals.dart' as globals; import 'package:irllink/src/domain/repositories/streamelements_repository.dart'; class StreamelementsRepositoryImpl extends StreamelementsRepository { @override - Future> login(StreamelementsAuthParams params) async { + Future> login( + StreamelementsAuthParams params) async { try { - // final url = Uri.https(kStreamelementsUrlBase, kStreamelementsAuthPath, { - // 'client_id': params.clientId, - // 'redirect_uri': params.redirectUri, - // 'response_type': params.responseType, - // 'scope': params.scopes, - // }); - - // final result = await FlutterWebAuth.authenticate( - // url: url.toString(), - // callbackUrlScheme: kRedirectScheme, - // preferEphemeral: true, - // ); - - // final code = Uri.parse(result).queryParameters['access_token']; - // final refreshToken = Uri.parse(result).queryParameters['refresh_token']; - // final expiresIn = Uri.parse(result).queryParameters['expires_in']; - - return const DataSuccess(null); + Uri url = Uri.https(kStreamelementsUrlBase, kStreamelementsAuthPath, { + 'client_id': params.clientId, + 'redirect_uri': params.redirectUri, + 'response_type': params.responseType, + 'scope': params.scopes, + }); + + String result = await FlutterWebAuth.authenticate( + url: url.toString(), + callbackUrlScheme: kRedirectScheme, + preferEphemeral: true, + ); + + String accessToken = Uri.parse(result).queryParameters['access_token']!; + String refreshToken = Uri.parse(result).queryParameters['refresh_token']!; + int expiresIn = + int.parse(Uri.parse(result).queryParameters['expires_in']!); + globals.talker?.info('StreamElements login successful'); + + DataState tokenInfos = await validateToken(accessToken); + final String scopes = tokenInfos.data['scopes'].join(' '); + + SeCredentials seCredentials = SeCredentials( + accessToken: accessToken, + refreshToken: refreshToken, + expiresIn: expiresIn, + scopes: scopes, + ); + + await storeCredentials(seCredentials); + + return DataSuccess(seCredentials); } catch (e) { - return const DataFailed("Unable to retrieve StreamElements token"); + return DataFailed("Unable to retrieve StreamElements token: $e"); + } + } + + @override + Future> refreshAccessToken( + SeCredentials seCredentials, + ) async { + Response response; + Dio dio = initDio(); + try { + final remoteConfig = FirebaseRemoteConfig.instance; + await remoteConfig.fetchAndActivate(); + String apiRefreshTokenUrl = + remoteConfig.getString('irllink_refresh_se_token_url'); + + response = await dio.get( + apiRefreshTokenUrl, + queryParameters: {'refresh_token': seCredentials.refreshToken}, + ); + + SeCredentials newSeCredentials = SeCredentials( + accessToken: response.data['access_token'], + refreshToken: response.data['refresh_token'], + expiresIn: response.data['expires_in'], + scopes: seCredentials.scopes, + ); + await storeCredentials(newSeCredentials); + + await validateToken(newSeCredentials.accessToken); + + return DataSuccess(newSeCredentials); + } on DioException catch (e) { + return DataFailed("Refresh SE token failed: ${e.message}"); + } + } + + Future storeCredentials(SeCredentials seCredentials) async { + GetStorage box = GetStorage(); + globals.talker?.info('Encoding SE credentials into a String'); + String jsonData = jsonEncode(seCredentials); + globals.talker?.info('Successfully encoded SE creds: $jsonData'); + await box.write('seCredentials', jsonData); + globals.talker?.info('SE creds saved in local'); + } + + Future> validateToken(String accessToken) async { + try { + Response response; + Dio dio = initDio(); + dio.options.headers["authorization"] = "OAuth $accessToken"; + response = + await dio.get('https://api.streamelements.com/oauth2/validate'); + globals.talker?.info('Token validated: ${response.data}'); + return DataSuccess(response.data); + } on DioException catch (e) { + globals.talker?.error(e.message); + return DataFailed( + "Unable to validate StreamElements token: ${e.message}"); } } @override - Future> disconnect() { - // TODO: implement disconnect - throw UnimplementedError(); + Future> disconnect(String accessToken) async { + Dio dio = initDio(); + dio.options.headers["authorization"] = "OAuth $accessToken"; + try { + await dio.post( + 'https://api.streamelements.com/oauth2/revoke', + queryParameters: { + 'client_id': kStreamelementsAuthClientId, + 'token': accessToken, + }, + ); + GetStorage box = GetStorage(); + box.remove('seCredentials'); + return DataSuccess(null); + } on DioException catch (e) { + return DataFailed("Unable to revoke StreamElements token: ${e.message}"); + } } @override Future replayActivity(String token, SeActivity activity) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; await dio.post( 'https://api.streamelements.com/kappa/v2/activities/${activity.channel}/${activity.id}/replay', ); @@ -61,6 +155,55 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { } } + @override + Future> getSeCredentialsFromLocal() async { + final box = GetStorage(); + globals.talker?.info('Getting SE creds from local storage'); + + var seCredentialsString = box.read('seCredentials'); + globals.talker?.info(seCredentialsString); + + if (seCredentialsString != null) { + Map seCredentialsJson = jsonDecode(seCredentialsString); + + SeCredentials seCredentials = + SeCredentialsDTO.fromJson(seCredentialsJson); + + globals.talker?.info('Checking if Scopes changed.'); + StreamelementsAuthParams params = const StreamelementsAuthParams(); + List paramsScopesList = params.scopes.split(' '); + paramsScopesList.sort((a, b) { + return a.compareTo(b); + }); + String paramsScopesOrdered = paramsScopesList.join(' '); + List savedScopesList = seCredentials.scopes.split(' '); + savedScopesList.sort((a, b) { + return a.compareTo(b); + }); + String savedScopesOrdered = savedScopesList.join(' '); + if (savedScopesOrdered != paramsScopesOrdered) { + globals.talker?.info('Scopes changed, user need to relogin to SE.'); + disconnect(seCredentials.accessToken); + return DataFailed("Scopes have been updated, please login again."); + } + globals.talker?.info('Scopes are the same: OK'); + + //refresh the access token to be sure the token is going to be valid after starting the app + DataState creds = await refreshAccessToken(seCredentials); + if (creds.error == null) { + seCredentials = creds.data!; + } else { + return DataFailed("Error refreshing SE Token"); + } + + globals.talker?.info('SE token refreshed.'); + + return DataSuccess(seCredentials); + } else { + return DataFailed("No SE Data in local storage"); + } + } + @override Future>> getLastActivities( String token, String channel) async { @@ -68,7 +211,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Response response; List activities = []; try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; response = await dio.get( 'https://api.streamelements.com/kappa/v2/activities/$channel', queryParameters: { @@ -100,7 +243,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { var dio = initDio(); List overlays = []; try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; Response response = await dio.get( 'https://api.streamelements.com/kappa/v2/overlays/$channel', queryParameters: {'search': ' ', 'type': 'regular'}, @@ -121,12 +264,13 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { var dio = initDio(); late SeMe me; try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; Response response = await dio.get( 'https://api.streamelements.com/kappa/v2/channels/me', ); me = SeMeDTO.fromJson(response.data); + globals.talker?.debug('SE me: $me'); return DataSuccess(me); } on DioException catch (e) { @@ -206,7 +350,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future> getSongPlaying(String token, String userId) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; Response response = await dio.get( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/playing', ); @@ -224,9 +368,10 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { return DataFailed(e.toString()); } } - + @override - Future updatePlayerState(String token, String userId, String state) async { + Future updatePlayerState( + String token, String userId, String state) async { var dio = initDio(); try { dio.options.headers["Authorization"] = "Bearer $token"; diff --git a/lib/src/data/repositories/twitch_repository_impl.dart b/lib/src/data/repositories/twitch_repository_impl.dart index 77b0c3d2..8d6b60a6 100644 --- a/lib/src/data/repositories/twitch_repository_impl.dart +++ b/lib/src/data/repositories/twitch_repository_impl.dart @@ -77,7 +77,7 @@ class TwitchRepositoryImpl extends TwitchRepository { await getTwitchUser(null, accessToken) .then((value) => twitchUser = value.data!); - final twitchData = TwitchCredentialsDTO( + TwitchCredentials twitchData = TwitchCredentialsDTO( accessToken: accessToken, idToken: idToken, refreshToken: refreshToken!, @@ -87,12 +87,11 @@ class TwitchRepositoryImpl extends TwitchRepository { scopes: scopes, ); - //save the twitch credentials on the smartphone setTwitchOnLocal(twitchData); return DataSuccess(twitchData); } catch (e) { - return const DataFailed("Unable to retrieve Twitch Data from Auth"); + return DataFailed("Unable to retrieve Twitch Data from Auth: $e"); } } @@ -193,7 +192,7 @@ class TwitchRepositoryImpl extends TwitchRepository { String savedScopesOrdered = savedScopesList.join(' '); if (savedScopesOrdered != paramsScopesOrdered) { - return const DataFailed("Scopes have been updated, please login again"); + return DataFailed("Scopes have been updated, please login again."); } //refresh the access token to be sure the token is going to be valid after starting the app @@ -202,11 +201,10 @@ class TwitchRepositoryImpl extends TwitchRepository { return DataSuccess(twitchData); } else { - return const DataFailed("No Twitch Data in local storage"); + return DataFailed("No Twitch Data in local storage"); } } - @override Future setTwitchOnLocal(TwitchCredentials twitchData) async { final box = GetStorage(); String jsonTwitchData = jsonEncode(twitchData); @@ -364,7 +362,7 @@ class TwitchRepositoryImpl extends TwitchRepository { }, data: jsonEncode(titleMap), ); - return const DataSuccess(null); + return DataSuccess(null); } on DioException catch (e) { return DataFailed(e.toString()); } @@ -396,7 +394,7 @@ class TwitchRepositoryImpl extends TwitchRepository { data: jsonEncode(body), ); - return const DataSuccess(""); + return DataSuccess(""); } on DioException catch (e) { return DataFailed(e.toString()); } diff --git a/lib/src/domain/entities/settings.dart b/lib/src/domain/entities/settings.dart index 1c5f5e6a..505227e5 100644 --- a/lib/src/domain/entities/settings.dart +++ b/lib/src/domain/entities/settings.dart @@ -23,7 +23,6 @@ class Settings extends Equatable { final bool? isObsConnected; final String? obsWebsocketUrl; final String? obsWebsocketPassword; - final String? streamElementsAccessToken; final List? browserTabs; final List? obsConnectionsHistory; final StreamElementsSettings? streamElementsSettings; @@ -48,7 +47,6 @@ class Settings extends Equatable { required this.isObsConnected, required this.obsWebsocketUrl, required this.obsWebsocketPassword, - required this.streamElementsAccessToken, required this.browserTabs, required this.obsConnectionsHistory, required this.streamElementsSettings, @@ -93,7 +91,6 @@ class Settings extends Equatable { this.isObsConnected = false, this.obsWebsocketUrl = "", this.obsWebsocketPassword = "", - this.streamElementsAccessToken = "", this.browserTabs = const [], this.obsConnectionsHistory = const [], this.streamElementsSettings = const StreamElementsSettings( @@ -104,6 +101,9 @@ class Settings extends Equatable { showRaidActivity: true, showHostActivity: true, showMerchActivity: true, + jwt: null, + overlayToken: null, + mutedOverlays: [], ), //TTS SETTINGS @@ -139,7 +139,6 @@ class Settings extends Equatable { 'isObsConnected': isObsConnected, 'obsWebsocketUrl': obsWebsocketUrl, 'obsWebsocketPassword': obsWebsocketPassword, - 'streamElementsAccessToken': streamElementsAccessToken, 'browserTabs': browserTabs, 'obsConnectionsHistory': obsConnectionsHistory, 'streamElementsSettings': streamElementsSettings?.toJson(), @@ -164,7 +163,6 @@ class Settings extends Equatable { isObsConnected, obsWebsocketUrl, obsWebsocketPassword, - streamElementsAccessToken, browserTabs, obsConnectionsHistory, streamElementsSettings, @@ -194,7 +192,6 @@ class Settings extends Equatable { bool? isObsConnected, String? obsWebsocketUrl, String? obsWebsocketPassword, - String? streamElementsAccessToken, List? browserTabs, List? obsConnectionsHistory, StreamElementsSettings? streamElementsSettings, @@ -215,8 +212,6 @@ class Settings extends Equatable { isObsConnected: isObsConnected ?? this.isObsConnected, obsWebsocketUrl: obsWebsocketUrl ?? this.obsWebsocketUrl, obsWebsocketPassword: obsWebsocketPassword ?? this.obsWebsocketPassword, - streamElementsAccessToken: - streamElementsAccessToken ?? this.streamElementsAccessToken, browserTabs: browserTabs ?? this.browserTabs, obsConnectionsHistory: obsConnectionsHistory ?? this.obsConnectionsHistory, diff --git a/lib/src/domain/entities/settings/stream_elements_settings.dart b/lib/src/domain/entities/settings/stream_elements_settings.dart index 011af4b1..ffaac547 100644 --- a/lib/src/domain/entities/settings/stream_elements_settings.dart +++ b/lib/src/domain/entities/settings/stream_elements_settings.dart @@ -8,6 +8,9 @@ class StreamElementsSettings extends Equatable { final bool showRaidActivity; final bool showHostActivity; final bool showMerchActivity; + final String? jwt; + final String? overlayToken; + final List mutedOverlays; const StreamElementsSettings({ required this.showFollowerActivity, @@ -17,6 +20,9 @@ class StreamElementsSettings extends Equatable { required this.showRaidActivity, required this.showHostActivity, required this.showMerchActivity, + required this.jwt, + required this.overlayToken, + required this.mutedOverlays, }); @override @@ -29,6 +35,9 @@ class StreamElementsSettings extends Equatable { showRaidActivity, showHostActivity, showMerchActivity, + jwt, + overlayToken, + mutedOverlays, ]; } @@ -40,6 +49,9 @@ class StreamElementsSettings extends Equatable { 'showRaidActivity': showRaidActivity, 'showHostActivity': showHostActivity, 'showMerchActivity': showMerchActivity, + 'jwt': jwt, + 'overlayToken': overlayToken, + 'mutedOverlays': mutedOverlays, }; @override @@ -53,6 +65,9 @@ class StreamElementsSettings extends Equatable { bool? showRaidActivity, bool? showHostActivity, bool? showMerchActivity, + String? jwt, + String? overlayToken, + List? mutedOverlays, }) { return StreamElementsSettings( showFollowerActivity: showFollowerActivity ?? this.showFollowerActivity, @@ -63,6 +78,9 @@ class StreamElementsSettings extends Equatable { showRaidActivity: showRaidActivity ?? this.showRaidActivity, showHostActivity: showHostActivity ?? this.showHostActivity, showMerchActivity: showMerchActivity ?? this.showMerchActivity, + jwt: jwt ?? this.jwt, + overlayToken: overlayToken ?? this.overlayToken, + mutedOverlays: mutedOverlays ?? this.mutedOverlays, ); } } diff --git a/lib/src/domain/entities/stream_elements/se_credentials.dart b/lib/src/domain/entities/stream_elements/se_credentials.dart new file mode 100644 index 00000000..27f777b4 --- /dev/null +++ b/lib/src/domain/entities/stream_elements/se_credentials.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; + +class SeCredentials extends Equatable { + final String accessToken; + final String refreshToken; + final int expiresIn; + final String scopes; + + const SeCredentials({ + required this.accessToken, + required this.refreshToken, + required this.expiresIn, + required this.scopes, + }); + + Map toJson() => { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'expiresIn': expiresIn, + 'scopes': scopes, + }; + + @override + List get props { + return [ + accessToken, + refreshToken, + expiresIn, + scopes, + ]; + } + + @override + bool get stringify => true; +} diff --git a/lib/src/domain/repositories/streamelements_repository.dart b/lib/src/domain/repositories/streamelements_repository.dart index bfb536a7..c8afc6a7 100644 --- a/lib/src/domain/repositories/streamelements_repository.dart +++ b/lib/src/domain/repositories/streamelements_repository.dart @@ -1,14 +1,19 @@ import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; abstract class StreamelementsRepository { - Future> login(StreamelementsAuthParams params); + Future> login(StreamelementsAuthParams params); - Future> disconnect(); + Future> refreshAccessToken(SeCredentials seCredentials); + + Future> disconnect(String accessToken); + + Future> getSeCredentialsFromLocal(); Future replayActivity(String token, SeActivity activity); diff --git a/lib/src/domain/repositories/twitch_repository.dart b/lib/src/domain/repositories/twitch_repository.dart index 92670070..bc473c14 100644 --- a/lib/src/domain/repositories/twitch_repository.dart +++ b/lib/src/domain/repositories/twitch_repository.dart @@ -19,10 +19,6 @@ abstract class TwitchRepository { Future> getTwitchFromLocal(); - Future setTwitchOnLocal( - TwitchCredentials twitchData, - ); - Future> logout( String accessToken, ); diff --git a/lib/src/domain/usecases/streamelements_usecase.dart b/lib/src/domain/usecases/streamelements_usecase.dart index 79d09f83..d03cc5ae 100644 --- a/lib/src/domain/usecases/streamelements_usecase.dart +++ b/lib/src/domain/usecases/streamelements_usecase.dart @@ -1,6 +1,7 @@ import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; @@ -10,12 +11,21 @@ class StreamelementsUseCase { final StreamelementsRepository streamelementsRepository; StreamelementsUseCase({required this.streamelementsRepository}); - Future> login({required StreamelementsAuthParams params}) { + Future> login({required StreamelementsAuthParams params}) { return streamelementsRepository.login(params); } - Future> disconnect() { - return streamelementsRepository.disconnect(); + Future> refreshAccessToken({required SeCredentials seCredentials}) { + return streamelementsRepository.refreshAccessToken(seCredentials); + } + + Future> getSeCredentialsFromLocal() { + return streamelementsRepository.getSeCredentialsFromLocal(); + } + + + Future> disconnect(String accessToken) { + return streamelementsRepository.disconnect(accessToken); } Future replayActivity(String token, SeActivity activity) { diff --git a/lib/src/presentation/controllers/chat_view_controller.dart b/lib/src/presentation/controllers/chat_view_controller.dart index 01af5cb8..419631ac 100644 --- a/lib/src/presentation/controllers/chat_view_controller.dart +++ b/lib/src/presentation/controllers/chat_view_controller.dart @@ -258,6 +258,11 @@ class ChatViewController extends GetxController } for (Channel kc in kickChannels) { + bool alreadyCreated = + kickChats.firstWhereOrNull((k) => k.username == kc.channel) != null; + if (alreadyCreated) { + return; + } createKickChat(kc); } @@ -370,11 +375,6 @@ class ChatViewController extends GetxController } Future createKickChat(Channel kc) async { - bool alreadyCreated = - kickChats.firstWhereOrNull((k) => k.username == kc.channel) != null; - if (alreadyCreated) { - return; - } KickChat kickChat = KickChat( kc.channel, onDone: () => {}, diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index 53d959d4..2443c6a5 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -2,10 +2,15 @@ import 'dart:async'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:irllink/src/core/resources/data_state.dart'; +import 'package:irllink/src/data/repositories/streamelements_repository_impl.dart'; import 'package:irllink/src/domain/entities/chat/chat_message.dart'; import 'package:irllink/src/domain/entities/settings.dart'; import 'package:irllink/src/domain/entities/settings/chat_settings.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_credentials.dart'; +import 'package:irllink/src/domain/usecases/streamelements_usecase.dart'; import 'package:irllink/src/presentation/controllers/dashboard_controller.dart'; import 'package:irllink/src/presentation/controllers/obs_tab_view_controller.dart'; import 'package:irllink/src/presentation/controllers/store_controller.dart'; @@ -38,7 +43,7 @@ class HomeViewController extends GetxController SplitViewController? splitViewController = SplitViewController(limits: [null, WeightLimit(min: 0.12, max: 0.92)]); - //TABS + // Tabs late TabController tabController; Rx tabIndex = 0.obs; RxList tabElements = [].obs; @@ -46,14 +51,18 @@ class HomeViewController extends GetxController TwitchCredentials? twitchData; - //chat input + // StreamElements + Rxn seCredentials = Rxn(); + Rxn seMe = Rxn(); + StreamelementsViewController? streamelementsViewController; + + // Chat input late TextEditingController chatInputController; RxList twitchEmotes = [].obs; - //emote picker + // Emote picker RxBool isPickingEmote = false.obs; ObsTabViewController? obsTabViewController; - StreamelementsViewController? streamelementsViewController; late Rx settings = const Settings.defaultSettings().obs; @@ -63,6 +72,7 @@ class HomeViewController extends GetxController RxBool displayDashboard = false.obs; + // Chats RxList channels = [].obs; ChatGroup? selectedChatGroup; int? selectedChatIndex; @@ -83,14 +93,23 @@ class HomeViewController extends GetxController twitchData = Get.arguments[0]; - timerRefreshToken = Timer.periodic( - const Duration(seconds: 13000), - (Timer t) => homeEvents - .refreshAccessToken(twitchData: twitchData!) - .then((value) => { - if (value.error == null) {twitchData = value.data!} - }), - ); + await setStreamElementsCredentials(); + + timerRefreshToken = + Timer.periodic(const Duration(seconds: 13000), (Timer t) { + homeEvents.refreshAccessToken(twitchData: twitchData!).then((value) => { + if (value.error == null) {twitchData = value.data!} + }); + + if (seCredentials.value != null) { + homeEvents + .refreshSeAccessToken(seCredentials: seCredentials.value!) + .then((value) => { + if (value.error == null) + {seCredentials.value = value.data!} + }); + } + }); } await getSettings(); @@ -107,6 +126,23 @@ class HomeViewController extends GetxController super.onClose(); } + Future setStreamElementsCredentials() async { + DataState seCreds = + await homeEvents.getSeCredentialsFromLocal(); + if (seCreds.error == null) { + seCredentials.value = seCreds.data!; + await setSeMe(seCredentials.value!); + } + } + + Future setSeMe(SeCredentials seCreds) async { + DataState seMeResult = + await homeEvents.getSeMe(seCredentials.value!.accessToken); + if (seMeResult.error == null) { + seMe.value = seMeResult.data!; + } + } + void lazyPutChat(ChatGroup chatGroup) { Get.lazyPut( () => ChatViewController( @@ -117,6 +153,9 @@ class HomeViewController extends GetxController settingsUseCase: SettingsUseCase( settingsRepository: SettingsRepositoryImpl(), ), + streamelementsUseCase: StreamelementsUseCase( + streamelementsRepository: StreamelementsRepositoryImpl(), + ), ), chatGroup: chatGroup, ), @@ -132,9 +171,7 @@ class HomeViewController extends GetxController bool isSubscribed = Get.find().isSubscribed(); if ((twitchData == null && isSubscribed) || - isSubscribed && - settings.value.streamElementsAccessToken != null && - settings.value.streamElementsAccessToken!.isNotEmpty) { + isSubscribed && seCredentials.value != null) { streamelementsViewController = Get.find(); StreamelementsTabView streamelementsPage = const StreamelementsTabView(); tabElements.add(streamelementsPage); @@ -193,7 +230,7 @@ class HomeViewController extends GetxController } for (var temp in chatViews) { - if(temp.chatGroup.id == '1') { + if (temp.chatGroup.id == '1') { continue; } ChatView view = channels diff --git a/lib/src/presentation/controllers/settings_view_controller.dart b/lib/src/presentation/controllers/settings_view_controller.dart index 3e8269ad..8d2b2fe1 100644 --- a/lib/src/presentation/controllers/settings_view_controller.dart +++ b/lib/src/presentation/controllers/settings_view_controller.dart @@ -1,22 +1,26 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:irllink/routes/app_routes.dart'; +import 'package:irllink/src/core/params/streamelements_auth_params.dart'; +import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/presentation/controllers/home_view_controller.dart'; import 'package:irllink/src/presentation/controllers/store_controller.dart'; import 'package:irllink/src/presentation/controllers/tts_controller.dart'; import 'package:irllink/src/presentation/events/settings_events.dart'; +import 'package:irllink/src/presentation/events/streamelements_events.dart'; import '../../domain/entities/twitch/twitch_user.dart'; class SettingsViewController extends GetxController { - SettingsViewController({required this.settingsEvents}); + SettingsViewController( + {required this.settingsEvents, required this.streamelementsEvents}); final SettingsEvents settingsEvents; + final StreamelementsEvents streamelementsEvents; late TextEditingController alternateChannelChatController; late TextEditingController obsWebsocketUrlFieldController; late TextEditingController obsWebsocketPasswordFieldController; - late TextEditingController streamElementsFieldController; late TextEditingController addBrowserTitleController; late TextEditingController addBrowserUrlController; late TextEditingController addHiddenUsernameController; @@ -32,6 +36,9 @@ class SettingsViewController extends GetxController { RxBool obsWebsocketPasswordShow = false.obs; RxBool obsWebsocketUrlShow = false.obs; RxBool seJwtShow = false.obs; + RxBool seOverlayTokenShow = false.obs; + late TextEditingController seJwtInputController; + late TextEditingController seOverlayTokenInputController; late TextEditingController addTtsIgnoredUsersController; late TextEditingController addTtsIgnoredPrefixsController; @@ -49,7 +56,6 @@ class SettingsViewController extends GetxController { storeController = Get.find(); obsWebsocketUrlFieldController = TextEditingController(); - streamElementsFieldController = TextEditingController(); obsWebsocketPasswordFieldController = TextEditingController(); addBrowserTitleController = TextEditingController(); addBrowserUrlController = TextEditingController(); @@ -57,6 +63,8 @@ class SettingsViewController extends GetxController { addTtsIgnoredUsersController = TextEditingController(); addTtsIgnoredPrefixsController = TextEditingController(); addTtsAllowedPrefixsController = TextEditingController(); + seJwtInputController = TextEditingController(); + seOverlayTokenInputController = TextEditingController(); usernamesHiddenUsers = [].obs; super.onInit(); @@ -69,8 +77,6 @@ class SettingsViewController extends GetxController { homeViewController.settings.value.obsWebsocketUrl!; obsWebsocketPasswordFieldController.text = homeViewController.settings.value.obsWebsocketPassword!; - streamElementsFieldController.text = - homeViewController.settings.value.streamElementsAccessToken!; getUsernames(); } @@ -90,6 +96,54 @@ class SettingsViewController extends GetxController { Get.offAllNamed(Routes.login); } + Future loginStreamElements() async { + StreamelementsAuthParams params = const StreamelementsAuthParams(); + await streamelementsEvents.login(params: params).then((value) { + if (value.error != null) { + Get.snackbar( + "Error", + "Login failed: ${value.error}", + snackPosition: SnackPosition.BOTTOM, + icon: const Icon(Icons.error_outline, color: Colors.red), + borderWidth: 1, + borderColor: Colors.red, + ); + } else { + homeViewController.setStreamElementsCredentials(); + homeViewController.seCredentials.refresh(); + homeViewController.seMe.refresh(); + Get.snackbar( + "StreamElements", + "Login successfull", + snackPosition: SnackPosition.BOTTOM, + icon: const Icon(Icons.check, color: Colors.green), + borderWidth: 1, + borderColor: Colors.green, + ); + } + }); + } + + Future disconnectStreamElements() async { + if (homeViewController.seCredentials.value == null) return; + DataState result = await streamelementsEvents + .disconnect(homeViewController.seCredentials.value!.accessToken); + if (result.error == null) { + homeViewController.seCredentials.value = null; + homeViewController.seMe.value = null; + homeViewController.seCredentials.refresh(); + homeViewController.seMe.refresh(); + Get.snackbar( + "StreamElements", + "Successfully disconnected.", + snackPosition: SnackPosition.BOTTOM, + icon: const Icon(Icons.check, color: Colors.green), + borderWidth: 1, + borderColor: Colors.green, + ); + } + } + void removeHiddenUser(userId) { List hiddenUsersIds = homeViewController.settings.value.hiddenUsersIds!; hiddenUsersIds.remove(userId); @@ -99,17 +153,6 @@ class SettingsViewController extends GetxController { homeViewController.settings.refresh(); } - void removeChatJoined(channel) { - // List channels = homeViewController.settings.value.chatSettings!.chatsJoined; - // channels.remove(channel); - // homeViewController.settings.value = homeViewController.settings.value - // .copyWith( - // chatSettings: homeViewController.settings.value.chatSettings - // ?.copyWith(chatsJoined: channels)); - saveSettings(); - homeViewController.settings.refresh(); - } - void addBrowserTab() { bool isValid = false; isValid = addBrowserTitleKey.currentState!.validate(); diff --git a/lib/src/presentation/controllers/store_controller.dart b/lib/src/presentation/controllers/store_controller.dart index fb4da4a8..1d085789 100644 --- a/lib/src/presentation/controllers/store_controller.dart +++ b/lib/src/presentation/controllers/store_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -29,10 +30,12 @@ class StoreController extends GetxController { //Function isSubscribed bool isSubscribed() { - return Platform.isIOS || kDebugMode || purchases.firstWhereOrNull( - (element) => element.productID == kIds.first, - ) != - null; + return Platform.isIOS || + kDebugMode || + purchases.firstWhereOrNull( + (element) => element.productID == kIds.first, + ) != + null; } // Function get subscription price @@ -122,10 +125,13 @@ class StoreController extends GetxController { String? pruchaseToken = purchaseDetails.verificationData.serverVerificationData; HomeViewController homeViewController = Get.find(); + final remoteConfig = FirebaseRemoteConfig.instance; + await remoteConfig.fetchAndActivate(); + String url = remoteConfig.getString('verify_android_purchase'); var dio = initDio(); try { await dio.post( - 'https://www.irllink.com/api/verify-android-purchase', + url, data: { 'purchaseToken': pruchaseToken, 'twitchId': homeViewController.twitchData?.twitchUser.id, diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index b33ed111..22c1df75 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:irllink/src/core/params/streamelements_auth_params.dart'; +import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; @@ -35,7 +35,8 @@ class StreamelementsViewController extends GetxController RxList overlays = [].obs; Socket? socket; - late String jwt = ""; + late String? jwt; + late String? overlayToken; RxBool isSocketConnected = false.obs; @@ -45,7 +46,7 @@ class StreamelementsViewController extends GetxController Future onInit() async { homeViewController = Get.find(); - tabController = TabController(length: 2, vsync: this); + tabController = TabController(length: 3, vsync: this); activitiesScrollController = ScrollController(); songRequestScrollController = ScrollController(); @@ -61,77 +62,83 @@ class StreamelementsViewController extends GetxController } void replayEvent(SeActivity activity) { - streamelementsEvents.replayActivity(jwt, activity); + String accessToken = homeViewController.seCredentials.value!.accessToken; + streamelementsEvents.replayActivity(accessToken, activity); } Future applySettings() async { - if (jwt != homeViewController.settings.value.streamElementsAccessToken!) { - socket?.dispose(); - socket = null; - activities.clear(); - jwt = homeViewController.settings.value.streamElementsAccessToken!; - streamelementsEvents.getMe(jwt).then((value) => { - if (value.error == null) {handleGetMe(value.data!)} - }); + if (homeViewController.seCredentials.value == null) return; + jwt = homeViewController.settings.value.streamElementsSettings?.jwt; + overlayToken = + homeViewController.settings.value.streamElementsSettings?.overlayToken; + if (homeViewController.seMe.value != null) { + handleGetMe(homeViewController.seMe.value!); + } + if (!isSocketConnected.value) { connectWebsocket(); } } - void handleGetMe(SeMe me) { + Future handleGetMe(SeMe me) async { userSeProfile = me; + String? accessToken = homeViewController.seCredentials.value?.accessToken; + if (accessToken == null) { + globals.talker?.error('There is no accessToken to use for SE api calls.'); + return; + } + streamelementsEvents - .getOverlays(jwt, me.id) - .then((value) => overlays.value = value.data!); - streamelementsEvents - .getLastActivities(jwt, me.id) - .then((value) => activities.value = value.data!); + .getOverlays(accessToken, me.id) + .then((value) => overlays.value = value.data ?? []); streamelementsEvents - .getSongQueue(jwt, me.id) - .then((value) => songRequestQueue.value = value.data!); + .getLastActivities(accessToken, me.id) + .then((value) => activities.value = value.data ?? []); streamelementsEvents - .getSongPlaying(jwt, me.id) - .then((value) => currentSong.value = value.data!); - } - - Future login() async { - StreamelementsAuthParams params = const StreamelementsAuthParams(); - await streamelementsEvents.login(params: params); + .getSongPlaying(accessToken, me.id) + .then((value) => currentSong.value = value.data); + + if (jwt != null) { + DataState> songQueue = + await streamelementsEvents.getSongQueue(jwt!, me.id); + if (songQueue.error == null) { + songRequestQueue.value = songQueue.data ?? []; + } + } } void updatePlayerState(String state) { - if (userSeProfile == null) return; - streamelementsEvents.updatePlayerState(jwt, userSeProfile!.id, state); + if (userSeProfile == null || jwt == null) return; + streamelementsEvents.updatePlayerState(jwt!, userSeProfile!.id, state); } void nextSong() { - if (userSeProfile == null) return; - streamelementsEvents.nextSong(jwt, userSeProfile!.id); + if (userSeProfile == null || jwt == null) return; + streamelementsEvents.nextSong(jwt!, userSeProfile!.id); } void removeSong(SeSong song) { - if (userSeProfile == null) return; - streamelementsEvents.removeSong(jwt, userSeProfile!.id, song.id); + if (userSeProfile == null || jwt == null) return; + streamelementsEvents.removeSong(jwt!, userSeProfile!.id, song.id); } void resetQueue() { - if (userSeProfile == null) return; - streamelementsEvents.resetQueue(jwt, userSeProfile!.id); + if (userSeProfile == null || jwt == null) return; + streamelementsEvents.resetQueue(jwt!, userSeProfile!.id); } /// Connect to WebSocket Future connectWebsocket() async { socket = io( 'https://realtime.streamelements.com', - OptionBuilder() - .setTransports(['websocket']) // for Flutter or Dart VM - .disableAutoConnect() + OptionBuilder().setTransports(['websocket']) + // .disableAutoConnect() .build()); - socket!.connect(); + + // socket!.connect(); socket!.on('connect_error', (data) => onError()); socket!.on('connect', (data) => onConnect()); socket!.on('disconnect', (data) => onDisconnect()); socket!.on('authenticated', (data) => onAuthenticated(data)); - socket!.on( 'event:test', (data) => { @@ -155,6 +162,8 @@ class StreamelementsViewController extends GetxController }, ); + socket!.onAny((event, data) => globals.talker?.debug(data),); + socket!.on( 'songrequest:song:next', (data) => onNextSong(data), @@ -194,13 +203,17 @@ class StreamelementsViewController extends GetxController } Future onConnect() async { - socket?.emit('authenticate', {"method": 'jwt', "token": jwt}); + String? accessToken = homeViewController.seCredentials.value?.accessToken; + if (accessToken != null) { + socket?.emit('authenticate', {"method": 'oauth2', "token": accessToken}); + } else { + globals.talker?.error('There is no accessToken to use for SE weboscket.'); + } } Future onError() async { isSocketConnected.value = false; globals.talker?.error('Error on StreamElements websocket'); - } Future onDisconnect() async { @@ -210,8 +223,8 @@ class StreamelementsViewController extends GetxController Future onAuthenticated(data) async { isSocketConnected.value = true; - globals.talker?.info('Connected to StreamElements websocket'); - + // socket?.emit('subscribe', {"room": 'songrequest::611168252645244a6f16ab67'}); + globals.talker?.info('SE WebSocket authenticated.'); } void onAddSongQueue(data) { @@ -227,10 +240,10 @@ class StreamelementsViewController extends GetxController void onNextSong(data) { dynamic songData = data[0]["nextSong"]; - if(songData == {}) return; + if (songData == null || songData == {}) return; SeSong song = SeSong.fromJson(songData); currentSong.value = song; - if(song.id != ''){ + if (song.id != '') { songRequestQueue.removeAt(0); } } diff --git a/lib/src/presentation/events/home_events.dart b/lib/src/presentation/events/home_events.dart index 60427806..475c775b 100644 --- a/lib/src/presentation/events/home_events.dart +++ b/lib/src/presentation/events/home_events.dart @@ -2,19 +2,23 @@ import 'package:dio/dio.dart'; import 'package:irllink/src/core/params/twitch_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/settings.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_credentials.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_poll.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_stream_infos.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_user.dart'; import 'package:irllink/src/domain/usecases/settings_usecase.dart'; +import 'package:irllink/src/domain/usecases/streamelements_usecase.dart'; import 'package:irllink/src/domain/usecases/twitch_usecase.dart'; import 'package:twitch_chat/twitch_chat.dart'; class HomeEvents { final TwitchUseCase twitchUseCase; final SettingsUseCase settingsUseCase; + final StreamelementsUseCase streamelementsUseCase; - HomeEvents({required this.twitchUseCase, required this.settingsUseCase}); + HomeEvents({required this.twitchUseCase, required this.settingsUseCase, required this.streamelementsUseCase}); Future> getTwitchFromLocal() { return twitchUseCase.getTwitchFromLocal(); @@ -41,6 +45,20 @@ class HomeEvents { return twitchUseCase.refreshAccessToken(twitchData: twitchData); } + Future> refreshSeAccessToken({ + required SeCredentials seCredentials, + }) { + return streamelementsUseCase.refreshAccessToken(seCredentials: seCredentials); + } + + Future> getSeCredentialsFromLocal() { + return streamelementsUseCase.getSeCredentialsFromLocal(); + } + + Future> getSeMe(String token) { + return streamelementsUseCase.getMe(token); + } + Future> getStreamInfo( String accessToken, String broadcasterId) { return twitchUseCase.getStreamInfo(accessToken, broadcasterId); diff --git a/lib/src/presentation/events/streamelements_events.dart b/lib/src/presentation/events/streamelements_events.dart index 7f343d00..658339e3 100644 --- a/lib/src/presentation/events/streamelements_events.dart +++ b/lib/src/presentation/events/streamelements_events.dart @@ -1,6 +1,7 @@ import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; @@ -18,12 +19,12 @@ class StreamelementsEvents { required this.settingsUseCase, }); - Future> login({required StreamelementsAuthParams params}) { + Future> login({required StreamelementsAuthParams params}) { return streamelementsUseCase.login(params: params); } - Future> disconnect() { - return streamelementsUseCase.disconnect(); + Future> disconnect(String accessToken) { + return streamelementsUseCase.disconnect(accessToken); } Future replayActivity(String token, SeActivity activity) { diff --git a/lib/src/presentation/views/settings_view.dart b/lib/src/presentation/views/settings_view.dart index 3f968333..f997eddc 100644 --- a/lib/src/presentation/views/settings_view.dart +++ b/lib/src/presentation/views/settings_view.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; @@ -535,7 +537,7 @@ class SettingsView extends GetView { ), const SizedBox(height: 10), Visibility( - visible: kDebugMode || + visible: Platform.isIOS || kDebugMode || storeController.storeFound.value && storeController.products.isNotEmpty, child: StreamElements(controller: controller), diff --git a/lib/src/presentation/widgets/chats/select_channel_dialog.dart b/lib/src/presentation/widgets/chats/select_channel_dialog.dart index 725e67db..0e7a6944 100644 --- a/lib/src/presentation/widgets/chats/select_channel_dialog.dart +++ b/lib/src/presentation/widgets/chats/select_channel_dialog.dart @@ -17,7 +17,7 @@ class SelectChannelDialog extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return SizedBox( width: 200, height: 60, child: ListView.separated( diff --git a/lib/src/presentation/widgets/settings/chats_joined.dart b/lib/src/presentation/widgets/settings/chats_joined.dart index cb9cba29..5aa007eb 100644 --- a/lib/src/presentation/widgets/settings/chats_joined.dart +++ b/lib/src/presentation/widgets/settings/chats_joined.dart @@ -282,7 +282,7 @@ class ChatsJoined extends GetView { var uuid = const Uuid(); ChatGroup newGroup = ChatGroup( id: uuid.v4(), - channels: [], + channels: const [], ); List? groups = []; groups.addAll(controller diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index 71646725..8c19b730 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -1,7 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:irllink/src/presentation/controllers/store_controller.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../../controllers/settings_view_controller.dart'; @@ -16,135 +16,318 @@ class StreamElements extends GetView { @override Widget build(BuildContext context) { - bool isSubscribed = Get.find().isSubscribed(); - return Obx( - () => Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'StreamElements', - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 18, - ), + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'StreamElements', + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color, + fontSize: 18, ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - margin: const EdgeInsets.only(left: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.tertiary, - borderRadius: BorderRadius.circular(20), - ), - child: Wrap( - children: [ - Text( - "Premium feature", - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 12, - ), - ), - const Icon( - CupertinoIcons.sparkles, - size: 12, - color: Colors.yellow, - ), - ], - ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + margin: const EdgeInsets.only(left: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiary, + borderRadius: BorderRadius.circular(20), ), - ], - ), - const SizedBox(height: 6), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all( - Radius.circular(8), + child: Wrap( + children: [ + Text( + "Premium feature", + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color, + fontSize: 12, + ), + ), + const Icon( + CupertinoIcons.sparkles, + size: 12, + color: Colors.yellow, + ), + ], ), - color: Theme.of(context).colorScheme.secondary, ), - child: Column( + ], + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + color: Theme.of(context).colorScheme.secondary, + ), + child: Obx( + () => Column( children: [ - TextFormField( - controller: controller.streamElementsFieldController, - onChanged: (value) { - controller.homeViewController.settings.value = controller - .homeViewController.settings.value - .copyWith(streamElementsAccessToken: value); - controller.saveSettings(); - }, - obscureText: !controller.seJwtShow.value, - enabled: isSubscribed ? true : false, - decoration: InputDecoration( - isDense: true, - disabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Colors.grey[700]!, - ), - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 7), - hintText: 'StreamElements Access Token', - labelText: isSubscribed - ? 'StreamElements Access Token' - : 'Subscribe to unlock this feature', - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.tertiary, - ), - suffixIcon: IconButton( - icon: Icon(controller.seJwtShow.value - ? Icons.visibility - : Icons.visibility_off), - color: Theme.of(context).primaryIconTheme.color, - onPressed: () { - controller.seJwtShow.value = - !controller.seJwtShow.value; - }, - ), - ), - ), - const SizedBox(height: 6), - RichText( - textAlign: TextAlign.center, - text: TextSpan( - children: [ - TextSpan( - text: 'To get your Access Token ', - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - ), - ), - WidgetSpan( - child: InkWell( - onTap: () { - launchUrlString( - "https://streamelements.com/dashboard/account/channels", - mode: LaunchMode.externalApplication, - ); - }, - child: Text( - "click here", + controller.homeViewController.seCredentials.value != null && + controller.homeViewController.seMe.value != null + ? Column( + children: [ + _profile( + controller.homeViewController.seMe.value!, + ), + const SizedBox( + height: 8, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 7, + child: TextFormField( + controller: controller.seJwtInputController, + obscureText: !controller.seJwtShow.value, + onChanged: (value) { + controller + .homeViewController.settings.value = + controller + .homeViewController.settings.value + .copyWith( + streamElementsSettings: controller + .homeViewController + .settings + .value + .streamElementsSettings! + .copyWith(jwt: value), + ); + controller.saveSettings(); + }, + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyLarge! + .color, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 7), + enabledBorder: Theme.of(context) + .inputDecorationTheme + .border, + hintText: 'JWT', + labelText: 'JWT', + labelStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .tertiary, + ), + suffixIcon: IconButton( + icon: Icon( + controller.obsWebsocketUrlShow.value + ? Icons.visibility + : Icons.visibility_off), + color: Theme.of(context) + .primaryIconTheme + .color, + onPressed: () { + controller.obsWebsocketUrlShow.value = + !controller + .obsWebsocketUrlShow.value; + }, + ), + ), + ), + ), + ], + ), + _jwtExplanation(context), + const SizedBox( + height: 8, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 7, + child: TextFormField( + controller: + controller.seOverlayTokenInputController, + obscureText: + !controller.seOverlayTokenShow.value, + onChanged: (value) { + controller + .homeViewController.settings.value = + controller + .homeViewController.settings.value + .copyWith( + streamElementsSettings: controller + .homeViewController + .settings + .value + .streamElementsSettings! + .copyWith(overlayToken: value), + ); + controller.saveSettings(); + }, + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyLarge! + .color, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 7), + enabledBorder: Theme.of(context) + .inputDecorationTheme + .border, + hintText: 'Token', + labelText: 'Overlay Token', + labelStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .tertiary, + ), + suffixIcon: IconButton( + icon: Icon( + controller.obsWebsocketUrlShow.value + ? Icons.visibility + : Icons.visibility_off), + color: Theme.of(context) + .primaryIconTheme + .color, + onPressed: () { + controller.obsWebsocketUrlShow.value = + !controller + .obsWebsocketUrlShow.value; + }, + ), + ), + ), + ), + ], + ), + Text( + 'Same as above for the overlay token', style: TextStyle( - color: Theme.of(context).colorScheme.tertiary, + color: + Theme.of(context).textTheme.bodyLarge!.color, ), ), + const SizedBox( + height: 8, + ), + Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + color: Theme.of(context) + .colorScheme + .tertiaryContainer, + ), + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: InkWell( + onTap: (() => + {controller.disconnectStreamElements()}), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image( + image: AssetImage( + "lib/assets/streamelements/seLogo.png"), + width: 30, + ), + SizedBox( + width: 12, + ), + Text( + 'Logout', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + ), + ], + ) + : InkWell( + onTap: (() => {controller.loginStreamElements()}), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image( + image: AssetImage( + "lib/assets/streamelements/seLogo.png"), + width: 30, + ), + SizedBox( + width: 12, + ), + Text( + 'Login with StreamElements', + style: TextStyle(fontSize: 16), + ), + ], ), ), - TextSpan( - text: - '. Then press "Show Secret" and copy your JWT Token!', - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - ), - ), - ], - ), - ), ], ), ), + ), + ], + ); + } + + Widget _profile(SeMe me) { + return Row( + children: [ + CircleAvatar( + foregroundImage: NetworkImage(me.avatar), + radius: 20, + ), + const SizedBox( + width: 8, + ), + Text(me.displayName), + ], + ); + } + + Widget _jwtExplanation(BuildContext context) { + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: 'To get your Access Token ', + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color, + ), + ), + WidgetSpan( + child: InkWell( + onTap: () { + launchUrlString( + "https://streamelements.com/dashboard/account/channels", + mode: LaunchMode.externalApplication, + ); + }, + child: Text( + "click here", + style: TextStyle( + color: Theme.of(context).colorScheme.tertiary, + ), + ), + ), + ), + TextSpan( + text: '. Then press "Show Secret" and copy your JWT Token!', + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color, + ), + ), ], ), ); diff --git a/lib/src/presentation/widgets/stream_elements/se_overlays.dart b/lib/src/presentation/widgets/stream_elements/se_overlays.dart new file mode 100644 index 00000000..7554e05c --- /dev/null +++ b/lib/src/presentation/widgets/stream_elements/se_overlays.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; +import 'package:irllink/src/presentation/controllers/streamelements_view_controller.dart'; +import 'package:irllink/src/presentation/widgets/web_page_view.dart'; + +class SeOverlays extends GetView { + @override + final StreamelementsViewController controller; + + const SeOverlays({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + child: Obx( + () => ListView.separated( + shrinkWrap: true, + itemCount: controller.overlays.length, + separatorBuilder: (context, index) => const SizedBox( + height: 8, + ), + itemBuilder: (BuildContext context, int index) { + SeOverlay overlay = controller.overlays[index]; + return _overlayRow(controller, overlay, context); + }, + ), + ), + ); + } +} + +Widget _overlayRow(StreamelementsViewController controller, SeOverlay overlay, + BuildContext context) { + String? overlayUrl; + Widget? webpage; + bool isMuted = controller + .homeViewController.settings.value.streamElementsSettings!.mutedOverlays + .contains(overlay.id); + if (controller.overlayToken != null && isMuted == false) { + overlayUrl = + 'https://streamelements.com/overlay/${overlay.id}/${controller.overlayToken}'; + webpage = WebPageView(overlay.name, overlayUrl); + } + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + ), + padding: const EdgeInsets.only(top: 6, left: 10, right: 10, bottom: 6), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(overlay.name), + ), + webpage != null + ? InkWell( + onTap: (() => { + Get.defaultDialog( + title: 'Overlay', + titleStyle: const TextStyle(color: Colors.white), + backgroundColor: const Color(0xFF0e0e10), + buttonColor: const Color(0xFF9147ff), + cancelTextColor: const Color(0xFF9147ff), + textCancel: "return".tr, + radius: 10, + content: SizedBox( + width: 384, height: 216, child: webpage!), + ) + }), + child: const Icon(Icons.preview), + ) + : Container(), + const SizedBox( + width: 10, + ), + InkWell( + onTap: () { + List mutedList = controller.homeViewController.settings + .value.streamElementsSettings!.mutedOverlays; + if (isMuted) { + mutedList.removeWhere((element) => element == overlay.id); + } else { + mutedList.add(overlay.id); + } + controller.homeViewController.settings.value = + controller.homeViewController.settings.value.copyWith( + streamElementsSettings: controller.homeViewController + .settings.value.streamElementsSettings! + .copyWith(mutedOverlays: mutedList)); + controller.homeViewController.saveSettings(); + controller.overlays.refresh(); + }, + child: Icon(isMuted ? Icons.volume_mute : Icons.volume_up), + ), + ], + ), + controller.overlayToken != null + ? SizedBox(width: 0, height: 0, child: webpage) + : Container(), + ], + ), + ); +} diff --git a/lib/src/presentation/widgets/stream_elements/se_song_requests.dart b/lib/src/presentation/widgets/stream_elements/se_song_requests.dart index 46966de4..d4b84d8c 100644 --- a/lib/src/presentation/widgets/stream_elements/se_song_requests.dart +++ b/lib/src/presentation/widgets/stream_elements/se_song_requests.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; @@ -19,33 +20,36 @@ class SeSongRequests extends GetView { margin: const EdgeInsets.only(left: 20, right: 20, top: 20), child: Column( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Wrap( - children: [ - // const Icon(Icons.skip_previous), - InkWell( - onTap: () { - controller.updatePlayerState(controller.isPlaying.value ? 'pause' : 'play'); - }, - child: controller.isPlaying.value ? const Icon(Icons.pause) : const Icon(Icons.play_arrow_outlined), - ), - InkWell( - onTap: () { - controller.nextSong(); - }, - child: const Icon(Icons.skip_next), - ), - ], - ), - InkWell( - onTap: () { - controller.resetQueue(); - }, - child: const Icon(Icons.delete), - ), - ], + Visibility( + visible: controller.jwt != null, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Wrap( + children: [ + // const Icon(Icons.skip_previous), + InkWell( + onTap: () { + controller.updatePlayerState(controller.isPlaying.value ? 'pause' : 'play'); + }, + child: controller.isPlaying.value ? const Icon(Icons.pause) : const Icon(Icons.play_arrow_outlined), + ), + InkWell( + onTap: () { + controller.nextSong(); + }, + child: const Icon(Icons.skip_next), + ), + ], + ), + InkWell( + onTap: () { + controller.resetQueue(); + }, + child: const Icon(Icons.delete), + ), + ], + ), ), const Padding( padding: EdgeInsets.only(top: 10), @@ -169,7 +173,7 @@ class SeSongRequests extends GetView { ), ), Visibility( - visible: removable, + visible: controller.jwt != null && removable, child: InkWell( onTap: () { controller.removeSong(song); diff --git a/lib/src/presentation/widgets/tabs/streamelements_tab_view.dart b/lib/src/presentation/widgets/tabs/streamelements_tab_view.dart index 979632c6..88c92cac 100644 --- a/lib/src/presentation/widgets/tabs/streamelements_tab_view.dart +++ b/lib/src/presentation/widgets/tabs/streamelements_tab_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:irllink/src/presentation/controllers/streamelements_view_controller.dart'; +import 'package:irllink/src/presentation/widgets/stream_elements/se_overlays.dart'; import 'package:irllink/src/presentation/widgets/stream_elements/se_song_requests.dart'; import '../stream_elements/se_activities_list.dart'; @@ -30,6 +31,7 @@ class StreamelementsTabView extends GetView { tabs: const [ Text("Activities"), Text("Song Requests"), + Text("Overlays"), ], ), ), @@ -47,6 +49,9 @@ class StreamelementsTabView extends GetView { SeSongRequests( controller: controller, ), + SeOverlays( + controller: controller, + ), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 1dc49ee3..7d32e9f8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -117,6 +117,7 @@ flutter: - lib/assets/i18n/ - lib/assets/twitch/ - lib/assets/kick/badges/ + - lib/assets/streamelements/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware.