Files
zap-stream-flutter/lib/pages/live.dart
Kieran 5ffab5f7bf fix: back press gesture blocked
fix: portrait player state issue
closes #49
2025-06-05 11:26:49 +01:00

428 lines
15 KiB
Dart

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: !_streaming,
onPopInvokedWithResult: (didPop, result) async {
if (_streaming && !didPop) {
final go = await showExitStreamDialog(context);
if (context.mounted && go == true) {
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)
.reversed
.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,
currentStream: ev,
);
},
).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");
};
}