bunny_video_player 0.1.0
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));
}
}