bunny_video_player 0.1.1
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 ─────────────────────────────────────────────────