8 Commits

Author SHA1 Message Date
edfb5bb80d chore: bump version 2025-05-30 14:06:58 +01:00
33ad784e87 fix: missing stream hides live page 2025-05-30 14:03:28 +01:00
0422341bf8 chore: bump flutter version 2025-05-30 13:59:46 +01:00
8af62e0b32 chore: bump ndk 2025-05-30 13:53:40 +01:00
cd6c50f9bd feat: add notifications button to profile page 2025-05-30 13:51:07 +01:00
428771462d feat: live streaming
closes #43
2025-05-30 13:48:37 +01:00
917147605b feat: default ln address to zap.stream for new accounts 2025-05-29 14:31:13 +01:00
8e3a4cbd41 feat: wallet balance
closes #42
2025-05-29 12:32:10 +01:00
27 changed files with 1209 additions and 121 deletions

View File

@ -12,7 +12,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.29.3
flutter-version: 3.32.0
- run: flutter pub get
- run: flutter build appbundle
env:
@ -53,6 +53,6 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.29.3
flutter-version: 3.32.0
- run: flutter pub get
- run: flutter build ios --no-codesign

View File

@ -16,7 +16,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.29.3
flutter-version: 3.32.0
- run: flutter pub get
- name: Build apk
env:

View File

@ -4,7 +4,4 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
</manifest>

View File

@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name="${applicationName}"

View File

@ -91,5 +91,9 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Live streaming</string>
<key>NSMicrophoneUsageDescription</key>
<string>Live streaming</string>
</dict>
</plist>

260
lib/api.dart Normal file
View File

@ -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<String> capabilities;
const IngestEndpoint({
required this.name,
required this.url,
required this.key,
required this.cost,
required this.capabilities,
});
static IngestEndpoint fromJson(Map<String, dynamic> json) {
return IngestEndpoint(
name: json["name"],
url: json["url"],
key: json["key"],
cost: IngestCost.fromJson(json["cost"]),
capabilities: List<String>.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<String, dynamic> 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<String, dynamic> json) {
return TosAccepted(accepted: json["accepted"], link: json["link"]);
}
}
class AccountInfo {
final double balance;
final List<IngestEndpoint> endpoints;
final TosAccepted tos;
final EventInfo? details;
const AccountInfo({
required this.balance,
required this.endpoints,
required this.tos,
this.details,
});
static AccountInfo fromJson(Map<String, dynamic> json) {
final balance = json["balance"] as int;
final endpoints = json["endpoints"] as Iterable<dynamic>;
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<String>? 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<String, dynamic> 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<String>.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<AccountInfo> getAccountInfo() async {
final url = "$base/account";
final rsp = await _sendGetRequest(url);
return AccountInfo.fromJson(JsonCodec().decode(rsp.body));
}
Future<void> updateDefaultStreamInfo({
String? title,
String? summary,
String? image,
String? contentWarning,
String? goal,
List<String>? tags,
}) async {
final url = "$base/event";
await _sendPatchRequest(
url,
body: {
"title": title,
"summary": summary,
"image": image,
"content_warning": contentWarning,
"goal": goal,
"tags": tags,
},
);
}
Future<void> acceptTos() async {
await _sendPatchRequest("$base/account", body: {"accept_tos": true});
}
Future<http.Response> _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<http.Response> _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<http.Response> _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<http.Response> _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<String> _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();
}
}

View File

@ -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) {

View File

@ -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<ModalRoute<void>> routeObserver =

View File

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 1650 (75 per locale)
/// Strings: 1668 (75 per locale)
///
/// Built on 2025-05-29 at 10:02 UTC
/// Built on 2025-05-30 at 11:38 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View File

@ -52,6 +52,8 @@ class Translations implements BaseTranslations<AppLocale, Translations> {
/// An anonymous user
String get anon => 'Anon';
String full_amount_sats({required num n}) => '${NumberFormat.decimalPattern('en').format(n)} sats';
/// Number of viewers of the stream
String viewers({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: '1 viewer',
@ -70,6 +72,7 @@ class Translations implements BaseTranslations<AppLocale, Translations> {
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
@ -206,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);
@ -290,6 +317,8 @@ class TranslationsSettingsWalletEn {
String get disconnect_wallet => 'Disconnect Wallet';
String get connect_1tap => '1-Tap Connection';
String get paste => 'Paste URL';
String get balance => 'Balance';
String get name => 'Wallet';
late final TranslationsSettingsWalletErrorEn error = TranslationsSettingsWalletErrorEn.internal(_root);
}
@ -303,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);
@ -381,6 +422,7 @@ extension on Translations {
case 'most_zapped_streamers': return 'Most Zapped Streamers';
case 'no_user_found': return 'No user found';
case 'anon': return 'Anon';
case 'full_amount_sats': return ({required num n}) => '${NumberFormat.decimalPattern('en').format(n)} sats';
case 'viewers': return ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: '1 viewer',
other: '${NumberFormat.decimalPattern('en').format(n)} viewers',
@ -458,6 +500,8 @@ extension on Translations {
case 'settings.wallet.disconnect_wallet': return 'Disconnect Wallet';
case 'settings.wallet.connect_1tap': return '1-Tap Connection';
case 'settings.wallet.paste': return 'Paste URL';
case 'settings.wallet.balance': return 'Balance';
case 'settings.wallet.name': return 'Wallet';
case 'settings.wallet.error.logged_out': return 'Cant connect wallet when logged out';
case 'settings.wallet.error.nwc_auth_event_not_found': return 'No wallet auth event found';
case 'login.username': return 'Username';
@ -465,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;
}
}

View File

@ -8,6 +8,7 @@ no_user_found: No user found
"@no_user_found":
description: No user found when searching
anon: Anon
full_amount_sats: ${n:decimalPattern} sats
viewers:
one: 1 viewer
other: ${n:decimalPattern} viewers
@ -122,6 +123,8 @@ settings:
disconnect_wallet: Disconnect Wallet
connect_1tap: 1-Tap Connection
paste: Paste URL
balance: Balance
name: Wallet
error:
logged_out: Cant connect wallet when logged out
nwc_auth_event_not_found: No wallet auth event found
@ -132,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

View File

@ -38,8 +38,16 @@ class WalletConfig {
}
}
class WalletInfo {
final String name;
final int balance;
const WalletInfo({required this.name, required this.balance});
}
abstract class SimpleWallet {
Future<String> payInvoice(String pr);
Future<WalletInfo> getInfo();
}
class NWCWrapper extends SimpleWallet {
@ -60,6 +68,13 @@ class NWCWrapper extends SimpleWallet {
return rsp.preimage!;
}
}
@override
Future<WalletInfo> getInfo() async {
final info = await ndk.nwc.getInfo(_conn);
final balance = await ndk.nwc.getBalance(_conn);
return WalletInfo(name: info.alias, balance: balance.balanceSats);
}
}
class LoginAccount {
@ -68,6 +83,7 @@ class LoginAccount {
final String? privateKey;
final List<String>? signerRelays;
final WalletConfig? wallet;
final String? streamEndpoint;
SimpleWallet? _cachedWallet;
@ -77,6 +93,7 @@ class LoginAccount {
this.privateKey,
this.signerRelays,
this.wallet,
this.streamEndpoint,
});
static LoginAccount nip19(String key) {
@ -124,6 +141,7 @@ class LoginAccount {
"pubKey": acc?.pubkey,
"privateKey": acc?.privateKey,
"wallet": acc?.wallet?.toJson(),
"streamEndpoint": acc?.streamEndpoint,
};
static LoginAccount? fromJson(Map<String, dynamic> json) {
@ -147,6 +165,7 @@ class LoginAccount {
json.containsKey("wallet") && json["wallet"] != null
? WalletConfig.fromJson(json["wallet"])
: null,
streamEndpoint: json["streamEndpoint"],
);
}
return null;
@ -200,4 +219,21 @@ class LoginData extends ValueNotifier<LoginAccount?> {
}
}
}
void configure({
List<String>? 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,
);
}
}
}

429
lib/pages/live.dart Normal file
View File

@ -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<bool?> showExitStreamDialog(BuildContext context) {
return showDialog<bool>(
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<StatefulWidget> createState() => _LivePage();
}
class _LivePage extends State<LivePage>
implements ApiVideoLiveStreamEventsListener {
late final ApiVideoLiveStreamController _controller;
late final ZapStreamApi _api;
AccountInfo? _account;
late final Timer _accountTimer;
bool _streaming = false;
Future<void> _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<Nip01Event>(
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");
final stream = ev != null ? StreamEvent(ev) : null;
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 ?? 0),
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 && stream != 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");
};
}

View File

@ -38,6 +38,7 @@ class _NewAccountPage extends State<NewAccountPage> {
pubKey: _privateKey.publicKey,
name: _name.text,
picture: _avatar,
lud16: "${_privateKey.publicKey}@zap.stream",
),
);
}

View File

@ -12,6 +12,7 @@ import 'package:zap_stream_flutter/widgets/button.dart';
import 'package:zap_stream_flutter/widgets/button_follow.dart';
import 'package:zap_stream_flutter/widgets/header.dart';
import 'package:zap_stream_flutter/widgets/nostr_text.dart';
import 'package:zap_stream_flutter/widgets/notifications_button.dart';
import 'package:zap_stream_flutter/widgets/profile.dart';
import 'package:zap_stream_flutter/widgets/stream_grid.dart';
@ -91,7 +92,14 @@ class ProfilePage extends StatelessWidget {
),
],
),
if (!isMe) FollowButton(pubkey: hexPubkey),
if (!isMe)
Row(
spacing: 8,
children: [
FollowButton(pubkey: hexPubkey),
NotificationsButtonWidget(pubkey: hexPubkey),
],
),
Text(
t.profile.past_streams,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),

View File

@ -87,7 +87,7 @@ class _Inner extends State<SettingsWalletPage> with ProtocolListener {
queryParameters: {
"relay": nwcRelays,
"name": "zap.stream",
"request_methods": "pay_invoice",
"request_methods": "pay_invoice get_info get_balance",
"icon": "https://zap.stream/logo.png",
"return_to": nwaHandlerUrl,
},
@ -100,13 +100,7 @@ class _Inner extends State<SettingsWalletPage> 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
@ -174,13 +168,43 @@ class _Inner extends State<SettingsWalletPage> with ProtocolListener {
],
);
} else {
return BasicButton.text(
t.settings.wallet.disconnect_wallet,
onTap: (context) {
_setWallet(null);
if (context.mounted) {
context.pop();
}
return FutureBuilder(
future: () async {
final wallet = await state!.getWallet();
return await wallet?.getInfo();
}(),
builder: (context, state) {
return Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Wallet: ${state.data?.name ?? ""}",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 24),
),
Text.rich(
TextSpan(
style: TextStyle(fontWeight: FontWeight.w500),
children: [
TextSpan(text: t.settings.wallet.balance),
TextSpan(text: ": "),
TextSpan(
text: t.full_amount_sats(n: state.data?.balance ?? 0),
),
],
),
),
BasicButton.text(
t.settings.wallet.disconnect_wallet,
onTap: (context) {
_setWallet(null);
if (context.mounted) {
context.pop();
}
},
),
],
);
},
);
}

View File

@ -144,10 +144,8 @@ class _StreamPage extends State<StreamPage> 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)
@ -161,7 +159,7 @@ class _StreamPage extends State<StreamPage> with RouteAware {
ProfileWidget.pubkey(
stream.info.host,
children: [
NotificationsButtonWidget(stream: widget.stream),
NotificationsButtonWidget(pubkey: widget.stream.info.host),
BasicButton(
Row(
children: [Icon(Icons.bolt, size: 14), Text(t.zap.button_zap)],

View File

@ -375,14 +375,6 @@ Map<String, TopZaps> topZapReceiver(Iterable<ZapReceipt> 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);

View File

@ -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(

View File

@ -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) {

View File

@ -24,6 +24,7 @@ class __WriteMessageWidget extends State<WriteMessageWidget> {
OverlayEntry? _entry;
late FocusNode _focusNode;
List<List<String>> _tags = List.empty(growable: true);
final GlobalKey _positioned = GlobalKey();
@override
void initState() {
@ -69,7 +70,8 @@ class __WriteMessageWidget extends State<WriteMessageWidget> {
_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<WriteMessageWidget> {
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<WriteMessageWidget> {
_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<WriteMessageWidget> {
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(

View File

@ -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)],
),
),
);

View File

@ -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<LiveTimerWidget> {
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),
),
);
}

View File

@ -1,12 +1,11 @@
import 'package:flutter/material.dart';
import 'package:zap_stream_flutter/notifications.dart';
import 'package:zap_stream_flutter/theme.dart';
import 'package:zap_stream_flutter/utils.dart';
class NotificationsButtonWidget extends StatefulWidget {
final StreamEvent stream;
final String pubkey;
const NotificationsButtonWidget({super.key, required this.stream});
const NotificationsButtonWidget({super.key, required this.pubkey});
@override
State<StatefulWidget> createState() => _NotificationsButtonWidget();
@ -18,9 +17,7 @@ class _NotificationsButtonWidget extends State<NotificationsButtonWidget> {
return ValueListenableBuilder(
valueListenable: notifications,
builder: (context, state, _) {
final isNotified = (state?.notifyKeys ?? []).contains(
widget.stream.info.host,
);
final isNotified = (state?.notifyKeys ?? []).contains(widget.pubkey);
return IconButton(
iconSize: 20,
onPressed: () async {
@ -28,9 +25,9 @@ class _NotificationsButtonWidget extends State<NotificationsButtonWidget> {
if (n == null) return;
if (isNotified) {
await n.removeWatchPubkey(widget.stream.info.host);
await n.removeWatchPubkey(widget.pubkey);
} else {
await n.watchPubkey(widget.stream.info.host, [30311]);
await n.watchPubkey(widget.pubkey, [30311]);
}
await notifications.reload();
},

View File

@ -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<StatefulWidget> createState() => _StreamConfigWidget();
}
class _StreamConfigWidget extends State<StreamConfigWidget> {
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<IngestEndpoint>(
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();
}
},
),
],
),
);
},
);
}
}

View File

@ -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:
@ -45,10 +53,10 @@ packages:
dependency: transitive
description:
name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.12.0"
version: "2.13.0"
audio_service:
dependency: "direct main"
description:
@ -294,10 +302,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
version: "1.3.3"
ffi:
dependency: transitive
description:
@ -326,10 +334,10 @@ packages:
dependency: transitive
description:
name: file_selector_macos
sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc"
sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711"
url: "https://pub.dev"
source: hosted
version: "0.9.4+2"
version: "0.9.4+3"
file_selector_platform_interface:
dependency: transitive
description:
@ -504,10 +512,10 @@ packages:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: "35c257fc7f98e34c1314d6c145e5ed54e7c94e8a9f469947e31c9298177d546f"
sha256: b416ff56002789e636244fb4cc449f587656eff995e5a7169457eb0593fcaddb
url: "https://pub.dev"
source: hosted
version: "2.7.0"
version: "2.10.0"
flutter_secure_storage:
dependency: "direct main"
description:
@ -690,10 +698,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.19.0"
version: "0.20.2"
js:
dependency: transitive
description:
@ -714,10 +722,10 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev"
source: hosted
version: "10.0.8"
version: "10.0.9"
leak_tracker_flutter_testing:
dependency: transitive
description:
@ -798,41 +806,50 @@ 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: "00eee5460ca478a02ec9a91c0a6100f2e50eddce"
resolved-ref: "00eee5460ca478a02ec9a91c0a6100f2e50eddce"
url: "https://github.com/relaystr/ndk"
source: git
version: "0.3.2"
version: "0.4.0"
ndk_amber:
dependency: "direct main"
description:
path: "packages/amber"
ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
ref: "00eee5460ca478a02ec9a91c0a6100f2e50eddce"
resolved-ref: "00eee5460ca478a02ec9a91c0a6100f2e50eddce"
url: "https://github.com/relaystr/ndk"
source: git
version: "0.3.0"
version: "0.3.1"
ndk_objectbox:
dependency: "direct main"
description:
path: "packages/objectbox"
ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
ref: "00eee5460ca478a02ec9a91c0a6100f2e50eddce"
resolved-ref: "00eee5460ca478a02ec9a91c0a6100f2e50eddce"
url: "https://github.com/relaystr/ndk"
source: git
version: "0.2.3"
version: "0.2.4"
ndk_rust_verifier:
dependency: "direct main"
description:
name: ndk_rust_verifier
sha256: f82d536042634ad3bb1c1af0afd59abd149e7544e88697ef14302f7e170581b7
url: "https://pub.dev"
source: hosted
version: "0.3.1"
path: "packages/rust_verifier"
ref: "00eee5460ca478a02ec9a91c0a6100f2e50eddce"
resolved-ref: "00eee5460ca478a02ec9a91c0a6100f2e50eddce"
url: "https://github.com/relaystr/ndk"
source: git
version: "0.4.0"
nested:
dependency: transitive
description:
@ -845,18 +862,18 @@ packages:
dependency: transitive
description:
name: objectbox
sha256: a07f93ea6b6a7c6cfe2efce743e12842ccd876d92dc6b6da532cc5221c507ef0
sha256: "25c2e24b417d938decb5598682dc831bc6a21856eaae65affbc57cfad326808d"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
version: "4.3.0"
objectbox_flutter_libs:
dependency: transitive
description:
name: objectbox_flutter_libs
sha256: "43b2b86b61aa4a6ef9e3b44d3cc09471586975d100c6620ef22960e490c9bcd5"
sha256: "574b0233ba79a7159fca9049c67974f790a2180b6141d4951112b20bd146016a"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
version: "4.3.0"
octo_image:
dependency: transitive
description:
@ -1061,10 +1078,10 @@ packages:
dependency: transitive
description:
name: rust_lib_ndk
sha256: "47dcfda62051315e869256c705e7c1c07649772d849eaad0474df11b10a2e1b0"
sha256: f06a1a47c6fbadf25250eac796b65b91ea99797b6377304abb152f06205c28ff
url: "https://pub.dev"
source: hosted
version: "0.1.5"
version: "0.1.7"
rxdart:
dependency: "direct main"
description:
@ -1154,10 +1171,10 @@ packages:
dependency: "direct main"
description:
name: slang
sha256: "13132690084bef34fb74dbc698a1e5496f97afbcd3eb58e20c09393e77ac46e6"
sha256: "6668a08355b370d5cb5446fc869c4492ed23c6433934fe88228876460fedac22"
url: "https://pub.dev"
source: hosted
version: "4.7.1"
version: "4.7.2"
slang_flutter:
dependency: "direct main"
description:
@ -1386,10 +1403,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331"
url: "https://pub.dev"
source: hosted
version: "1.1.16"
version: "1.1.17"
vector_math:
dependency: transitive
description:
@ -1410,10 +1427,10 @@ packages:
dependency: transitive
description:
name: video_player_android
sha256: "28dcc4122079f40f93a0965b3679aff1a5f4251cf79611bd8011f937eb6b69de"
sha256: "4a5135754a62dbc827a64a42ef1f8ed72c962e191c97e2d48744225c2b9ebb73"
url: "https://pub.dev"
source: hosted
version: "2.8.4"
version: "2.8.7"
video_player_avfoundation:
dependency: transitive
description:
@ -1442,10 +1459,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "14.3.1"
version: "15.0.0"
wakelock_plus:
dependency: "direct main"
description:
@ -1498,10 +1515,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:
@ -1552,4 +1569,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.7.2 <4.0.0"
flutter: ">=3.27.1"
flutter: ">=3.32.0"

View File

@ -1,7 +1,7 @@
name: zap_stream_flutter
description: "zap.stream"
publish_to: "none"
version: 1.0.0+13
version: 1.1.0+14
environment:
sdk: ^3.7.2
@ -36,7 +36,7 @@ dependencies:
duration: ^4.0.3
slang: ^4.7.1
slang_flutter: ^4.7.0
intl: ^0.19.0
intl: ^0.20.2
flutter_localizations:
sdk: flutter
firebase_core: ^3.13.1
@ -46,23 +46,29 @@ 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: 00eee5460ca478a02ec9a91c0a6100f2e50eddce
ndk_objectbox:
git:
url: https://github.com/relaystr/ndk
path: packages/objectbox
ref: 6242899ee4ff7f65e57518d4eb29e2a253b3c4da
ref: 00eee5460ca478a02ec9a91c0a6100f2e50eddce
ndk_amber:
git:
url: https://github.com/relaystr/ndk
path: packages/amber
ref: 6242899ee4ff7f65e57518d4eb29e2a253b3c4da
ref: 00eee5460ca478a02ec9a91c0a6100f2e50eddce
ndk_rust_verifier:
git:
url: https://github.com/relaystr/ndk
path: packages/rust_verifier
ref: 00eee5460ca478a02ec9a91c0a6100f2e50eddce
emoji_picker_flutter:
git:
url: https://github.com/nostrlabs-io/emoji_picker_flutter