Files
pyramid/mockup/flutter_export/lib/pyramid_video_player.dart
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:18 +02:00

545 lines
20 KiB
Dart

// 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<Duration>? onSeek;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<double>? onSpeedChange;
final ValueChanged<String>? 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<PyramidVideoPlayer> createState() => _PyramidVideoPlayerState();
}
class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
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<double> onSpeed;
final ValueChanged<String> onQuality;
final List<double> speeds;
final List<String> 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<void>(
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<void>(
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<double> onSpeed;
final ValueChanged<String> onQuality;
final List<double> speeds;
final List<String> 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;
}