mirror of
https://github.com/nostrlabs-io/zap-stream-flutter.git
synced 2025-06-16 20:08:50 +00:00
429
lib/pages/live.dart
Normal file
429
lib/pages/live.dart
Normal file
@ -0,0 +1,429 @@
|
||||
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: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (_streaming) {
|
||||
final go = await showExitStreamDialog(context);
|
||||
if (context.mounted) {
|
||||
if (go == true) {
|
||||
context.go("/");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
.firstWhereOrNull((e) => e.getFirstTag("status") == "live");
|
||||
if (ev == null) return SizedBox();
|
||||
final stream = StreamEvent(ev);
|
||||
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!),
|
||||
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,
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
_reloadAccount();
|
||||
});
|
||||
},
|
||||
icon: Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_account != 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");
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user