diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 6be01e2..4d95910 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,7 +4,4 @@ to allow setting breakpoints, to provide hot reload, etc. --> - - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cddc286..3106e1b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSCameraUsageDescription + Live streaming + NSMicrophoneUsageDescription + Live streaming diff --git a/lib/api.dart b/lib/api.dart new file mode 100644 index 0000000..851e720 --- /dev/null +++ b/lib/api.dart @@ -0,0 +1,260 @@ +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/const.dart'; + +class IngestEndpoint { + final String name; + final String url; + final String key; + final IngestCost cost; + final List capabilities; + + const IngestEndpoint({ + required this.name, + required this.url, + required this.key, + required this.cost, + required this.capabilities, + }); + + static IngestEndpoint fromJson(Map json) { + return IngestEndpoint( + name: json["name"], + url: json["url"], + key: json["key"], + cost: IngestCost.fromJson(json["cost"]), + capabilities: List.from(json["capabilities"]), + ); + } + + @override + int get hashCode => name.hashCode; + + @override + bool operator ==(Object other) { + if (other is IngestEndpoint) { + return other.name == name; + } + return false; + } +} + +class IngestCost { + final String unit; + final double rate; + + const IngestCost({required this.unit, required this.rate}); + + static IngestCost fromJson(Map json) { + return IngestCost(unit: json["unit"], rate: json["rate"]); + } +} + +class TosAccepted { + final bool accepted; + final String? link; + + const TosAccepted({required this.accepted, required this.link}); + + static TosAccepted fromJson(Map json) { + return TosAccepted(accepted: json["accepted"], link: json["link"]); + } +} + +class AccountInfo { + final double balance; + final List endpoints; + final TosAccepted tos; + final EventInfo? details; + + const AccountInfo({ + required this.balance, + required this.endpoints, + required this.tos, + this.details, + }); + + static AccountInfo fromJson(Map json) { + final balance = json["balance"] as int; + final endpoints = json["endpoints"] as Iterable; + return AccountInfo( + balance: balance.toDouble(), + endpoints: endpoints.map((e) => IngestEndpoint.fromJson(e)).toList(), + tos: TosAccepted.fromJson(json["tos"]), + details: + json.containsKey("details") + ? EventInfo.fromJson(json["details"]) + : null, + ); + } +} + +class EventInfo { + final String? id; + final String? title; + final String? summary; + final String? image; + final String? contentWarning; + final String? goal; + final List? tags; + + EventInfo({ + required this.id, + required this.title, + required this.summary, + required this.image, + required this.contentWarning, + required this.goal, + required this.tags, + }); + + static EventInfo fromJson(Map json) { + return EventInfo( + id: json["id"], + title: json["title"], + summary: json["summary"], + image: json["image"], + contentWarning: json["content_warning"], + goal: json["goal"], + tags: json.containsKey("tags") ? List.from(json["tags"]) : null, + ); + } +} + +class ZapStreamApi { + final String base; + final EventSigner signer; + + ZapStreamApi(this.base, this.signer); + + static ZapStreamApi instance() { + return ZapStreamApi(apiUrl, ndk.accounts.getLoggedAccount()!.signer); + } + + Future getAccountInfo() async { + final url = "$base/account"; + final rsp = await _sendGetRequest(url); + return AccountInfo.fromJson(JsonCodec().decode(rsp.body)); + } + + Future updateDefaultStreamInfo({ + String? title, + String? summary, + String? image, + String? contentWarning, + String? goal, + List? tags, + }) async { + final url = "$base/event"; + await _sendPatchRequest( + url, + body: { + "title": title, + "summary": summary, + "image": image, + "content_warning": contentWarning, + "goal": goal, + "tags": tags, + }, + ); + } + + Future acceptTos() async { + await _sendPatchRequest("$base/account", body: {"accept_tos": true}); + } + + Future _sendPatchRequest(String url, {Object? body}) async { + final jsonBody = body != null ? JsonCodec().encode(body) : null; + final auth = await _makeAuth("PATCH", url, body: jsonBody); + final rsp = await http + .patch( + Uri.parse(url), + body: jsonBody, + headers: { + "authorization": "Nostr $auth", + "accept": "application/json", + "content-type": "application/json", + }, + ) + .timeout(Duration(seconds: 10)); + developer.log(rsp.body); + return rsp; + } + + Future _sendPutRequest(String url, {Object? body}) async { + final jsonBody = body != null ? JsonCodec().encode(body) : null; + final auth = await _makeAuth("PUT", url, body: jsonBody); + final rsp = await http + .put( + Uri.parse(url), + body: jsonBody, + headers: { + "authorization": "Nostr $auth", + "accept": "application/json", + "content-type": "application/json", + }, + ) + .timeout(Duration(seconds: 10)); + developer.log(rsp.body); + return rsp; + } + + Future _sendGetRequest(String url, {Object? body}) async { + final jsonBody = body != null ? JsonCodec().encode(body) : null; + final auth = await _makeAuth("GET", url, body: jsonBody); + final rsp = await http + .get( + Uri.parse(url), + headers: { + "authorization": "Nostr $auth", + "accept": "application/json", + "content-type": "application/json", + }, + ) + .timeout(Duration(seconds: 10)); + developer.log(rsp.body); + return rsp; + } + + Future _sendDeleteRequest(String url, {Object? body}) async { + final jsonBody = body != null ? JsonCodec().encode(body) : null; + final auth = await _makeAuth("DELETE", url, body: jsonBody); + final rsp = await http + .delete( + Uri.parse(url), + headers: { + "authorization": "Nostr $auth", + "accept": "application/json", + "content-type": "application/json", + }, + ) + .timeout(Duration(seconds: 10)); + developer.log(rsp.body); + return rsp; + } + + Future _makeAuth(String method, String url, {String? body}) async { + final pubkey = signer.getPublicKey(); + var tags = [ + ["u", url], + ["method", method], + ]; + if (body != null) { + final hash = hex.encode(sha256.convert(utf8.encode(body)).bytes); + tags.add(["payload", hash]); + } + final authEvent = Nip01Event( + pubKey: pubkey, + kind: 27235, + tags: tags, + content: "", + ); + await signer.sign(authEvent); + return authEvent.toBase64(); + } +} diff --git a/lib/app.dart b/lib/app.dart index 6ae5bdd..1630728 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,6 +6,7 @@ import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/pages/category.dart'; import 'package:zap_stream_flutter/pages/hashtag.dart'; import 'package:zap_stream_flutter/pages/home.dart'; +import 'package:zap_stream_flutter/pages/live.dart'; import 'package:zap_stream_flutter/pages/login.dart'; import 'package:zap_stream_flutter/pages/login_input.dart'; import 'package:zap_stream_flutter/pages/new_account.dart'; @@ -135,6 +136,10 @@ void runZapStream() { ), ], ), + GoRoute( + path: "/live", + builder: (context, state) => LivePage(), + ), GoRoute( path: "/:id", redirect: (context, state) { diff --git a/lib/const.dart b/lib/const.dart index d49e800..a3b2fb8 100644 --- a/lib/const.dart +++ b/lib/const.dart @@ -1,6 +1,7 @@ import 'package:amberflutter/amberflutter.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk_amber/ndk_amber.dart'; @@ -36,6 +37,7 @@ const defaultRelays = [ ]; const searchRelays = ["wss://relay.nostr.band", "wss://search.nos.today"]; const nwcRelays = ["wss://relay.getalby.com/v1"]; +final apiUrl = dotenv.env["API_URL"] ?? "https://api.zap.stream/api/nostr"; final loginData = LoginData(); final RouteObserver> routeObserver = diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index feb4884..93432b7 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 1653 (75 per locale) +/// Strings: 1668 (75 per locale) /// -/// Built on 2025-05-29 at 11:29 UTC +/// Built on 2025-05-30 at 11:38 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 337b552..84992f6 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -72,6 +72,7 @@ class Translations implements BaseTranslations { late final TranslationsProfileEn profile = TranslationsProfileEn.internal(_root); late final TranslationsSettingsEn settings = TranslationsSettingsEn.internal(_root); late final TranslationsLoginEn login = TranslationsLoginEn.internal(_root); + late final TranslationsLiveEn live = TranslationsLiveEn.internal(_root); } // Path: stream @@ -208,6 +209,30 @@ class TranslationsLoginEn { late final TranslationsLoginErrorEn error = TranslationsLoginErrorEn.internal(_root); } +// Path: live +class TranslationsLiveEn { + TranslationsLiveEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get start => 'GO LIVE'; + String get configure_stream => 'Configure Stream'; + String get endpoint => 'Endpoint'; + String get accept_tos => 'Accept TOS'; + String balance_left({required num n, required Object time}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, + zero: '∞', + other: '~${time}', + ); + String get title => 'Title'; + String get summary => 'Summary'; + String get image => 'Cover Image'; + String get tags => 'Tags'; + String get nsfw => 'NSFW Content'; + String get nsfw_description => 'Check here if this stream contains nudity or pornographic content.'; + late final TranslationsLiveErrorEn error = TranslationsLiveErrorEn.internal(_root); +} + // Path: stream.status class TranslationsStreamStatusEn { TranslationsStreamStatusEn.internal(this._root); @@ -307,6 +332,18 @@ class TranslationsLoginErrorEn { String get invalid_key => 'Invalid key'; } +// Path: live.error +class TranslationsLiveErrorEn { + TranslationsLiveErrorEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get failed => 'Stream failed'; + String get connection_error => 'Connection Error'; + String get start_failed => 'Stream start failed, please check your balance'; +} + // Path: stream.chat.write class TranslationsStreamChatWriteEn { TranslationsStreamChatWriteEn.internal(this._root); @@ -472,6 +509,23 @@ extension on Translations { case 'login.key': return 'Login with Key'; case 'login.create': return 'Create Account'; case 'login.error.invalid_key': return 'Invalid key'; + case 'live.start': return 'GO LIVE'; + case 'live.configure_stream': return 'Configure Stream'; + case 'live.endpoint': return 'Endpoint'; + case 'live.accept_tos': return 'Accept TOS'; + case 'live.balance_left': return ({required num n, required Object time}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, + zero: '∞', + other: '~${time}', + ); + case 'live.title': return 'Title'; + case 'live.summary': return 'Summary'; + case 'live.image': return 'Cover Image'; + case 'live.tags': return 'Tags'; + case 'live.nsfw': return 'NSFW Content'; + case 'live.nsfw_description': return 'Check here if this stream contains nudity or pornographic content.'; + case 'live.error.failed': return 'Stream failed'; + case 'live.error.connection_error': return 'Connection Error'; + case 'live.error.start_failed': return 'Stream start failed, please check your balance'; default: return null; } } diff --git a/lib/i18n/translated/en.i18n.yaml b/lib/i18n/translated/en.i18n.yaml index 7292a7f..82f3eb3 100644 --- a/lib/i18n/translated/en.i18n.yaml +++ b/lib/i18n/translated/en.i18n.yaml @@ -135,3 +135,21 @@ login: create: Create Account error: invalid_key: Invalid key +live: + start: "GO LIVE" + configure_stream: Configure Stream + endpoint: Endpoint + accept_tos: Accept TOS + balance_left: + zero: "∞" + other: "~${time}" + title: Title + summary: Summary + image: Cover Image + tags: Tags + nsfw: NSFW Content + nsfw_description: Check here if this stream contains nudity or pornographic content. + error: + failed: Stream failed + connection_error: Connection Error + start_failed: Stream start failed, please check your balance diff --git a/lib/login.dart b/lib/login.dart index 16b5389..c3e26c3 100644 --- a/lib/login.dart +++ b/lib/login.dart @@ -83,6 +83,7 @@ class LoginAccount { final String? privateKey; final List? signerRelays; final WalletConfig? wallet; + final String? streamEndpoint; SimpleWallet? _cachedWallet; @@ -92,6 +93,7 @@ class LoginAccount { this.privateKey, this.signerRelays, this.wallet, + this.streamEndpoint, }); static LoginAccount nip19(String key) { @@ -139,6 +141,7 @@ class LoginAccount { "pubKey": acc?.pubkey, "privateKey": acc?.privateKey, "wallet": acc?.wallet?.toJson(), + "streamEndpoint": acc?.streamEndpoint, }; static LoginAccount? fromJson(Map json) { @@ -162,6 +165,7 @@ class LoginAccount { json.containsKey("wallet") && json["wallet"] != null ? WalletConfig.fromJson(json["wallet"]) : null, + streamEndpoint: json["streamEndpoint"], ); } return null; @@ -215,4 +219,21 @@ class LoginData extends ValueNotifier { } } } + + void configure({ + List? signerRelays, + WalletConfig? wallet, + String? streamEndpoint, + }) { + if (value != null) { + value = LoginAccount( + type: value!.type, + pubkey: value!.pubkey, + privateKey: value!.privateKey, + signerRelays: signerRelays ?? value!.signerRelays, + wallet: wallet, + streamEndpoint: streamEndpoint ?? value!.streamEndpoint, + ); + } + } } diff --git a/lib/pages/live.dart b/lib/pages/live.dart new file mode 100644 index 0000000..52cba0f --- /dev/null +++ b/lib/pages/live.dart @@ -0,0 +1,429 @@ +import 'dart:async'; +import 'dart:developer' as developer; + +import 'package:apivideo_live_stream/apivideo_live_stream.dart'; +import 'package:collection/collection.dart'; +import 'package:duration/duration.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ndk/ndk.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:zap_stream_flutter/api.dart'; +import 'package:zap_stream_flutter/const.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; +import 'package:zap_stream_flutter/rx_filter.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/utils.dart'; +import 'package:zap_stream_flutter/widgets/button.dart'; +import 'package:zap_stream_flutter/widgets/chat.dart'; +import 'package:zap_stream_flutter/widgets/pill.dart'; +import 'package:zap_stream_flutter/widgets/stream_config.dart'; + +Future showExitStreamDialog(BuildContext context) { + return showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (context) { + return Dialog( + child: Container( + padding: EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + Text("Exit live stream?", style: TextStyle(fontSize: 24)), + Row( + spacing: 16, + children: [ + Flexible( + child: BasicButton.text( + "Yes, stop stream", + onTap: (context) => context.pop(true), + ), + ), + Flexible( + child: BasicButton.text( + "No", + onTap: (context) => context.pop(false), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); +} + +class LivePage extends StatefulWidget { + const LivePage({super.key}); + + @override + State createState() => _LivePage(); +} + +class _LivePage extends State + implements ApiVideoLiveStreamEventsListener { + late final ApiVideoLiveStreamController _controller; + late final ZapStreamApi _api; + AccountInfo? _account; + late final Timer _accountTimer; + bool _streaming = false; + + Future _reloadAccount() async { + final info = await _api.getAccountInfo(); + setState(() { + _account = info; + }); + } + + @override + void initState() { + _controller = ApiVideoLiveStreamController( + initialAudioConfig: AudioConfig(), + initialVideoConfig: VideoConfig.withDefaultBitrate(), + ); + _controller.initialize(); + _api = ZapStreamApi.instance(); + _reloadAccount(); + _accountTimer = Timer.periodic(Duration(seconds: 30), (_) async { + await _reloadAccount(); + }); + _controller.addEventsListener(this); + WakelockPlus.enable(); + + super.initState(); + } + + @override + void dispose() { + _accountTimer.cancel(); + _controller.stopStreaming(); + _controller.dispose(); + WakelockPlus.disable(); + super.dispose(); + } + + void _showError(BuildContext context, String msg, {Exception? error}) { + if (error != null) { + developer.log(error.toString()); + } + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: WARNING, + content: Text(msg, style: TextStyle(fontWeight: FontWeight.bold)), + ), + ); + } + + String _calcTimeRemaining(IngestEndpoint endpoint, double balance) { + if (endpoint.cost.rate == 0) { + return ""; + } + final units = balance / endpoint.cost.rate; + if (endpoint.cost.unit == "min") { + return Duration( + seconds: (units * 60).clamp(0, double.infinity).floor(), + ).pretty(abbreviated: true); + } + return "0s"; + } + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (_streaming) { + final go = await showExitStreamDialog(context); + if (context.mounted) { + if (go == true) { + context.go("/"); + } + } + } else { + context.go("/"); + } + }, + child: ValueListenableBuilder( + valueListenable: loginData, + builder: (context, state, _) { + final endpoint = _account?.endpoints.firstWhereOrNull( + (e) => e.name == state?.streamEndpoint, + ); + final balance = _account?.balance ?? 0; + + return RxFilter( + Key("live-stream"), + filters: [ + Filter( + kinds: [30_311], + limit: 100, + pTags: [loginData.value!.pubkey], + ), + Filter( + kinds: [30_311], + limit: 100, + authors: [loginData.value!.pubkey], + ), + ], + builder: (context, streamState) { + final ev = streamState + ?.sortedBy((e) => e.createdAt) + .firstWhereOrNull((e) => e.getFirstTag("status") == "live"); + if (ev == null) return SizedBox(); + final stream = StreamEvent(ev); + return Stack( + children: [ + ApiVideoCameraPreview(controller: _controller), + Positioned( + top: 10, + left: 10, + width: mq.size.width - 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + PillWidget( + color: LAYER_2, + child: Row( + spacing: 4, + children: [ + Text(t.full_amount_sats(n: balance)), + if (endpoint != null) + Text( + t.live.balance_left( + n: endpoint.cost.rate, + time: _calcTimeRemaining(endpoint, balance), + ), + style: TextStyle(color: LAYER_5), + ), + ], + ), + ), + if ((stream.info.participants ?? 0) > 0) + PillWidget( + color: LAYER_2, + child: Text( + t.viewers(n: stream.info.participants!), + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ), + ], + ), + ), + if (_account != null) + Positioned( + width: mq.size.width, + bottom: 15, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton.filled( + iconSize: 40, + style: ButtonStyle( + iconColor: WidgetStateColor.resolveWith( + (_) => FONT_COLOR, + ), + backgroundColor: WidgetStateColor.resolveWith( + (_) => LAYER_3, + ), + ), + onPressed: () { + _controller.switchCamera(); + }, + icon: Icon(Icons.cameraswitch_rounded), + ), + Spacer(), + if (_account != null && !_account!.tos.accepted) + Column( + spacing: 16, + children: [ + BasicButton.text( + "Read TOS", + onTap: (context) { + if (_account?.tos.link != null) { + launchUrl(Uri.parse(_account!.tos.link!)); + } + }, + ), + BasicButton.text( + t.live.accept_tos, + color: WARNING, + onTap: (context) { + _api + .acceptTos() + .then((_) { + _reloadAccount(); + }) + .catchError((e) { + _showError( + context, + e.toString(), + error: e, + ); + }); + }, + ), + ], + ) + else if (state?.streamEndpoint == null || + endpoint == null) + BasicButton.text( + t.live.configure_stream, + color: WARNING, + ), + if (endpoint != null) + IconButton.filled( + iconSize: 40, + style: ButtonStyle( + iconColor: WidgetStateColor.resolveWith( + (_) => WARNING, + ), + backgroundColor: WidgetStateColor.resolveWith( + (_) => LAYER_3, + ), + ), + onPressed: () async { + if (_streaming) { + _controller.stopStreaming().catchError((e) { + _showError(context, e.toString(), error: e); + }); + } else { + _controller + .startStreaming( + streamKey: endpoint.key, + url: endpoint.url, + ) + .catchError((e) { + _showError( + context, + t.live.error.start_failed, + error: e, + ); + }); + } + }, + icon: Icon( + _streaming ? Icons.stop : Icons.circle, + ), + ), + Spacer(), + IconButton.filled( + iconSize: 40, + style: ButtonStyle( + iconColor: WidgetStateColor.resolveWith( + (_) => FONT_COLOR, + ), + backgroundColor: WidgetStateColor.resolveWith( + (_) => LAYER_3, + ), + ), + onPressed: () { + showModalBottomSheet( + context: context, + constraints: BoxConstraints.expand(), + builder: (context) { + return StreamConfigWidget( + api: _api, + account: _account!, + hideEndpointConfig: _streaming, + ); + }, + ).then((_) { + _reloadAccount(); + }); + }, + icon: Icon(Icons.settings), + ), + ], + ), + ), + if (_account != null) + Positioned( + bottom: 80, + child: Container( + width: mq.size.width, + padding: EdgeInsets.symmetric(horizontal: 10), + constraints: BoxConstraints( + maxHeight: mq.size.height * 0.3, + minHeight: 200, + ), + child: ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.white.withAlpha(255), + Colors.white.withAlpha(200), + Colors.white.withAlpha(0), + ], + stops: [0.0, 0.7, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: ChatWidget( + stream: stream, + showGoals: false, + showTopZappers: false, + ), + ), + ), + ), + ], + ); + }, + ); + }, + ), + ); + } + + @override + get onConnectionFailed => (s) { + developer.log(s, name: "onConnectionFailed"); + _showError(context, t.live.error.connection_error); + }; + + @override + get onConnectionSuccess => () { + developer.log("Connected", name: "onConnectionSuccess"); + setState(() { + _streaming = true; + }); + }; + + @override + get onDisconnection => () { + developer.log("Disconnected", name: "onDisconnection"); + setState(() { + _streaming = false; + }); + }; + + @override + get onError => (e) { + developer.log(e.toString(), name: "onError"); + if (e is PlatformException) { + if (e.details is String && + (e.details as String).contains("Connection error")) { + _showError(context, t.live.error.connection_error, error: e); + } + } + }; + + @override + get onVideoSizeChanged => (s) { + developer.log(s.toString(), name: "onVideoSizeChanged"); + }; +} diff --git a/lib/pages/settings_wallet.dart b/lib/pages/settings_wallet.dart index 435b4d8..e272f0f 100644 --- a/lib/pages/settings_wallet.dart +++ b/lib/pages/settings_wallet.dart @@ -100,13 +100,7 @@ class _Inner extends State with ProtocolListener { } _setWallet(WalletConfig? cfg) { - loginData.value = LoginAccount( - type: loginData.value!.type, - pubkey: loginData.value!.pubkey, - privateKey: loginData.value!.privateKey, - signerRelays: loginData.value!.signerRelays, - wallet: cfg, - ); + loginData.configure(wallet: cfg); } @override diff --git a/lib/pages/stream.dart b/lib/pages/stream.dart index ebd9c4b..18f843d 100644 --- a/lib/pages/stream.dart +++ b/lib/pages/stream.dart @@ -144,10 +144,8 @@ class _StreamPage extends State with RouteAware { ? MainVideoPlayerWidget( url: stream.info.stream!, placeholder: stream.info.image, - aspectRatio: 16 / 9, isLive: true, title: stream.info.title, - ) : (stream.info.image?.isNotEmpty ?? false) ? ProxyImg(url: stream.info.image) diff --git a/lib/utils.dart b/lib/utils.dart index c660760..06d572b 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -375,14 +375,6 @@ Map topZapReceiver(Iterable zaps) { ); } -String formatSecondsToHHMMSS(int seconds) { - int hours = seconds ~/ 3600; - int minutes = (seconds % 3600) ~/ 60; - int remainingSeconds = seconds % 60; - - return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; -} - String bech32ToHex(String bech32) { final decoder = Bech32Decoder(); final data = decoder.convert(bech32, 10_000); diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index ade0385..1f54653 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -3,6 +3,7 @@ import 'package:zap_stream_flutter/theme.dart'; class BasicButton extends StatelessWidget { final Widget? child; + final Color? color; final BoxDecoration? decoration; final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? margin; @@ -12,6 +13,7 @@ class BasicButton extends StatelessWidget { const BasicButton( this.child, { super.key, + this.color, this.decoration, this.padding, this.margin, @@ -21,6 +23,7 @@ class BasicButton extends StatelessWidget { static Widget text( String text, { + Color? color, BoxDecoration? decoration, EdgeInsetsGeometry? padding, EdgeInsetsGeometry? margin, @@ -46,6 +49,7 @@ class BasicButton extends StatelessWidget { ), ), disabled: disabled, + color: color, decoration: decoration, padding: padding ?? EdgeInsets.symmetric(vertical: 4, horizontal: 12), margin: margin, @@ -55,12 +59,17 @@ class BasicButton extends StatelessWidget { @override Widget build(BuildContext context) { + assert( + !(color != null && decoration != null), + "Cant set both 'color' and 'decoration'", + ); final defaultBr = BorderRadius.all(Radius.circular(100)); final inner = Container( padding: padding, margin: margin, decoration: - decoration ?? BoxDecoration(color: LAYER_2, borderRadius: defaultBr), + decoration ?? + BoxDecoration(color: color ?? LAYER_2, borderRadius: defaultBr), child: Center(child: child), ); return GestureDetector( diff --git a/lib/widgets/chat.dart b/lib/widgets/chat.dart index b093e9a..5065da9 100644 --- a/lib/widgets/chat.dart +++ b/lib/widgets/chat.dart @@ -18,8 +18,17 @@ import 'package:zap_stream_flutter/widgets/profile.dart'; class ChatWidget extends StatelessWidget { final StreamEvent stream; + final bool? showGoals; + final bool? showTopZappers; + final bool? showRaids; - const ChatWidget({super.key, required this.stream}); + const ChatWidget({ + super.key, + required this.stream, + this.showGoals, + this.showTopZappers, + this.showRaids, + }); @override Widget build(BuildContext context) { @@ -31,7 +40,8 @@ class ChatWidget extends StatelessWidget { var filters = [ Filter(kinds: [1311, 9735], limit: 200, aTags: [stream.aTag]), - Filter(kinds: [1312, 1313], limit: 200, aTags: [stream.aTag]), + if (showRaids ?? true) + Filter(kinds: [1312, 1313], limit: 200, aTags: [stream.aTag]), Filter(kinds: [Nip51List.kMute], authors: moderators), Filter(kinds: [1314], authors: moderators), Filter(kinds: [8], authors: [stream.info.host]), @@ -108,10 +118,13 @@ class ChatWidget extends StatelessWidget { spacing: 8, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (zaps.isNotEmpty) _TopZappersWidget(events: zaps), - if (stream.info.goal != null) GoalWidget.id(stream.info.goal!), + if (zaps.isNotEmpty && (showTopZappers ?? true)) + _TopZappersWidget(events: zaps), + if (stream.info.goal != null && (showGoals ?? true)) + GoalWidget.id(stream.info.goal!), Expanded( child: ListView.builder( + padding: EdgeInsets.only(top: 80), reverse: true, itemCount: filteredChat.length, itemBuilder: (ctx, idx) { diff --git a/lib/widgets/chat_write.dart b/lib/widgets/chat_write.dart index 219c253..39d2006 100644 --- a/lib/widgets/chat_write.dart +++ b/lib/widgets/chat_write.dart @@ -24,6 +24,7 @@ class __WriteMessageWidget extends State { OverlayEntry? _entry; late FocusNode _focusNode; List> _tags = List.empty(growable: true); + final GlobalKey _positioned = GlobalKey(); @override void initState() { @@ -69,7 +70,8 @@ class __WriteMessageWidget extends State { _entry = null; } - final pos = context.findRenderObject() as RenderBox?; + final pos = _positioned.currentContext!.findRenderObject() as RenderBox?; + final posGlobal = pos?.localToGlobal(Offset.zero); _entry = OverlayEntry( builder: (context) { return ValueListenableBuilder( @@ -85,12 +87,13 @@ class __WriteMessageWidget extends State { if (search.isEmpty) { return SizedBox(); } + final mq = MediaQuery.of(context); return Stack( children: [ Positioned( - left: 0, - bottom: (pos?.paintBounds.bottom ?? 0), - width: MediaQuery.of(context).size.width, + left: posGlobal?.dx, + bottom: mq.size.height - (posGlobal?.dy ?? 0) - 30, + width: pos?.size.width, child: Container( padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8), decoration: BoxDecoration( @@ -162,15 +165,17 @@ class __WriteMessageWidget extends State { _entry = null; } - final pos = context.findRenderObject() as RenderBox?; + final pos = _positioned.currentContext!.findRenderObject() as RenderBox?; + final posGlobal = pos?.localToGlobal(Offset.zero); _entry = OverlayEntry( builder: (context) { + final mq = MediaQuery.of(context); return Stack( children: [ Positioned( - left: 0, - bottom: (pos?.paintBounds.bottom ?? 0), - width: MediaQuery.of(context).size.width, + left: posGlobal?.dx, + bottom: mq.size.height - (posGlobal?.dy ?? 0) - 30, + width: pos?.size.width, child: Container( padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8), decoration: BoxDecoration( @@ -239,9 +244,13 @@ class __WriteMessageWidget extends State { final isLogin = ndk.accounts.isLoggedIn; return Container( + key: _positioned, margin: EdgeInsets.fromLTRB(4, 8, 4, 0), padding: EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration(color: LAYER_2, borderRadius: DEFAULT_BR), + decoration: BoxDecoration( + color: LAYER_2.withAlpha(200), + borderRadius: DEFAULT_BR, + ), child: canSign ? Row( diff --git a/lib/widgets/header.dart b/lib/widgets/header.dart index 1c17315..b021ddc 100644 --- a/lib/widgets/header.dart +++ b/lib/widgets/header.dart @@ -6,6 +6,7 @@ import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/const.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/widgets/avatar.dart'; +import 'package:zap_stream_flutter/widgets/button.dart'; class HeaderWidget extends StatefulWidget { const HeaderWidget({super.key}); @@ -39,12 +40,36 @@ class LoginButtonWidget extends StatelessWidget { @override Widget build(BuildContext context) { if (ndk.accounts.isLoggedIn) { - return GestureDetector( - onTap: - () => context.go( - "/p/${Nip19.encodePubKey(ndk.accounts.getPublicKey()!)}", + return Row( + spacing: 8, + children: [ + BasicButton( + padding: EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + border: Border.all(color: WARNING), + borderRadius: DEFAULT_BR, ), - child: AvatarWidget.pubkey(ndk.accounts.getPublicKey()!), + Row( + spacing: 4, + children: [ + Icon(Icons.videocam), + Text( + t.live.start, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + onTap: (context) => context.push("/live"), + ), + + GestureDetector( + onTap: + () => context.push( + "/p/${Nip19.encodePubKey(ndk.accounts.getPublicKey()!)}", + ), + child: AvatarWidget.pubkey(ndk.accounts.getPublicKey()!), + ), + ], ); } else { return GestureDetector( @@ -59,10 +84,7 @@ class LoginButtonWidget extends StatelessWidget { ), child: Row( spacing: 8, - children: [ - Text(t.button.login), - Icon(Icons.login, size: 16), - ], + children: [Text(t.button.login), Icon(Icons.login, size: 16)], ), ), ); diff --git a/lib/widgets/live_timer.dart b/lib/widgets/live_timer.dart index df54195..40079f4 100644 --- a/lib/widgets/live_timer.dart +++ b/lib/widgets/live_timer.dart @@ -1,7 +1,7 @@ import 'dart:async'; +import 'package:duration/duration.dart'; import 'package:flutter/material.dart'; import 'package:zap_stream_flutter/theme.dart'; -import 'package:zap_stream_flutter/utils.dart'; import 'package:zap_stream_flutter/widgets/pill.dart'; class LiveTimerWidget extends StatefulWidget { @@ -37,12 +37,13 @@ class _LiveTimerWidget extends State { return PillWidget( color: LAYER_2, child: Text( - formatSecondsToHHMMSS( - ((DateTime.now().millisecondsSinceEpoch - - widget.started.millisecondsSinceEpoch) / - 1000) - .toInt(), - ), + Duration( + seconds: + ((DateTime.now().millisecondsSinceEpoch - + widget.started.millisecondsSinceEpoch) / + 1000) + .toInt(), + ).pretty(abbreviated: true), ), ); } diff --git a/lib/widgets/stream_config.dart b/lib/widgets/stream_config.dart new file mode 100644 index 0000000..5148e76 --- /dev/null +++ b/lib/widgets/stream_config.dart @@ -0,0 +1,174 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:zap_stream_flutter/api.dart'; +import 'package:zap_stream_flutter/const.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/widgets/button.dart'; +import 'package:zap_stream_flutter/widgets/pill.dart'; + +class StreamConfigWidget extends StatefulWidget { + final ZapStreamApi api; + final AccountInfo account; + final bool? hideEndpointConfig; + + const StreamConfigWidget({ + super.key, + required this.api, + required this.account, + this.hideEndpointConfig, + }); + + @override + State createState() => _StreamConfigWidget(); +} + +class _StreamConfigWidget extends State { + late bool _nsfw; + late final TextEditingController _title; + late final TextEditingController _summary; + late final TextEditingController _tags; + + @override + void initState() { + _title = TextEditingController(text: widget.account.details?.title); + _summary = TextEditingController(text: widget.account.details?.summary); + _tags = TextEditingController( + text: widget.account.details?.tags?.join(",") ?? "irl", + ); + _nsfw = widget.account.details?.contentWarning?.isNotEmpty ?? false; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: loginData, + builder: (context, state, _) { + final endpoint = widget.account.endpoints.firstWhereOrNull( + (e) => e.name == state?.streamEndpoint, + ); + return Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text(t.live.configure_stream, style: TextStyle(fontSize: 24)), + if (!(widget.hideEndpointConfig ?? false)) + Row( + spacing: 8, + children: [ + Icon(Icons.power), + Expanded( + child: DropdownButton( + value: endpoint, + hint: Text(t.live.endpoint), + items: + widget.account.endpoints + .map( + (e) => DropdownMenuItem( + value: e, + child: Text(e.name), + ), + ) + .toList(), + onChanged: (x) { + if (x != null) { + loginData.configure(streamEndpoint: x.name); + } + }, + ), + ), + if (endpoint != null) + Text( + "${t.full_amount_sats(n: endpoint.cost.rate)}/${endpoint.cost.unit}", + ), + ], + ), + if (endpoint != null && !(widget.hideEndpointConfig ?? false)) + Row( + spacing: 8, + children: + endpoint.capabilities + .map( + (e) => PillWidget(color: LAYER_3, child: Text(e)), + ) + .toList(), + ), + + TextField( + controller: _title, + decoration: InputDecoration(labelText: t.live.title), + ), + TextField( + controller: _summary, + decoration: InputDecoration(labelText: t.live.summary), + minLines: 3, + maxLines: 5, + ), + + GestureDetector( + onTap: () { + setState(() { + _nsfw = !_nsfw; + }); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: WARNING), + borderRadius: DEFAULT_BR, + ), + padding: EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Checkbox( + value: _nsfw, + onChanged: (v) { + setState(() { + _nsfw = !_nsfw; + }); + }, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.live.nsfw, + style: TextStyle( + color: WARNING, + fontWeight: FontWeight.bold, + ), + ), + Text(t.live.nsfw_description), + ], + ), + ), + ], + ), + ), + ), + + BasicButton.text( + t.button.save, + onTap: (context) async { + await widget.api.updateDefaultStreamInfo( + title: _title.text, + summary: _summary.text, + contentWarning: _nsfw ? "nsfw" : null, + tags: _tags.text.split(","), + ); + if (context.mounted) { + context.pop(); + } + }, + ), + ], + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9a1dcd4..3d72414 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.9" + apivideo_live_stream: + dependency: "direct main" + description: + name: apivideo_live_stream + sha256: f873f8cdd3e35838d6e32ce55c1963f8889b9bfccf4a37f9ed86cfe79512b309 + url: "https://pub.dev" + source: hosted + version: "1.2.0" archive: dependency: transitive description: @@ -798,12 +806,20 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_device_orientation: + dependency: transitive + description: + name: native_device_orientation + sha256: "744a03030fad5a332a54833cd34f1e2ee51ae9acf477b4ef85bacc8823af9937" + url: "https://pub.dev" + source: hosted + version: "1.2.1" ndk: dependency: "direct main" description: path: "packages/ndk" - ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" - resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" + ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e + resolved-ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e url: "https://github.com/relaystr/ndk" source: git version: "0.3.2" @@ -811,8 +827,8 @@ packages: dependency: "direct main" description: path: "packages/amber" - ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" - resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" + ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e + resolved-ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e url: "https://github.com/relaystr/ndk" source: git version: "0.3.0" @@ -820,8 +836,8 @@ packages: dependency: "direct main" description: path: "packages/objectbox" - ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" - resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" + ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e + resolved-ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e url: "https://github.com/relaystr/ndk" source: git version: "0.2.3" @@ -1498,10 +1514,10 @@ packages: dependency: transitive description: name: web_socket_client - sha256: "0ec5230852349191188c013112e4d2be03e3fc83dbe80139ead9bf3a136e53b5" + sha256: "394789177aa3bc1b7b071622a1dbf52a4631d7ce23c555c39bb2523e92316b07" url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.2.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fe7eb8e..7323ab1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,23 +46,24 @@ dependencies: flutter_dotenv: ^5.2.1 protocol_handler: ^0.2.0 audio_service: ^0.18.18 + apivideo_live_stream: ^1.2.0 dependency_overrides: ndk: git: url: https://github.com/relaystr/ndk path: packages/ndk - ref: 6242899ee4ff7f65e57518d4eb29e2a253b3c4da + ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e ndk_objectbox: git: url: https://github.com/relaystr/ndk path: packages/objectbox - ref: 6242899ee4ff7f65e57518d4eb29e2a253b3c4da + ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e ndk_amber: git: url: https://github.com/relaystr/ndk path: packages/amber - ref: 6242899ee4ff7f65e57518d4eb29e2a253b3c4da + ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e emoji_picker_flutter: git: url: https://github.com/nostrlabs-io/emoji_picker_flutter