bunny_video_player 0.1.0 copy "bunny_video_player: ^0.1.0" to clipboard
bunny_video_player: ^0.1.0 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

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';

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

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

class _Colors {
  static const bg = Color(0xFF080C14);
  static const surface = Color(0xFF0E1420);
  static const card = Color(0xFF131B2A);
  static const border = Color(0xFF1E2D45);
  static const accent = Color(0xFF3D7BFF);
  static const accentGlow = Color(0x403D7BFF);
  static const accentSecondary = Color(0xFF7B3DFF);
  static const textPrimary = Color(0xFFEBF0FF);
  static const textSecondary = Color(0xFF7A90B8);
  static const textMuted = Color(0xFF3D5070);
  static const success = Color(0xFF22D4A0);
  static const error = Color(0xFFFF4D6A);
  static const warning = Color(0xFFFFAA22);
}

class _Gradients {
  static const accent = LinearGradient(colors: [_Colors.accent, _Colors.accentSecondary]);
  static const cardOverlay = LinearGradient(
    begin: Alignment.topLeft,
    end: Alignment.bottomRight,
    colors: [Color(0x14FFFFFF), Color(0x04FFFFFF)],
  );
}

// ── Video Source Model ────────────────────────────────────────────────────────

class VideoSource {
  final String title;
  final String libraryId;
  final String videoId;
  final String accessKey;

  const VideoSource({required this.title, required this.libraryId, required this.videoId, required this.accessKey});
}

// ── 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: '596670',
    videoId: 'c6726c75-178e-45a1-9bfd-1469c636011f',
    accessKey: '5c46ffb5-a8fb-4176-9bbe-eff9e224c3198f047803-c5c5-4495-b87d-e170c3e95dfb',
  );
  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 ─────────────────────────────────────────────────

class _ChangeVideoSheet extends StatefulWidget {
  final VideoSource currentSource;
  final void Function(VideoSource) onSourceSelected;

  const _ChangeVideoSheet({required this.currentSource, required this.onSourceSelected});

  @override
  State<_ChangeVideoSheet> createState() => _ChangeVideoSheetState();
}

class _ChangeVideoSheetState extends State<_ChangeVideoSheet> with SingleTickerProviderStateMixin {
  late TabController _tabController;

  final _titleCtrl = TextEditingController();
  final _libraryIdCtrl = TextEditingController();
  final _videoIdCtrl = TextEditingController();
  final _accessKeyCtrl = TextEditingController();
  final _formKey = GlobalKey<FormState>();

  bool _obscureKey = true;

  // Example preset videos — replace with your own
  final List<VideoSource> _presets = const [
    VideoSource(
      title: 'Demo Video',
      libraryId: '596670',
      videoId: 'c6726c75-178e-45a1-9bfd-1469c636011f',
      accessKey: '5c46ffb5-a8fb-4176-9bbe-eff9e224c3198f047803-c5c5-4495-b87d-e170c3e95dfb',
    ),
    VideoSource(
      title: 'Earth Video',
      libraryId: '589214',
      videoId: '58a20151-6e94-44fd-82a1-6c7d45446410',
      accessKey: '5c46ffb5-a8fb-4176-9bbe-eff9e224c3198f047803-c5c5-4495-b87d-e170c3e95dfb',
    ),
    VideoSource(
      title: 'Sample Clip B',
      libraryId: '597891',
      videoId: '74594843-929a-4b56-b900-633dfd7d6133',
      accessKey: '5c46ffb5-a8fb-4176-9bbe-eff9e224c3198f047803-c5c5-4495-b87d-e170c3e95dfb',
    ),
  ];

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
    // Pre-fill with current values
    _titleCtrl.text = widget.currentSource.title;
    _libraryIdCtrl.text = widget.currentSource.libraryId;
    _videoIdCtrl.text = widget.currentSource.videoId;
    _accessKeyCtrl.text = widget.currentSource.accessKey;
  }

  @override
  void dispose() {
    _tabController.dispose();
    _titleCtrl.dispose();
    _libraryIdCtrl.dispose();
    _videoIdCtrl.dispose();
    _accessKeyCtrl.dispose();
    super.dispose();
  }

  void _submitCustom() {
    if (!_formKey.currentState!.validate()) return;
    widget.onSourceSelected(
      VideoSource(
        title: _titleCtrl.text.trim().isEmpty ? 'Custom Video' : _titleCtrl.text.trim(),
        libraryId: _libraryIdCtrl.text.trim(),
        videoId: _videoIdCtrl.text.trim(),
        accessKey: _accessKeyCtrl.text.trim(),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final bottomPadding = MediaQuery.of(context).viewInsets.bottom;

    return ClipRRect(
      borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
        child: Container(
          constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.88),
          padding: EdgeInsets.only(bottom: bottomPadding),
          decoration: const BoxDecoration(
            color: _Colors.surface,
            borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
            border: Border(top: BorderSide(color: _Colors.border)),
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              // Handle
              const SizedBox(height: 12),
              Container(
                width: 36,
                height: 4,
                decoration: BoxDecoration(color: _Colors.border, borderRadius: BorderRadius.circular(2)),
              ),
              const SizedBox(height: 20),
              // Header
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 20),
                child: Row(
                  children: [
                    Container(
                      width: 36,
                      height: 36,
                      decoration: BoxDecoration(gradient: _Gradients.accent, borderRadius: BorderRadius.circular(10)),
                      child: const Icon(Icons.video_library_rounded, size: 18, color: Colors.white),
                    ),
                    const SizedBox(width: 12),
                    const Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          'Change Video',
                          style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: _Colors.textPrimary),
                        ),
                        Text('Select a preset or enter custom IDs', style: TextStyle(fontSize: 12, color: _Colors.textMuted)),
                      ],
                    ),
                    const Spacer(),
                    GestureDetector(
                      onTap: () => Navigator.pop(context),
                      child: Container(
                        width: 30,
                        height: 30,
                        decoration: BoxDecoration(
                          color: _Colors.card,
                          borderRadius: BorderRadius.circular(8),
                          border: Border.all(color: _Colors.border),
                        ),
                        child: const Icon(Icons.close_rounded, size: 16, color: _Colors.textSecondary),
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 20),
              // Tabs
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 20),
                child: Container(
                  height: 40,
                  padding: const EdgeInsets.all(4),
                  decoration: BoxDecoration(
                    color: _Colors.card,
                    borderRadius: BorderRadius.circular(12),
                    border: Border.all(color: _Colors.border),
                  ),
                  child: TabBar(
                    controller: _tabController,
                    indicator: BoxDecoration(gradient: _Gradients.accent, borderRadius: BorderRadius.circular(8)),
                    indicatorSize: TabBarIndicatorSize.tab,
                    dividerColor: Colors.transparent,
                    labelColor: Colors.white,
                    unselectedLabelColor: _Colors.textMuted,
                    labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
                    tabs: const [
                      Tab(text: 'Presets'),
                      Tab(text: 'Custom'),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 16),
              Flexible(
                child: TabBarView(controller: _tabController, children: [_buildPresetsTab(), _buildCustomTab()]),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildPresetsTab() {
    return ListView.separated(
      padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
      itemCount: _presets.length,
      separatorBuilder: (_, __) => const SizedBox(height: 10),
      itemBuilder: (_, i) {
        final preset = _presets[i];
        final isCurrent = preset.videoId == widget.currentSource.videoId;
        return GestureDetector(
          onTap: () => widget.onSourceSelected(preset),
          child: AnimatedContainer(
            duration: const Duration(milliseconds: 200),
            padding: const EdgeInsets.all(14),
            decoration: BoxDecoration(
              color: isCurrent ? _Colors.accent.withValues(alpha: 0.1) : _Colors.card,
              borderRadius: BorderRadius.circular(14),
              border: Border.all(
                color: isCurrent ? _Colors.accent.withValues(alpha: 0.5) : _Colors.border,
                width: isCurrent ? 1.5 : 1,
              ),
            ),
            child: Row(
              children: [
                Container(
                  width: 42,
                  height: 42,
                  decoration: BoxDecoration(
                    color: isCurrent ? _Colors.accent.withValues(alpha: 0.2) : _Colors.surface,
                    borderRadius: BorderRadius.circular(10),
                    border: Border.all(color: isCurrent ? _Colors.accent.withValues(alpha: 0.4) : _Colors.border),
                  ),
                  child: Icon(
                    isCurrent ? Icons.play_circle_rounded : Icons.play_circle_outline_rounded,
                    color: isCurrent ? _Colors.accent : _Colors.textMuted,
                    size: 22,
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Row(
                        children: [
                          Text(
                            preset.title,
                            style: TextStyle(
                              fontSize: 14,
                              fontWeight: FontWeight.w600,
                              color: isCurrent ? _Colors.accent : _Colors.textPrimary,
                            ),
                          ),
                          if (isCurrent) ...[
                            const SizedBox(width: 8),
                            Container(
                              padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
                              decoration: BoxDecoration(
                                color: _Colors.success.withValues(alpha: 0.15),
                                borderRadius: BorderRadius.circular(4),
                              ),
                              child: const Text(
                                'NOW PLAYING',
                                style: TextStyle(fontSize: 9, fontWeight: FontWeight.w800, color: _Colors.success, letterSpacing: 0.5),
                              ),
                            ),
                          ],
                        ],
                      ),
                      const SizedBox(height: 3),
                      Text(
                        'Library ${preset.libraryId} · ${preset.videoId.substring(0, 8)}…',
                        style: const TextStyle(fontSize: 11, color: _Colors.textMuted),
                      ),
                    ],
                  ),
                ),
                Icon(Icons.arrow_forward_ios_rounded, size: 14, color: isCurrent ? _Colors.accent : _Colors.textMuted),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildCustomTab() {
    return SingleChildScrollView(
      padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
      child: Form(
        key: _formKey,
        child: Column(
          children: [
            _SheetTextField(
              controller: _titleCtrl,
              label: 'Video Title',
              hint: 'My Awesome Video',
              icon: Icons.title_rounded,
              required: false,
            ),
            const SizedBox(height: 12),
            _SheetTextField(
              controller: _libraryIdCtrl,
              label: 'Library ID',
              hint: '596670',
              icon: Icons.folder_outlined,
              keyboardType: TextInputType.number,
              validator: (v) => (v == null || v.trim().isEmpty) ? 'Library ID is required' : null,
            ),
            const SizedBox(height: 12),
            _SheetTextField(
              controller: _videoIdCtrl,
              label: 'Video ID',
              hint: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
              icon: Icons.video_file_outlined,
              validator: (v) => (v == null || v.trim().isEmpty) ? 'Video ID is required' : null,
            ),
            const SizedBox(height: 12),
            _SheetTextField(
              controller: _accessKeyCtrl,
              label: 'Access Key',
              hint: 'Your Bunny access key',
              icon: Icons.vpn_key_outlined,
              obscureText: _obscureKey,
              validator: (v) => (v == null || v.trim().isEmpty) ? 'Access key is required' : null,
              suffix: GestureDetector(
                onTap: () => setState(() => _obscureKey = !_obscureKey),
                child: Icon(
                  _obscureKey ? Icons.visibility_off_outlined : Icons.visibility_outlined,
                  size: 18,
                  color: _Colors.textMuted,
                ),
              ),
            ),
            const SizedBox(height: 20),
            // Submit button
            GestureDetector(
              onTap: _submitCustom,
              child: Container(
                width: double.infinity,
                height: 52,
                decoration: BoxDecoration(
                  gradient: _Gradients.accent,
                  borderRadius: BorderRadius.circular(14),
                  boxShadow: [BoxShadow(color: _Colors.accent.withValues(alpha: 0.35), blurRadius: 20, offset: const Offset(0, 6))],
                ),
                child: const Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(Icons.play_arrow_rounded, color: Colors.white, size: 22),
                    SizedBox(width: 8),
                    Text(
                      'Load Video',
                      style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Sheet Text Field ──────────────────────────────────────────────────────────

class _SheetTextField extends StatelessWidget {
  final TextEditingController controller;
  final String label;
  final String hint;
  final IconData icon;
  final bool obscureText;
  final bool required;
  final TextInputType? keyboardType;
  final String? Function(String?)? validator;
  final Widget? suffix;

  const _SheetTextField({
    required this.controller,
    required this.label,
    required this.hint,
    required this.icon,
    this.obscureText = false,
    this.required = true,
    this.keyboardType,
    this.validator,
    this.suffix,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.only(left: 4, bottom: 6),
          child: Text(
            label,
            style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: _Colors.textSecondary, letterSpacing: 0.2),
          ),
        ),
        TextFormField(
          controller: controller,
          obscureText: obscureText,
          keyboardType: keyboardType,
          validator: validator,
          style: const TextStyle(fontSize: 14, color: _Colors.textPrimary, fontWeight: FontWeight.w500),
          decoration: InputDecoration(
            hintText: hint,
            hintStyle: const TextStyle(fontSize: 13, color: _Colors.textMuted),
            prefixIcon: Icon(icon, size: 18, color: _Colors.textMuted),
            suffixIcon: suffix != null ? Padding(padding: const EdgeInsets.only(right: 12), child: suffix) : null,
            suffixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0),
            filled: true,
            fillColor: _Colors.card,
            contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
            border: OutlineInputBorder(
              borderRadius: BorderRadius.circular(12),
              borderSide: const BorderSide(color: _Colors.border),
            ),
            enabledBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(12),
              borderSide: const BorderSide(color: _Colors.border),
            ),
            focusedBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(12),
              borderSide: const BorderSide(color: _Colors.accent, width: 1.5),
            ),
            errorBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(12),
              borderSide: const BorderSide(color: _Colors.error),
            ),
            focusedErrorBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(12),
              borderSide: const BorderSide(color: _Colors.error, width: 1.5),
            ),
            errorStyle: const TextStyle(color: _Colors.error, fontSize: 11),
          ),
        ),
      ],
    );
  }
}

// ── Playing Indicator ─────────────────────────────────────────────────────────

class _PlayingIndicator extends StatefulWidget {
  final bool isPlaying;
  const _PlayingIndicator({required this.isPlaying});

  @override
  State<_PlayingIndicator> createState() => _PlayingIndicatorState();
}

class _PlayingIndicatorState extends State<_PlayingIndicator> with SingleTickerProviderStateMixin {
  late AnimationController _ctrl;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true);
  }

  @override
  void dispose() {
    _ctrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 32,
      height: 32,
      child: widget.isPlaying
          ? AnimatedBuilder(
              animation: _ctrl,
              builder: (_, __) => Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                crossAxisAlignment: CrossAxisAlignment.end,
                children: List.generate(3, (i) {
                  final heights = [0.5, 1.0, 0.7];
                  final delays = [0.0, 0.3, 0.6];
                  final h =
                      8.0 +
                      8.0 *
                          ((_ctrl.value + delays[i]) % 1.0 < 0.5
                              ? (_ctrl.value + delays[i]) % 1.0 * 2
                              : (1 - ((_ctrl.value + delays[i]) % 1.0)) * 2) *
                          heights[i];
                  return Container(
                    width: 3,
                    height: h,
                    decoration: BoxDecoration(color: _Colors.accent, borderRadius: BorderRadius.circular(2)),
                  );
                }),
              ),
            )
          : const Icon(Icons.music_note_rounded, size: 18, color: _Colors.textMuted),
    );
  }
}

// ── Reusable Components ──────────────────────────────────────────────────────

class _GlassCard extends StatelessWidget {
  final Widget child;
  const _GlassCard({required this.child});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(18),
      decoration: BoxDecoration(
        color: _Colors.card,
        borderRadius: BorderRadius.circular(20),
        border: Border.all(color: _Colors.border),
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [_Colors.card, _Colors.card.withBlue(38)],
        ),
      ),
      child: child,
    );
  }
}

class _GlassIconButton extends StatelessWidget {
  final IconData icon;
  final VoidCallback onTap;
  const _GlassIconButton({required this.icon, required this.onTap});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        width: 38,
        height: 38,
        decoration: BoxDecoration(
          color: _Colors.card,
          borderRadius: BorderRadius.circular(10),
          border: Border.all(color: _Colors.border),
        ),
        child: Icon(icon, color: _Colors.textSecondary, size: 18),
      ),
    );
  }
}

class _StatCard extends StatelessWidget {
  final IconData icon;
  final String label;
  final String value;
  final Color? valueColor;
  const _StatCard({required this.icon, required this.label, required this.value, this.valueColor});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
      decoration: BoxDecoration(
        color: _Colors.card,
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: _Colors.border),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Icon(icon, color: _Colors.textMuted, size: 14),
          const SizedBox(height: 6),
          Text(
            value,
            style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: valueColor ?? _Colors.textPrimary, letterSpacing: -0.5),
          ),
          const SizedBox(height: 2),
          Text(label, style: const TextStyle(fontSize: 11, color: _Colors.textMuted)),
        ],
      ),
    );
  }
}

class _StateBadge extends StatelessWidget {
  final String label;
  final Color color;
  const _StateBadge({required this.label, required this.color});

  @override
  Widget build(BuildContext context) {
    return ClipRRect(
      borderRadius: BorderRadius.circular(8),
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
          decoration: BoxDecoration(
            color: Colors.black.withValues(alpha: 0.45),
            borderRadius: BorderRadius.circular(8),
            border: Border.all(color: color.withValues(alpha: 0.4)),
          ),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                width: 6,
                height: 6,
                decoration: BoxDecoration(color: color, shape: BoxShape.circle),
              ),
              const SizedBox(width: 5),
              Text(
                label,
                style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 0.8),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _SectionLabel extends StatelessWidget {
  final String text;
  const _SectionLabel({required this.text});

  @override
  Widget build(BuildContext context) {
    return Text(
      text,
      style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: _Colors.textSecondary, letterSpacing: 0.3),
    );
  }
}

class _PrimaryButton extends StatelessWidget {
  final VoidCallback onTap;
  final Gradient gradient;
  final IconData icon;
  final String label;
  const _PrimaryButton({required this.onTap, required this.gradient, required this.icon, required this.label});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        height: 50,
        decoration: BoxDecoration(
          gradient: gradient,
          borderRadius: BorderRadius.circular(14),
          boxShadow: [BoxShadow(color: _Colors.accent.withValues(alpha: 0.35), blurRadius: 20, offset: const Offset(0, 6))],
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(icon, color: Colors.white, size: 22),
            const SizedBox(width: 8),
            Text(
              label,
              style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15),
            ),
          ],
        ),
      ),
    );
  }
}

class _SecondaryButton extends StatelessWidget {
  final IconData icon;
  final String label;
  final VoidCallback onTap;
  const _SecondaryButton({required this.icon, required this.label, required this.onTap});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        height: 50,
        decoration: BoxDecoration(
          color: _Colors.surface,
          borderRadius: BorderRadius.circular(14),
          border: Border.all(color: _Colors.border),
        ),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(icon, color: _Colors.textSecondary, size: 18),
            const SizedBox(height: 3),
            Text(
              label,
              style: const TextStyle(color: _Colors.textSecondary, fontSize: 11, fontWeight: FontWeight.w500),
            ),
          ],
        ),
      ),
    );
  }
}

class _ModernSwitch extends StatelessWidget {
  final String label;
  final String subtitle;
  final IconData icon;
  final bool value;
  final ValueChanged<bool> onChanged;
  const _ModernSwitch({required this.label, required this.subtitle, required this.icon, required this.value, required this.onChanged});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 10),
      child: Row(
        children: [
          Container(
            width: 36,
            height: 36,
            decoration: BoxDecoration(
              color: value ? _Colors.accent.withValues(alpha: 0.15) : _Colors.surface,
              borderRadius: BorderRadius.circular(10),
              border: Border.all(color: value ? _Colors.accent.withValues(alpha: 0.4) : _Colors.border),
            ),
            child: Icon(icon, color: value ? _Colors.accent : _Colors.textMuted, size: 17),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  label,
                  style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _Colors.textPrimary),
                ),
                Text(subtitle, style: const TextStyle(fontSize: 11, color: _Colors.textMuted)),
              ],
            ),
          ),
          GestureDetector(
            onTap: () => onChanged(!value),
            child: AnimatedContainer(
              duration: const Duration(milliseconds: 250),
              curve: Curves.easeInOut,
              width: 46,
              height: 26,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(13),
                gradient: value ? _Gradients.accent : null,
                color: value ? null : _Colors.surface,
                border: Border.all(color: value ? Colors.transparent : _Colors.border),
              ),
              child: AnimatedAlign(
                duration: const Duration(milliseconds: 250),
                curve: Curves.easeInOut,
                alignment: value ? Alignment.centerRight : Alignment.centerLeft,
                child: Container(
                  width: 20,
                  height: 20,
                  margin: const EdgeInsets.symmetric(horizontal: 3),
                  decoration: BoxDecoration(
                    color: Colors.white,
                    shape: BoxShape.circle,
                    boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.2), blurRadius: 4)],
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _Divider extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(height: 1, margin: const EdgeInsets.symmetric(vertical: 2), color: _Colors.border.withValues(alpha: 0.5));
  }
}
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