bunny_video_player 0.1.1 copy "bunny_video_player: ^0.1.1" to clipboard
bunny_video_player: ^0.1.1 copied to clipboard

Flutter video player for Bunny.net with signed/tokenized HLS/MP4, PiP, and full control from Dart. Android and iOS.

example/lib/main.dart

library;

import 'dart:async';
import 'dart:ui';

import 'package:bunny_video_player/bunny_video_player.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

part 'src/core/design_tokens.dart';
part 'src/models/video_source.dart';
part 'src/sheets/change_video_sheet.dart';
part 'src/widgets/demo_widgets.dart';

const String _exampleLibraryId = String.fromEnvironment('BUNNY_LIBRARY_ID', defaultValue: '');
const String _exampleVideoId = String.fromEnvironment('BUNNY_VIDEO_ID', defaultValue: '');
const String _exampleAccessKey = String.fromEnvironment('BUNNY_ACCESS_KEY', defaultValue: '');

void main() {
  runApp(const BunnyDemoApp());
}

// ── Design Tokens ────────────────────────────────────────────────────────────

// ── App Root ─────────────────────────────────────────────────────────────────

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark().copyWith(
        scaffoldBackgroundColor: _Colors.bg,
        colorScheme: const ColorScheme.dark(primary: _Colors.accent, surface: _Colors.surface),
      ),
      home: const BunnyDemoPage(),
    );
  }
}

// ── Main Page ─────────────────────────────────────────────────────────────────

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

  @override
  State<BunnyDemoPage> createState() => _BunnyDemoPageState();
}

class _BunnyDemoPageState extends State<BunnyDemoPage> with WidgetsBindingObserver, TickerProviderStateMixin {
  // ── Active video source ───────────────────────────────────────────────────
  static const VideoSource _defaultSource = VideoSource(
    title: 'Demo Video',
    libraryId: _exampleLibraryId,
    videoId: _exampleVideoId,
    accessKey: _exampleAccessKey,
  );
  VideoSource _activeSource = _defaultSource;

  late BunnyVideoController _controller;
  StreamSubscription<BunnyPlaybackState>? _stateSubscription;
  StreamSubscription<Duration>? _positionSubscription;
  StreamSubscription<Duration>? _bufferedSubscription;
  StreamSubscription<String>? _errorSubscription;
  StreamSubscription<bool>? _pipSubscription;

  BunnyPlaybackState _playbackState = BunnyPlaybackState.idle;
  Duration _position = Duration.zero;
  Duration _buffered = Duration.zero;
  String? _errorMessage;
  bool _autoPlay = false;
  bool _looping = false;
  bool _allowBackgroundPlayback = true;
  bool _disposed = false;
  bool _isPipMode = false;

  late AnimationController _pulseController;
  late AnimationController _fadeController;
  late Animation<double> _fadeAnimation;

  @override
  void initState() {
    super.initState();
    SystemChrome.setSystemUIOverlayStyle(
      const SystemUiOverlayStyle(statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.light),
    );
    WidgetsBinding.instance.addObserver(this);
    _pulseController = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat(reverse: true);
    _fadeController = AnimationController(vsync: this, duration: const Duration(milliseconds: 600));
    _fadeAnimation = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut);
    _createController();
    _initialize();
    _fadeController.forward();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (!mounted) return;
    setState(() => _errorMessage = null);
    if (state == AppLifecycleState.resumed && _isPipMode) {
      setState(() => _isPipMode = false);
    }
  }

  void _createController() {
    _controller = BunnyVideoController(playerId: 0);
    _disposed = false;
    _stateSubscription = _controller.playbackStateStream.listen((s) {
      if (!mounted) return;
      setState(() => _playbackState = s);
    });
    _positionSubscription = _controller.positionStream.listen((p) {
      if (!mounted) return;
      setState(() => _position = p);
    });
    _bufferedSubscription = _controller.bufferedStream.listen((b) {
      if (!mounted) return;
      setState(() => _buffered = b);
    });
    _errorSubscription = _controller.errorStream.listen((e) {
      if (!mounted) return;
      setState(() => _errorMessage = e);
    });
    _pipSubscription = _controller.pipModeStream.listen((active) {
      if (!mounted) return;
      setState(() => _isPipMode = active);
    });
  }

  Future<void> _initialize() async {
    if (_disposed) return;
    try {
      setState(() => _errorMessage = null);
      await _controller.initialize(
        libraryId: _activeSource.libraryId,
        videoId: _activeSource.videoId,
        accessKey: _activeSource.accessKey,
        autoPlay: _autoPlay,
        looping: _looping,
        allowBackgroundPlayback: _allowBackgroundPlayback,
      );
    } catch (e) {
      if (!mounted) return;
      final raw = e.toString();
      setState(() {
        _errorMessage = _is404(raw) ? 'Video not found (404). Check libraryId/videoId/accessKey for "${_activeSource.title}".' : raw;
      });
    }
  }

  Future<void> _disposeController() async {
    await _stateSubscription?.cancel();
    await _positionSubscription?.cancel();
    await _bufferedSubscription?.cancel();
    await _errorSubscription?.cancel();
    await _pipSubscription?.cancel();
    await _controller.dispose();
    if (!mounted) return;
    setState(() {
      _disposed = true;
      _playbackState = BunnyPlaybackState.idle;
      _position = Duration.zero;
      _buffered = Duration.zero;
    });
  }

  Future<void> _recreate() async {
    await _disposeController();
    _createController();
    await _initialize();
  }

  Future<void> _resetToDefaultSource() async {
    await _switchToSource(_defaultSource);
  }

  Future<void> _togglePlayPause() async {
    if (_disposed) return;
    try {
      if (_playbackState == BunnyPlaybackState.playing) {
        await _controller.pause();
      } else {
        await _controller.play();
      }
    } catch (e) {
      if (!mounted) return;
      setState(() => _errorMessage = e.toString());
    }
  }

  Future<void> _enterPip() async {
    if (_disposed) return;
    setState(() => _isPipMode = true);
    await _controller.enterPictureInPicture();
  }

  // ── Change Video ──────────────────────────────────────────────────────────

  Future<void> _switchToSource(VideoSource source) async {
    if (_disposed) return;
    final previous = _activeSource;
    setState(() => _activeSource = source);
    try {
      await _controller.switchSource(
        libraryId: source.libraryId,
        videoId: source.videoId,
        accessKey: source.accessKey,
        looping: _looping,
        allowBackgroundPlayback: _allowBackgroundPlayback,
      );
      if (!mounted) return;
      setState(() => _errorMessage = null);
    } catch (e) {
      if (!mounted) return;
      final raw = e.toString();
      if (_is404(raw)) {
        setState(() {
          _activeSource = previous;
          _errorMessage = 'Selected video not found (404). Reverted to "${previous.title}".';
        });
        try {
          await _controller.switchSource(
            libraryId: previous.libraryId,
            videoId: previous.videoId,
            accessKey: previous.accessKey,
            looping: _looping,
            allowBackgroundPlayback: _allowBackgroundPlayback,
          );
        } catch (_) {}
      } else {
        setState(() => _errorMessage = raw);
      }
    }
  }

  bool _is404(String message) {
    final lower = message.toLowerCase();
    return lower.contains('404') || lower.contains('not found');
  }

  void _openChangeVideoSheet() {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (_) => _ChangeVideoSheet(
        currentSource: _activeSource,
        onSourceSelected: (source) async {
          Navigator.pop(context);
          await _switchToSource(source);
        },
      ),
    );
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _pulseController.dispose();
    _fadeController.dispose();
    _disposeController();
    super.dispose();
  }

  // ── Helpers ──────────────────────────────────────────────────────────────

  String _formatDuration(Duration d) {
    final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
    final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
    return '$m:$s';
  }

  Color get _stateColor => switch (_playbackState) {
    BunnyPlaybackState.playing => _Colors.success,
    BunnyPlaybackState.paused => _Colors.warning,
    BunnyPlaybackState.error => _Colors.error,
    _ => _Colors.textMuted,
  };

  String get _stateLabel => switch (_playbackState) {
    BunnyPlaybackState.idle => 'IDLE',
    BunnyPlaybackState.loading => 'LOADING',
    BunnyPlaybackState.playing => 'PLAYING',
    BunnyPlaybackState.paused => 'PAUSED',
    BunnyPlaybackState.completed => 'COMPLETED',
    BunnyPlaybackState.error => 'ERROR',
    _ => _playbackState.toString().split('.').last.toUpperCase(),
  };

  // ── Build ─────────────────────────────────────────────────────────────────

  @override
  Widget build(BuildContext context) {
    if (_isPipMode) {
      return Scaffold(
        backgroundColor: _Colors.bg,
        body: SafeArea(child: Center(child: _buildVideoCard())),
      );
    }

    return Scaffold(
      backgroundColor: _Colors.bg,
      body: FadeTransition(
        opacity: _fadeAnimation,
        child: CustomScrollView(
          physics: const BouncingScrollPhysics(),
          slivers: [
            _buildSliverAppBar(),
            SliverToBoxAdapter(
              child: Padding(
                padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    if (_errorMessage != null) ...[_buildErrorBanner(), const SizedBox(height: 16)],
                    _buildVideoCard(),
                    const SizedBox(height: 12),
                    _buildNowPlayingBar(),
                    const SizedBox(height: 20),
                    _buildStatsRow(),
                    const SizedBox(height: 20),
                    _buildControlsCard(),
                    const SizedBox(height: 20),
                    _buildSettingsCard(),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildSliverAppBar() {
    return SliverAppBar(
      backgroundColor: _Colors.bg,
      expandedHeight: 64,
      pinned: true,
      centerTitle: false,
      title: Row(
        children: [
          Container(
            width: 28,
            height: 28,
            decoration: BoxDecoration(
              gradient: _Gradients.accent,
              borderRadius: BorderRadius.circular(8),
              boxShadow: [BoxShadow(color: _Colors.accentGlow, blurRadius: 12)],
            ),
            child: const Icon(Icons.play_arrow_rounded, size: 18, color: Colors.white),
          ),
          const SizedBox(width: 10),
          const Text(
            'Bunny Player',
            style: TextStyle(
              fontFamily: 'SF Pro Display',
              fontSize: 18,
              fontWeight: FontWeight.w700,
              color: _Colors.textPrimary,
              letterSpacing: -0.3,
            ),
          ),
          const SizedBox(width: 6),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
            decoration: BoxDecoration(gradient: _Gradients.accent, borderRadius: BorderRadius.circular(4)),
            child: const Text(
              'PRO',
              style: TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: Colors.white),
            ),
          ),
        ],
      ),
      actions: [
        _GlassIconButton(icon: Icons.picture_in_picture_alt_rounded, onTap: _enterPip),
        const SizedBox(width: 8),
      ],
    );
  }

  Widget _buildVideoCard() {
    return Container(
      padding: const EdgeInsets.all(8),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(20),
        border: Border.all(color: _Colors.border),
        boxShadow: [BoxShadow(color: _Colors.accentGlow.withValues(alpha: 0.15), blurRadius: 40, spreadRadius: -5)],
      ),
      clipBehavior: Clip.antiAlias,
      child: Stack(
        children: [
          ClipRRect(
            borderRadius: BorderRadius.circular(14),
            child: const AspectRatio(
              aspectRatio: 16 / 9,
              child: BunnyVideoView(config: BunnyPlayerViewConfig(progressBarBottomMarginDp: -5)),
            ),
          ),
          Positioned(
            top: 12,
            right: 12,
            child: _StateBadge(label: _stateLabel, color: _stateColor),
          ),
        ],
      ),
    );
  }

  Widget _buildNowPlayingBar() {
    return GestureDetector(
      onTap: _openChangeVideoSheet,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
        decoration: BoxDecoration(
          color: _Colors.card,
          borderRadius: BorderRadius.circular(14),
          border: Border.all(color: _Colors.border),
        ),
        child: Row(
          children: [
            // Animated playing indicator
            _PlayingIndicator(isPlaying: _playbackState == BunnyPlaybackState.playing),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    _activeSource.title,
                    style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: _Colors.textPrimary),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                  const SizedBox(height: 2),
                  Text(
                    'ID: ${_activeSource.videoId.length > 20 ? '${_activeSource.videoId.substring(0, 20)}…' : _activeSource.videoId}',
                    style: const TextStyle(fontSize: 11, color: _Colors.textMuted),
                  ),
                ],
              ),
            ),
            const SizedBox(width: 10),
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
              decoration: BoxDecoration(gradient: _Gradients.accent, borderRadius: BorderRadius.circular(8)),
              child: const Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(Icons.swap_horiz_rounded, size: 14, color: Colors.white),
                  SizedBox(width: 4),
                  Text(
                    'Change',
                    style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildStatsRow() {
    return Row(
      children: [
        Expanded(
          child: _StatCard(icon: Icons.access_time_rounded, label: 'Position', value: _formatDuration(_position)),
        ),
        const SizedBox(width: 10),
        Expanded(
          child: _StatCard(icon: Icons.cloud_download_outlined, label: 'Buffered', value: _formatDuration(_buffered)),
        ),
        const SizedBox(width: 10),
        Expanded(
          child: _StatCard(icon: Icons.radio_button_checked_rounded, label: 'State', value: _stateLabel, valueColor: _stateColor),
        ),
      ],
    );
  }

  Widget _buildErrorBanner() {
    return Container(
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        color: _Colors.error.withValues(alpha: 0.1),
        border: Border.all(color: _Colors.error.withValues(alpha: 0.3)),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Row(
        children: [
          Icon(Icons.error_outline_rounded, color: _Colors.error, size: 18),
          const SizedBox(width: 10),
          Expanded(
            child: Text(_errorMessage!, style: const TextStyle(color: _Colors.error, fontSize: 13)),
          ),
          GestureDetector(
            onTap: () => setState(() => _errorMessage = null),
            child: Icon(Icons.close_rounded, color: _Colors.error.withValues(alpha: 0.6), size: 16),
          ),
        ],
      ),
    );
  }

  Widget _buildControlsCard() {
    final isPlaying = _playbackState == BunnyPlaybackState.playing;

    return _GlassCard(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const _SectionLabel(text: 'Playback Controls'),
          const SizedBox(height: 16),
          Row(
            children: [
              Expanded(
                flex: 2,
                child: _PrimaryButton(
                  onTap: _togglePlayPause,
                  gradient: _Gradients.accent,
                  icon: isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
                  label: isPlaying ? 'Pause' : 'Play',
                ),
              ),
              const SizedBox(width: 10),
              Expanded(
                child: _SecondaryButton(icon: Icons.refresh_rounded, label: 'Reinit', onTap: _initialize),
              ),
              const SizedBox(width: 10),
              Expanded(
                child: _SecondaryButton(icon: Icons.restart_alt_rounded, label: 'Reset', onTap: _resetToDefaultSource),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildSettingsCard() {
    return _GlassCard(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const _SectionLabel(text: 'Player Settings'),
          const SizedBox(height: 8),
          _ModernSwitch(
            label: 'Auto Play',
            subtitle: 'Start playback on initialization',
            icon: Icons.play_circle_outline_rounded,
            value: _autoPlay,
            onChanged: (v) => setState(() => _autoPlay = v),
          ),
          _Divider(),
          _ModernSwitch(
            label: 'Looping',
            subtitle: 'Repeat video on completion',
            icon: Icons.loop_rounded,
            value: _looping,
            onChanged: (v) {
              setState(() => _looping = v);
              _controller.setLooping(v);
            },
          ),
          _Divider(),
          _ModernSwitch(
            label: 'Background Playback',
            subtitle: 'Continue playing when app is hidden',
            icon: Icons.headphones_rounded,
            value: _allowBackgroundPlayback,
            onChanged: (v) => setState(() => _allowBackgroundPlayback = v),
          ),
        ],
      ),
    );
  }
}

// ── Change Video Bottom Sheet ─────────────────────────────────────────────────
1
likes
0
points
254
downloads

Publisher

unverified uploader

Weekly Downloads

Flutter video player for Bunny.net with signed/tokenized HLS/MP4, PiP, and full control from Dart. Android and iOS.

Homepage

Topics

#video #streaming #player #pip #bunny

License

unknown (license)

Dependencies

flutter, meta

More

Packages that depend on bunny_video_player

Packages that implement bunny_video_player