gravix_effects_webrtc 1.1.2
gravix_effects_webrtc: ^1.1.2 copied to clipboard
Gravix Effects output for flutter_webrtc / LiveKit — publishes the running GravixCamera as a WebRTC video track.
example/lib/main.dart
// Gravix Effects + LiveKit — the camera with effects, looks, the ready-made beauty panel, and Go live / End live to a
// LiveKit room with the Gravix picture + the microphone.
//
// flutter run --dart-define=LK_URL=wss://<your livekit host> --dart-define=LK_TOKEN=<a join token for a room>
//
// Any flutter_webrtc stack works the same way: GravixWebRtc.attach(camera) gives a normal MediaStream + video track.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gravix_effects_webrtc/beauty_ui.dart';
import 'package:gravix_effects_webrtc/gravix_effects_webrtc.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:permission_handler/permission_handler.dart';
const _liveKitUrl = String.fromEnvironment('LK_URL');
const _liveKitToken = String.fromEnvironment('LK_TOKEN');
void main() => runApp(const MaterialApp(debugShowCheckedModeBanner: false, home: LiveScreen()));
class LiveScreen extends StatefulWidget {
const LiveScreen({super.key});
@override
State<LiveScreen> createState() => _LiveScreenState();
}
class _LiveScreenState extends State<LiveScreen> {
final _beauty = GravixBeautyController.shared; // remembers the chosen look + sliders across camera restarts
GravixCamera? _cam;
GravixWebRtcOutput? _out; // the camera's picture as a flutter_webrtc track
lk.Room? _room;
List<GravixLook> _looks = const [];
String? _error;
bool _busy = false;
@override
void initState() {
super.initState();
_start();
}
Future<void> _start() async {
if (!GravixCamera.isSupported) return setState(() => _error = 'Gravix Effects runs on Android only.');
try {
unawaited(GravixCamera.preload()); // loads the face model while the permission dialog is open
if (!await GravixCamera.requestPermission()) return setState(() => _error = 'Camera permission is needed.');
// streaming: false — no frames go to WebRTC until Go live (saves the per-frame stream copy on the preview screen).
final cam = await GravixCamera.start(lens: GravixLens.front, fps: 30, streaming: false);
await _beauty.attach(cam);
final out = await GravixWebRtc.attach(cam);
final looks = [for (final name in await cam.builtinLooks()) await GravixLooks.builtin(name)];
if (!mounted) {
await cam.stop();
return;
}
setState(() {
_cam = cam;
_out = out;
_looks = looks;
});
} on GravixException catch (e) {
setState(() => _error = 'Camera error ${e.code}: ${e.message ?? ''}');
}
}
Future<void> _goLive() async {
final cam = _cam, out = _out;
if (cam == null || out == null) return;
if (_liveKitUrl.isEmpty || _liveKitToken.isEmpty) {
return _toast('Run with --dart-define=LK_URL=… --dart-define=LK_TOKEN=…');
}
if (!(await Permission.microphone.request()).isGranted) return _toast('Microphone permission is needed.');
setState(() => _busy = true);
try {
final room = lk.Room(roomOptions: const lk.RoomOptions(adaptiveStream: false, dynacast: true));
await room.connect(_liveKitUrl, _liveKitToken);
await cam.setStreaming(true);
// The Gravix track, published like a camera track. The stream is sent un-mirrored (as the camera sees it);
// viewers mirror a front-camera host themselves, or start the camera with mirrorStream: true.
// ignore: invalid_use_of_internal_member
final track = lk.LocalVideoTrack(lk.TrackSource.camera, out.stream, out.videoTrack, const lk.CameraCaptureOptions());
await room.localParticipant!.publishVideoTrack(track,
publishOptions: const lk.VideoPublishOptions(
videoCodec: 'vp8',
simulcast: true,
videoEncoding: lk.VideoEncoding(maxBitrate: 1200000, maxFramerate: 30),
));
await room.localParticipant!.setMicrophoneEnabled(true);
setState(() => _room = room);
} catch (e) {
await cam.setStreaming(false);
_toast('Could not go live: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _endLive() async {
final cam = _cam;
setState(() => _busy = true);
await _room?.disconnect();
// LiveKit disposes a published track on unpublish: give the next Go live a fresh one.
await _out?.detach();
if (cam != null) {
await cam.setStreaming(false);
_out = await GravixWebRtc.attach(cam);
}
if (mounted) {
setState(() {
_room = null;
_busy = false;
});
}
}
void _toast(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
@override
void dispose() {
_room?.disconnect();
final cam = _cam;
if (cam != null) {
_beauty.detach(cam);
cam.stop(); // also disposes the output's track + stream
}
super.dispose();
}
void _openBeautyPanel() => showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
barrierColor: Colors.transparent,
builder: (_) => GravixBeautyPanel(controller: _beauty),
);
@override
Widget build(BuildContext context) {
final cam = _cam;
final live = _room != null;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(fit: StackFit.expand, children: [
if (cam != null)
GravixPreview(cam) // mirrored for the front camera, fills the screen
else
Center(
child: _error == null
? const CircularProgressIndicator()
: Text(_error!, style: const TextStyle(color: Colors.white), textAlign: TextAlign.center),
),
if (live)
const SafeArea(
child: Padding(
padding: EdgeInsets.all(16),
child: Chip(label: Text('LIVE'), backgroundColor: Colors.red, labelStyle: TextStyle(color: Colors.white)),
),
),
if (cam != null)
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
LooksRow(looks: _looks, controller: _beauty),
Padding(
padding: const EdgeInsets.all(16),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
FilledButton.tonalIcon(
onPressed: _openBeautyPanel, icon: const Icon(Icons.face_retouching_natural), label: const Text('Beauty')),
FilledButton.tonalIcon(onPressed: cam.switchCamera, icon: const Icon(Icons.cameraswitch), label: const Text('Flip')),
FilledButton.icon(
onPressed: _busy ? null : (live ? _endLive : _goLive),
style: FilledButton.styleFrom(backgroundColor: live ? Colors.grey : Colors.red),
icon: Icon(live ? Icons.stop : Icons.videocam),
label: Text(live ? 'End live' : 'Go live'),
),
]),
),
]),
),
),
]),
);
}
}
/// The built-in looks as tappable thumbnails; the chosen one is outlined.
class LooksRow extends StatelessWidget {
const LooksRow({super.key, required this.looks, required this.controller});
final List<GravixLook> looks;
final GravixBeautyController controller;
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: controller,
builder: (context, _) => SizedBox(
height: 92,
child: ListView(scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), children: [
for (final look in looks)
GestureDetector(
onTap: () => controller.selectLookFile(look),
child: Container(
width: 68,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: controller.isLook(look) ? Colors.white : Colors.transparent, width: 2),
image: look.thumbnail == null ? null : DecorationImage(image: MemoryImage(look.thumbnail!), fit: BoxFit.cover),
color: Colors.white24,
),
),
const SizedBox(height: 4),
Text(look.name, style: const TextStyle(color: Colors.white, fontSize: 11), overflow: TextOverflow.ellipsis),
]),
),
),
]),
),
);
}