mirror of
https://github.com/nostrlabs-io/zap-stream-flutter.git
synced 2025-06-15 11:48:21 +00:00
Compare commits
15 Commits
1575c7dd09
...
main
Author | SHA1 | Date | |
---|---|---|---|
a66eb8c338
|
|||
f570f4c1f2
|
|||
71563bc5ce
|
|||
b5b5ef0daf
|
|||
3b07f7d073
|
|||
64655ba21c
|
|||
5ffab5f7bf
|
|||
0d8ce78009
|
|||
ef1c03d8d5
|
|||
97a6708ae2
|
|||
6bb19ce1f5
|
|||
f15e693a54
|
|||
8605761dff
|
|||
f553ecdab3
|
|||
b7764d82c9
|
@ -1,6 +1,10 @@
|
|||||||
<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">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
@ -143,6 +143,7 @@ class ZapStreamApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateDefaultStreamInfo({
|
Future<void> updateDefaultStreamInfo({
|
||||||
|
String? id,
|
||||||
String? title,
|
String? title,
|
||||||
String? summary,
|
String? summary,
|
||||||
String? image,
|
String? image,
|
||||||
@ -154,6 +155,7 @@ class ZapStreamApi {
|
|||||||
await _sendPatchRequest(
|
await _sendPatchRequest(
|
||||||
url,
|
url,
|
||||||
body: {
|
body: {
|
||||||
|
"id": id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"image": image,
|
"image": image,
|
||||||
|
49
lib/app.dart
49
lib/app.dart
@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:ndk/shared/nips/nip19/nip19.dart';
|
||||||
import 'package:zap_stream_flutter/const.dart';
|
import 'package:zap_stream_flutter/const.dart';
|
||||||
import 'package:zap_stream_flutter/i18n/strings.g.dart';
|
import 'package:zap_stream_flutter/i18n/strings.g.dart';
|
||||||
import 'package:zap_stream_flutter/pages/category.dart';
|
import 'package:zap_stream_flutter/pages/category.dart';
|
||||||
@ -19,6 +22,26 @@ import 'package:zap_stream_flutter/theme.dart';
|
|||||||
import 'package:zap_stream_flutter/utils.dart';
|
import 'package:zap_stream_flutter/utils.dart';
|
||||||
import 'package:zap_stream_flutter/widgets/header.dart';
|
import 'package:zap_stream_flutter/widgets/header.dart';
|
||||||
|
|
||||||
|
/// Resolves a NIP-05 identifier to a pubkey
|
||||||
|
Future<String?> resolveNip05(String handle, String domain) async {
|
||||||
|
try {
|
||||||
|
final url = "https://$domain/.well-known/nostr.json?name=$handle";
|
||||||
|
final response = await http.get(Uri.parse(url));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
final names = json["names"] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
if (names != null) {
|
||||||
|
return names[handle] as String?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// NIP-05 resolution failed
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void runZapStream() {
|
void runZapStream() {
|
||||||
runApp(
|
runApp(
|
||||||
MaterialApp.router(
|
MaterialApp.router(
|
||||||
@ -142,7 +165,7 @@ void runZapStream() {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: "/:id",
|
path: "/:id",
|
||||||
redirect: (context, state) {
|
redirect: (context, state) async {
|
||||||
final id = state.pathParameters["id"]!;
|
final id = state.pathParameters["id"]!;
|
||||||
if (id.startsWith("naddr1") ||
|
if (id.startsWith("naddr1") ||
|
||||||
id.startsWith("nevent1") ||
|
id.startsWith("nevent1") ||
|
||||||
@ -151,6 +174,30 @@ void runZapStream() {
|
|||||||
} else if (id.startsWith("npub1") ||
|
} else if (id.startsWith("npub1") ||
|
||||||
id.startsWith("nprofile1")) {
|
id.startsWith("nprofile1")) {
|
||||||
return "/p/$id";
|
return "/p/$id";
|
||||||
|
} else {
|
||||||
|
// Handle short URL format (handle@domain or just handle)
|
||||||
|
try {
|
||||||
|
final handleParts = id.contains("@")
|
||||||
|
? id.split("@")
|
||||||
|
: [id, "zap.stream"];
|
||||||
|
|
||||||
|
if (handleParts.length == 2) {
|
||||||
|
final handle = handleParts[0];
|
||||||
|
final domain = handleParts[1];
|
||||||
|
|
||||||
|
// Try to resolve NIP-05
|
||||||
|
final hexPubkey = await resolveNip05(handle, domain);
|
||||||
|
if (hexPubkey != null) {
|
||||||
|
// Check if they have a current live stream
|
||||||
|
// For now, redirect to profile - we could enhance this later
|
||||||
|
// to check for active streams and redirect to /e/{stream_id} instead
|
||||||
|
final npub = Nip19.encodePubKey(hexPubkey);
|
||||||
|
return "/p/$npub";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If NIP-05 resolution fails, continue to show 404 or fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
@ -4,9 +4,9 @@
|
|||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 22
|
/// Locales: 22
|
||||||
/// Strings: 2010 (91 per locale)
|
/// Strings: 2011 (91 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2025-05-30 at 13:17 UTC
|
/// Built on 2025-06-03 at 10:11 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
@ -86,6 +86,7 @@ class TranslationsStreamEn {
|
|||||||
String started({required Object timestamp}) => 'Started ${timestamp}';
|
String started({required Object timestamp}) => 'Started ${timestamp}';
|
||||||
String notification({required Object name}) => '${name} went live!';
|
String notification({required Object name}) => '${name} went live!';
|
||||||
late final TranslationsStreamChatEn chat = TranslationsStreamChatEn.internal(_root);
|
late final TranslationsStreamChatEn chat = TranslationsStreamChatEn.internal(_root);
|
||||||
|
late final TranslationsStreamErrorEn error = TranslationsStreamErrorEn.internal(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: goal
|
// Path: goal
|
||||||
@ -280,6 +281,16 @@ class TranslationsStreamChatEn {
|
|||||||
late final TranslationsStreamChatRaidEn raid = TranslationsStreamChatRaidEn.internal(_root);
|
late final TranslationsStreamChatRaidEn raid = TranslationsStreamChatRaidEn.internal(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: stream.error
|
||||||
|
class TranslationsStreamErrorEn {
|
||||||
|
TranslationsStreamErrorEn.internal(this._root);
|
||||||
|
|
||||||
|
final Translations _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
String load_failed({required Object url}) => 'Failed to load stream from ${url}';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: zap.error
|
// Path: zap.error
|
||||||
class TranslationsZapErrorEn {
|
class TranslationsZapErrorEn {
|
||||||
TranslationsZapErrorEn.internal(this._root);
|
TranslationsZapErrorEn.internal(this._root);
|
||||||
@ -455,6 +466,7 @@ extension on Translations {
|
|||||||
case 'stream.chat.raid.to': return ({required Object name}) => 'RAIDING ${name}';
|
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.from': return ({required Object name}) => 'RAID FROM ${name}';
|
||||||
case 'stream.chat.raid.countdown': return ({required Object time}) => 'Raiding in ${time}';
|
case 'stream.chat.raid.countdown': return ({required Object time}) => 'Raiding in ${time}';
|
||||||
|
case 'stream.error.load_failed': return ({required Object url}) => 'Failed to load stream from ${url}';
|
||||||
case 'goal.title': return ({required Object amount}) => 'Goal: ${amount}';
|
case 'goal.title': return ({required Object amount}) => 'Goal: ${amount}';
|
||||||
case 'goal.remaining': return ({required Object amount}) => 'Remaining: ${amount}';
|
case 'goal.remaining': return ({required Object amount}) => 'Remaining: ${amount}';
|
||||||
case 'goal.complete': return 'COMPLETE';
|
case 'goal.complete': return 'COMPLETE';
|
||||||
|
@ -59,6 +59,8 @@ stream:
|
|||||||
countdown: Raiding in ${time}
|
countdown: Raiding in ${time}
|
||||||
"@countdown":
|
"@countdown":
|
||||||
description: Countdown timer for auto-raiding
|
description: Countdown timer for auto-raiding
|
||||||
|
error:
|
||||||
|
load_failed: Failed to load stream from ${url}
|
||||||
goal:
|
goal:
|
||||||
title: "Goal: $amount"
|
title: "Goal: $amount"
|
||||||
remaining: "Remaining: $amount"
|
remaining: "Remaining: $amount"
|
||||||
|
@ -241,7 +241,7 @@ Future<void> _showNotification(
|
|||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
notification.android!.channelId ?? "fcm",
|
notification.android!.channelId ?? "fcm",
|
||||||
"Push Notifications",
|
"Push Notifications",
|
||||||
category: AndroidNotificationCategory.social
|
category: AndroidNotificationCategory.social,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -270,45 +270,48 @@ Future<void> setupNotifications() async {
|
|||||||
|
|
||||||
final signer = ndk.accounts.getLoggedAccount()?.signer;
|
final signer = ndk.accounts.getLoggedAccount()?.signer;
|
||||||
if (signer != null) {
|
if (signer != null) {
|
||||||
FirebaseMessaging.onMessage.listen(_onNotification);
|
await configureNotifications(signer);
|
||||||
//FirebaseMessaging.onBackgroundMessage(_onBackgroundNotification);
|
|
||||||
FirebaseMessaging.onMessageOpenedApp.listen(_onOpenMessage);
|
|
||||||
|
|
||||||
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();
|
|
||||||
if (apnsToken == null) {
|
|
||||||
throw "APNS token not availble";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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]);
|
|
||||||
});
|
|
||||||
|
|
||||||
final fcmToken = await FirebaseMessaging.instance.getToken();
|
|
||||||
if (fcmToken == null) {
|
|
||||||
throw "Push token is null";
|
|
||||||
}
|
|
||||||
await pusher.register(fcmToken);
|
|
||||||
await pusher.setNotificationSettings(fcmToken, [30_311]);
|
|
||||||
|
|
||||||
notifications.value = await NotificationsState.init(
|
|
||||||
settings.authorizationStatus,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> configureNotifications(EventSigner signer) async {
|
||||||
|
FirebaseMessaging.onMessage.listen(_onNotification);
|
||||||
|
//FirebaseMessaging.onBackgroundMessage(_onBackgroundNotification);
|
||||||
|
FirebaseMessaging.onMessageOpenedApp.listen(_onOpenMessage);
|
||||||
|
|
||||||
|
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();
|
||||||
|
if (apnsToken == null) {
|
||||||
|
throw "APNS token not availble";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
|
final fcmToken = await FirebaseMessaging.instance.getToken();
|
||||||
|
if (fcmToken == null) {
|
||||||
|
throw "Push token is null";
|
||||||
|
}
|
||||||
|
await pusher.register(fcmToken);
|
||||||
|
await pusher.setNotificationSettings(fcmToken, [30_311]);
|
||||||
|
|
||||||
|
notifications.value = await NotificationsState.init(
|
||||||
|
settings.authorizationStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -139,17 +139,13 @@ class _LivePage extends State<LivePage>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final mq = MediaQuery.of(context);
|
final mq = MediaQuery.of(context);
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: !_streaming,
|
||||||
onPopInvokedWithResult: (didPop, result) async {
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
if (_streaming) {
|
if (_streaming && !didPop) {
|
||||||
final go = await showExitStreamDialog(context);
|
final go = await showExitStreamDialog(context);
|
||||||
if (context.mounted) {
|
if (context.mounted && go == true) {
|
||||||
if (go == true) {
|
context.go("/");
|
||||||
context.go("/");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
context.go("/");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: ValueListenableBuilder(
|
child: ValueListenableBuilder(
|
||||||
@ -177,6 +173,7 @@ class _LivePage extends State<LivePage>
|
|||||||
builder: (context, streamState) {
|
builder: (context, streamState) {
|
||||||
final ev = streamState
|
final ev = streamState
|
||||||
?.sortedBy((e) => e.createdAt)
|
?.sortedBy((e) => e.createdAt)
|
||||||
|
.reversed
|
||||||
.firstWhereOrNull((e) => e.getFirstTag("status") == "live");
|
.firstWhereOrNull((e) => e.getFirstTag("status") == "live");
|
||||||
|
|
||||||
final stream = ev != null ? StreamEvent(ev) : null;
|
final stream = ev != null ? StreamEvent(ev) : null;
|
||||||
@ -337,6 +334,7 @@ class _LivePage extends State<LivePage>
|
|||||||
api: _api,
|
api: _api,
|
||||||
account: _account!,
|
account: _account!,
|
||||||
hideEndpointConfig: _streaming,
|
hideEndpointConfig: _streaming,
|
||||||
|
currentStream: ev,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
).then((_) {
|
).then((_) {
|
||||||
|
@ -8,6 +8,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
|
|||||||
import 'package:zap_stream_flutter/i18n/strings.g.dart';
|
import 'package:zap_stream_flutter/i18n/strings.g.dart';
|
||||||
import 'package:zap_stream_flutter/imgproxy.dart';
|
import 'package:zap_stream_flutter/imgproxy.dart';
|
||||||
import 'package:zap_stream_flutter/const.dart';
|
import 'package:zap_stream_flutter/const.dart';
|
||||||
|
import 'package:zap_stream_flutter/main.dart';
|
||||||
import 'package:zap_stream_flutter/rx_filter.dart';
|
import 'package:zap_stream_flutter/rx_filter.dart';
|
||||||
import 'package:zap_stream_flutter/theme.dart';
|
import 'package:zap_stream_flutter/theme.dart';
|
||||||
import 'package:zap_stream_flutter/utils.dart';
|
import 'package:zap_stream_flutter/utils.dart';
|
||||||
@ -56,6 +57,7 @@ class StreamPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _StreamPage extends State<StreamPage> with RouteAware {
|
class _StreamPage extends State<StreamPage> with RouteAware {
|
||||||
bool _offScreen = false;
|
bool _offScreen = false;
|
||||||
|
final GlobalKey _playerKey = GlobalKey();
|
||||||
|
|
||||||
bool isWidgetVisible(BuildContext context) {
|
bool isWidgetVisible(BuildContext context) {
|
||||||
final router = GoRouter.of(context);
|
final router = GoRouter.of(context);
|
||||||
@ -127,97 +129,195 @@ class _StreamPage extends State<StreamPage> with RouteAware {
|
|||||||
],
|
],
|
||||||
builder: (ctx, state) {
|
builder: (ctx, state) {
|
||||||
final stream = StreamEvent(state?.firstOrNull ?? widget.stream.event);
|
final stream = StreamEvent(state?.firstOrNull ?? widget.stream.event);
|
||||||
return _buildStream(context, stream);
|
final streamWidget = _buildPlayer(ctx, stream);
|
||||||
|
return ValueListenableBuilder(
|
||||||
|
valueListenable: mainPlayer.state,
|
||||||
|
builder: (context, playerState, child) {
|
||||||
|
if (playerState?.isPortrait == true) {
|
||||||
|
return _buildPortraitStream(context, stream, child!);
|
||||||
|
}
|
||||||
|
return _buildLandscapeStream(context, stream, child!);
|
||||||
|
},
|
||||||
|
child: streamWidget,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStream(BuildContext context, StreamEvent stream) {
|
Widget _buildPlayer(BuildContext context, StreamEvent stream) {
|
||||||
|
return (stream.info.stream != null && !_offScreen)
|
||||||
|
? MainVideoPlayerWidget(
|
||||||
|
key: _playerKey,
|
||||||
|
url: stream.info.stream!,
|
||||||
|
placeholder: stream.info.image,
|
||||||
|
isLive: true,
|
||||||
|
title: stream.info.title,
|
||||||
|
)
|
||||||
|
: AspectRatio(
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
child:
|
||||||
|
(stream.info.image?.isNotEmpty ?? false)
|
||||||
|
? ProxyImg(url: stream.info.image)
|
||||||
|
: Container(decoration: BoxDecoration(color: LAYER_1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPortraitStream(
|
||||||
|
BuildContext context,
|
||||||
|
StreamEvent stream,
|
||||||
|
Widget child,
|
||||||
|
) {
|
||||||
|
final mq = MediaQuery.of(context);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(child: child),
|
||||||
|
Positioned(
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 5, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
LAYER_0.withAlpha(255),
|
||||||
|
LAYER_0.withAlpha(200),
|
||||||
|
LAYER_0.withAlpha(10),
|
||||||
|
],
|
||||||
|
stops: [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: _streamInfo(context, stream),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 10,
|
||||||
|
left: 0,
|
||||||
|
child: Container(
|
||||||
|
width: mq.size.width,
|
||||||
|
height: mq.size.height * 0.4,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [
|
||||||
|
LAYER_0.withAlpha(200),
|
||||||
|
LAYER_0.withAlpha(150),
|
||||||
|
LAYER_0.withAlpha(0),
|
||||||
|
],
|
||||||
|
stops: [0.0, 0.8, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ShaderMask(
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
shaderCallback:
|
||||||
|
(rect) => LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [
|
||||||
|
LAYER_0.withAlpha(255),
|
||||||
|
LAYER_0.withAlpha(200),
|
||||||
|
LAYER_0.withAlpha(0),
|
||||||
|
],
|
||||||
|
stops: [0.0, 0.8, 1.0],
|
||||||
|
).createShader(rect),
|
||||||
|
child: ChatWidget(
|
||||||
|
stream: stream,
|
||||||
|
showGoals: false,
|
||||||
|
showTopZappers: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLandscapeStream(
|
||||||
|
BuildContext context,
|
||||||
|
StreamEvent stream,
|
||||||
|
Widget child,
|
||||||
|
) {
|
||||||
return Column(
|
return Column(
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
AspectRatio(
|
child,
|
||||||
aspectRatio: 16 / 9,
|
..._streamInfo(context, stream),
|
||||||
child:
|
|
||||||
(stream.info.stream != null && !_offScreen)
|
|
||||||
? MainVideoPlayerWidget(
|
|
||||||
url: stream.info.stream!,
|
|
||||||
placeholder: stream.info.image,
|
|
||||||
isLive: true,
|
|
||||||
title: stream.info.title,
|
|
||||||
)
|
|
||||||
: (stream.info.image?.isNotEmpty ?? false)
|
|
||||||
? ProxyImg(url: stream.info.image)
|
|
||||||
: Container(decoration: BoxDecoration(color: LAYER_1)),
|
|
||||||
),
|
|
||||||
if (stream.info.title?.isNotEmpty ?? false)
|
|
||||||
Text(
|
|
||||||
stream.info.title!,
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
|
|
||||||
),
|
|
||||||
ProfileWidget.pubkey(
|
|
||||||
stream.info.host,
|
|
||||||
children: [
|
|
||||||
NotificationsButtonWidget(pubkey: widget.stream.info.host),
|
|
||||||
BasicButton(
|
|
||||||
Row(
|
|
||||||
children: [Icon(Icons.bolt, size: 14), Text(t.zap.button_zap)],
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: PRIMARY_1,
|
|
||||||
borderRadius: DEFAULT_BR,
|
|
||||||
),
|
|
||||||
onTap: (context) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
constraints: BoxConstraints.expand(),
|
|
||||||
builder: (context) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
primary: false,
|
|
||||||
child: ZapWidget(
|
|
||||||
pubkey: stream.info.host,
|
|
||||||
target: stream.event,
|
|
||||||
onPaid: (_) {
|
|
||||||
context.pop();
|
|
||||||
},
|
|
||||||
zapTags:
|
|
||||||
// tag goal onto zap request
|
|
||||||
stream.info.goal != null
|
|
||||||
? [
|
|
||||||
["e", stream.info.goal!],
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (stream.info.participants != null)
|
|
||||||
PillWidget(
|
|
||||||
color: LAYER_1,
|
|
||||||
child: Text(
|
|
||||||
t.viewers(n: stream.info.participants!),
|
|
||||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
constraints: BoxConstraints.expand(),
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => StreamInfoWidget(stream: stream),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Icon(Icons.info),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Expanded(child: ChatWidget(stream: stream)),
|
Expanded(child: ChatWidget(stream: stream)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Widget> _streamInfo(BuildContext context, StreamEvent stream) {
|
||||||
|
return [
|
||||||
|
if (stream.info.title?.isNotEmpty ?? false)
|
||||||
|
Text(
|
||||||
|
stream.info.title!,
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
|
||||||
|
),
|
||||||
|
ProfileWidget.pubkey(
|
||||||
|
stream.info.host,
|
||||||
|
children: [
|
||||||
|
NotificationsButtonWidget(pubkey: widget.stream.info.host),
|
||||||
|
BasicButton(
|
||||||
|
Row(children: [Icon(Icons.bolt, size: 14), Text(t.zap.button_zap)]),
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: PRIMARY_1,
|
||||||
|
borderRadius: DEFAULT_BR,
|
||||||
|
),
|
||||||
|
onTap: (context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
constraints: BoxConstraints.expand(),
|
||||||
|
builder: (context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
primary: false,
|
||||||
|
child: ZapWidget(
|
||||||
|
pubkey: stream.info.host,
|
||||||
|
target: stream.event,
|
||||||
|
onPaid: (_) {
|
||||||
|
context.pop();
|
||||||
|
},
|
||||||
|
zapTags:
|
||||||
|
// tag goal onto zap request
|
||||||
|
stream.info.goal != null
|
||||||
|
? [
|
||||||
|
["e", stream.info.goal!],
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (stream.info.participants != null)
|
||||||
|
PillWidget(
|
||||||
|
color: LAYER_1,
|
||||||
|
child: Text(
|
||||||
|
t.viewers(n: stream.info.participants!),
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
constraints: BoxConstraints.expand(),
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StreamInfoWidget(stream: stream),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Icon(Icons.info),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
163
lib/player.dart
163
lib/player.dart
@ -1,12 +1,70 @@
|
|||||||
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:chewie/chewie.dart';
|
import 'package:chewie/chewie.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import 'package:zap_stream_flutter/const.dart';
|
import 'package:zap_stream_flutter/const.dart';
|
||||||
|
import 'package:zap_stream_flutter/i18n/strings.g.dart';
|
||||||
import 'package:zap_stream_flutter/imgproxy.dart';
|
import 'package:zap_stream_flutter/imgproxy.dart';
|
||||||
|
|
||||||
|
class PlayerState {
|
||||||
|
final int? width;
|
||||||
|
final int? height;
|
||||||
|
final bool isPlaying;
|
||||||
|
final Exception? error;
|
||||||
|
|
||||||
|
bool get isPortrait {
|
||||||
|
return width != null && height != null ? width! / height! < 1.0 : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerState({
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.isPlaying = false,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class MainPlayer extends BaseAudioHandler {
|
class MainPlayer extends BaseAudioHandler {
|
||||||
|
String? _url;
|
||||||
VideoPlayerController? _controller;
|
VideoPlayerController? _controller;
|
||||||
ChewieController? _chewieController;
|
ChewieController? _chewieController;
|
||||||
|
ValueNotifier<PlayerState?> state = ValueNotifier(null);
|
||||||
|
|
||||||
|
MainPlayer() {
|
||||||
|
AppLifecycleListener(onStateChange: _onStateChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChanged(AppLifecycleState state) async {
|
||||||
|
developer.log(state.name);
|
||||||
|
switch (state) {
|
||||||
|
case AppLifecycleState.detached:
|
||||||
|
{
|
||||||
|
await dispose();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case AppLifecycleState.resumed:
|
||||||
|
{
|
||||||
|
if (_controller == null && _url != null) {
|
||||||
|
await loadUrl(_url!);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await super.stop();
|
||||||
|
await _controller?.dispose();
|
||||||
|
_chewieController?.dispose();
|
||||||
|
_controller = null;
|
||||||
|
_chewieController = null;
|
||||||
|
state.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
ChewieController? get chewie {
|
ChewieController? get chewie {
|
||||||
return _chewieController;
|
return _chewieController;
|
||||||
@ -24,10 +82,10 @@ class MainPlayer extends BaseAudioHandler {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _chewieController?.pause();
|
await dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadUrl(
|
Future<void> loadUrl(
|
||||||
String url, {
|
String url, {
|
||||||
String? title,
|
String? title,
|
||||||
bool? autoPlay,
|
bool? autoPlay,
|
||||||
@ -35,42 +93,68 @@ class MainPlayer extends BaseAudioHandler {
|
|||||||
bool? isLive,
|
bool? isLive,
|
||||||
String? placeholder,
|
String? placeholder,
|
||||||
String? artist,
|
String? artist,
|
||||||
}) {
|
}) async {
|
||||||
if (_chewieController != null) {
|
if (_controller?.dataSource == url) {
|
||||||
_chewieController!.dispose();
|
return;
|
||||||
_controller!.dispose();
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
developer.log("PLAYER loading $url");
|
||||||
|
if (_chewieController != null) {
|
||||||
|
_controller!.removeListener(updatePlayerState);
|
||||||
|
await _controller!.dispose();
|
||||||
|
_controller = null;
|
||||||
|
_chewieController!.dispose();
|
||||||
|
_chewieController = null;
|
||||||
|
}
|
||||||
|
state.value = null;
|
||||||
|
_controller = VideoPlayerController.networkUrl(
|
||||||
|
Uri.parse(url),
|
||||||
|
httpHeaders: Map.from({"user-agent": userAgent}),
|
||||||
|
videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true),
|
||||||
|
);
|
||||||
|
await _controller!.initialize();
|
||||||
|
_controller!.addListener(updatePlayerState);
|
||||||
|
_chewieController = ChewieController(
|
||||||
|
videoPlayerController: _controller!,
|
||||||
|
autoPlay: autoPlay ?? true,
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
isLive: isLive ?? false,
|
||||||
|
allowedScreenSleep: false,
|
||||||
|
placeholder:
|
||||||
|
(placeholder?.isNotEmpty ?? false)
|
||||||
|
? ProxyImg(url: placeholder!)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
_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
|
// insert media item
|
||||||
mediaItem.add(
|
mediaItem.add(
|
||||||
MediaItem(
|
MediaItem(
|
||||||
id: url.hashCode.toString(),
|
id: url.hashCode.toString(),
|
||||||
title: title ?? url,
|
title: title ?? url,
|
||||||
artist: artist,
|
artist: artist,
|
||||||
isLive: _chewieController!.isLive,
|
isLive: _chewieController!.isLive,
|
||||||
artUri:
|
artUri:
|
||||||
(placeholder?.isNotEmpty ?? false) ? Uri.parse(placeholder!) : null,
|
(placeholder?.isNotEmpty ?? false)
|
||||||
),
|
? Uri.parse(placeholder!)
|
||||||
);
|
: null,
|
||||||
_chewieController!.videoPlayerController.addListener(updatePlayerState);
|
),
|
||||||
|
);
|
||||||
|
// Update player state immediately after initialization
|
||||||
|
updatePlayerState();
|
||||||
|
_url = url;
|
||||||
|
} catch (e) {
|
||||||
|
if (e is PlatformException && e.code == "VideoError") {
|
||||||
|
state.value = PlayerState(
|
||||||
|
error: Exception(t.stream.error.load_failed(url: url)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state.value = PlayerState(
|
||||||
|
error: e is Exception ? e : Exception(e.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
developer.log("Failed to start player: ${e.toString()}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updatePlayerState() {
|
void updatePlayerState() {
|
||||||
@ -87,6 +171,7 @@ class MainPlayer extends BaseAudioHandler {
|
|||||||
MediaControl.stop,
|
MediaControl.stop,
|
||||||
],
|
],
|
||||||
playing: isPlaying,
|
playing: isPlaying,
|
||||||
|
androidCompactActionIndices: [1],
|
||||||
processingState: switch (_chewieController
|
processingState: switch (_chewieController
|
||||||
?.videoPlayerController
|
?.videoPlayerController
|
||||||
.value
|
.value
|
||||||
@ -97,5 +182,13 @@ class MainPlayer extends BaseAudioHandler {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (_controller?.value.isInitialized == true && _controller!.value.size != Size.zero) {
|
||||||
|
state.value = PlayerState(
|
||||||
|
width: _controller!.value.size.width.floor(),
|
||||||
|
height: _controller!.value.size.height.floor(),
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -233,7 +233,7 @@ StreamInfo extractStreamInfo(Nip01Event ev) {
|
|||||||
|
|
||||||
String getHost(Nip01Event ev) {
|
String getHost(Nip01Event ev) {
|
||||||
return ev.tags.firstWhere(
|
return ev.tags.firstWhere(
|
||||||
(t) => t[0] == "p" && t.length > 2 && t[3] == "host",
|
(t) => t[0] == "p" && t.length > 3 && t[3] == "host",
|
||||||
orElse: () => ["p", ev.pubKey], // fake p tag with event pubkey
|
orElse: () => ["p", ev.pubKey], // fake p tag with event pubkey
|
||||||
)[1];
|
)[1];
|
||||||
}
|
}
|
||||||
|
@ -18,16 +18,16 @@ import 'package:zap_stream_flutter/widgets/profile.dart';
|
|||||||
|
|
||||||
class ChatWidget extends StatelessWidget {
|
class ChatWidget extends StatelessWidget {
|
||||||
final StreamEvent stream;
|
final StreamEvent stream;
|
||||||
final bool? showGoals;
|
final bool showGoals;
|
||||||
final bool? showTopZappers;
|
final bool showTopZappers;
|
||||||
final bool? showRaids;
|
final bool showRaids;
|
||||||
|
|
||||||
const ChatWidget({
|
const ChatWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.stream,
|
required this.stream,
|
||||||
this.showGoals,
|
this.showGoals = true,
|
||||||
this.showTopZappers,
|
this.showTopZappers = true,
|
||||||
this.showRaids,
|
this.showRaids = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -40,7 +40,7 @@ class ChatWidget extends StatelessWidget {
|
|||||||
|
|
||||||
var filters = [
|
var filters = [
|
||||||
Filter(kinds: [1311, 9735], limit: 200, aTags: [stream.aTag]),
|
Filter(kinds: [1311, 9735], limit: 200, aTags: [stream.aTag]),
|
||||||
if (showRaids ?? true)
|
if (showRaids)
|
||||||
Filter(kinds: [1312, 1313], limit: 200, aTags: [stream.aTag]),
|
Filter(kinds: [1312, 1313], limit: 200, aTags: [stream.aTag]),
|
||||||
Filter(kinds: [Nip51List.kMute], authors: moderators),
|
Filter(kinds: [Nip51List.kMute], authors: moderators),
|
||||||
Filter(kinds: [1314], authors: moderators),
|
Filter(kinds: [1314], authors: moderators),
|
||||||
@ -118,9 +118,9 @@ class ChatWidget extends StatelessWidget {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (zaps.isNotEmpty && (showTopZappers ?? true))
|
if (zaps.isNotEmpty && showTopZappers)
|
||||||
_TopZappersWidget(events: zaps),
|
_TopZappersWidget(events: zaps),
|
||||||
if (stream.info.goal != null && (showGoals ?? true))
|
if (stream.info.goal != null && showGoals)
|
||||||
GoalWidget.id(stream.info.goal!),
|
GoalWidget.id(stream.info.goal!),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
@ -33,11 +33,15 @@ class __ChatRaidMessage extends State<ChatRaidMessage>
|
|||||||
|
|
||||||
_from =
|
_from =
|
||||||
widget.event.tags.firstWhereOrNull(
|
widget.event.tags.firstWhereOrNull(
|
||||||
(t) => t[0] == "a" && (t[3] == "from" || t[3] == "root"),
|
(t) =>
|
||||||
|
t[0] == "a" && t.length > 3 && (t[3] == "from" || t[3] == "root"),
|
||||||
)?[1];
|
)?[1];
|
||||||
_to =
|
_to =
|
||||||
widget.event.tags.firstWhereOrNull(
|
widget.event.tags.firstWhereOrNull(
|
||||||
(t) => t[0] == "a" && (t[3] == "to" || t[3] == "mention"),
|
(t) =>
|
||||||
|
t[0] == "a" &&
|
||||||
|
t.length > 3 &&
|
||||||
|
(t[3] == "to" || t[3] == "mention"),
|
||||||
)?[1];
|
)?[1];
|
||||||
_isRaiding = _from == widget.stream.aTag;
|
_isRaiding = _from == widget.stream.aTag;
|
||||||
final isAutoRaid =
|
final isAutoRaid =
|
||||||
|
@ -27,7 +27,19 @@ class ChatZapWidget extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_zapperRowZap(context, parsed),
|
_zapperRowZap(context, parsed),
|
||||||
if (parsed.comment?.isNotEmpty ?? false) NoteText(event: zap),
|
if (parsed.comment?.isNotEmpty ?? false)
|
||||||
|
RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
children: textToSpans(
|
||||||
|
context,
|
||||||
|
parsed.comment ?? "",
|
||||||
|
[],
|
||||||
|
parsed.sender ?? "",
|
||||||
|
showEmbeds: false,
|
||||||
|
embedMedia: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -17,7 +17,8 @@ class _NotificationsButtonWidget extends State<NotificationsButtonWidget> {
|
|||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
valueListenable: notifications,
|
valueListenable: notifications,
|
||||||
builder: (context, state, _) {
|
builder: (context, state, _) {
|
||||||
final isNotified = (state?.notifyKeys ?? []).contains(widget.pubkey);
|
if (state == null) return SizedBox();
|
||||||
|
final isNotified = state.notifyKeys.contains(widget.pubkey);
|
||||||
return IconButton(
|
return IconButton(
|
||||||
iconSize: 20,
|
iconSize: 20,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:ndk/entities.dart';
|
||||||
import 'package:zap_stream_flutter/api.dart';
|
import 'package:zap_stream_flutter/api.dart';
|
||||||
import 'package:zap_stream_flutter/const.dart';
|
import 'package:zap_stream_flutter/const.dart';
|
||||||
import 'package:zap_stream_flutter/i18n/strings.g.dart';
|
import 'package:zap_stream_flutter/i18n/strings.g.dart';
|
||||||
@ -12,12 +13,14 @@ class StreamConfigWidget extends StatefulWidget {
|
|||||||
final ZapStreamApi api;
|
final ZapStreamApi api;
|
||||||
final AccountInfo account;
|
final AccountInfo account;
|
||||||
final bool? hideEndpointConfig;
|
final bool? hideEndpointConfig;
|
||||||
|
final Nip01Event? currentStream;
|
||||||
|
|
||||||
const StreamConfigWidget({
|
const StreamConfigWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.api,
|
required this.api,
|
||||||
required this.account,
|
required this.account,
|
||||||
this.hideEndpointConfig,
|
this.hideEndpointConfig,
|
||||||
|
this.currentStream,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -41,6 +44,19 @@ class _StreamConfigWidget extends State<StreamConfigWidget> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _variantWidget(String cap) {
|
||||||
|
if (cap.startsWith("variant:")) {
|
||||||
|
final caps = cap.split(":");
|
||||||
|
return PillWidget(
|
||||||
|
color: LAYER_3,
|
||||||
|
child: Text(caps[1].replaceAll("h", "p")),
|
||||||
|
);
|
||||||
|
} else if (cap.startsWith("dvr:")) {
|
||||||
|
return PillWidget(color: LAYER_3, child: Text("Recording"));
|
||||||
|
}
|
||||||
|
return PillWidget(color: LAYER_3, child: Text(cap));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
@ -88,14 +104,15 @@ class _StreamConfigWidget extends State<StreamConfigWidget> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (endpoint != null && !(widget.hideEndpointConfig ?? false))
|
if (endpoint != null && !(widget.hideEndpointConfig ?? false))
|
||||||
Row(
|
SingleChildScrollView(
|
||||||
spacing: 8,
|
scrollDirection: Axis.horizontal,
|
||||||
children:
|
child: Row(
|
||||||
endpoint.capabilities
|
spacing: 8,
|
||||||
.map(
|
children:
|
||||||
(e) => PillWidget(color: LAYER_3, child: Text(e)),
|
endpoint.capabilities
|
||||||
)
|
.map((e) => _variantWidget(e))
|
||||||
.toList(),
|
.toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
@ -154,6 +171,19 @@ class _StreamConfigWidget extends State<StreamConfigWidget> {
|
|||||||
BasicButton.text(
|
BasicButton.text(
|
||||||
t.button.save,
|
t.button.save,
|
||||||
onTap: (context) async {
|
onTap: (context) async {
|
||||||
|
final current = widget.currentStream?.getFirstTag("d");
|
||||||
|
|
||||||
|
// Update current first
|
||||||
|
if (current != null) {
|
||||||
|
await widget.api.updateDefaultStreamInfo(
|
||||||
|
id: current,
|
||||||
|
title: _title.text,
|
||||||
|
summary: _summary.text,
|
||||||
|
contentWarning: _nsfw ? "nsfw" : null,
|
||||||
|
tags: _tags.text.split(","),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Updated default stream info (no id)
|
||||||
await widget.api.updateDefaultStreamInfo(
|
await widget.api.updateDefaultStreamInfo(
|
||||||
title: _title.text,
|
title: _title.text,
|
||||||
summary: _summary.text,
|
summary: _summary.text,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:chewie/chewie.dart';
|
import 'package:chewie/chewie.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:zap_stream_flutter/main.dart';
|
import 'package:zap_stream_flutter/main.dart';
|
||||||
|
import 'package:zap_stream_flutter/theme.dart';
|
||||||
|
|
||||||
class MainVideoPlayerWidget extends StatefulWidget {
|
class MainVideoPlayerWidget extends StatefulWidget {
|
||||||
final String url;
|
final String url;
|
||||||
@ -34,7 +35,7 @@ class _MainVideoPlayerWidget extends State<MainVideoPlayerWidget> {
|
|||||||
aspectRatio: widget.aspectRatio,
|
aspectRatio: widget.aspectRatio,
|
||||||
autoPlay: widget.autoPlay,
|
autoPlay: widget.autoPlay,
|
||||||
isLive: widget.isLive,
|
isLive: widget.isLive,
|
||||||
artist: "zap.stream"
|
artist: "zap.stream",
|
||||||
);
|
);
|
||||||
|
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -48,6 +49,26 @@ class _MainVideoPlayerWidget extends State<MainVideoPlayerWidget> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Chewie(controller: mainPlayer.chewie!);
|
return ValueListenableBuilder(
|
||||||
|
valueListenable: mainPlayer.state,
|
||||||
|
builder: (context, state, _) {
|
||||||
|
final innerWidget =
|
||||||
|
mainPlayer.chewie != null
|
||||||
|
? Chewie(controller: mainPlayer.chewie!)
|
||||||
|
: Center(
|
||||||
|
child:
|
||||||
|
state?.error != null
|
||||||
|
? Text(
|
||||||
|
state!.error.toString(),
|
||||||
|
style: TextStyle(color: WARNING),
|
||||||
|
)
|
||||||
|
: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
if (state?.isPortrait == true) {
|
||||||
|
return innerWidget;
|
||||||
|
}
|
||||||
|
return AspectRatio(aspectRatio: 16 / 9, child: innerWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
name: zap_stream_flutter
|
name: zap_stream_flutter
|
||||||
description: "zap.stream"
|
description: "zap.stream"
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.1.0+14
|
version: 1.1.4+18
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.7.2
|
sdk: ^3.7.2
|
||||||
|
flutter: ^3.32.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
|
Reference in New Issue
Block a user