12 Commits

Author SHA1 Message Date
b5b5ef0daf chore: bump version 2025-06-05 12:05:39 +01:00
3b07f7d073 feat: improve vertical player design 2025-06-05 12:05:07 +01:00
64655ba21c fix: handle short urls
closes #52
2025-06-05 11:47:32 +01:00
5ffab5f7bf fix: back press gesture blocked
fix: portrait player state issue
closes #49
2025-06-05 11:26:49 +01:00
0d8ce78009 chore: bump version 2025-06-03 15:27:52 +01:00
ef1c03d8d5 fix: stop player while detached
closes #51
2025-06-03 15:24:27 +01:00
97a6708ae2 chore: bump version 2025-06-03 14:27:20 +01:00
6bb19ce1f5 feat: protrait video style 2025-06-03 13:54:56 +01:00
f15e693a54 fix: update default & current stream info 2025-06-03 10:29:40 +01:00
8605761dff feat: improve variant display and stream editing
closes #45
2025-06-03 10:25:26 +01:00
f553ecdab3 fix: notifications icon 2025-05-31 12:02:01 +01:00
b7764d82c9 fix: zap comments missing 2025-05-30 17:08:00 +01:00
16 changed files with 516 additions and 191 deletions

View File

@ -1,6 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
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.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View File

@ -143,6 +143,7 @@ class ZapStreamApi {
}
Future<void> updateDefaultStreamInfo({
String? id,
String? title,
String? summary,
String? image,
@ -154,6 +155,7 @@ class ZapStreamApi {
await _sendPatchRequest(
url,
body: {
"id": id,
"title": title,
"summary": summary,
"image": image,

View File

@ -1,6 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.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/i18n/strings.g.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/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() {
runApp(
MaterialApp.router(
@ -142,7 +165,7 @@ void runZapStream() {
),
GoRoute(
path: "/:id",
redirect: (context, state) {
redirect: (context, state) async {
final id = state.pathParameters["id"]!;
if (id.startsWith("naddr1") ||
id.startsWith("nevent1") ||
@ -151,6 +174,30 @@ void runZapStream() {
} else if (id.startsWith("npub1") ||
id.startsWith("nprofile1")) {
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;
},

View File

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// 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
// ignore_for_file: type=lint, unused_import

View File

@ -86,6 +86,7 @@ class TranslationsStreamEn {
String started({required Object timestamp}) => 'Started ${timestamp}';
String notification({required Object name}) => '${name} went live!';
late final TranslationsStreamChatEn chat = TranslationsStreamChatEn.internal(_root);
late final TranslationsStreamErrorEn error = TranslationsStreamErrorEn.internal(_root);
}
// Path: goal
@ -280,6 +281,16 @@ class TranslationsStreamChatEn {
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
class TranslationsZapErrorEn {
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.from': return ({required Object name}) => 'RAID FROM ${name}';
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.remaining': return ({required Object amount}) => 'Remaining: ${amount}';
case 'goal.complete': return 'COMPLETE';

View File

@ -59,6 +59,8 @@ stream:
countdown: Raiding in ${time}
"@countdown":
description: Countdown timer for auto-raiding
error:
load_failed: Failed to load stream from ${url}
goal:
title: "Goal: $amount"
remaining: "Remaining: $amount"

View File

@ -241,7 +241,7 @@ Future<void> _showNotification(
android: AndroidNotificationDetails(
notification.android!.channelId ?? "fcm",
"Push Notifications",
category: AndroidNotificationCategory.social
category: AndroidNotificationCategory.social,
),
),
);
@ -270,45 +270,48 @@ Future<void> setupNotifications() async {
final signer = ndk.accounts.getLoggedAccount()?.signer;
if (signer != null) {
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,
);
await configureNotifications(signer);
}
}
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,
);
}

View File

@ -139,17 +139,13 @@ class _LivePage extends State<LivePage>
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
return PopScope(
canPop: false,
canPop: !_streaming,
onPopInvokedWithResult: (didPop, result) async {
if (_streaming) {
if (_streaming && !didPop) {
final go = await showExitStreamDialog(context);
if (context.mounted) {
if (go == true) {
context.go("/");
}
if (context.mounted && go == true) {
context.go("/");
}
} else {
context.go("/");
}
},
child: ValueListenableBuilder(
@ -177,6 +173,7 @@ class _LivePage extends State<LivePage>
builder: (context, streamState) {
final ev = streamState
?.sortedBy((e) => e.createdAt)
.reversed
.firstWhereOrNull((e) => e.getFirstTag("status") == "live");
final stream = ev != null ? StreamEvent(ev) : null;
@ -337,6 +334,7 @@ class _LivePage extends State<LivePage>
api: _api,
account: _account!,
hideEndpointConfig: _streaming,
currentStream: ev,
);
},
).then((_) {

View File

@ -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/imgproxy.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/theme.dart';
import 'package:zap_stream_flutter/utils.dart';
@ -56,6 +57,7 @@ class StreamPage extends StatefulWidget {
class _StreamPage extends State<StreamPage> with RouteAware {
bool _offScreen = false;
final GlobalKey _playerKey = GlobalKey();
bool isWidgetVisible(BuildContext context) {
final router = GoRouter.of(context);
@ -127,97 +129,195 @@ class _StreamPage extends State<StreamPage> with RouteAware {
],
builder: (ctx, state) {
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(
spacing: 4,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
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),
),
],
),
child,
..._streamInfo(context, 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),
),
],
),
];
}
}

View File

@ -1,12 +1,70 @@
import 'dart:developer' as developer;
import 'package:audio_service/audio_service.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:zap_stream_flutter/const.dart';
import 'package:zap_stream_flutter/i18n/strings.g.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 {
String? _url;
VideoPlayerController? _controller;
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 {
return _chewieController;
@ -24,10 +82,10 @@ class MainPlayer extends BaseAudioHandler {
@override
Future<void> stop() async {
await _chewieController?.pause();
await dispose();
}
void loadUrl(
Future<void> loadUrl(
String url, {
String? title,
bool? autoPlay,
@ -35,42 +93,68 @@ class MainPlayer extends BaseAudioHandler {
bool? isLive,
String? placeholder,
String? artist,
}) {
if (_chewieController != null) {
_chewieController!.dispose();
_controller!.dispose();
}) async {
if (_controller?.dataSource == url) {
return;
}
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
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);
// 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,
),
);
// 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() {
@ -87,6 +171,7 @@ class MainPlayer extends BaseAudioHandler {
MediaControl.stop,
],
playing: isPlaying,
androidCompactActionIndices: [1],
processingState: switch (_chewieController
?.videoPlayerController
.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,
);
}
}
}

View File

@ -18,16 +18,16 @@ import 'package:zap_stream_flutter/widgets/profile.dart';
class ChatWidget extends StatelessWidget {
final StreamEvent stream;
final bool? showGoals;
final bool? showTopZappers;
final bool? showRaids;
final bool showGoals;
final bool showTopZappers;
final bool showRaids;
const ChatWidget({
super.key,
required this.stream,
this.showGoals,
this.showTopZappers,
this.showRaids,
this.showGoals = true,
this.showTopZappers = true,
this.showRaids = true,
});
@override
@ -40,7 +40,7 @@ class ChatWidget extends StatelessWidget {
var filters = [
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: [Nip51List.kMute], authors: moderators),
Filter(kinds: [1314], authors: moderators),
@ -118,9 +118,9 @@ class ChatWidget extends StatelessWidget {
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (zaps.isNotEmpty && (showTopZappers ?? true))
if (zaps.isNotEmpty && showTopZappers)
_TopZappersWidget(events: zaps),
if (stream.info.goal != null && (showGoals ?? true))
if (stream.info.goal != null && showGoals)
GoalWidget.id(stream.info.goal!),
Expanded(
child: ListView.builder(

View File

@ -27,7 +27,19 @@ class ChatZapWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_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,
),
),
),
],
),
);

View File

@ -17,7 +17,8 @@ class _NotificationsButtonWidget extends State<NotificationsButtonWidget> {
return ValueListenableBuilder(
valueListenable: notifications,
builder: (context, state, _) {
final isNotified = (state?.notifyKeys ?? []).contains(widget.pubkey);
if (state == null) return SizedBox();
final isNotified = state.notifyKeys.contains(widget.pubkey);
return IconButton(
iconSize: 20,
onPressed: () async {

View File

@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.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/const.dart';
import 'package:zap_stream_flutter/i18n/strings.g.dart';
@ -12,12 +13,14 @@ class StreamConfigWidget extends StatefulWidget {
final ZapStreamApi api;
final AccountInfo account;
final bool? hideEndpointConfig;
final Nip01Event? currentStream;
const StreamConfigWidget({
super.key,
required this.api,
required this.account,
this.hideEndpointConfig,
this.currentStream,
});
@override
@ -41,6 +44,19 @@ class _StreamConfigWidget extends State<StreamConfigWidget> {
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
Widget build(BuildContext context) {
return ValueListenableBuilder(
@ -88,14 +104,15 @@ class _StreamConfigWidget extends State<StreamConfigWidget> {
],
),
if (endpoint != null && !(widget.hideEndpointConfig ?? false))
Row(
spacing: 8,
children:
endpoint.capabilities
.map(
(e) => PillWidget(color: LAYER_3, child: Text(e)),
)
.toList(),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children:
endpoint.capabilities
.map((e) => _variantWidget(e))
.toList(),
),
),
TextField(
@ -154,6 +171,19 @@ class _StreamConfigWidget extends State<StreamConfigWidget> {
BasicButton.text(
t.button.save,
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(
title: _title.text,
summary: _summary.text,

View File

@ -1,6 +1,7 @@
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/theme.dart';
class MainVideoPlayerWidget extends StatefulWidget {
final String url;
@ -34,7 +35,7 @@ class _MainVideoPlayerWidget extends State<MainVideoPlayerWidget> {
aspectRatio: widget.aspectRatio,
autoPlay: widget.autoPlay,
isLive: widget.isLive,
artist: "zap.stream"
artist: "zap.stream",
);
super.initState();
@ -48,6 +49,26 @@ class _MainVideoPlayerWidget extends State<MainVideoPlayerWidget> {
@override
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);
},
);
}
}

View File

@ -1,7 +1,7 @@
name: zap_stream_flutter
description: "zap.stream"
publish_to: "none"
version: 1.1.0+14
version: 1.1.3+17
environment:
sdk: ^3.7.2