Compare commits
12 Commits
Author | SHA1 | Date | |
---|---|---|---|
edfb5bb80d
|
|||
33ad784e87
|
|||
0422341bf8
|
|||
8af62e0b32
|
|||
cd6c50f9bd
|
|||
428771462d
|
|||
917147605b
|
|||
8e3a4cbd41
|
|||
c66127cac2
|
|||
ab9fdd6b71
|
|||
99c163a51a
|
|||
fb4821ffdd
|
4
.github/workflows/build.yml
vendored
@ -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
|
2
.github/workflows/release.yml
vendored
@ -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:
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
BIN
android/app/src/main/res/drawable-hdpi/ic_stat_name.png
Normal file
After Width: | Height: | Size: 531 B |
BIN
android/app/src/main/res/drawable-mdpi/ic_stat_name.png
Normal file
After Width: | Height: | Size: 366 B |
BIN
android/app/src/main/res/drawable-xhdpi/ic_stat_name.png
Normal file
After Width: | Height: | Size: 702 B |
BIN
android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/logo.jpg
Normal file
After Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 771 B After Width: | Height: | Size: 460 B |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 784 B |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.3 KiB |
@ -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
@ -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();
|
||||
}
|
||||
}
|
11
lib/app.dart
@ -6,6 +6,7 @@ import 'package:zap_stream_flutter/i18n/strings.g.dart';
|
||||
import 'package:zap_stream_flutter/pages/category.dart';
|
||||
import 'package:zap_stream_flutter/pages/hashtag.dart';
|
||||
import 'package:zap_stream_flutter/pages/home.dart';
|
||||
import 'package:zap_stream_flutter/pages/live.dart';
|
||||
import 'package:zap_stream_flutter/pages/login.dart';
|
||||
import 'package:zap_stream_flutter/pages/login_input.dart';
|
||||
import 'package:zap_stream_flutter/pages/new_account.dart';
|
||||
@ -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) {
|
||||
|
@ -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 =
|
||||
|
@ -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
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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';
|
||||
|
@ -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: [
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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';
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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 på ${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 på ${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';
|
||||
|
@ -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}';
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: انتهى البث
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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":
|
||||
|
@ -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 ΤΕΛΕΙΩΣΕ
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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ő
|
||||
|
@ -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
|
||||
|
@ -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: 配信終了
|
||||
|
@ -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: 스트림 종료
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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: ТРАНСЛЯЦИЯ ОКОНЧЕНА
|
||||
|
@ -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 på $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
|
||||
|
@ -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
|
||||
|
@ -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: СТРІМ ЗАКІНЧИВСЯ
|
||||
|
@ -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: 串流結束
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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
@ -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");
|
||||
};
|
||||
}
|
@ -38,6 +38,7 @@ class _NewAccountPage extends State<NewAccountPage> {
|
||||
pubKey: _privateKey.publicKey,
|
||||
name: _name.text,
|
||||
picture: _avatar,
|
||||
lud16: "${_privateKey.publicKey}@zap.stream",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -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
@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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(
|
||||
|
@ -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) {
|
||||
|
@ -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(
|
||||
|
@ -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)],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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();
|
||||
},
|
||||
|
174
lib/widgets/stream_config.dart
Normal file
@ -0,0 +1,174 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:zap_stream_flutter/api.dart';
|
||||
import 'package:zap_stream_flutter/const.dart';
|
||||
import 'package:zap_stream_flutter/i18n/strings.g.dart';
|
||||
import 'package:zap_stream_flutter/theme.dart';
|
||||
import 'package:zap_stream_flutter/widgets/button.dart';
|
||||
import 'package:zap_stream_flutter/widgets/pill.dart';
|
||||
|
||||
class StreamConfigWidget extends StatefulWidget {
|
||||
final ZapStreamApi api;
|
||||
final AccountInfo account;
|
||||
final bool? hideEndpointConfig;
|
||||
|
||||
const StreamConfigWidget({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.account,
|
||||
this.hideEndpointConfig,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _StreamConfigWidget();
|
||||
}
|
||||
|
||||
class _StreamConfigWidget extends State<StreamConfigWidget> {
|
||||
late bool _nsfw;
|
||||
late final TextEditingController _title;
|
||||
late final TextEditingController _summary;
|
||||
late final TextEditingController _tags;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_title = TextEditingController(text: widget.account.details?.title);
|
||||
_summary = TextEditingController(text: widget.account.details?.summary);
|
||||
_tags = TextEditingController(
|
||||
text: widget.account.details?.tags?.join(",") ?? "irl",
|
||||
);
|
||||
_nsfw = widget.account.details?.contentWarning?.isNotEmpty ?? false;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: loginData,
|
||||
builder: (context, state, _) {
|
||||
final endpoint = widget.account.endpoints.firstWhereOrNull(
|
||||
(e) => e.name == state?.streamEndpoint,
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(t.live.configure_stream, style: TextStyle(fontSize: 24)),
|
||||
if (!(widget.hideEndpointConfig ?? false))
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(Icons.power),
|
||||
Expanded(
|
||||
child: DropdownButton<IngestEndpoint>(
|
||||
value: endpoint,
|
||||
hint: Text(t.live.endpoint),
|
||||
items:
|
||||
widget.account.endpoints
|
||||
.map(
|
||||
(e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (x) {
|
||||
if (x != null) {
|
||||
loginData.configure(streamEndpoint: x.name);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
if (endpoint != null)
|
||||
Text(
|
||||
"${t.full_amount_sats(n: endpoint.cost.rate)}/${endpoint.cost.unit}",
|
||||
),
|
||||
],
|
||||
),
|
||||
if (endpoint != null && !(widget.hideEndpointConfig ?? false))
|
||||
Row(
|
||||
spacing: 8,
|
||||
children:
|
||||
endpoint.capabilities
|
||||
.map(
|
||||
(e) => PillWidget(color: LAYER_3, child: Text(e)),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
|
||||
TextField(
|
||||
controller: _title,
|
||||
decoration: InputDecoration(labelText: t.live.title),
|
||||
),
|
||||
TextField(
|
||||
controller: _summary,
|
||||
decoration: InputDecoration(labelText: t.live.summary),
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
),
|
||||
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_nsfw = !_nsfw;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: WARNING),
|
||||
borderRadius: DEFAULT_BR,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _nsfw,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_nsfw = !_nsfw;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.live.nsfw,
|
||||
style: TextStyle(
|
||||
color: WARNING,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(t.live.nsfw_description),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BasicButton.text(
|
||||
t.button.save,
|
||||
onTap: (context) async {
|
||||
await widget.api.updateDefaultStreamInfo(
|
||||
title: _title.text,
|
||||
summary: _summary.text,
|
||||
contentWarning: _nsfw ? "nsfw" : null,
|
||||
tags: _tags.text.split(","),
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
53
lib/widgets/video_player_main.dart
Normal 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!);
|
||||
}
|
||||
}
|
@ -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"))
|
||||
|