mirror of
https://github.com/nostrlabs-io/zap-stream-flutter.git
synced 2025-06-16 11:58:50 +00:00
@ -4,7 +4,4 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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>
|
</manifest>
|
||||||
|
@ -5,6 +5,8 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<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
|
<application
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
@ -91,5 +91,9 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Live streaming</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Live streaming</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
260
lib/api.dart
Normal file
260
lib/api.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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/category.dart';
|
||||||
import 'package:zap_stream_flutter/pages/hashtag.dart';
|
import 'package:zap_stream_flutter/pages/hashtag.dart';
|
||||||
import 'package:zap_stream_flutter/pages/home.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.dart';
|
||||||
import 'package:zap_stream_flutter/pages/login_input.dart';
|
import 'package:zap_stream_flutter/pages/login_input.dart';
|
||||||
import 'package:zap_stream_flutter/pages/new_account.dart';
|
import 'package:zap_stream_flutter/pages/new_account.dart';
|
||||||
@ -135,6 +136,10 @@ void runZapStream() {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: "/live",
|
||||||
|
builder: (context, state) => LivePage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: "/:id",
|
path: "/:id",
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:amberflutter/amberflutter.dart';
|
import 'package:amberflutter/amberflutter.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:ndk/ndk.dart';
|
import 'package:ndk/ndk.dart';
|
||||||
import 'package:ndk_amber/ndk_amber.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 searchRelays = ["wss://relay.nostr.band", "wss://search.nos.today"];
|
||||||
const nwcRelays = ["wss://relay.getalby.com/v1"];
|
const nwcRelays = ["wss://relay.getalby.com/v1"];
|
||||||
|
final apiUrl = dotenv.env["API_URL"] ?? "https://api.zap.stream/api/nostr";
|
||||||
|
|
||||||
final loginData = LoginData();
|
final loginData = LoginData();
|
||||||
final RouteObserver<ModalRoute<void>> routeObserver =
|
final RouteObserver<ModalRoute<void>> routeObserver =
|
||||||
|
@ -4,9 +4,9 @@
|
|||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 22
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
@ -72,6 +72,7 @@ class Translations implements BaseTranslations<AppLocale, Translations> {
|
|||||||
late final TranslationsProfileEn profile = TranslationsProfileEn.internal(_root);
|
late final TranslationsProfileEn profile = TranslationsProfileEn.internal(_root);
|
||||||
late final TranslationsSettingsEn settings = TranslationsSettingsEn.internal(_root);
|
late final TranslationsSettingsEn settings = TranslationsSettingsEn.internal(_root);
|
||||||
late final TranslationsLoginEn login = TranslationsLoginEn.internal(_root);
|
late final TranslationsLoginEn login = TranslationsLoginEn.internal(_root);
|
||||||
|
late final TranslationsLiveEn live = TranslationsLiveEn.internal(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: stream
|
// Path: stream
|
||||||
@ -208,6 +209,30 @@ class TranslationsLoginEn {
|
|||||||
late final TranslationsLoginErrorEn error = TranslationsLoginErrorEn.internal(_root);
|
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
|
// Path: stream.status
|
||||||
class TranslationsStreamStatusEn {
|
class TranslationsStreamStatusEn {
|
||||||
TranslationsStreamStatusEn.internal(this._root);
|
TranslationsStreamStatusEn.internal(this._root);
|
||||||
@ -307,6 +332,18 @@ class TranslationsLoginErrorEn {
|
|||||||
String get invalid_key => 'Invalid key';
|
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
|
// Path: stream.chat.write
|
||||||
class TranslationsStreamChatWriteEn {
|
class TranslationsStreamChatWriteEn {
|
||||||
TranslationsStreamChatWriteEn.internal(this._root);
|
TranslationsStreamChatWriteEn.internal(this._root);
|
||||||
@ -472,6 +509,23 @@ extension on Translations {
|
|||||||
case 'login.key': return 'Login with Key';
|
case 'login.key': return 'Login with Key';
|
||||||
case 'login.create': return 'Create Account';
|
case 'login.create': return 'Create Account';
|
||||||
case 'login.error.invalid_key': return 'Invalid key';
|
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;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -135,3 +135,21 @@ login:
|
|||||||
create: Create Account
|
create: Create Account
|
||||||
error:
|
error:
|
||||||
invalid_key: Invalid key
|
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
|
||||||
|
@ -83,6 +83,7 @@ class LoginAccount {
|
|||||||
final String? privateKey;
|
final String? privateKey;
|
||||||
final List<String>? signerRelays;
|
final List<String>? signerRelays;
|
||||||
final WalletConfig? wallet;
|
final WalletConfig? wallet;
|
||||||
|
final String? streamEndpoint;
|
||||||
|
|
||||||
SimpleWallet? _cachedWallet;
|
SimpleWallet? _cachedWallet;
|
||||||
|
|
||||||
@ -92,6 +93,7 @@ class LoginAccount {
|
|||||||
this.privateKey,
|
this.privateKey,
|
||||||
this.signerRelays,
|
this.signerRelays,
|
||||||
this.wallet,
|
this.wallet,
|
||||||
|
this.streamEndpoint,
|
||||||
});
|
});
|
||||||
|
|
||||||
static LoginAccount nip19(String key) {
|
static LoginAccount nip19(String key) {
|
||||||
@ -139,6 +141,7 @@ class LoginAccount {
|
|||||||
"pubKey": acc?.pubkey,
|
"pubKey": acc?.pubkey,
|
||||||
"privateKey": acc?.privateKey,
|
"privateKey": acc?.privateKey,
|
||||||
"wallet": acc?.wallet?.toJson(),
|
"wallet": acc?.wallet?.toJson(),
|
||||||
|
"streamEndpoint": acc?.streamEndpoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
static LoginAccount? fromJson(Map<String, dynamic> json) {
|
static LoginAccount? fromJson(Map<String, dynamic> json) {
|
||||||
@ -162,6 +165,7 @@ class LoginAccount {
|
|||||||
json.containsKey("wallet") && json["wallet"] != null
|
json.containsKey("wallet") && json["wallet"] != null
|
||||||
? WalletConfig.fromJson(json["wallet"])
|
? WalletConfig.fromJson(json["wallet"])
|
||||||
: null,
|
: null,
|
||||||
|
streamEndpoint: json["streamEndpoint"],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -215,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
429
lib/pages/live.dart
Normal 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");
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
}
|
@ -100,13 +100,7 @@ class _Inner extends State<SettingsWalletPage> with ProtocolListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_setWallet(WalletConfig? cfg) {
|
_setWallet(WalletConfig? cfg) {
|
||||||
loginData.value = LoginAccount(
|
loginData.configure(wallet: cfg);
|
||||||
type: loginData.value!.type,
|
|
||||||
pubkey: loginData.value!.pubkey,
|
|
||||||
privateKey: loginData.value!.privateKey,
|
|
||||||
signerRelays: loginData.value!.signerRelays,
|
|
||||||
wallet: cfg,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -144,10 +144,8 @@ class _StreamPage extends State<StreamPage> with RouteAware {
|
|||||||
? MainVideoPlayerWidget(
|
? MainVideoPlayerWidget(
|
||||||
url: stream.info.stream!,
|
url: stream.info.stream!,
|
||||||
placeholder: stream.info.image,
|
placeholder: stream.info.image,
|
||||||
aspectRatio: 16 / 9,
|
|
||||||
isLive: true,
|
isLive: true,
|
||||||
title: stream.info.title,
|
title: stream.info.title,
|
||||||
|
|
||||||
)
|
)
|
||||||
: (stream.info.image?.isNotEmpty ?? false)
|
: (stream.info.image?.isNotEmpty ?? false)
|
||||||
? ProxyImg(url: stream.info.image)
|
? ProxyImg(url: stream.info.image)
|
||||||
|
@ -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) {
|
String bech32ToHex(String bech32) {
|
||||||
final decoder = Bech32Decoder();
|
final decoder = Bech32Decoder();
|
||||||
final data = decoder.convert(bech32, 10_000);
|
final data = decoder.convert(bech32, 10_000);
|
||||||
|
@ -3,6 +3,7 @@ import 'package:zap_stream_flutter/theme.dart';
|
|||||||
|
|
||||||
class BasicButton extends StatelessWidget {
|
class BasicButton extends StatelessWidget {
|
||||||
final Widget? child;
|
final Widget? child;
|
||||||
|
final Color? color;
|
||||||
final BoxDecoration? decoration;
|
final BoxDecoration? decoration;
|
||||||
final EdgeInsetsGeometry? padding;
|
final EdgeInsetsGeometry? padding;
|
||||||
final EdgeInsetsGeometry? margin;
|
final EdgeInsetsGeometry? margin;
|
||||||
@ -12,6 +13,7 @@ class BasicButton extends StatelessWidget {
|
|||||||
const BasicButton(
|
const BasicButton(
|
||||||
this.child, {
|
this.child, {
|
||||||
super.key,
|
super.key,
|
||||||
|
this.color,
|
||||||
this.decoration,
|
this.decoration,
|
||||||
this.padding,
|
this.padding,
|
||||||
this.margin,
|
this.margin,
|
||||||
@ -21,6 +23,7 @@ class BasicButton extends StatelessWidget {
|
|||||||
|
|
||||||
static Widget text(
|
static Widget text(
|
||||||
String text, {
|
String text, {
|
||||||
|
Color? color,
|
||||||
BoxDecoration? decoration,
|
BoxDecoration? decoration,
|
||||||
EdgeInsetsGeometry? padding,
|
EdgeInsetsGeometry? padding,
|
||||||
EdgeInsetsGeometry? margin,
|
EdgeInsetsGeometry? margin,
|
||||||
@ -46,6 +49,7 @@ class BasicButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
disabled: disabled,
|
disabled: disabled,
|
||||||
|
color: color,
|
||||||
decoration: decoration,
|
decoration: decoration,
|
||||||
padding: padding ?? EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
padding: padding ?? EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||||
margin: margin,
|
margin: margin,
|
||||||
@ -55,12 +59,17 @@ class BasicButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
assert(
|
||||||
|
!(color != null && decoration != null),
|
||||||
|
"Cant set both 'color' and 'decoration'",
|
||||||
|
);
|
||||||
final defaultBr = BorderRadius.all(Radius.circular(100));
|
final defaultBr = BorderRadius.all(Radius.circular(100));
|
||||||
final inner = Container(
|
final inner = Container(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
margin: margin,
|
margin: margin,
|
||||||
decoration:
|
decoration:
|
||||||
decoration ?? BoxDecoration(color: LAYER_2, borderRadius: defaultBr),
|
decoration ??
|
||||||
|
BoxDecoration(color: color ?? LAYER_2, borderRadius: defaultBr),
|
||||||
child: Center(child: child),
|
child: Center(child: child),
|
||||||
);
|
);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
@ -18,8 +18,17 @@ import 'package:zap_stream_flutter/widgets/profile.dart';
|
|||||||
|
|
||||||
class ChatWidget extends StatelessWidget {
|
class ChatWidget extends StatelessWidget {
|
||||||
final StreamEvent stream;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -31,7 +40,8 @@ class ChatWidget extends StatelessWidget {
|
|||||||
|
|
||||||
var filters = [
|
var filters = [
|
||||||
Filter(kinds: [1311, 9735], limit: 200, aTags: [stream.aTag]),
|
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: [Nip51List.kMute], authors: moderators),
|
||||||
Filter(kinds: [1314], authors: moderators),
|
Filter(kinds: [1314], authors: moderators),
|
||||||
Filter(kinds: [8], authors: [stream.info.host]),
|
Filter(kinds: [8], authors: [stream.info.host]),
|
||||||
@ -108,10 +118,13 @@ class ChatWidget extends StatelessWidget {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (zaps.isNotEmpty) _TopZappersWidget(events: zaps),
|
if (zaps.isNotEmpty && (showTopZappers ?? true))
|
||||||
if (stream.info.goal != null) GoalWidget.id(stream.info.goal!),
|
_TopZappersWidget(events: zaps),
|
||||||
|
if (stream.info.goal != null && (showGoals ?? true))
|
||||||
|
GoalWidget.id(stream.info.goal!),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.only(top: 80),
|
||||||
reverse: true,
|
reverse: true,
|
||||||
itemCount: filteredChat.length,
|
itemCount: filteredChat.length,
|
||||||
itemBuilder: (ctx, idx) {
|
itemBuilder: (ctx, idx) {
|
||||||
|
@ -24,6 +24,7 @@ class __WriteMessageWidget extends State<WriteMessageWidget> {
|
|||||||
OverlayEntry? _entry;
|
OverlayEntry? _entry;
|
||||||
late FocusNode _focusNode;
|
late FocusNode _focusNode;
|
||||||
List<List<String>> _tags = List.empty(growable: true);
|
List<List<String>> _tags = List.empty(growable: true);
|
||||||
|
final GlobalKey _positioned = GlobalKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -69,7 +70,8 @@ class __WriteMessageWidget extends State<WriteMessageWidget> {
|
|||||||
_entry = null;
|
_entry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final pos = context.findRenderObject() as RenderBox?;
|
final pos = _positioned.currentContext!.findRenderObject() as RenderBox?;
|
||||||
|
final posGlobal = pos?.localToGlobal(Offset.zero);
|
||||||
_entry = OverlayEntry(
|
_entry = OverlayEntry(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
@ -85,12 +87,13 @@ class __WriteMessageWidget extends State<WriteMessageWidget> {
|
|||||||
if (search.isEmpty) {
|
if (search.isEmpty) {
|
||||||
return SizedBox();
|
return SizedBox();
|
||||||
}
|
}
|
||||||
|
final mq = MediaQuery.of(context);
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: posGlobal?.dx,
|
||||||
bottom: (pos?.paintBounds.bottom ?? 0),
|
bottom: mq.size.height - (posGlobal?.dy ?? 0) - 30,
|
||||||
width: MediaQuery.of(context).size.width,
|
width: pos?.size.width,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -162,15 +165,17 @@ class __WriteMessageWidget extends State<WriteMessageWidget> {
|
|||||||
_entry = null;
|
_entry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final pos = context.findRenderObject() as RenderBox?;
|
final pos = _positioned.currentContext!.findRenderObject() as RenderBox?;
|
||||||
|
final posGlobal = pos?.localToGlobal(Offset.zero);
|
||||||
_entry = OverlayEntry(
|
_entry = OverlayEntry(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
final mq = MediaQuery.of(context);
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: posGlobal?.dx,
|
||||||
bottom: (pos?.paintBounds.bottom ?? 0),
|
bottom: mq.size.height - (posGlobal?.dy ?? 0) - 30,
|
||||||
width: MediaQuery.of(context).size.width,
|
width: pos?.size.width,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -239,9 +244,13 @@ class __WriteMessageWidget extends State<WriteMessageWidget> {
|
|||||||
final isLogin = ndk.accounts.isLoggedIn;
|
final isLogin = ndk.accounts.isLoggedIn;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
key: _positioned,
|
||||||
margin: EdgeInsets.fromLTRB(4, 8, 4, 0),
|
margin: EdgeInsets.fromLTRB(4, 8, 4, 0),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
decoration: BoxDecoration(color: LAYER_2, borderRadius: DEFAULT_BR),
|
decoration: BoxDecoration(
|
||||||
|
color: LAYER_2.withAlpha(200),
|
||||||
|
borderRadius: DEFAULT_BR,
|
||||||
|
),
|
||||||
child:
|
child:
|
||||||
canSign
|
canSign
|
||||||
? Row(
|
? Row(
|
||||||
|
@ -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/const.dart';
|
||||||
import 'package:zap_stream_flutter/theme.dart';
|
import 'package:zap_stream_flutter/theme.dart';
|
||||||
import 'package:zap_stream_flutter/widgets/avatar.dart';
|
import 'package:zap_stream_flutter/widgets/avatar.dart';
|
||||||
|
import 'package:zap_stream_flutter/widgets/button.dart';
|
||||||
|
|
||||||
class HeaderWidget extends StatefulWidget {
|
class HeaderWidget extends StatefulWidget {
|
||||||
const HeaderWidget({super.key});
|
const HeaderWidget({super.key});
|
||||||
@ -39,12 +40,36 @@ class LoginButtonWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (ndk.accounts.isLoggedIn) {
|
if (ndk.accounts.isLoggedIn) {
|
||||||
return GestureDetector(
|
return Row(
|
||||||
onTap:
|
spacing: 8,
|
||||||
() => context.go(
|
children: [
|
||||||
"/p/${Nip19.encodePubKey(ndk.accounts.getPublicKey()!)}",
|
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 {
|
} else {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@ -59,10 +84,7 @@ class LoginButtonWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [Text(t.button.login), Icon(Icons.login, size: 16)],
|
||||||
Text(t.button.login),
|
|
||||||
Icon(Icons.login, size: 16),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:duration/duration.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:zap_stream_flutter/theme.dart';
|
import 'package:zap_stream_flutter/theme.dart';
|
||||||
import 'package:zap_stream_flutter/utils.dart';
|
|
||||||
import 'package:zap_stream_flutter/widgets/pill.dart';
|
import 'package:zap_stream_flutter/widgets/pill.dart';
|
||||||
|
|
||||||
class LiveTimerWidget extends StatefulWidget {
|
class LiveTimerWidget extends StatefulWidget {
|
||||||
@ -37,12 +37,13 @@ class _LiveTimerWidget extends State<LiveTimerWidget> {
|
|||||||
return PillWidget(
|
return PillWidget(
|
||||||
color: LAYER_2,
|
color: LAYER_2,
|
||||||
child: Text(
|
child: Text(
|
||||||
formatSecondsToHHMMSS(
|
Duration(
|
||||||
((DateTime.now().millisecondsSinceEpoch -
|
seconds:
|
||||||
widget.started.millisecondsSinceEpoch) /
|
((DateTime.now().millisecondsSinceEpoch -
|
||||||
1000)
|
widget.started.millisecondsSinceEpoch) /
|
||||||
.toInt(),
|
1000)
|
||||||
),
|
.toInt(),
|
||||||
|
).pretty(abbreviated: true),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
174
lib/widgets/stream_config.dart
Normal file
174
lib/widgets/stream_config.dart
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
32
pubspec.lock
32
pubspec.lock
@ -17,6 +17,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.9"
|
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:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -798,12 +806,20 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
ndk:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "packages/ndk"
|
path: "packages/ndk"
|
||||||
ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
|
ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e
|
||||||
resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
|
resolved-ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e
|
||||||
url: "https://github.com/relaystr/ndk"
|
url: "https://github.com/relaystr/ndk"
|
||||||
source: git
|
source: git
|
||||||
version: "0.3.2"
|
version: "0.3.2"
|
||||||
@ -811,8 +827,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "packages/amber"
|
path: "packages/amber"
|
||||||
ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
|
ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e
|
||||||
resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
|
resolved-ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e
|
||||||
url: "https://github.com/relaystr/ndk"
|
url: "https://github.com/relaystr/ndk"
|
||||||
source: git
|
source: git
|
||||||
version: "0.3.0"
|
version: "0.3.0"
|
||||||
@ -820,8 +836,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "packages/objectbox"
|
path: "packages/objectbox"
|
||||||
ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
|
ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e
|
||||||
resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da"
|
resolved-ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e
|
||||||
url: "https://github.com/relaystr/ndk"
|
url: "https://github.com/relaystr/ndk"
|
||||||
source: git
|
source: git
|
||||||
version: "0.2.3"
|
version: "0.2.3"
|
||||||
@ -1498,10 +1514,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: web_socket_client
|
name: web_socket_client
|
||||||
sha256: "0ec5230852349191188c013112e4d2be03e3fc83dbe80139ead9bf3a136e53b5"
|
sha256: "394789177aa3bc1b7b071622a1dbf52a4631d7ce23c555c39bb2523e92316b07"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.5"
|
version: "0.2.1"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -46,23 +46,24 @@ dependencies:
|
|||||||
flutter_dotenv: ^5.2.1
|
flutter_dotenv: ^5.2.1
|
||||||
protocol_handler: ^0.2.0
|
protocol_handler: ^0.2.0
|
||||||
audio_service: ^0.18.18
|
audio_service: ^0.18.18
|
||||||
|
apivideo_live_stream: ^1.2.0
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
ndk:
|
ndk:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/relaystr/ndk
|
url: https://github.com/relaystr/ndk
|
||||||
path: packages/ndk
|
path: packages/ndk
|
||||||
ref: 6242899ee4ff7f65e57518d4eb29e2a253b3c4da
|
ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e
|
||||||
ndk_objectbox:
|
ndk_objectbox:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/relaystr/ndk
|
url: https://github.com/relaystr/ndk
|
||||||
path: packages/objectbox
|
path: packages/objectbox
|
||||||
ref: 6242899ee4ff7f65e57518d4eb29e2a253b3c4da
|
ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e
|
||||||
ndk_amber:
|
ndk_amber:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/relaystr/ndk
|
url: https://github.com/relaystr/ndk
|
||||||
path: packages/amber
|
path: packages/amber
|
||||||
ref: 6242899ee4ff7f65e57518d4eb29e2a253b3c4da
|
ref: a829c2805efe9a929c4c8a679d9c4324f0ebd20e
|
||||||
emoji_picker_flutter:
|
emoji_picker_flutter:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/nostrlabs-io/emoji_picker_flutter
|
url: https://github.com/nostrlabs-io/emoji_picker_flutter
|
||||||
|
Reference in New Issue
Block a user