mirror of
https://github.com/nostrlabs-io/zap-stream-flutter.git
synced 2025-06-15 11:48:21 +00:00
428 lines
15 KiB
Dart
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");
|
|
};
|
|
}
|