6 Commits

Author SHA1 Message Date
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
11 changed files with 385 additions and 140 deletions

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

@ -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

@ -177,6 +177,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 +338,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;
StreamEvent? _stream;
bool isWidgetVisible(BuildContext context) {
final router = GoRouter.of(context);
@ -127,97 +129,184 @@ 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, state, player) {
if (state?.isPortrait == true) {
return _buildPortraitStream(context, stream, player!);
}
return _buildLandscapeStream(context, stream, player!);
},
child: streamWidget,
);
},
);
}
Widget _buildStream(BuildContext context, StreamEvent stream) {
Widget _buildPlayer(BuildContext context, StreamEvent stream) {
return (stream.info.stream != null && !_offScreen)
? MainVideoPlayerWidget(
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: [
child,
Positioned(child: child),
Positioned(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
LAYER_0.withAlpha(50),
LAYER_0.withAlpha(200),
LAYER_0.withAlpha(255),
],
stops: [0.0, 0.2, 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),
child: ShaderMask(
shaderCallback: (Rect bounds) {
return 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(bounds);
},
blendMode: BlendMode.dstIn,
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,65 @@ 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,
),
);
_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 +168,7 @@ class MainPlayer extends BaseAudioHandler {
MediaControl.stop,
],
playing: isPlaying,
androidCompactActionIndices: [1],
processingState: switch (_chewieController
?.videoPlayerController
.value
@ -97,5 +179,10 @@ class MainPlayer extends BaseAudioHandler {
},
),
);
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

@ -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.2+16
environment:
sdk: ^3.7.2