12 Commits

102 changed files with 1762 additions and 310 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

@ -1,5 +1,4 @@
import com.android.build.api.dsl.ApkSigningConfig
import com.android.build.api.dsl.SigningConfig
import org.jetbrains.kotlin.gradle.targets.js.toHex
import java.io.FileInputStream
import java.util.Base64
@ -8,11 +7,9 @@ import java.util.Properties
plugins {
id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
// END: FlutterFire Configuration
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
}
fun getKeystoreFile(base64String: String?, hash: String, fileName: String): File {

View File

@ -3,6 +3,5 @@
the Flutter tool needs it to communicate with the running application
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.INTERNET" />
</manifest>

View File

@ -1,52 +1,75 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<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" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="zap.stream"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:label="zap.stream">
<activity
android:name=".MainActivity"
android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="zap.stream" />
<data android:host="zap.stream" />
<data android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="zswc" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
android:permission="android.permission.FOREGROUND_SERVICE"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@ -55,10 +78,11 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />

View File

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="33"
android:viewportHeight="23"
android:tint="#FFFFFF">
<group android:scaleX="0.777027"
android:scaleY="0.5415643"
android:translateX="3.679054"
android:translateY="5.272011">
<path
android:pathData="M32.788,1.721C32.356,0.677 31.344,0 30.216,0H10.68C10.674,0 10.667,0 10.661,0C10.655,0 10.648,0 10.642,0C6.542,0 3.21,3.332 3.21,7.425C3.21,9.578 4.138,11.582 5.704,12.975L0.812,17.867C0.013,18.666 -0.225,19.858 0.207,20.896C0.638,21.94 1.65,22.617 2.778,22.617H22.314C22.32,22.617 22.327,22.617 22.34,22.617C22.346,22.617 22.353,22.617 22.366,22.617C26.458,22.617 29.791,19.285 29.791,15.192C29.791,13.039 28.862,11.035 27.296,9.642L32.188,4.75C32.981,3.951 33.22,2.759 32.788,1.721ZM2.714,19.858C2.694,19.813 2.707,19.8 2.733,19.775L8.109,14.399L22.391,19.452C22.404,19.459 22.424,19.465 22.437,19.465C22.456,19.472 22.475,19.478 22.494,19.491C22.527,19.51 22.552,19.542 22.572,19.581C22.578,19.588 22.578,19.594 22.585,19.601C22.591,19.613 22.591,19.626 22.591,19.639C22.591,19.652 22.598,19.665 22.598,19.678C22.598,19.794 22.469,19.897 22.327,19.897H2.785C2.753,19.91 2.733,19.91 2.714,19.858ZM25.208,18.956C25.208,18.95 25.201,18.937 25.201,18.93C25.176,18.827 25.143,18.73 25.105,18.634C25.098,18.621 25.098,18.608 25.092,18.595C25.053,18.492 25.002,18.395 24.95,18.299C24.944,18.286 24.931,18.266 24.924,18.253C24.815,18.06 24.686,17.88 24.538,17.712C24.525,17.699 24.512,17.686 24.499,17.673C24.422,17.596 24.344,17.519 24.26,17.448C24.248,17.441 24.235,17.428 24.228,17.422C24.151,17.358 24.067,17.299 23.977,17.242C23.964,17.235 23.951,17.222 23.938,17.216C23.848,17.158 23.751,17.106 23.655,17.055C23.635,17.042 23.61,17.035 23.59,17.022C23.493,16.977 23.39,16.932 23.281,16.9L16.674,14.56L9.037,11.86L9.011,11.853C9.004,11.853 9.004,11.853 8.998,11.847C8.811,11.776 8.624,11.692 8.437,11.595C6.884,10.777 5.924,9.178 5.924,7.425C5.924,5.891 6.658,4.525 7.799,3.661C7.799,3.667 7.806,3.68 7.806,3.687C7.831,3.783 7.864,3.88 7.902,3.977C7.915,4.009 7.928,4.041 7.941,4.074C7.973,4.144 8.005,4.209 8.038,4.28C8.051,4.312 8.07,4.344 8.083,4.37C8.128,4.454 8.179,4.531 8.237,4.608C8.263,4.641 8.283,4.673 8.308,4.705C8.353,4.763 8.399,4.821 8.45,4.873C8.469,4.899 8.489,4.924 8.515,4.944C8.579,5.015 8.656,5.079 8.727,5.143C8.753,5.169 8.779,5.189 8.811,5.208C8.882,5.266 8.953,5.317 9.03,5.369C9.043,5.382 9.056,5.388 9.075,5.401C9.166,5.459 9.262,5.511 9.359,5.556C9.385,5.569 9.411,5.582 9.436,5.595C9.539,5.64 9.643,5.685 9.746,5.717L24.009,10.764C24.016,10.764 24.022,10.77 24.022,10.77C24.209,10.841 24.389,10.919 24.563,11.015C26.117,11.834 27.077,13.432 27.077,15.185C27.084,16.726 26.342,18.092 25.208,18.956ZM30.267,2.836L24.892,8.211C24.879,8.205 24.86,8.199 24.847,8.192L10.622,3.165C10.603,3.158 10.584,3.152 10.571,3.145H10.564C10.545,3.139 10.532,3.132 10.513,3.12C10.506,3.113 10.5,3.107 10.493,3.1C10.48,3.094 10.474,3.081 10.467,3.074C10.461,3.068 10.455,3.055 10.455,3.049C10.448,3.036 10.442,3.023 10.435,3.01C10.435,3.004 10.429,2.991 10.429,2.978C10.429,2.965 10.422,2.946 10.422,2.926C10.422,2.913 10.429,2.907 10.429,2.894C10.448,2.797 10.564,2.714 10.687,2.714H30.229C30.261,2.714 30.28,2.714 30.3,2.759C30.306,2.797 30.293,2.817 30.267,2.836Z"
android:fillColor="#ffffff"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 B

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -72,6 +72,7 @@
<array>
<string>fetch</string>
<string>remote-notification</string>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
@ -90,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';
@ -25,7 +26,11 @@ void runZapStream() {
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: ThemeData.localize(
ThemeData(colorScheme: ColorScheme.dark(), highlightColor: PRIMARY_1),
ThemeData(
colorScheme: ColorScheme.dark(),
highlightColor: PRIMARY_1,
useMaterial3: true,
),
TextTheme(),
),
routerConfig: GoRouter(
@ -131,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';
@ -17,7 +18,7 @@ class NoVerify extends EventVerifier {
final ndkCache = DbObjectBox();
final eventVerifier = kDebugMode ? NoVerify() : RustEventVerifier();
var ndk = Ndk(
final ndk = Ndk(
NdkConfig(
eventVerifier: eventVerifier,
cache: ndkCache,
@ -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: 1628 (74 per locale)
/// Strings: 1668 (75 per locale)
///
/// Built on 2025-05-28 at 12:41 UTC
/// Built on 2025-05-30 at 11:38 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View File

@ -54,7 +54,7 @@ class TranslationsAr extends Translations {
/// عدد مشاهدي البث
@override String viewers({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ar'))(n,
one: '1 مشاهد',
other: '${NumberFormat.decimalPattern('ar').format(n)} المشاهدين',
other: '{n:decimalPattern} المشاهدين',
);
@override late final _TranslationsStreamAr stream = _TranslationsStreamAr._(_root);
@ -80,6 +80,7 @@ class _TranslationsStreamAr extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusAr status = _TranslationsStreamStatusAr._(_root);
@override String started({required Object timestamp}) => 'بدأ ${timestamp}';
@override String notification({required Object name}) => '${name} بدأ البث المباشر!';
@override late final _TranslationsStreamChatAr chat = _TranslationsStreamChatAr._(_root);
}
@ -381,12 +382,13 @@ extension on TranslationsAr {
case 'anon': return 'هوية مخفية';
case 'viewers': return ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ar'))(n,
one: '1 مشاهد',
other: '${NumberFormat.decimalPattern('ar').format(n)} المشاهدين',
other: '{n:decimalPattern} المشاهدين',
);
case 'stream.status.live': return 'بث مباشر';
case 'stream.status.ended': return 'انتهى';
case 'stream.status.planned': return 'مخطط';
case 'stream.started': return ({required Object timestamp}) => 'بدأ ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} بدأ البث المباشر!';
case 'stream.chat.disabled': return 'تم تعطيل الدردشة';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'تنتهي المهلة: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamCs extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusCs status = _TranslationsStreamStatusCs._(_root);
@override String started({required Object timestamp}) => 'Založeno ${timestamp}';
@override String notification({required Object name}) => '${name} byl spuštěn!';
@override late final _TranslationsStreamChatCs chat = _TranslationsStreamChatCs._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsCs {
case 'stream.status.ended': return 'KONEC';
case 'stream.status.planned': return 'PLÁNOVANÉ';
case 'stream.started': return ({required Object timestamp}) => 'Založeno ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} byl spuštěn!';
case 'stream.chat.disabled': return 'CHAT ZRUŠEN';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Časový limit vyprší: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamDa extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusDa status = _TranslationsStreamStatusDa._(_root);
@override String started({required Object timestamp}) => 'Startet ${timestamp}';
@override String notification({required Object name}) => '${name} gik live!';
@override late final _TranslationsStreamChatDa chat = _TranslationsStreamChatDa._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsDa {
case 'stream.status.ended': return 'AFSLUTTET';
case 'stream.status.planned': return 'PLANLAGT';
case 'stream.started': return ({required Object timestamp}) => 'Startet ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} gik live!';
case 'stream.chat.disabled': return 'CHAT DEAKTIVERET';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Timeout udløber: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -42,7 +42,7 @@ class TranslationsDe extends Translations {
/// Text, der den Benutzer auffordert, auf den Avatar-Platzhalter zu klicken, um den Upload zu starten
@override String get upload_avatar => 'Avatar hochladen';
/// Überschrift über gelistete Top-Streamer von zaps
/// Überschrift über gelistete Top-Streamer nach Zaps
@override String get most_zapped_streamers => 'Meistgezappte Streamer';
/// Kein Benutzer bei der Suche gefunden
@ -51,7 +51,7 @@ class TranslationsDe extends Translations {
/// Ein anonymer Benutzer
@override String get anon => 'Anon';
/// Anzahl der Betrachter des Streams
/// Anzahl der Zuschauer des Streams
@override String viewers({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('de'))(n,
one: '1 Zuschauer',
other: '${NumberFormat.decimalPattern('de').format(n)} Zuschauer',
@ -80,6 +80,7 @@ class _TranslationsStreamDe extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusDe status = _TranslationsStreamStatusDe._(_root);
@override String started({required Object timestamp}) => 'Gestartet ${timestamp}';
@override String notification({required Object name}) => '${name} ging live!';
@override late final _TranslationsStreamChatDe chat = _TranslationsStreamChatDe._(_root);
}
@ -212,7 +213,7 @@ class _TranslationsStreamStatusDe extends TranslationsStreamStatusEn {
// Translations
@override String get live => 'LIVE';
@override String get ended => 'ENDED';
@override String get ended => 'BEENDET';
@override String get planned => 'GEPLANT';
}
@ -224,21 +225,21 @@ class _TranslationsStreamChatDe extends TranslationsStreamChatEn {
// Translations
@override String get disabled => 'CHAT DEAKTIVIERT';
@override String disabled_timeout({required Object time}) => 'Die Zeitüberschreitung läuft ab: ${time}';
@override String disabled_timeout({required Object time}) => 'Timeout läuft ab: ${time}';
/// Chat-Nachricht mit Zeitüberschreitungsereignissen
/// Chat-Nachricht mit Timeout-Ereignissen
@override TextSpan timeout({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [
mod,
const TextSpan(text: ' Zeitüberschreitung '),
const TextSpan(text: ' gibt '),
user,
const TextSpan(text: ' für '),
const TextSpan(text: ' einen Timeout für '),
time,
]);
/// Stream beendet Fußzeile am Ende des Chats
@override String get ended => 'STREAM BEENDET';
/// Chatnachricht mit Stream Zaps
/// Chat-Nachricht mit Stream-Zaps
@override TextSpan zap({required InlineSpan user, required InlineSpan amount}) => TextSpan(children: [
user,
const TextSpan(text: ' hat '),
@ -384,16 +385,17 @@ extension on TranslationsDe {
other: '${NumberFormat.decimalPattern('de').format(n)} Zuschauer',
);
case 'stream.status.live': return 'LIVE';
case 'stream.status.ended': return 'ENDED';
case 'stream.status.ended': return 'BEENDET';
case 'stream.status.planned': return 'GEPLANT';
case 'stream.started': return ({required Object timestamp}) => 'Gestartet ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} ging live!';
case 'stream.chat.disabled': return 'CHAT DEAKTIVIERT';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Die Zeitüberschreitung läuft ab: ${time}';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Timeout läuft ab: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [
mod,
const TextSpan(text: ' Zeitüberschreitung '),
const TextSpan(text: ' gibt '),
user,
const TextSpan(text: ' für '),
const TextSpan(text: ' einen Timeout für '),
time,
]);
case 'stream.chat.ended': return 'STREAM BEENDET';

View File

@ -80,6 +80,7 @@ class _TranslationsStreamEl extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusEl status = _TranslationsStreamStatusEl._(_root);
@override String started({required Object timestamp}) => 'Ξεκίνησε ${timestamp}';
@override String notification({required Object name}) => '${name} βγήκε ζωντανά!';
@override late final _TranslationsStreamChatEl chat = _TranslationsStreamChatEl._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsEl {
case 'stream.status.ended': return 'ENDED';
case 'stream.status.planned': return 'ΣΧΕΔΙΑΣΜΟΣ';
case 'stream.started': return ({required Object timestamp}) => 'Ξεκίνησε ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} βγήκε ζωντανά!';
case 'stream.chat.disabled': return 'ΑΠΕΝΕΡΓΟΠΟΙΗΜΈΝΗ ΣΥΝΟΜΙΛΊΑ';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Το χρονικό όριο λήγει: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

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
@ -81,6 +84,7 @@ class TranslationsStreamEn {
// Translations
late final TranslationsStreamStatusEn status = TranslationsStreamStatusEn.internal(_root);
String started({required Object timestamp}) => 'Started ${timestamp}';
String notification({required Object name}) => '${name} went live!';
late final TranslationsStreamChatEn chat = TranslationsStreamChatEn.internal(_root);
}
@ -205,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);
@ -289,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);
}
@ -302,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);
@ -380,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',
@ -388,6 +431,7 @@ extension on Translations {
case 'stream.status.ended': return 'ENDED';
case 'stream.status.planned': return 'PLANNED';
case 'stream.started': return ({required Object timestamp}) => 'Started ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} went live!';
case 'stream.chat.disabled': return 'CHAT DISABLED';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Timeout expires: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [
@ -456,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';
@ -463,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

@ -80,6 +80,7 @@ class _TranslationsStreamEs extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusEs status = _TranslationsStreamStatusEs._(_root);
@override String started({required Object timestamp}) => 'Comenzó ${timestamp}';
@override String notification({required Object name}) => '${name} ¡se ha puesto en marcha!';
@override late final _TranslationsStreamChatEs chat = _TranslationsStreamChatEs._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsEs {
case 'stream.status.ended': return 'FIN';
case 'stream.status.planned': return 'PLANIFICADO';
case 'stream.started': return ({required Object timestamp}) => 'Comenzó ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} ¡se ha puesto en marcha!';
case 'stream.chat.disabled': return 'CHAT DESHABILITADO';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'El tiempo de espera expira: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamFi extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusFi status = _TranslationsStreamStatusFi._(_root);
@override String started({required Object timestamp}) => 'Aloitettu ${timestamp}';
@override String notification({required Object name}) => '${name} meni suoraksi!';
@override late final _TranslationsStreamChatFi chat = _TranslationsStreamChatFi._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsFi {
case 'stream.status.ended': return 'ENDED';
case 'stream.status.planned': return 'SUUNNITELTU';
case 'stream.started': return ({required Object timestamp}) => 'Aloitettu ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} meni suoraksi!';
case 'stream.chat.disabled': return 'CHAT POISTETTU KÄYTÖSTÄ';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Aikakatkaisu päättyy: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -54,7 +54,7 @@ class TranslationsFr extends Translations {
/// Nombre de spectateurs du flux
@override String viewers({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('fr'))(n,
one: '1 téléspectateur',
other: '${NumberFormat.decimalPattern('fr').format(n)} téléspectateurs',
other: '{n:decimalPattern} téléspectateurs',
);
@override late final _TranslationsStreamFr stream = _TranslationsStreamFr._(_root);
@ -80,6 +80,7 @@ class _TranslationsStreamFr extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusFr status = _TranslationsStreamStatusFr._(_root);
@override String started({required Object timestamp}) => 'Commencé à ${timestamp}';
@override String notification({required Object name}) => '${name} est en ligne !';
@override late final _TranslationsStreamChatFr chat = _TranslationsStreamChatFr._(_root);
}
@ -381,12 +382,13 @@ extension on TranslationsFr {
case 'anon': return 'Anonyme';
case 'viewers': return ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('fr'))(n,
one: '1 téléspectateur',
other: '${NumberFormat.decimalPattern('fr').format(n)} téléspectateurs',
other: '{n:decimalPattern} téléspectateurs',
);
case 'stream.status.live': return 'VIVRE';
case 'stream.status.ended': return 'FINI';
case 'stream.status.planned': return 'PRÉVU';
case 'stream.started': return ({required Object timestamp}) => 'Commencé à ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} est en ligne !';
case 'stream.chat.disabled': return 'CHAT DISABLED';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Le délai expire : ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamHu extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusHu status = _TranslationsStreamStatusHu._(_root);
@override String started({required Object timestamp}) => 'Elindult ${timestamp}';
@override String notification({required Object name}) => '${name} elindult!';
@override late final _TranslationsStreamChatHu chat = _TranslationsStreamChatHu._(_root);
}
@ -132,7 +133,7 @@ class _TranslationsEmbedHu extends TranslationsEmbedEn {
// Translations
@override String article_by({required Object name}) => 'Cikk ${name}';
@override String note_by({required Object name}) => '${name} bejegyzése';
@override String live_stream_by({required Object name}) => 'Élő közvetítés a ${name}oldalon';
@override String live_stream_by({required Object name}) => 'Élő közvetítés a ${name} oldalon';
}
// Path: stream_list
@ -347,7 +348,7 @@ class _TranslationsStreamChatRaidHu extends TranslationsStreamChatRaidEn {
@override String from({required Object name}) => 'RAID FROM ${name}';
/// Visszaszámláló időzítő az automatikus lovagláshoz
@override String countdown({required Object time}) => 'Raiding a ${time}oldalon';
@override String countdown({required Object time}) => 'Raiding a ${time} oldalon';
}
// Path: settings.profile.error
@ -388,6 +389,7 @@ extension on TranslationsHu {
case 'stream.status.ended': return 'ENDED';
case 'stream.status.planned': return 'TERVEZETT';
case 'stream.started': return ({required Object timestamp}) => 'Elindult ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} elindult!';
case 'stream.chat.disabled': return 'CHAT KIKAPCSOLVA';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Az időkorlát lejár: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [
@ -411,7 +413,7 @@ extension on TranslationsHu {
case 'stream.chat.badge.awarded_to': return 'Elnyerte:';
case 'stream.chat.raid.to': return ({required Object name}) => 'RAIDING ${name}';
case 'stream.chat.raid.from': return ({required Object name}) => 'RAID FROM ${name}';
case 'stream.chat.raid.countdown': return ({required Object time}) => 'Raiding a ${time}oldalon';
case 'stream.chat.raid.countdown': return ({required Object time}) => 'Raiding a ${time} oldalon';
case 'goal.title': return ({required Object amount}) => 'Cél: ${amount}';
case 'goal.remaining': return ({required Object amount}) => 'Maradék: ${amount}';
case 'goal.complete': return 'TELJES';
@ -428,7 +430,7 @@ extension on TranslationsHu {
case 'button.settings': return 'Beállítások';
case 'embed.article_by': return ({required Object name}) => 'Cikk ${name}';
case 'embed.note_by': return ({required Object name}) => '${name} bejegyzése';
case 'embed.live_stream_by': return ({required Object name}) => 'Élő közvetítés a ${name}oldalon';
case 'embed.live_stream_by': return ({required Object name}) => 'Élő közvetítés a ${name} oldalon';
case 'stream_list.following': return 'Követettek bejegyzései';
case 'stream_list.live': return 'Élő';
case 'stream_list.planned': return 'Tervezett';

View File

@ -80,6 +80,7 @@ class _TranslationsStreamIt extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusIt status = _TranslationsStreamStatusIt._(_root);
@override String started({required Object timestamp}) => 'Avviato ${timestamp}';
@override String notification({required Object name}) => '${name} è andato in onda!';
@override late final _TranslationsStreamChatIt chat = _TranslationsStreamChatIt._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsIt {
case 'stream.status.ended': return 'FINE';
case 'stream.status.planned': return 'PREVISTO';
case 'stream.started': return ({required Object timestamp}) => 'Avviato ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} è andato in onda!';
case 'stream.chat.disabled': return 'CHAT DISABILITATA';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Il timeout scade: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamJa extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusJa status = _TranslationsStreamStatusJa._(_root);
@override String started({required Object timestamp}) => '${timestamp} を開始';
@override String notification({required Object name}) => '${name} がライブを開始した!';
@override late final _TranslationsStreamChatJa chat = _TranslationsStreamChatJa._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsJa {
case 'stream.status.ended': return '終了';
case 'stream.status.planned': return '予定';
case 'stream.started': return ({required Object timestamp}) => '${timestamp} を開始';
case 'stream.notification': return ({required Object name}) => '${name} がライブを開始した!';
case 'stream.chat.disabled': return 'チャット無効';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'タイムアウト: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -54,7 +54,7 @@ class TranslationsKo extends Translations {
/// 스트림 시청자 수
@override String viewers({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n,
one: '시청자 1명',
other: '${NumberFormat.decimalPattern('ko').format(n)} 시청자',
other: '{n:decimalPattern} 시청자',
);
@override late final _TranslationsStreamKo stream = _TranslationsStreamKo._(_root);
@ -80,6 +80,7 @@ class _TranslationsStreamKo extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusKo status = _TranslationsStreamStatusKo._(_root);
@override String started({required Object timestamp}) => '시작 ${timestamp}';
@override String notification({required Object name}) => '${name} 라이브가 시작되었습니다!';
@override late final _TranslationsStreamChatKo chat = _TranslationsStreamChatKo._(_root);
}
@ -381,12 +382,13 @@ extension on TranslationsKo {
case 'anon': return 'Anon';
case 'viewers': return ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n,
one: '시청자 1명',
other: '${NumberFormat.decimalPattern('ko').format(n)} 시청자',
other: '{n:decimalPattern} 시청자',
);
case 'stream.status.live': return '라이브';
case 'stream.status.ended': return '종료';
case 'stream.status.planned': return '계획된';
case 'stream.started': return ({required Object timestamp}) => '시작 ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} 라이브가 시작되었습니다!';
case 'stream.chat.disabled': return '채팅 사용 안 함';
case 'stream.chat.disabled_timeout': return ({required Object time}) => '시간 초과가 만료되었습니다: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamNl extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusNl status = _TranslationsStreamStatusNl._(_root);
@override String started({required Object timestamp}) => 'Begonnen met ${timestamp}';
@override String notification({required Object name}) => '${name} ging live!';
@override late final _TranslationsStreamChatNl chat = _TranslationsStreamChatNl._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsNl {
case 'stream.status.ended': return 'GESLOTEN';
case 'stream.status.planned': return 'GEPLAND';
case 'stream.started': return ({required Object timestamp}) => 'Begonnen met ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} ging live!';
case 'stream.chat.disabled': return 'CHAT UITGESCHAKELD';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Time-out loopt af: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamPl extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusPl status = _TranslationsStreamStatusPl._(_root);
@override String started({required Object timestamp}) => 'Start ${timestamp}';
@override String notification({required Object name}) => '${name} został uruchomiony!';
@override late final _TranslationsStreamChatPl chat = _TranslationsStreamChatPl._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsPl {
case 'stream.status.ended': return 'ZAKOŃCZONY';
case 'stream.status.planned': return 'PLANOWANE';
case 'stream.started': return ({required Object timestamp}) => 'Start ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} został uruchomiony!';
case 'stream.chat.disabled': return 'CZAT WYŁĄCZONY';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Upłynął limit czasu: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamPt extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusPt status = _TranslationsStreamStatusPt._(_root);
@override String started({required Object timestamp}) => 'Iniciado em ${timestamp}';
@override String notification({required Object name}) => '${name} foi ao ar!';
@override late final _TranslationsStreamChatPt chat = _TranslationsStreamChatPt._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsPt {
case 'stream.status.ended': return 'FINALIZADO';
case 'stream.status.planned': return 'PLANEJADO';
case 'stream.started': return ({required Object timestamp}) => 'Iniciado em ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} foi ao ar!';
case 'stream.chat.disabled': return 'BATE-PAPO DESATIVADO';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'O tempo limite expira: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamRo extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusRo status = _TranslationsStreamStatusRo._(_root);
@override String started({required Object timestamp}) => 'A început ${timestamp}';
@override String notification({required Object name}) => '${name} a intrat în direct!';
@override late final _TranslationsStreamChatRo chat = _TranslationsStreamChatRo._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsRo {
case 'stream.status.ended': return 'TERMINAT';
case 'stream.status.planned': return 'PLANIFICATE';
case 'stream.started': return ({required Object timestamp}) => 'A început ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} a intrat în direct!';
case 'stream.chat.disabled': return 'CHAT DEZACTIVAT';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Timpul expiră: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamRu extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusRu status = _TranslationsStreamStatusRu._(_root);
@override String started({required Object timestamp}) => 'Начало ${timestamp}';
@override String notification({required Object name}) => '${name} запустился!';
@override late final _TranslationsStreamChatRu chat = _TranslationsStreamChatRu._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsRu {
case 'stream.status.ended': return 'КОНЕЦ';
case 'stream.status.planned': return 'ПЛАНИРУЕМЫЙ';
case 'stream.started': return ({required Object timestamp}) => 'Начало ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} запустился!';
case 'stream.chat.disabled': return 'ЧАТ ОТКЛЮЧЕН';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Таймаут истекает: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -39,19 +39,19 @@ class TranslationsSv extends Translations {
// Translations
/// Text som uppmanar användaren att trycka på avatarplatshållaren för att påbörja uppladdningen
/// Text som uppmanar användaren att trycka på avatar platshållaren för att påbörja uppladdningen
@override String get upload_avatar => 'Ladda upp avatar';
/// Rubrik över listade toppstreamers av zaps
@override String get most_zapped_streamers => 'De flesta zappade streamers';
/// Rubrik över listade topp streamers av zaps
@override String get most_zapped_streamers => 'De flest zappade streamers';
/// Ingen användare hittades vid sökning
@override String get no_user_found => 'Ingen användare hittades';
/// En anonym användare
@override String get anon => 'Anon';
@override String get anon => 'Anno';
/// Antal tittare på streamingen
/// Antal tittare på strömmingen
@override String viewers({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('sv'))(n,
one: '1 tittare',
other: '${NumberFormat.decimalPattern('sv').format(n)} tittare',
@ -79,7 +79,8 @@ class _TranslationsStreamSv extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusSv status = _TranslationsStreamStatusSv._(_root);
@override String started({required Object timestamp}) => 'Startade ${timestamp}';
@override String started({required Object timestamp}) => 'Startad ${timestamp}';
@override String notification({required Object name}) => '${name} gick live!';
@override late final _TranslationsStreamChatSv chat = _TranslationsStreamChatSv._(_root);
}
@ -112,7 +113,7 @@ class _TranslationsButtonSv extends TranslationsButtonEn {
/// Knapptext för följ-knappen
@override String get follow => 'Följ';
/// Knapptext för avföljningsknappen
/// Knapptext för sluta följa knappen
@override String get unfollow => 'Sluta följa';
@override String get mute => 'Tysta';
@ -235,7 +236,7 @@ class _TranslationsStreamChatSv extends TranslationsStreamChatEn {
time,
]);
/// Stream avslutade sidfoten längst ner på chatten
/// Streama slutade sidfot längst ned i chatten
@override String get ended => 'STREAM AVSLUTAD';
/// Chattmeddelande som visar strömavbrott
@ -272,8 +273,8 @@ class _TranslationsSettingsProfileSv extends TranslationsSettingsProfileEn {
// Translations
@override String get display_name => 'Visa namn';
@override String get about => 'Om';
@override String get nip05 => 'Nostr Adress';
@override String get lud16 => 'Adress för blixtnedslag';
@override String get nip05 => 'Nostr adress';
@override String get lud16 => 'Lightning-adress';
@override late final _TranslationsSettingsProfileErrorSv error = _TranslationsSettingsProfileErrorSv._(_root);
}
@ -284,9 +285,9 @@ class _TranslationsSettingsWalletSv extends TranslationsSettingsWalletEn {
final TranslationsSv _root; // ignore: unused_field
// Translations
@override String get connect_wallet => 'Connect plånbok (NWC nostr+walletconnect://)';
@override String get connect_wallet => 'Anslut plånbok (NWC nostr+walletconnect://)';
@override String get disconnect_wallet => 'Koppla bort plånboken';
@override String get connect_1tap => '1-Tap-anslutning';
@override String get connect_1tap => '1-tryck anslutning';
@override String get paste => 'Klistra in URL';
@override late final _TranslationsSettingsWalletErrorSv error = _TranslationsSettingsWalletErrorSv._(_root);
}
@ -312,8 +313,8 @@ class _TranslationsStreamChatWriteSv extends TranslationsStreamChatWriteEn {
/// Etikett på inmatningsrutan för chattmeddelanden
@override String get label => 'Skriv meddelande';
/// Chattinmatningsmeddelande som visas när användaren endast är inloggad med pubkey
@override String get no_signer => 'Det går inte att skriva meddelanden med npub-inloggning';
/// Chattinmatningsmeddelande som visas när användaren endast är inloggad med publik nyckel
@override String get no_signer => 'Det går inte att skriva meddelanden med n-pub inloggning';
/// Chattinmatningsmeddelande som visas när användaren är utloggad
@override String get login => 'Logga in för att skicka meddelanden';
@ -327,7 +328,7 @@ class _TranslationsStreamChatBadgeSv extends TranslationsStreamChatBadgeEn {
// Translations
/// Rubrik över lista över användare som tilldelats en badge
/// Rubrik över listan över användare som tilldelas ett märke
@override String get awarded_to => 'Tilldelas till:';
}
@ -339,14 +340,14 @@ class _TranslationsStreamChatRaidSv extends TranslationsStreamChatRaidEn {
// Translations
/// Chatta raidmeddelande till en annan ström
/// Chatt raid meddelande till en annan ström
@override String to({required Object name}) => 'RAIDING ${name}';
/// Chat raid-meddelande från en annan ström
/// Chatt raid meddelande från en annan ström
@override String from({required Object name}) => 'RAID FRÅN ${name}';
/// Nedräkningstimer för auto-raiding
@override String countdown({required Object time}) => 'Raiding ${time}';
/// Nedräkningstimer för auto- radiering
@override String countdown({required Object time}) => 'Radiering i ${time}';
}
// Path: settings.profile.error
@ -366,7 +367,7 @@ class _TranslationsSettingsWalletErrorSv extends TranslationsSettingsWalletError
final TranslationsSv _root; // ignore: unused_field
// Translations
@override String get logged_out => 'Kan inte ansluta plånbok när du är inloggad';
@override String get logged_out => 'Kan inte ansluta plånbok när du är utloggad';
@override String get nwc_auth_event_not_found => 'Inget autentiseringshändelse för plånbok hittades';
}
@ -376,9 +377,9 @@ extension on TranslationsSv {
dynamic _flatMapFunction(String path) {
switch (path) {
case 'upload_avatar': return 'Ladda upp avatar';
case 'most_zapped_streamers': return 'De flesta zappade streamers';
case 'most_zapped_streamers': return 'De flest zappade streamers';
case 'no_user_found': return 'Ingen användare hittades';
case 'anon': return 'Anon';
case 'anon': return 'Anno';
case 'viewers': return ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('sv'))(n,
one: '1 tittare',
other: '${NumberFormat.decimalPattern('sv').format(n)} tittare',
@ -386,7 +387,8 @@ extension on TranslationsSv {
case 'stream.status.live': return 'LIVE';
case 'stream.status.ended': return 'AVSLUTAD';
case 'stream.status.planned': return 'PLANERADE';
case 'stream.started': return ({required Object timestamp}) => 'Startade ${timestamp}';
case 'stream.started': return ({required Object timestamp}) => 'Startad ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} gick live!';
case 'stream.chat.disabled': return 'CHAT AVSTÄNGD';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Tidsgränsen går ut: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [
@ -404,12 +406,12 @@ extension on TranslationsSv {
const TextSpan(text: ' sats'),
]);
case 'stream.chat.write.label': return 'Skriv meddelande';
case 'stream.chat.write.no_signer': return 'Det går inte att skriva meddelanden med npub-inloggning';
case 'stream.chat.write.no_signer': return 'Det går inte att skriva meddelanden med n-pub inloggning';
case 'stream.chat.write.login': return 'Logga in för att skicka meddelanden';
case 'stream.chat.badge.awarded_to': return 'Tilldelas till:';
case 'stream.chat.raid.to': return ({required Object name}) => 'RAIDING ${name}';
case 'stream.chat.raid.from': return ({required Object name}) => 'RAID FRÅN ${name}';
case 'stream.chat.raid.countdown': return ({required Object time}) => 'Raiding ${time}';
case 'stream.chat.raid.countdown': return ({required Object time}) => 'Radiering i ${time}';
case 'goal.title': return ({required Object amount}) => 'Mål: ${amount}';
case 'goal.remaining': return ({required Object amount}) => 'Kvarvarande: ${amount}';
case 'goal.complete': return 'KOMPLETT';
@ -448,14 +450,14 @@ extension on TranslationsSv {
case 'settings.button_wallet': return 'Inställningar för plånbok';
case 'settings.profile.display_name': return 'Visa namn';
case 'settings.profile.about': return 'Om';
case 'settings.profile.nip05': return 'Nostr Adress';
case 'settings.profile.lud16': return 'Adress för blixtnedslag';
case 'settings.profile.nip05': return 'Nostr adress';
case 'settings.profile.lud16': return 'Lightning-adress';
case 'settings.profile.error.logged_out': return 'Kan inte redigera profil när jag är utloggad';
case 'settings.wallet.connect_wallet': return 'Connect plånbok (NWC nostr+walletconnect://)';
case 'settings.wallet.connect_wallet': return 'Anslut plånbok (NWC nostr+walletconnect://)';
case 'settings.wallet.disconnect_wallet': return 'Koppla bort plånboken';
case 'settings.wallet.connect_1tap': return '1-Tap-anslutning';
case 'settings.wallet.connect_1tap': return '1-tryck anslutning';
case 'settings.wallet.paste': return 'Klistra in URL';
case 'settings.wallet.error.logged_out': return 'Kan inte ansluta plånbok när du är inloggad';
case 'settings.wallet.error.logged_out': return 'Kan inte ansluta plånbok när du är utloggad';
case 'settings.wallet.error.nwc_auth_event_not_found': return 'Inget autentiseringshändelse för plånbok hittades';
case 'login.username': return 'Användarnamn';
case 'login.amber': return 'Logga in med Amber';

View File

@ -80,6 +80,7 @@ class _TranslationsStreamTr extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusTr status = _TranslationsStreamStatusTr._(_root);
@override String started({required Object timestamp}) => 'Başlatıldı ${timestamp}';
@override String notification({required Object name}) => '${name} yayına girdi!';
@override late final _TranslationsStreamChatTr chat = _TranslationsStreamChatTr._(_root);
}
@ -231,8 +232,9 @@ class _TranslationsStreamChatTr extends TranslationsStreamChatEn {
mod,
const TextSpan(text: ' zaman aşımına uğradı '),
user,
const TextSpan(text: ' için '),
const TextSpan(text: ' '),
time,
const TextSpan(text: 'için'),
]);
/// Sohbetin alt kısmında akış sona erdi altbilgisi
@ -343,7 +345,7 @@ class _TranslationsStreamChatRaidTr extends TranslationsStreamChatRaidEn {
@override String to({required Object name}) => 'RAIDING ${name}';
/// Başka bir akıştan sohbet baskını mesajı
@override String from({required Object name}) => '${name}ADRESINDEN RAID';
@override String from({required Object name}) => '${name} ADRESINDEN RAID';
/// Otomatik sürüş için geri sayım sayacı
@override String countdown({required Object time}) => '${time}adresinde baskın';
@ -387,14 +389,16 @@ extension on TranslationsTr {
case 'stream.status.ended': return 'SONLANDI';
case 'stream.status.planned': return 'PLANLANMIŞ';
case 'stream.started': return ({required Object timestamp}) => 'Başlatıldı ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} yayına girdi!';
case 'stream.chat.disabled': return 'SOHBET DEVRE DIŞI';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Zaman aşımı sona eriyor: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [
mod,
const TextSpan(text: ' zaman aşımına uğradı '),
user,
const TextSpan(text: ' için '),
const TextSpan(text: ' '),
time,
const TextSpan(text: 'için'),
]);
case 'stream.chat.ended': return 'YAYIN SONLANDI';
case 'stream.chat.zap': return ({required InlineSpan user, required InlineSpan amount}) => TextSpan(children: [
@ -408,7 +412,7 @@ extension on TranslationsTr {
case 'stream.chat.write.login': return 'Mesaj göndermek için lütfen giriş yapın';
case 'stream.chat.badge.awarded_to': return 'Ödüllendirildi:';
case 'stream.chat.raid.to': return ({required Object name}) => 'RAIDING ${name}';
case 'stream.chat.raid.from': return ({required Object name}) => '${name}ADRESINDEN RAID';
case 'stream.chat.raid.from': return ({required Object name}) => '${name} ADRESINDEN RAID';
case 'stream.chat.raid.countdown': return ({required Object time}) => '${time}adresinde baskın';
case 'goal.title': return ({required Object amount}) => 'Hedef: ${amount}';
case 'goal.remaining': return ({required Object amount}) => 'Kalan: ${amount}';

View File

@ -80,6 +80,7 @@ class _TranslationsStreamUk extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusUk status = _TranslationsStreamStatusUk._(_root);
@override String started({required Object timestamp}) => 'Запустив ${timestamp}';
@override String notification({required Object name}) => '${name} запрацював!';
@override late final _TranslationsStreamChatUk chat = _TranslationsStreamChatUk._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsUk {
case 'stream.status.ended': return 'ЗАКІНЧЕНО';
case 'stream.status.planned': return 'ЗАПЛАНОВАНО';
case 'stream.started': return ({required Object timestamp}) => 'Запустив ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} запрацював!';
case 'stream.chat.disabled': return 'ЧАТ ВІДКЛЮЧЕНО';
case 'stream.chat.disabled_timeout': return ({required Object time}) => 'Тайм-аут закінчився: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -80,6 +80,7 @@ class _TranslationsStreamZh extends TranslationsStreamEn {
// Translations
@override late final _TranslationsStreamStatusZh status = _TranslationsStreamStatusZh._(_root);
@override String started({required Object timestamp}) => '開始 ${timestamp}';
@override String notification({required Object name}) => '${name} 已啟用!';
@override late final _TranslationsStreamChatZh chat = _TranslationsStreamChatZh._(_root);
}
@ -387,6 +388,7 @@ extension on TranslationsZh {
case 'stream.status.ended': return '結束';
case 'stream.status.planned': return '計劃';
case 'stream.started': return ({required Object timestamp}) => '開始 ${timestamp}';
case 'stream.notification': return ({required Object name}) => '${name} 已啟用!';
case 'stream.chat.disabled': return '關閉聊天';
case 'stream.chat.disabled_timeout': return ({required Object time}) => '超時過期: ${time}';
case 'stream.chat.timeout': return ({required InlineSpan mod, required InlineSpan user, required InlineSpan time}) => TextSpan(children: [

View File

@ -10,7 +10,7 @@ no_user_found: لم يتم العثور على مستخدم
anon: هوية مخفية
viewers:
one: 1 مشاهد
other: "${n:decimalPattern} المشاهدين"
other: "{n:decimalPattern} المشاهدين"
"@viewers":
description: عدد مشاهدي البث
"@anon":
@ -21,10 +21,11 @@ stream:
ended: انتهى
planned: مخطط
started: بدأ $timestamp
notification: ${name} بدأ البث المباشر!
chat:
disabled: تم تعطيل الدردشة
disabled_timeout: "تنتهي المهلة: $time"
timeout(rich): $mod انتهى الوقت $user لـ $time
timeout(rich): $mod انتهى الوقت $user لـ ${time}
"@timeout":
description: رسالة دردشة تظهر أحداث المهلة
ended: انتهى البث

View File

@ -22,10 +22,11 @@ stream:
ended: KONEC
planned: PLÁNOVANÉ
started: Založeno $timestamp
notification: ${name} byl spuštěn!
chat:
disabled: CHAT ZRUŠEN
disabled_timeout: "Časový limit vyprší: $time"
timeout(rich): $mod vypršel čas $user pro $time
timeout(rich): $mod vypršel čas $user pro ${time}
"@timeout":
description: Zpráva chatu zobrazující události časového limitu
ended: STREAM UKONČEN

View File

@ -22,10 +22,11 @@ stream:
ended: AFSLUTTET
planned: PLANLAGT
started: Startet $timestamp
notification: ${name} gik live!
chat:
disabled: CHAT DEAKTIVERET
disabled_timeout: "Timeout udløber: $time"
timeout(rich): $mod udløbet $user for $time
timeout(rich): $mod udløbet $user for ${time}
"@timeout":
description: Chatbesked, der viser timeout-hændelser
ended: STREAM AFSLUTTET

View File

@ -4,7 +4,7 @@ upload_avatar: Avatar hochladen
klicken, um den Upload zu starten
most_zapped_streamers: Meistgezappte Streamer
"@most_zapped_streamers":
description: Überschrift über gelistete Top-Streamer von zaps
description: Überschrift über gelistete Top-Streamer nach Zaps
no_user_found: Kein Benutzer gefunden
"@no_user_found":
description: Kein Benutzer bei der Suche gefunden
@ -13,27 +13,28 @@ viewers:
one: 1 Zuschauer
other: ${n:decimalPattern} Zuschauer
"@viewers":
description: Anzahl der Betrachter des Streams
description: Anzahl der Zuschauer des Streams
"@anon":
description: Ein anonymer Benutzer
stream:
status:
live: LIVE
ended: ENDED
ended: BEENDET
planned: GEPLANT
started: Gestartet $timestamp
notification: ${name} ging live!
chat:
disabled: CHAT DEAKTIVIERT
disabled_timeout: "Die Zeitüberschreitung läuft ab: $time"
timeout(rich): $mod Zeitüberschreitung $user für $time
disabled_timeout: "Timeout läuft ab: $time"
timeout(rich): $mod gibt $user einen Timeout für ${time}
"@timeout":
description: Chat-Nachricht mit Zeitüberschreitungsereignissen
description: Chat-Nachricht mit Timeout-Ereignissen
ended: STREAM BEENDET
"@ended":
description: Stream beendet Fußzeile am Ende des Chats
zap(rich): $user hat $amount sats gezappt
"@zap":
description: Chatnachricht mit Stream Zaps
description: Chat-Nachricht mit Stream-Zaps
write:
label: Nachricht schreiben
"@label":

View File

@ -22,10 +22,11 @@ stream:
ended: ENDED
planned: ΣΧΕΔΙΑΣΜΟΣ
started: Ξεκίνησε $timestamp
notification: ${name} βγήκε ζωντανά!
chat:
disabled: ΑΠΕΝΕΡΓΟΠΟΙΗΜΈΝΗ ΣΥΝΟΜΙΛΊΑ
disabled_timeout: "Το χρονικό όριο λήγει: $time"
timeout(rich): $mod χρονομετρημένη λήξη $user για $time
timeout(rich): $mod χρονομετρημένη λήξη $user για ${time}
"@timeout":
description: Μήνυμα συνομιλίας που εμφανίζει συμβάντα timeout
ended: STREAM ΤΕΛΕΙΩΣΕ

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
@ -21,6 +22,7 @@ stream:
ended: ENDED
planned: PLANNED
started: Started $timestamp
notification: ${name} went live!
chat:
disabled: CHAT DISABLED
disabled_timeout: "Timeout expires: $time"
@ -121,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
@ -131,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

@ -22,10 +22,11 @@ stream:
ended: FIN
planned: PLANIFICADO
started: Comenzó $timestamp
notification: ${name} ¡se ha puesto en marcha!
chat:
disabled: CHAT DESHABILITADO
disabled_timeout: "El tiempo de espera expira: $time"
timeout(rich): $mod timed out $user para $time
timeout(rich): $mod timed out $user para ${time}
"@timeout":
description: Mensaje de chat que muestra los eventos de tiempo de espera
ended: STREAM FINED

View File

@ -22,10 +22,11 @@ stream:
ended: ENDED
planned: SUUNNITELTU
started: Aloitettu $timestamp
notification: ${name} meni suoraksi!
chat:
disabled: CHAT POISTETTU KÄYTÖSTÄ
disabled_timeout: "Aikakatkaisu päättyy: $time"
timeout(rich): $mod ajastettu $user for $time
timeout(rich): $mod ajastettu $user for ${time}
"@timeout":
description: Chat-viesti, joka näyttää aikakatkaisutapahtumat
ended: STREAM PÄÄTTYNYT

View File

@ -11,7 +11,7 @@ no_user_found: Aucun utilisateur trouvé
anon: Anonyme
viewers:
one: 1 téléspectateur
other: "${n:decimalPattern} téléspectateurs"
other: "{n:decimalPattern} téléspectateurs"
"@viewers":
description: Nombre de spectateurs du flux
"@anon":
@ -22,10 +22,11 @@ stream:
ended: FINI
planned: PRÉVU
started: Commencé à $timestamp
notification: ${name} est en ligne !
chat:
disabled: CHAT DISABLED
disabled_timeout: "Le délai expire : $time"
timeout(rich): $mod $user a expiré dans le temps pour $time
timeout(rich): $mod $user a expiré dans le temps pour ${time}
"@timeout":
description: Message de chat indiquant les événements de dépassement de délai
ended: STREAM ENDED

View File

@ -22,6 +22,7 @@ stream:
ended: ENDED
planned: TERVEZETT
started: Elindult $timestamp
notification: ${name} elindult!
chat:
disabled: CHAT KIKAPCSOLVA
disabled_timeout: "Az időkorlát lejár: $time"
@ -56,7 +57,7 @@ stream:
from: RAID FROM $name
"@from":
description: Chat raid üzenet egy másik folyamból
countdown: Raiding a ${time}oldalon
countdown: Raiding a ${time} oldalon
"@countdown":
description: Visszaszámláló időzítő az automatikus lovagláshoz
goal:
@ -84,7 +85,7 @@ button:
embed:
article_by: Cikk ${name}
note_by: $name bejegyzése
live_stream_by: Élő közvetítés a ${name}oldalon
live_stream_by: Élő közvetítés a ${name} oldalon
stream_list:
following: Követettek bejegyzései
live: Élő

View File

@ -22,10 +22,11 @@ stream:
ended: FINE
planned: PREVISTO
started: Avviato $timestamp
notification: ${name} è andato in onda!
chat:
disabled: CHAT DISABILITATA
disabled_timeout: "Il timeout scade: $time"
timeout(rich): $mod time out $user per $time
timeout(rich): $mod time out $user per ${time}
"@timeout":
description: Messaggio di chat che mostra gli eventi di timeout
ended: STREAM ENDED

View File

@ -21,10 +21,11 @@ stream:
ended: 終了
planned: 予定
started: $timestamp を開始
notification: ${name} がライブを開始した!
chat:
disabled: チャット無効
disabled_timeout: タイムアウト: $time
timeout(rich): $mod タイムアウト $user for $time
timeout(rich): $mod タイムアウト $user for ${time}
"@timeout":
description: タイムアウトイベントを表示するチャットメッセージ
ended: 配信終了

View File

@ -10,7 +10,7 @@ no_user_found: 사용자를 찾을 수 없습니다.
anon: Anon
viewers:
one: 시청자 1명
other: "${n:decimalPattern} 시청자"
other: "{n:decimalPattern} 시청자"
"@viewers":
description: 스트림 시청자 수
"@anon":
@ -21,10 +21,11 @@ stream:
ended: 종료
planned: 계획된
started: 시작 $timestamp
notification: ${name} 라이브가 시작되었습니다!
chat:
disabled: 채팅 사용 안 함
disabled_timeout: "시간 초과가 만료되었습니다: $time"
timeout(rich): $mod 시간 초과됨 $user $time
timeout(rich): $mod 시간 초과됨 $user ${time}
"@timeout":
description: 시간 초과 이벤트를 표시하는 채팅 메시지
ended: 스트림 종료

View File

@ -22,10 +22,11 @@ stream:
ended: GESLOTEN
planned: GEPLAND
started: Begonnen met $timestamp
notification: ${name} ging live!
chat:
disabled: CHAT UITGESCHAKELD
disabled_timeout: "Time-out loopt af: $time"
timeout(rich): $mod timed out $user voor $time
timeout(rich): $mod timed out $user voor ${time}
"@timeout":
description: Chatbericht met time-outgebeurtenissen
ended: STREAM BEËINDIGD

View File

@ -22,10 +22,11 @@ stream:
ended: ZAKOŃCZONY
planned: PLANOWANE
started: Start $timestamp
notification: ${name} został uruchomiony!
chat:
disabled: CZAT WYŁĄCZONY
disabled_timeout: "Upłynął limit czasu: $time"
timeout(rich): $mod upłynął limit czasu $user dla $time
timeout(rich): $mod upłynął limit czasu $user dla ${time}
"@timeout":
description: Komunikat czatu pokazujący zdarzenia przekroczenia limitu czasu
ended: TRANSMISJA ZAKOŃCZONA

View File

@ -22,10 +22,11 @@ stream:
ended: FINALIZADO
planned: PLANEJADO
started: Iniciado em $timestamp
notification: ${name} foi ao ar!
chat:
disabled: BATE-PAPO DESATIVADO
disabled_timeout: "O tempo limite expira: $time"
timeout(rich): $mod Tempo esgotado $user para $time
timeout(rich): $mod Tempo esgotado $user para ${time}
"@timeout":
description: Mensagem de bate-papo mostrando eventos de tempo limite
ended: TRANSMISSÃO ENCERRADA

View File

@ -22,10 +22,11 @@ stream:
ended: TERMINAT
planned: PLANIFICATE
started: A început $timestamp
notification: ${name} a intrat în direct!
chat:
disabled: CHAT DEZACTIVAT
disabled_timeout: "Timpul expiră: $time"
timeout(rich): $mod Timed out $user pentru $time
timeout(rich): $mod Timed out $user pentru ${time}
"@timeout":
description: Mesaj de chat care afișează evenimentele de timeout
ended: STREAM ÎNCHEIAT

View File

@ -22,10 +22,11 @@ stream:
ended: КОНЕЦ
planned: ПЛАНИРУЕМЫЙ
started: Начало $timestamp
notification: ${name} запустился!
chat:
disabled: ЧАТ ОТКЛЮЧЕН
disabled_timeout: "Таймаут истекает: $time"
timeout(rich): $mod тайм-аут $user для $time
timeout(rich): $mod тайм-аут $user для ${time}
"@timeout":
description: Сообщение в чате, показывающее события по тайм-ауту
ended: ТРАНСЛЯЦИЯ ОКОНЧЕНА

View File

@ -1,19 +1,19 @@
upload_avatar: Ladda upp avatar
"@upload_avatar":
description: Text som uppmanar användaren att trycka på avatarplatshållaren för
description: Text som uppmanar användaren att trycka på avatar platshållaren för
att påbörja uppladdningen
most_zapped_streamers: De flesta zappade streamers
most_zapped_streamers: De flest zappade streamers
"@most_zapped_streamers":
description: Rubrik över listade toppstreamers av zaps
description: Rubrik över listade topp streamers av zaps
no_user_found: Ingen användare hittades
"@no_user_found":
description: Ingen användare hittades vid sökning
anon: Anon
anon: Anno
viewers:
one: 1 tittare
other: ${n:decimalPattern} tittare
"@viewers":
description: Antal tittare på streamingen
description: Antal tittare på strömmingen
"@anon":
description: En anonym användare
stream:
@ -21,16 +21,17 @@ stream:
live: LIVE
ended: AVSLUTAD
planned: PLANERADE
started: Startade $timestamp
started: Startad $timestamp
notification: ${name} gick live!
chat:
disabled: CHAT AVSTÄNGD
disabled_timeout: "Tidsgränsen går ut: $time"
timeout(rich): $mod tidsbegränsad $user för $time
timeout(rich): $mod tidsbegränsad $user för ${time}
"@timeout":
description: Chattmeddelande som visar timeout-händelser
ended: STREAM AVSLUTAD
"@ended":
description: Stream avslutade sidfoten längst ner på chatten
description: Streama slutade sidfot längst ned i chatten
zap(rich): $user zapped $amount sats
"@zap":
description: Chattmeddelande som visar strömavbrott
@ -38,27 +39,27 @@ stream:
label: Skriv meddelande
"@label":
description: Etikett på inmatningsrutan för chattmeddelanden
no_signer: Det går inte att skriva meddelanden med npub-inloggning
no_signer: Det går inte att skriva meddelanden med n-pub inloggning
"@no_signer":
description: Chattinmatningsmeddelande som visas när användaren endast är
inloggad med pubkey
inloggad med publik nyckel
login: Logga in för att skicka meddelanden
"@login":
description: Chattinmatningsmeddelande som visas när användaren är utloggad
badge:
awarded_to: "Tilldelas till:"
"@awarded_to":
description: Rubrik över lista över användare som tilldelats en badge
description: Rubrik över listan över användare som tilldelas ett märke
raid:
to: RAIDING $name
to: RAIDING ${name}
"@to":
description: Chatta raidmeddelande till en annan ström
from: RAID FRÅN $name
description: Chatt raid meddelande till en annan ström
from: RAID FRÅN ${name}
"@from":
description: Chat raid-meddelande från en annan ström
countdown: Raiding $time
description: Chatt raid meddelande från en annan ström
countdown: Radiering i ${time}
"@countdown":
description: Nedräkningstimer för auto-raiding
description: Nedräkningstimer för auto- radiering
goal:
title: "Mål: $amount"
remaining: "Kvarvarande: $amount"
@ -74,7 +75,7 @@ button:
description: Knapptext för följ-knappen
unfollow: Sluta följa
"@unfollow":
description: Knapptext för avföljningsknappen
description: Knapptext för sluta följa knappen
mute: Tysta
unmute: Avtysta
share: Dela
@ -82,9 +83,9 @@ button:
connect: Anslut
settings: Inställningar
embed:
article_by: Artikel av $name
article_by: Artikel av ${name}
note_by: Anteckning av $name
live_stream_by: Direktsändning via $name
live_stream_by: Direktsändning via ${name}
stream_list:
following: Följer
live: Live
@ -114,17 +115,17 @@ settings:
profile:
display_name: Visa namn
about: Om
nip05: Nostr Adress
lud16: Adress för blixtnedslag
nip05: Nostr adress
lud16: Lightning-adress
error:
logged_out: Kan inte redigera profil när jag är utloggad
wallet:
connect_wallet: Connect plånbok (NWC nostr+walletconnect://)
connect_wallet: Anslut plånbok (NWC nostr+walletconnect://)
disconnect_wallet: Koppla bort plånboken
connect_1tap: 1-Tap-anslutning
connect_1tap: 1-tryck anslutning
paste: Klistra in URL
error:
logged_out: Kan inte ansluta plånbok när du är inloggad
logged_out: Kan inte ansluta plånbok när du är utloggad
nwc_auth_event_not_found: Inget autentiseringshändelse för plånbok hittades
login:
username: Användarnamn

View File

@ -22,10 +22,11 @@ stream:
ended: SONLANDI
planned: PLANLANMIŞ
started: Başlatıldı $timestamp
notification: ${name} yayına girdi!
chat:
disabled: SOHBET DEVRE DIŞI
disabled_timeout: "Zaman aşımı sona eriyor: $time"
timeout(rich): $mod zaman aşımına uğradı $user için $time
timeout(rich): $mod zaman aşımına uğradı $user ${time}için
"@timeout":
description: Zaman aşımı olaylarını gösteren sohbet mesajı
ended: YAYIN SONLANDI
@ -53,7 +54,7 @@ stream:
to: RAIDING ${name}
"@to":
description: Başka bir akışa sohbet baskını mesajı
from: ${name}ADRESINDEN RAID
from: ${name} ADRESINDEN RAID
"@from":
description: Başka bir akıştan sohbet baskını mesajı
countdown: ${time}adresinde baskın

View File

@ -22,10 +22,11 @@ stream:
ended: ЗАКІНЧЕНО
planned: ЗАПЛАНОВАНО
started: Запустив $timestamp
notification: ${name} запрацював!
chat:
disabled: ЧАТ ВІДКЛЮЧЕНО
disabled_timeout: "Тайм-аут закінчився: $time"
timeout(rich): $mod таймінг $user для $time
timeout(rich): $mod таймінг $user для ${time}
"@timeout":
description: Повідомлення в чаті про події тайм-ауту
ended: СТРІМ ЗАКІНЧИВСЯ

View File

@ -21,10 +21,11 @@ stream:
ended: 結束
planned: 計劃
started: 開始 $timestamp
notification: ${name} 已啟用!
chat:
disabled: 關閉聊天
disabled_timeout: 超時過期: $time
timeout(rich): $mod 超時 $user for $time
timeout(rich): $mod 超時 $user for ${time}
"@timeout":
description: 顯示逾時事件的聊天訊息
ended: 串流結束

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

View File

@ -1,5 +1,6 @@
import 'dart:developer' as developer;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
@ -7,6 +8,9 @@ import 'package:zap_stream_flutter/app.dart';
import 'package:zap_stream_flutter/const.dart';
import 'package:zap_stream_flutter/i18n/strings.g.dart';
import 'package:zap_stream_flutter/notifications.dart';
import 'package:zap_stream_flutter/player.dart';
late final MainPlayer mainPlayer;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -20,5 +24,14 @@ Future<void> main() async {
developer.log("Failed to setup notifications: $e");
});
mainPlayer = await AudioService.init(
builder: () => MainPlayer(),
config: AudioServiceConfig(
androidNotificationChannelId: "io.nostrlabs.zap_stream_flutter.player",
androidNotificationChannelName: "Player Status",
androidNotificationOngoing: true
),
);
runZapStream();
}

View File

@ -10,11 +10,14 @@ import 'package:flutter/foundation.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_objectbox/ndk_objectbox.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:zap_stream_flutter/const.dart';
import 'package:http/http.dart' as http;
import 'package:zap_stream_flutter/firebase_options.dart';
import 'package:zap_stream_flutter/i18n/strings.g.dart';
import 'package:zap_stream_flutter/utils.dart';
import 'package:zap_stream_flutter/widgets/profile.dart';
class Notepush {
final String base;
@ -183,6 +186,82 @@ class NotificationsStore extends ValueNotifier<NotificationsState?> {
}
}
Future<void> _initLocalNotifications() async {
await localNotifications.initialize(
InitializationSettings(
android: AndroidInitializationSettings("@drawable/ic_stat_name"),
iOS: DarwinInitializationSettings(),
),
);
}
@pragma('vm:entry-point')
Future<void> _onBackgroundNotification(RemoteMessage msg) async {
await LocaleSettings.useDeviceLocale();
final cache = DbObjectBox(attach: true);
await _initLocalNotifications();
await _handleNotification(msg, cache);
}
Future<void> _onNotification(RemoteMessage msg) async {
await _handleNotification(msg, ndkCache);
}
Future<void> _handleNotification(RemoteMessage msg, DbObjectBox cache) async {
final notification = msg.notification;
if (notification != null && notification.android != null) {
final String? json = msg.data["nostr_event"];
final event =
json != null ? Nip01Event.fromJson(JsonCodec().decode(json)) : null;
await _showNotification(notification, ndkCache, event);
}
}
Future<void> _showNotification(
RemoteNotification notification,
DbObjectBox cache,
Nip01Event? event,
) async {
final stream = event != null ? StreamEvent(event) : null;
final hostProfile =
stream != null ? await cache.loadMetadata(stream.info.host) : null;
final newTitle =
hostProfile != null
? t.stream.notification(
name: ProfileNameWidget.nameFromProfile(hostProfile),
)
: null;
localNotifications.show(
notification.hashCode,
newTitle ?? notification.title,
stream?.info.title ?? notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
notification.android!.channelId ?? "fcm",
"Push Notifications",
category: AndroidNotificationCategory.social
),
),
);
}
Future<void> _onOpenMessage(RemoteMessage msg) async {
try {
final notification = msg.notification;
final String? json = msg.data["nostr_event"];
if (notification != null && json != null) {
// Just launch the URL because we support deep links
final event = Nip01Event.fromJson(JsonCodec().decode(json));
final stream = StreamEvent(event);
launchUrl(Uri.parse("https://zap.stream/${stream.link}"));
}
} catch (e) {
developer.log("Failed to process push notification\n ${e.toString()}");
}
}
// global notifications store
final notifications = NotificationsStore(null);
@ -191,47 +270,20 @@ Future<void> setupNotifications() async {
final signer = ndk.accounts.getLoggedAccount()?.signer;
if (signer != null) {
final pusher = Notepush(dotenv.env["NOTEPUSH_URL"]!, signer: signer);
final fbase = FirebaseMessaging.instance;
FirebaseMessaging.onMessage.listen((msg) {
developer.log(msg.notification?.body ?? "");
final notification = msg.notification;
if (notification != null && notification.android != null) {
FlutterLocalNotificationsPlugin().show(
notification.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
notification.android!.channelId ?? "fcm",
"fcm",
),
),
);
}
});
FirebaseMessaging.onMessageOpenedApp.listen((msg) {
try {
final notification = msg.notification;
final String? json = msg.data["nostr_event"];
if (notification != null && json != null) {
// Just launch the URL because we support deep links
final event = Nip01Event.fromJson(JsonCodec().decode(json));
final stream = StreamEvent(event);
launchUrl(Uri.parse("https://zap.stream/${stream.link}"));
}
} catch (e) {
developer.log("Failed to process push notification\n ${e.toString()}");
}
});
FirebaseMessaging.onMessage.listen(_onNotification);
//FirebaseMessaging.onBackgroundMessage(_onBackgroundNotification);
FirebaseMessaging.onMessageOpenedApp.listen(_onOpenMessage);
final settings = await fbase.requestPermission(provisional: true);
await fbase.setAutoInitEnabled(true);
await fbase.setForegroundNotificationPresentationOptions(
alert: true,
badge: true,
sound: true,
final settings = await FirebaseMessaging.instance.requestPermission(
provisional: true,
);
await FirebaseMessaging.instance.setAutoInitEnabled(true);
await FirebaseMessaging.instance
.setForegroundNotificationPresentationOptions(
alert: true,
badge: true,
sound: true,
);
if (Platform.isIOS) {
final apnsToken = await FirebaseMessaging.instance.getAPNSToken();
@ -239,13 +291,10 @@ Future<void> setupNotifications() async {
throw "APNS token not availble";
}
}
await localNotifications.initialize(
InitializationSettings(
android: AndroidInitializationSettings("@mipmap/ic_launcher"),
iOS: DarwinInitializationSettings(),
),
);
fbase.onTokenRefresh.listen((token) async {
await _initLocalNotifications();
final pusher = Notepush(dotenv.env["NOTEPUSH_URL"]!, signer: signer);
FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
developer.log("NEW TOKEN: $token");
await pusher.register(token);
await pusher.setNotificationSettings(token, [30_311]);

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

@ -17,7 +17,7 @@ import 'package:zap_stream_flutter/widgets/notifications_button.dart';
import 'package:zap_stream_flutter/widgets/pill.dart';
import 'package:zap_stream_flutter/widgets/profile.dart';
import 'package:zap_stream_flutter/widgets/stream_info.dart';
import 'package:zap_stream_flutter/widgets/video_player.dart';
import 'package:zap_stream_flutter/widgets/video_player_main.dart';
import 'package:zap_stream_flutter/widgets/zap.dart';
class StreamPage extends StatefulWidget {
@ -141,11 +141,11 @@ class _StreamPage extends State<StreamPage> with RouteAware {
aspectRatio: 16 / 9,
child:
(stream.info.stream != null && !_offScreen)
? VideoPlayerWidget(
? 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)
@ -159,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)],

101
lib/player.dart Normal file
View File

@ -0,0 +1,101 @@
import 'package:audio_service/audio_service.dart';
import 'package:chewie/chewie.dart';
import 'package:video_player/video_player.dart';
import 'package:zap_stream_flutter/const.dart';
import 'package:zap_stream_flutter/imgproxy.dart';
class MainPlayer extends BaseAudioHandler {
VideoPlayerController? _controller;
ChewieController? _chewieController;
ChewieController? get chewie {
return _chewieController;
}
@override
Future<void> play() async {
await _chewieController?.play();
}
@override
Future<void> pause() async {
await _chewieController?.pause();
}
@override
Future<void> stop() async {
await _chewieController?.pause();
}
void loadUrl(
String url, {
String? title,
bool? autoPlay,
double? aspectRatio,
bool? isLive,
String? placeholder,
String? artist,
}) {
if (_chewieController != null) {
_chewieController!.dispose();
_controller!.dispose();
}
_controller = VideoPlayerController.networkUrl(
Uri.parse(url),
httpHeaders: Map.from({"user-agent": userAgent}),
videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true),
);
_chewieController = ChewieController(
videoPlayerController: _controller!,
autoPlay: autoPlay ?? true,
aspectRatio: aspectRatio,
isLive: isLive ?? false,
autoInitialize: true,
allowedScreenSleep: false,
placeholder:
(placeholder?.isNotEmpty ?? false)
? ProxyImg(url: placeholder!)
: null,
);
// insert media item
mediaItem.add(
MediaItem(
id: url.hashCode.toString(),
title: title ?? url,
artist: artist,
isLive: _chewieController!.isLive,
artUri:
(placeholder?.isNotEmpty ?? false) ? Uri.parse(placeholder!) : null,
),
);
_chewieController!.videoPlayerController.addListener(updatePlayerState);
}
void updatePlayerState() {
final isPlaying =
_chewieController?.videoPlayerController.value.isPlaying ?? false;
playbackState.add(
playbackState.value.copyWith(
controls: [
if (playbackState.value.playing)
MediaControl.pause
else
MediaControl.play,
MediaControl.stop,
],
playing: isPlaying,
processingState: switch (_chewieController
?.videoPlayerController
.value
.isInitialized) {
true => AudioProcessingState.ready,
false => AudioProcessingState.idle,
_ => AudioProcessingState.completed,
},
),
);
}
}

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

@ -0,0 +1,53 @@
import 'package:chewie/chewie.dart';
import 'package:flutter/widgets.dart';
import 'package:zap_stream_flutter/main.dart';
class MainVideoPlayerWidget extends StatefulWidget {
final String url;
final String? title;
final String? placeholder;
final double? aspectRatio;
final bool? autoPlay;
final bool? isLive;
const MainVideoPlayerWidget({
super.key,
required this.url,
this.title,
this.placeholder,
this.aspectRatio,
this.autoPlay,
this.isLive,
});
@override
State<StatefulWidget> createState() => _MainVideoPlayerWidget();
}
class _MainVideoPlayerWidget extends State<MainVideoPlayerWidget> {
@override
void initState() {
mainPlayer.loadUrl(
widget.url,
title: widget.title,
placeholder: widget.placeholder,
aspectRatio: widget.aspectRatio,
autoPlay: widget.autoPlay,
isLive: widget.isLive,
artist: "zap.stream"
);
super.initState();
}
@override
void dispose() {
mainPlayer.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Chewie(controller: mainPlayer.chewie!);
}
}

View File

@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import audio_service
import audio_session
import emoji_picker_flutter
import file_selector_macos
import firebase_core
@ -23,6 +25,8 @@ import video_player_avfoundation
import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))

Some files were not shown because too many files have changed in this diff Show More