livekit_pip 0.1.0+1 copy "livekit_pip: ^0.1.0+1" to clipboard
livekit_pip: ^0.1.0+1 copied to clipboard

Native Picture-in-Picture support for LiveKit video calls on Android and iOS.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:livekit_pip/livekit_pip.dart';
import 'package:permission_handler/permission_handler.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'livekit_pip Example',
      theme: ThemeData.dark(useMaterial3: true),
      home: const ConnectPage(),
    );
  }
}

// ──── Connect Page ──────────────────────────────────────────────────────

class ConnectPage extends StatefulWidget {
  const ConnectPage({super.key});

  @override
  State<ConnectPage> createState() => _ConnectPageState();
}

class _ConnectPageState extends State<ConnectPage> {
  final _urlController = TextEditingController(
    text: 'wss://piptest-fiws50ef.livekit.cloud',
  );
  final _tokenController = TextEditingController(
    text:
        // ignore: lines_longer_than_80_chars, JWT token cannot be split
        'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBUEkyUWFVVEM3d3ozRkIiLCJzdWIiOiJ1c2VyMiIsIm5iZiI6MTc4MTk1Mjk1NiwiZXhwIjoxNzgxOTU2NTU2LCJuYW1lIjoiVXNlciAyIiwidmlkZW8iOnsicm9vbSI6InRlc3Qtcm9vbSIsInJvb21Kb2luIjp0cnVlLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWUsImNhblB1Ymxpc2hEYXRhIjp0cnVlfX0.9S0KcU2DNT_a1Jf10a6ccP8KGqXSDF40BZs8Ig38Tgc',
  );
  bool _connecting = false;

  @override
  void dispose() {
    _urlController.dispose();
    _tokenController.dispose();
    super.dispose();
  }

  Future<void> _connect() async {
    final url = _urlController.text.trim();
    final token = _tokenController.text.trim();
    if (url.isEmpty || token.isEmpty) return;
    setState(() => _connecting = true);
    Room? room;
    try {
      room = Room();
      await room.connect(url, token);
    } catch (e) {
      room?.disconnect().ignore();
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Connect failed: $e')),
        );
      }
      setState(() => _connecting = false);
      return;
    }

    // Request camera + mic at runtime before enabling tracks.
    // Non-fatal: navigate to call page regardless so remote video still works.
    await [Permission.camera, Permission.microphone].request();

    try {
      await room.localParticipant?.setCameraEnabled(true);
    } catch (e) {
      debugPrint('[livekit_pip_example] setCameraEnabled failed: $e');
    }
    try {
      await room.localParticipant?.setMicrophoneEnabled(true);
    } catch (e) {
      debugPrint('[livekit_pip_example] setMicrophoneEnabled failed: $e');
    }

    if (!mounted) {
      await room.disconnect();
      return;
    }
    setState(() => _connecting = false);
    await Navigator.of(context).push(
      MaterialPageRoute<void>(builder: (_) => CallPage(room: room!)),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('livekit_pip — Connect')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(
              controller: _urlController,
              decoration: const InputDecoration(
                labelText: 'Server URL',
                hintText: 'wss://your-project.livekit.cloud',
                border: OutlineInputBorder(),
              ),
              keyboardType: TextInputType.url,
              autocorrect: false,
            ),
            const SizedBox(height: 16),
            TextField(
              controller: _tokenController,
              decoration: const InputDecoration(
                labelText: 'Token',
                hintText: 'Paste JWT token here',
                border: OutlineInputBorder(),
              ),
              maxLines: 3,
              autocorrect: false,
            ),
            const SizedBox(height: 8),
            Text(
              'Generate: node scripts/gen_tokens.mjs my-room',
              style: Theme.of(context).textTheme.bodySmall,
            ),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: _connecting ? null : _connect,
              child: _connecting
                  ? const SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : const Text('Connect'),
            ),
          ],
        ),
      ),
    );
  }
}

// ──── Call Page ─────────────────────────────────────────────────────────

class CallPage extends StatefulWidget {
  const CallPage({required this.room, super.key});

  final Room room;

  @override
  State<CallPage> createState() => _CallPageState();
}

class _CallPageState extends State<CallPage> {
  late final EventsListener<RoomEvent> _listener;
  final _pip = LiveKitPip();

  @override
  void initState() {
    super.initState();
    _listener = widget.room.createListener()
      ..on<ParticipantConnectedEvent>((_) => setState(() {}))
      ..on<ParticipantDisconnectedEvent>((_) => setState(() {}))
      ..on<TrackSubscribedEvent>((_) => setState(() {}))
      ..on<TrackUnsubscribedEvent>((_) => setState(() {}))
      ..on<LocalTrackPublishedEvent>((_) => setState(() {}))
      ..on<LocalTrackUnpublishedEvent>((_) => setState(() {}));
    unawaited(_initPip());
  }

  Future<void> _initPip() async {
    try {
      await _pip.initialize(
        room: widget.room,
        config: LiveKitPipConfiguration(
          android: AndroidPipConfiguration(
            pipWidgetBuilder: (ctx, room) => _AndroidPipContent(room: room),
          ),
          ios: const IosPipConfiguration(),
        ),
      );
    } on Exception catch (e) {
      debugPrint('[livekit_pip] initialize failed: $e');
    }
  }

  Future<void> _hangUp() async {
    await _pip.dispose();
    await widget.room.disconnect();
    if (mounted) Navigator.of(context).pop();
  }

  @override
  void dispose() {
    unawaited(_pip.dispose());
    unawaited(_listener.dispose());
    unawaited(widget.room.disconnect());
    super.dispose();
  }

  VideoTrack? _localVideo() {
    final local = widget.room.localParticipant;
    if (local == null) return null;
    for (final pub in local.videoTrackPublications) {
      if (pub.source == TrackSource.camera) return pub.track;
    }
    return null;
  }

  List<_ParticipantVideo> _remoteVideos() {
    final result = <_ParticipantVideo>[];
    for (final p in widget.room.remoteParticipants.values) {
      for (final pub in p.videoTrackPublications) {
        if (pub.source == TrackSource.camera) {
          final t = pub.track;
          if (t != null) {
            result.add(
              _ParticipantVideo(
                name: p.name.isNotEmpty ? p.name : p.identity,
                track: t,
              ),
            );
          }
        }
      }
    }
    return result;
  }

  @override
  Widget build(BuildContext context) {
    final remoteVideos = _remoteVideos();
    final localVideo = _localVideo();

    return Scaffold(
      backgroundColor: Colors.black,
      body: LiveKitPipScaffold(
        pip: _pip,
        builder: (context) => Stack(
          children: [
            // iOS: transparent source view for AVPictureInPictureController.
            // Must fill the screen so isPictureInPicturePossible stays true.
            // Placed first (lowest z-order) so Flutter content renders on top.
            Positioned.fill(child: LiveKitPipView(room: widget.room)),

            if (remoteVideos.isEmpty)
              const Center(
                child: Text(
                  'Waiting for participants…',
                  style: TextStyle(color: Colors.white54),
                ),
              )
            else
              _VideoGrid(videos: remoteVideos),

            if (localVideo != null)
              Positioned(
                right: 16,
                bottom: 100,
                width: 96,
                height: 128,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(8),
                  child: VideoTrackRenderer(
                    localVideo,
                    mirrorMode: VideoViewMirrorMode.mirror,
                    fit: VideoViewFit.cover,
                  ),
                ),
              ),

            Positioned(
              bottom: 0,
              left: 0,
              right: 0,
              child: SafeArea(
                child: _BottomBar(room: widget.room, onHangUp: _hangUp),
              ),
            ),
          ],
        ), // end Stack children list + Stack
      ), // end LiveKitPipScaffold
    ); // end Scaffold
  }
}

// ──── Video grid ────────────────────────────────────────────────────────

class _ParticipantVideo {
  const _ParticipantVideo({required this.name, required this.track});

  final String name;
  final VideoTrack track;
}

class _VideoGrid extends StatelessWidget {
  const _VideoGrid({required this.videos});

  final List<_ParticipantVideo> videos;

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      physics: const NeverScrollableScrollPhysics(),
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: videos.length == 1 ? 1 : 2,
        childAspectRatio: 3 / 4,
      ),
      itemCount: videos.length,
      itemBuilder: (_, i) {
        final v = videos[i];
        return RepaintBoundary(
          key: ValueKey(v.track),
          child: Stack(
            children: [
              VideoTrackRenderer(v.track, fit: VideoViewFit.cover),
              Positioned(
                bottom: 4,
                left: 4,
                child: Container(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 6,
                    vertical: 2,
                  ),
                  decoration: BoxDecoration(
                    color: Colors.black54,
                    borderRadius: BorderRadius.circular(4),
                  ),
                  child: Text(
                    v.name,
                    style: const TextStyle(color: Colors.white, fontSize: 11),
                  ),
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

// ──── Bottom control bar ────────────────────────────────────────────────

class _BottomBar extends StatefulWidget {
  const _BottomBar({required this.room, required this.onHangUp});

  final Room room;
  final VoidCallback onHangUp;

  @override
  State<_BottomBar> createState() => _BottomBarState();
}

class _BottomBarState extends State<_BottomBar> {
  late final EventsListener<RoomEvent> _listener;

  @override
  void initState() {
    super.initState();
    _listener = widget.room.createListener()
      ..on<TrackMutedEvent>((_) => setState(() {}))
      ..on<TrackUnmutedEvent>((_) => setState(() {}))
      ..on<LocalTrackPublishedEvent>((_) => setState(() {}))
      ..on<LocalTrackUnpublishedEvent>((_) => setState(() {}));
  }

  @override
  void dispose() {
    unawaited(_listener.dispose());
    super.dispose();
  }

  bool get _micEnabled =>
      widget.room.localParticipant?.isMicrophoneEnabled() ?? false;
  bool get _camEnabled =>
      widget.room.localParticipant?.isCameraEnabled() ?? false;

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.black87,
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          IconButton(
            icon: Icon(_micEnabled ? Icons.mic : Icons.mic_off),
            color: _micEnabled ? Colors.white : Colors.red,
            onPressed: () async {
              await widget.room.localParticipant?.setMicrophoneEnabled(
                !_micEnabled,
              );
            },
          ),
          IconButton(
            icon: Icon(_camEnabled ? Icons.videocam : Icons.videocam_off),
            color: _camEnabled ? Colors.white : Colors.red,
            onPressed: () async {
              await widget.room.localParticipant?.setCameraEnabled(
                !_camEnabled,
              );
            },
          ),
          IconButton(
            icon: const Icon(Icons.call_end),
            color: Colors.red,
            onPressed: widget.onHangUp,
          ),
        ],
      ),
    );
  }
}

// ──── Android PiP fallback content ─────────────────────────────────────

class _AndroidPipContent extends StatelessWidget {
  const _AndroidPipContent({required this.room});

  final Room room;

  @override
  Widget build(BuildContext context) {
    for (final p in room.remoteParticipants.values) {
      for (final pub in p.videoTrackPublications) {
        final track = pub.track;
        if (track != null && pub.source == TrackSource.camera) {
          return ColoredBox(
            color: Colors.black,
            child: VideoTrackRenderer(track, fit: VideoViewFit.cover),
          );
        }
      }
    }
    return const ColoredBox(
      color: Colors.black,
      child: Center(child: Icon(Icons.videocam, color: Colors.white, size: 32)),
    );
  }
}