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