// pyramid_video_player.dart // Pyramid · Video Player // // Drei Varianten: // VideoPlayerVariant.defaultHover → Chrome erscheint nur bei Hover/Tap (für Vollbild-Embeds) // VideoPlayerVariant.minimal → Chrome immer sichtbar (für kleine Inline-Embeds) // VideoPlayerVariant.framed → Editorial: Stage in Card, Controls außerhalb (für Galerie-Karten) // // Hinweis: Dies ist ein UI-Mockup. Für echte Wiedergabe diesen Widget mit // `video_player`-Package verbinden (controller.value.position etc. an `position` mappen). // // Verwendung: // PyramidVideoPlayer( // variant: VideoPlayerVariant.defaultHover, // title: 'test clip', // subtitle: 'pyramid · 1080p', // duration: Duration(minutes: 4, seconds: 12), // position: Duration(minutes: 1, seconds: 28), // buffered: 0.65, // playing: true, // onTogglePlay: () { ... }, // onSeek: (Duration to) { ... }, // onSpeedChange: (double speed) { ... }, // onQualityChange: (String quality) { ... }, // ); import 'package:flutter/material.dart'; import 'pyramid_theme.dart'; enum VideoPlayerVariant { defaultHover, minimal, framed } class PyramidVideoPlayer extends StatefulWidget { final VideoPlayerVariant variant; final String title; final String subtitle; final Duration duration; final Duration position; final double buffered; // 0..1 final bool playing; final double volume; // 0..1 final VoidCallback? onTogglePlay; final ValueChanged? onSeek; final ValueChanged? onVolumeChange; final ValueChanged? onSpeedChange; final ValueChanged? onQualityChange; final VoidCallback? onSkipBack; final VoidCallback? onSkipForward; final VoidCallback? onFullscreen; final VoidCallback? onCaptions; final VoidCallback? onMore; const PyramidVideoPlayer({ super.key, this.variant = VideoPlayerVariant.defaultHover, required this.title, this.subtitle = '', required this.duration, required this.position, this.buffered = 0.0, this.playing = false, this.volume = 0.7, this.onTogglePlay, this.onSeek, this.onVolumeChange, this.onSpeedChange, this.onQualityChange, this.onSkipBack, this.onSkipForward, this.onFullscreen, this.onCaptions, this.onMore, }); @override State createState() => _PyramidVideoPlayerState(); } class _PyramidVideoPlayerState extends State { bool _hovered = false; String _quality = 'Auto'; double _speed = 1.0; bool _settingsOpen = false; static const _qualities = ['Auto', '1080p', '720p', '480p', '360p']; static const _speeds = [0.5, 1.0, 1.25, 1.5, 2.0]; double get progress => widget.duration.inMilliseconds == 0 ? 0 : widget.position.inMilliseconds / widget.duration.inMilliseconds; bool get _showChrome { if (widget.variant == VideoPlayerVariant.minimal) return true; if (widget.variant == VideoPlayerVariant.framed) return true; return _hovered || !widget.playing; } String _fmt(Duration d) { final m = d.inMinutes; final s = (d.inSeconds % 60).toString().padLeft(2, '0'); return '$m:$s'; } @override Widget build(BuildContext context) { if (widget.variant == VideoPlayerVariant.framed) return _buildFramed(); return _buildOverlay(); } // ─── Default / Minimal ───────────────────────────────────────────── Widget _buildOverlay() { final aspect = widget.variant == VideoPlayerVariant.minimal ? 16 / 9 : 16 / 10; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() { _hovered = false; _settingsOpen = false; }), child: GestureDetector( onTap: widget.onTogglePlay, child: ClipRRect( borderRadius: BorderRadius.circular(PyramidTheme.rLg), child: AspectRatio( aspectRatio: aspect, child: Stack(fit: StackFit.expand, children: [ _stage(), _grain(), if (!widget.playing) Center(child: _CenterPlay(onTap: widget.onTogglePlay)), AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _showChrome ? 1 : 0, child: _topBar(onDark: true), ), AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _showChrome ? 1 : 0, child: Align(alignment: Alignment.bottomCenter, child: _bottomBar(onDark: true)), ), ]), ), ), ), ); } // ─── Framed ──────────────────────────────────────────────────────── Widget _buildFramed() { return Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: PyramidTheme.bg1, border: Border.all(color: PyramidTheme.border), borderRadius: BorderRadius.circular(PyramidTheme.rLg), ), child: Column(mainAxisSize: MainAxisSize.min, children: [ ClipRRect( borderRadius: BorderRadius.circular(PyramidTheme.rLg - 4), child: AspectRatio( aspectRatio: 16 / 9, child: MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTogglePlay, child: Stack(fit: StackFit.expand, children: [ _stage(), _grain(), if (!widget.playing) Center(child: _CenterPlay(onTap: widget.onTogglePlay)), _topBar(onDark: true), ]), ), ), ), ), const SizedBox(height: 12), _bottomBar(onDark: false), ]), ); } // ─── Stage (placeholder bg) ──────────────────────────────────────── Widget _stage() { return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [const Color(0xFF1A1A22), const Color(0xFF0C0C10)], ), ), alignment: Alignment.center, child: Opacity( opacity: 0.35, child: CustomPaint(size: const Size(72, 60), painter: _BigTriPainter()), ), ); } Widget _grain() => IgnorePointer(child: Opacity( opacity: 0.05, child: Container(decoration: const BoxDecoration(color: Colors.white12)), )); // ─── Top bar (title + captions + more) ───────────────────────────── Widget _topBar({required bool onDark}) { final fg = onDark ? Colors.white : PyramidTheme.fg; return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( gradient: onDark ? LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black.withOpacity(0.5), Colors.transparent]) : null, ), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(widget.title, style: TextStyle(color: fg, fontSize: 13, fontFamily: PyramidTheme.fontSans, fontWeight: FontWeight.w500)), if (widget.subtitle.isNotEmpty) Text(widget.subtitle.toUpperCase(), style: TextStyle(color: fg.withOpacity(0.65), fontSize: 10, fontFamily: PyramidTheme.fontMono, letterSpacing: 1.2)), ])), _OverlayBtn(icon: Icons.closed_caption_outlined, onTap: widget.onCaptions, onDark: onDark), _OverlayBtn(icon: Icons.more_horiz, onTap: widget.onMore, onDark: onDark), ]), ); } // ─── Bottom bar (scrubber + controls) ────────────────────────────── Widget _bottomBar({required bool onDark}) { final iconColor = onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted; final timeColor = onDark ? Colors.white.withOpacity(0.78) : PyramidTheme.fgDim; return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( gradient: onDark ? LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black.withOpacity(0.75), Colors.transparent]) : null, ), child: Column(mainAxisSize: MainAxisSize.min, children: [ _scrubber(onDark: onDark), const SizedBox(height: 10), Row(children: [ // Left side _PlayMini(playing: widget.playing, onTap: widget.onTogglePlay, onDark: onDark), const SizedBox(width: 4), _OverlayBtn(icon: Icons.replay_10, onTap: widget.onSkipBack, onDark: onDark), _OverlayBtn(icon: Icons.forward_10, onTap: widget.onSkipForward, onDark: onDark), _OverlayBtn(icon: Icons.volume_up_outlined, onTap: null, onDark: onDark), const SizedBox(width: 8), Text('${_fmt(widget.position)} / ${_fmt(widget.duration)}', style: TextStyle(color: timeColor, fontSize: 11, fontFamily: PyramidTheme.fontMono, letterSpacing: 0.4)), const Spacer(), // Right side _QualityBadge(label: _quality == 'Auto' ? '1080p' : _quality, onDark: onDark), const SizedBox(width: 4), _SettingsButton( open: _settingsOpen, onTap: () => setState(() => _settingsOpen = !_settingsOpen), speed: '${_speed == _speed.toInt() ? _speed.toInt() : _speed}x', quality: _quality, onDark: onDark, onSpeed: (s) { setState(() { _speed = s; _settingsOpen = false; }); widget.onSpeedChange?.call(s); }, onQuality: (q) { setState(() { _quality = q; _settingsOpen = false; }); widget.onQualityChange?.call(q); }, speeds: _speeds, qualities: _qualities, ), _OverlayBtn(icon: Icons.fullscreen, onTap: widget.onFullscreen, onDark: onDark), ]), ]), ); } Widget _scrubber({required bool onDark}) { final trackBg = onDark ? Colors.white.withOpacity(0.18) : PyramidTheme.bg3; final bufferedBg = onDark ? Colors.white.withOpacity(0.28) : PyramidTheme.borderStrong; return LayoutBuilder(builder: (_, c) => GestureDetector( onTapDown: (d) => widget.onSeek?.call(Duration( milliseconds: (widget.duration.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round(), )), onPanUpdate: (d) => widget.onSeek?.call(Duration( milliseconds: (widget.duration.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round(), )), child: SizedBox( height: 14, child: Stack(alignment: Alignment.centerLeft, children: [ Container(height: 3, decoration: BoxDecoration(color: trackBg, borderRadius: BorderRadius.circular(2))), FractionallySizedBox(widthFactor: widget.buffered, child: Container(height: 3, decoration: BoxDecoration(color: bufferedBg, borderRadius: BorderRadius.circular(2)))), FractionallySizedBox(widthFactor: progress, child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.accent, borderRadius: BorderRadius.circular(2)))), Positioned( left: c.maxWidth * progress - 5, child: Container(width: 10, height: 10, decoration: BoxDecoration(color: PyramidTheme.accent, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.35), blurRadius: 0, spreadRadius: 3)])), ), ]), ), )); } } // ───────────────────────── Overlay sub-widgets ───────────────────────── class _CenterPlay extends StatelessWidget { final VoidCallback? onTap; const _CenterPlay({this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( width: 64, height: 64, decoration: BoxDecoration( color: Colors.white.withOpacity(0.08), shape: BoxShape.circle, border: Border.all(color: Colors.white.withOpacity(0.15)), ), child: const Icon(Icons.play_arrow, color: Colors.white, size: 28), ), ); } } class _OverlayBtn extends StatelessWidget { final IconData icon; final VoidCallback? onTap; final bool onDark; const _OverlayBtn({required this.icon, this.onTap, required this.onDark}); @override Widget build(BuildContext context) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(PyramidTheme.rSm), child: Container( width: 32, height: 32, alignment: Alignment.center, child: Icon(icon, size: 17, color: onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted), ), ); } } class _PlayMini extends StatelessWidget { final bool playing; final VoidCallback? onTap; final bool onDark; const _PlayMini({required this.playing, this.onTap, required this.onDark}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( width: 36, height: 36, decoration: BoxDecoration( color: onDark ? Colors.white : PyramidTheme.accent, shape: BoxShape.circle, ), child: Icon(playing ? Icons.pause : Icons.play_arrow, color: onDark ? const Color(0xFF0A0A0C) : PyramidTheme.accentFg, size: 16), ), ); } } class _QualityBadge extends StatelessWidget { final String label; final bool onDark; const _QualityBadge({required this.label, required this.onDark}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( color: onDark ? Colors.white.withOpacity(0.08) : PyramidTheme.bg2, borderRadius: BorderRadius.circular(PyramidTheme.rSm), ), child: Text(label.toUpperCase(), style: TextStyle( fontFamily: PyramidTheme.fontMono, fontSize: 10, fontWeight: FontWeight.w500, letterSpacing: 0.6, color: onDark ? Colors.white.withOpacity(0.78) : PyramidTheme.fgMuted, )), ); } } // ───────────────────────── Settings menu ───────────────────────── class _SettingsButton extends StatelessWidget { final bool open; final VoidCallback onTap; final String speed; final String quality; final ValueChanged onSpeed; final ValueChanged onQuality; final List speeds; final List qualities; final bool onDark; const _SettingsButton({ required this.open, required this.onTap, required this.speed, required this.quality, required this.onSpeed, required this.onQuality, required this.speeds, required this.qualities, required this.onDark, }); @override Widget build(BuildContext context) { return PopupMenuButton( tooltip: 'Einstellungen', offset: const Offset(0, -12), position: PopupMenuPosition.over, color: onDark ? const Color(0xFF14141A).withOpacity(0.96) : PyramidTheme.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(PyramidTheme.rBase), side: BorderSide(color: onDark ? Colors.white.withOpacity(0.1) : PyramidTheme.border), ), itemBuilder: (ctx) => [ PopupMenuItem( enabled: false, padding: EdgeInsets.zero, child: _SettingsMenu( speed: speed, quality: quality, onSpeed: onSpeed, onQuality: onQuality, speeds: speeds, qualities: qualities, onDark: onDark, ), ), ], child: Container( width: 32, height: 32, alignment: Alignment.center, child: Icon(Icons.settings_outlined, size: 17, color: onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted), ), ); } } class _SettingsMenu extends StatefulWidget { final String speed; final String quality; final ValueChanged onSpeed; final ValueChanged onQuality; final List speeds; final List qualities; final bool onDark; const _SettingsMenu({ required this.speed, required this.quality, required this.onSpeed, required this.onQuality, required this.speeds, required this.qualities, required this.onDark, }); @override State<_SettingsMenu> createState() => _SettingsMenuState(); } class _SettingsMenuState extends State<_SettingsMenu> { String? section; // null | 'speed' | 'quality' @override Widget build(BuildContext context) { final fg = widget.onDark ? Colors.white.withOpacity(0.9) : PyramidTheme.fg; final dim = widget.onDark ? Colors.white.withOpacity(0.6) : PyramidTheme.fgMuted; Widget row(String label, String value, VoidCallback onTap) => InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row(mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle(color: fg, fontSize: 12, fontFamily: PyramidTheme.fontSans)), const SizedBox(width: 24), Text(value, style: TextStyle(color: dim, fontSize: 11, fontFamily: PyramidTheme.fontMono)), const SizedBox(width: 4), Icon(Icons.chevron_right, size: 14, color: dim), ]), ), ); Widget item(String label, bool active, VoidCallback onTap) => InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row(mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 18, child: active ? Icon(Icons.check, size: 14, color: PyramidTheme.accent) : null), Text(label, style: TextStyle( color: active ? PyramidTheme.accent : fg, fontSize: 12, fontFamily: section == 'speed' ? PyramidTheme.fontMono : PyramidTheme.fontSans, )), ]), ), ); if (section == null) { return SizedBox( width: 200, child: Column(mainAxisSize: MainAxisSize.min, children: [ row('Geschwindigkeit', widget.speed, () => setState(() => section = 'speed')), row('Qualität', widget.quality, () => setState(() => section = 'quality')), ]), ); } final isSpeed = section == 'speed'; return SizedBox( width: 180, child: Column(mainAxisSize: MainAxisSize.min, children: [ InkWell( onTap: () => setState(() => section = null), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.arrow_back, size: 12, color: dim), const SizedBox(width: 8), Text(isSpeed ? 'GESCHWINDIGKEIT' : 'QUALITÄT', style: TextStyle(color: dim, fontSize: 10, fontFamily: PyramidTheme.fontMono, letterSpacing: 0.8)), ]), ), ), Container(height: 1, color: widget.onDark ? Colors.white.withOpacity(0.08) : PyramidTheme.border), if (isSpeed) ...widget.speeds.map((s) { final lbl = '${s == s.toInt() ? s.toInt() : s}x'; return item(lbl, lbl == widget.speed, () => widget.onSpeed(s)); }) else ...widget.qualities.map((q) => item(q, q == widget.quality, () => widget.onQuality(q))), ]), ); } } class _BigTriPainter extends CustomPainter { @override void paint(Canvas canvas, Size s) { final p = Paint()..color = PyramidTheme.accent; final path = Path() ..moveTo(s.width / 2, 0) ..lineTo(s.width, s.height) ..lineTo(0, s.height) ..close(); canvas.drawPath(path, p); } @override bool shouldRepaint(_) => false; }