import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/chat/media_viewer.dart'; // ─── Audio Player ───────────────────────────────────────────────────────────── // // isVoice=true → compact row (Sprachnachricht in Chat) // isVoice=false → defaultBars card mit Waveform (Audiodatei) class PyramidAudioPlayer extends StatefulWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; final bool isVoice; const PyramidAudioPlayer({ super.key, required this.bytes, required this.filename, required this.pt, this.isVoice = false, }); @override State createState() => _PyramidAudioPlayerState(); } class _PyramidAudioPlayerState extends State { late final Player _player; bool _loading = true; double _speed = 1.0; static const _speeds = [1.0, 1.5, 2.0]; @override void initState() { super.initState(); _player = Player(); _init(); } Future _init() async { final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'ogg'; final dir = await getTemporaryDirectory(); final file = File('${dir.path}/pyr_audio_${widget.bytes.hashCode}.$ext'); await file.writeAsBytes(widget.bytes); await _player.open(Media('file://${file.path}'), play: false); if (mounted) setState(() => _loading = false); } @override void dispose() { _player.dispose(); super.dispose(); } String _fmt(Duration d) { final m = d.inMinutes.remainder(60); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$m:$s'; } void _seek(Duration to) => _player.seek(to); void _skipBack() => _player.seek(_player.state.position - const Duration(seconds: 10)); void _skipForward() => _player.seek(_player.state.position + const Duration(seconds: 10)); void _setSpeed(double s) { setState(() => _speed = s); _player.setRate(s); } @override Widget build(BuildContext context) { final pt = widget.pt; if (_loading) return _buildLoading(pt); return StreamBuilder( stream: _player.stream.playing, builder: (_, playSnap) => StreamBuilder( stream: _player.stream.position, builder: (_, posSnap) => StreamBuilder( stream: _player.stream.duration, builder: (_, durSnap) => StreamBuilder( stream: _player.stream.volume, builder: (_, volSnap) { final playing = playSnap.data ?? false; final pos = posSnap.data ?? Duration.zero; final dur = durSnap.data ?? Duration.zero; final vol = (volSnap.data ?? 100.0) / 100.0; return widget.isVoice ? _buildCompact(pt, playing, pos, dur) : _buildDefaultBars(pt, playing, pos, dur, vol); }, ), ), ), ); } Widget _buildLoading(PyramidTheme pt) => Container( margin: const EdgeInsets.only(top: 4, bottom: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), constraints: const BoxConstraints(maxWidth: 340), decoration: BoxDecoration( color: pt.bg1, border: Border.all(color: pt.border), borderRadius: BorderRadius.circular(pt.rLg), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 32, height: 32, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), const SizedBox(width: 12), Text('Lade…', style: TextStyle(color: pt.fgMuted, fontSize: 12)), ]), ); // ── Compact: Sprachnachricht ─────────────────────────────────────────────── Widget _buildCompact(PyramidTheme pt, bool playing, Duration pos, Duration dur) { final progress = dur.inMilliseconds == 0 ? 0.0 : pos.inMilliseconds / dur.inMilliseconds; final title = widget.filename.contains('.') ? widget.filename.split('.').first : widget.filename; return Container( margin: const EdgeInsets.only(top: 4, bottom: 4), padding: const EdgeInsets.fromLTRB(12, 12, 14, 12), constraints: const BoxConstraints(maxWidth: 340), decoration: BoxDecoration( color: pt.bg1, border: Border.all(color: pt.border), borderRadius: BorderRadius.circular(pt.rLg), ), child: Row(children: [ _AArtwork(size: 36, accent: pt.accent), const SizedBox(width: 10), Flexible(child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(title, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), Text('Sprachnachricht', style: TextStyle(color: pt.fgMuted, fontSize: 11)), ], )), const SizedBox(width: 10), _APlayButton(playing: playing, onTap: () => playing ? _player.pause() : _player.play(), size: 32, accent: pt.accent, accentGlow: pt.accentGlow), const SizedBox(width: 10), Text(_fmt(pos), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)), const SizedBox(width: 8), Expanded(child: _AScrubber(progress: progress, accent: pt.accent, track: pt.bg3, onSeek: (f) => _seek(Duration(milliseconds: (dur.inMilliseconds * f).round())))), const SizedBox(width: 8), Text(_fmt(dur), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)), ]), ); } // ── DefaultBars: Audiodatei mit Waveform ─────────────────────────────────── Widget _buildDefaultBars(PyramidTheme pt, bool playing, Duration pos, Duration dur, double vol) { final progress = dur.inMilliseconds == 0 ? 0.0 : pos.inMilliseconds / dur.inMilliseconds; final nameParts = widget.filename.split('.'); final title = nameParts.length > 1 ? nameParts.sublist(0, nameParts.length - 1).join('.') : widget.filename; final ext = nameParts.length > 1 ? nameParts.last.toUpperCase() : 'AUDIO'; final remaining = pos <= dur ? dur - pos : Duration.zero; return Container( margin: const EdgeInsets.only(top: 4, bottom: 4), padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), constraints: const BoxConstraints(maxWidth: 380), decoration: BoxDecoration( color: pt.bg1, border: Border.all(color: pt.border), borderRadius: BorderRadius.circular(pt.rLg), ), child: Column(mainAxisSize: MainAxisSize.min, children: [ // ── Header ─────────────────────────────────────────────────────────── Row(children: [ _AArtwork(size: 48, accent: pt.accent), const SizedBox(width: 14), Expanded(child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(title, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), Text(ext, style: TextStyle(color: pt.fgMuted, fontSize: 12)), ], )), ]), const SizedBox(height: 14), // ── Waveform ───────────────────────────────────────────────────────── LayoutBuilder(builder: (_, c) => GestureDetector( onTapDown: (d) { final f = (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0); _seek(Duration(milliseconds: (dur.inMilliseconds * f).round())); }, onPanUpdate: (d) { final f = (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0); _seek(Duration(milliseconds: (dur.inMilliseconds * f).round())); }, child: SizedBox(height: 40, child: CustomPaint( painter: _ChunkyBarsPainter( progress: progress, played: pt.accent, unplayed: pt.bg3, cursor: pt.fg, ), size: Size.infinite, )), )), const SizedBox(height: 8), // ── Zeitanzeige ────────────────────────────────────────────────────── Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(_fmt(pos), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)), Text('−${_fmt(remaining)}', style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)), ]), const SizedBox(height: 14), // ── Steuerung ──────────────────────────────────────────────────────── Row(children: [ Icon(Icons.volume_up_outlined, size: 16, color: pt.fgMuted), const SizedBox(width: 6), SizedBox(width: 64, child: _AMiniBar(value: vol, track: pt.bg3, fill: pt.fgMuted)), const Spacer(), _AIconBtn(icon: Icons.replay_10, color: pt.fgMuted, rSm: pt.rSm, onTap: _skipBack), const SizedBox(width: 4), _APlayButton( playing: playing, onTap: () => playing ? _player.pause() : _player.play(), size: 40, accent: pt.accent, accentGlow: pt.accentGlow, ), const SizedBox(width: 4), _AIconBtn(icon: Icons.forward_10, color: pt.fgMuted, rSm: pt.rSm, onTap: _skipForward), const Spacer(), ..._speeds.map((s) => _ASpeedPill( label: '${s == s.toInt() ? s.toInt() : s}x', active: _speed == s, accent: pt.accent, accentSoft: pt.accentSoft, bg: pt.bg2, fgMuted: pt.fgMuted, rSm: pt.rSm, onTap: () => _setSpeed(s), )), ]), ]), ); } } // ─── Audio: Bausteine ──────────────────────────────────────────────────────── class _AArtwork extends StatelessWidget { final double size; final Color accent; const _AArtwork({required this.size, required this.accent}); @override Widget build(BuildContext context) { final hsl = HSLColor.fromColor(accent); final c1 = hsl.withLightness((hsl.lightness - 0.08).clamp(0.0, 1.0)).toColor(); final c2 = hsl.withHue((hsl.hue + 30) % 360) .withLightness((hsl.lightness - 0.18).clamp(0.0, 1.0)).toColor(); return Container( width: size, height: size, decoration: BoxDecoration( borderRadius: BorderRadius.circular(size * 0.3), gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [c1, c2]), ), child: Icon(Icons.music_note, color: Colors.white.withValues(alpha: 0.85), size: size * 0.48), ); } } class _APlayButton extends StatelessWidget { final bool playing; final VoidCallback? onTap; final double size; final Color accent, accentGlow; const _APlayButton({required this.playing, this.onTap, required this.size, required this.accent, required this.accentGlow}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( width: size, height: size, decoration: BoxDecoration( color: accent, shape: BoxShape.circle, boxShadow: [BoxShadow(color: accentGlow, blurRadius: 16, offset: const Offset(0, 4))], ), child: Icon(playing ? Icons.pause : Icons.play_arrow, color: PyramidColors.accentFg, size: size * 0.48), ), ); } } class _AIconBtn extends StatelessWidget { final IconData icon; final Color color; final double rSm; final VoidCallback? onTap; const _AIconBtn({required this.icon, required this.color, required this.rSm, this.onTap}); @override Widget build(BuildContext context) => InkWell( onTap: onTap, borderRadius: BorderRadius.circular(rSm), child: SizedBox(width: 32, height: 32, child: Icon(icon, size: 18, color: color)), ); } class _ASpeedPill extends StatelessWidget { final String label; final bool active; final VoidCallback? onTap; final Color accent, accentSoft, bg, fgMuted; final double rSm; const _ASpeedPill({required this.label, this.active = false, this.onTap, required this.accent, required this.accentSoft, required this.bg, required this.fgMuted, required this.rSm}); @override Widget build(BuildContext context) => GestureDetector( onTap: onTap, child: Container( margin: const EdgeInsets.symmetric(horizontal: 1), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: active ? accentSoft : bg, borderRadius: BorderRadius.circular(rSm), ), child: Text(label, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: active ? accent : fgMuted, letterSpacing: 0.2, )), ), ); } class _AScrubber extends StatelessWidget { final double progress; final Color accent, track; final ValueChanged? onSeek; const _AScrubber({required this.progress, required this.accent, required this.track, this.onSeek}); @override Widget build(BuildContext context) => LayoutBuilder(builder: (_, c) => GestureDetector( onTapDown: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)), onPanUpdate: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)), child: SizedBox(height: 14, child: Stack(alignment: Alignment.centerLeft, children: [ Container(height: 3, decoration: BoxDecoration(color: track, borderRadius: BorderRadius.circular(2))), FractionallySizedBox( widthFactor: progress, child: Container(height: 3, decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2))), ), ])), )); } class _AMiniBar extends StatelessWidget { final double value; final Color track, fill; const _AMiniBar({required this.value, required this.track, required this.fill}); @override Widget build(BuildContext context) => Stack(alignment: Alignment.centerLeft, children: [ Container(height: 3, decoration: BoxDecoration(color: track, borderRadius: BorderRadius.circular(2))), FractionallySizedBox( widthFactor: value.clamp(0.0, 1.0), child: Container(height: 3, decoration: BoxDecoration(color: fill, borderRadius: BorderRadius.circular(2))), ), ]); } class _ChunkyBarsPainter extends CustomPainter { final double progress; final Color played, unplayed, cursor; static const int _count = 56; static final List _heights = _seed(); static List _seed() { final r = math.Random(7); return List.generate(_count, (i) { final env = math.sin((i / _count) * math.pi) * 0.6 + 0.4; return 0.2 + r.nextDouble() * 0.8 * env; }); } const _ChunkyBarsPainter({required this.progress, required this.played, required this.unplayed, required this.cursor}); @override void paint(Canvas canvas, Size s) { const gap = 2.0; final barW = (s.width - gap * (_count - 1)) / _count; final cur = (progress * _count).floor(); for (int i = 0; i < _count; i++) { final h = _heights[i] * s.height; final x = i * (barW + gap); final y = (s.height - h) / 2; final color = i == cur ? cursor : (i < cur ? played : unplayed); canvas.drawRRect( RRect.fromRectAndRadius(Rect.fromLTWH(x, y, barW, h), const Radius.circular(1)), Paint()..color = color, ); } } @override bool shouldRepaint(_ChunkyBarsPainter old) => old.progress != progress || old.played != played; } // ─── Video Player ───────────────────────────────────────────────────────────── // // Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide) class PyramidVideoPlayer extends StatefulWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; final String senderName; const PyramidVideoPlayer({ super.key, required this.bytes, required this.filename, required this.pt, this.senderName = '', }); @override State createState() => _PyramidVideoPlayerState(); } class _PyramidVideoPlayerState extends State { late final Player _player; late final VideoController _controller; bool _loading = true; double _speed = 1.0; String _quality = 'Auto'; static const _speeds = [0.5, 1.0, 1.25, 1.5, 2.0]; static const _qualities = ['Auto', '1080p', '720p', '480p', '360p']; @override void initState() { super.initState(); _player = Player(); _controller = VideoController(_player); _init(); } Future _init() async { final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4'; final dir = await getTemporaryDirectory(); final file = File('${dir.path}/pyr_video_${widget.bytes.hashCode}.$ext'); await file.writeAsBytes(widget.bytes); await _player.open(Media('file://${file.path}'), play: false); if (mounted) setState(() => _loading = false); } @override void dispose() { _player.dispose(); super.dispose(); } String _fmt(Duration d) { final m = d.inMinutes.remainder(60); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$m:$s'; } void _openFullscreen() => MediaViewer.show( context, senderName: widget.senderName, filename: widget.filename, bytes: widget.bytes, isVideo: true, ); @override Widget build(BuildContext context) { final pt = widget.pt; final title = widget.filename.contains('.') ? widget.filename.split('.').first : widget.filename; if (_loading) { return Container( margin: const EdgeInsets.only(top: 4, bottom: 4), constraints: const BoxConstraints(maxWidth: 360), height: 200, decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(pt.rLg), border: Border.all(color: pt.border), ), child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), ); } return Container( margin: const EdgeInsets.only(top: 4, bottom: 4), constraints: const BoxConstraints(maxWidth: 420), decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(pt.rLg), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: AspectRatio( aspectRatio: 16 / 9, child: StreamBuilder( stream: _player.stream.playing, builder: (_, playSnap) => StreamBuilder( stream: _player.stream.position, builder: (_, posSnap) => StreamBuilder( stream: _player.stream.duration, builder: (_, durSnap) { final playing = playSnap.data ?? false; final pos = posSnap.data ?? Duration.zero; final dur = durSnap.data ?? Duration.zero; final progress = dur.inMilliseconds == 0 ? 0.0 : pos.inMilliseconds / dur.inMilliseconds; return GestureDetector( onTap: () => playing ? _player.pause() : _player.play(), child: Stack(fit: StackFit.expand, children: [ // Video Video(controller: _controller, controls: NoVideoControls), // Top: Titelleiste mit Farbverlauf Align( alignment: Alignment.topCenter, child: _VTopBar(title: title, onFullscreen: _openFullscreen), ), // Center: Play-Button wenn pausiert if (!playing) const Center(child: _VCenterPlay()), // Bottom: Steuerleiste (minimal = immer sichtbar) Align( alignment: Alignment.bottomCenter, child: _VBottomBar( playing: playing, pos: pos, dur: dur, progress: progress, speed: _speed, quality: _quality, speeds: _speeds, qualities: _qualities, fmt: _fmt, pt: pt, onTogglePlay: () => playing ? _player.pause() : _player.play(), onSeek: (d) => _player.seek(d), onSkipBack: () => _player.seek(pos - const Duration(seconds: 10)), onSkipForward: () => _player.seek(pos + const Duration(seconds: 10)), onSpeed: (s) { setState(() => _speed = s); _player.setRate(s); }, onQuality: (q) => setState(() => _quality = q), onFullscreen: _openFullscreen, ), ), ]), ); }, ), ), ), ), ); } } // ─── Video: Bausteine ──────────────────────────────────────────────────────── class _VTopBar extends StatelessWidget { final String title; final VoidCallback? onFullscreen; const _VTopBar({required this.title, this.onFullscreen}); @override Widget build(BuildContext context) => Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(14, 12, 8, 20), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x99000000), Colors.transparent], ), ), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(child: Text(title, style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis)), _VOverlayBtn(icon: Icons.fullscreen, onTap: onFullscreen), ]), ); } class _VCenterPlay extends StatelessWidget { const _VCenterPlay(); @override Widget build(BuildContext context) => Container( width: 56, height: 56, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.10), shape: BoxShape.circle, border: Border.all(color: Colors.white.withValues(alpha: 0.18)), ), child: const Icon(Icons.play_arrow, color: Colors.white, size: 26), ); } class _VBottomBar extends StatelessWidget { final bool playing; final Duration pos, dur; final double progress, speed; final String quality; final List speeds; final List qualities; final String Function(Duration) fmt; final PyramidTheme pt; final VoidCallback onTogglePlay, onSkipBack, onSkipForward, onFullscreen; final ValueChanged onSeek; final ValueChanged onSpeed; final ValueChanged onQuality; const _VBottomBar({ required this.playing, required this.pos, required this.dur, required this.progress, required this.speed, required this.quality, required this.speeds, required this.qualities, required this.fmt, required this.pt, required this.onTogglePlay, required this.onSeek, required this.onSkipBack, required this.onSkipForward, required this.onSpeed, required this.onQuality, required this.onFullscreen, }); @override Widget build(BuildContext context) => Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(12, 16, 12, 12), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Color(0xCC000000), Colors.transparent], ), ), child: Column(mainAxisSize: MainAxisSize.min, children: [ _VScrubber(progress: progress, dur: dur, onSeek: onSeek, accent: pt.accent), const SizedBox(height: 8), Row(children: [ _VPlayMini(playing: playing, onTap: onTogglePlay), _VOverlayBtn(icon: Icons.replay_10, onTap: onSkipBack), _VOverlayBtn(icon: Icons.forward_10, onTap: onSkipForward), const SizedBox(width: 6), Text('${fmt(pos)} / ${fmt(dur)}', style: const TextStyle(color: Colors.white70, fontSize: 10, letterSpacing: 0.4)), const Spacer(), _VSettingsButton( speed: speed, quality: quality, speeds: speeds, qualities: qualities, onSpeed: onSpeed, onQuality: onQuality, ), _VOverlayBtn(icon: Icons.fullscreen, onTap: onFullscreen), ]), ]), ); } class _VScrubber extends StatelessWidget { final double progress; final Duration dur; final ValueChanged onSeek; final Color accent; const _VScrubber({required this.progress, required this.dur, required this.onSeek, required this.accent}); @override Widget build(BuildContext context) => LayoutBuilder(builder: (_, c) => GestureDetector( onTapDown: (d) => onSeek(Duration( milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())), onPanUpdate: (d) => onSeek(Duration( milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())), child: SizedBox(height: 16, child: Stack(alignment: Alignment.centerLeft, children: [ Container(height: 3, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.20), borderRadius: BorderRadius.circular(2))), FractionallySizedBox(widthFactor: progress, child: Container(height: 3, decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)))), Positioned( left: c.maxWidth * progress.clamp(0.0, 1.0) - 5, child: Container(width: 10, height: 10, decoration: BoxDecoration(color: accent, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.4), blurRadius: 0, spreadRadius: 2)])), ), ])), )); } class _VPlayMini extends StatelessWidget { final bool playing; final VoidCallback? onTap; const _VPlayMini({required this.playing, this.onTap}); @override Widget build(BuildContext context) => GestureDetector( onTap: onTap, child: Container( width: 34, height: 34, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), child: Icon(playing ? Icons.pause : Icons.play_arrow, color: const Color(0xFF0A0A0C), size: 16), ), ); } class _VOverlayBtn extends StatelessWidget { final IconData icon; final VoidCallback? onTap; const _VOverlayBtn({required this.icon, this.onTap}); @override Widget build(BuildContext context) => InkWell( onTap: onTap, borderRadius: BorderRadius.circular(6), child: SizedBox(width: 32, height: 32, child: Icon(icon, size: 17, color: Colors.white.withValues(alpha: 0.85))), ); } class _VSettingsButton extends StatelessWidget { final double speed; final String quality; final List speeds; final List qualities; final ValueChanged onSpeed; final ValueChanged onQuality; const _VSettingsButton({required this.speed, required this.quality, required this.speeds, required this.qualities, required this.onSpeed, required this.onQuality}); String get _speedLabel { final s = speed; return '${s == s.toInt() ? s.toInt() : s}x'; } @override Widget build(BuildContext context) => PopupMenuButton( tooltip: 'Einstellungen', offset: const Offset(0, -8), position: PopupMenuPosition.over, color: const Color(0xF014141A), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), ), itemBuilder: (_) => [PopupMenuItem( enabled: false, padding: EdgeInsets.zero, child: _VSettingsMenu( speed: _speedLabel, quality: quality, speeds: speeds, qualities: qualities, onSpeed: onSpeed, onQuality: onQuality, ), )], child: SizedBox(width: 32, height: 32, child: Icon(Icons.settings_outlined, size: 16, color: Colors.white.withValues(alpha: 0.85))), ); } class _VSettingsMenu extends StatefulWidget { final String speed, quality; final List speeds; final List qualities; final ValueChanged onSpeed; final ValueChanged onQuality; const _VSettingsMenu({required this.speed, required this.quality, required this.speeds, required this.qualities, required this.onSpeed, required this.onQuality}); @override State<_VSettingsMenu> createState() => _VSettingsMenuState(); } class _VSettingsMenuState extends State<_VSettingsMenu> { String? _section; // null | 'speed' | 'quality' static const _fg = Color(0xE5FFFFFF); static const _dim = Color(0x99FFFFFF); @override Widget build(BuildContext context) { if (_section == null) { return SizedBox( width: 190, 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: 170, 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: [ const Icon(Icons.arrow_back, size: 12, color: _dim), const SizedBox(width: 8), Text(isSpeed ? 'GESCHWINDIGKEIT' : 'QUALITÄT', style: const TextStyle(color: _dim, fontSize: 10, letterSpacing: 0.8)), ]), ), ), Container(height: 1, color: Colors.white.withValues(alpha: 0.08)), 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))), ]), ); } 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: const TextStyle(color: _fg, fontSize: 12)), const SizedBox(width: 16), const Spacer(), Text(value, style: const TextStyle(color: _dim, fontSize: 11, letterSpacing: 0.3)), const SizedBox(width: 4), const 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 ? const Icon(Icons.check, size: 14, color: PyramidColors.accentAmber) : null), Text(label, style: TextStyle( color: active ? PyramidColors.accentAmber : _fg, fontSize: 12, )), ]), ), ); } // ─── Fullscreen Video Player ────────────────────────────────────────────────── // Verwendet AdaptiveVideoControls für native System-Fullscreen-Integration class FullscreenVideoPlayer extends StatefulWidget { final Uint8List bytes; final String filename; const FullscreenVideoPlayer({super.key, required this.bytes, required this.filename}); @override State createState() => _FullscreenVideoPlayerState(); } class _FullscreenVideoPlayerState extends State { late final Player _player; late final VideoController _controller; bool _loading = true; @override void initState() { super.initState(); _player = Player(); _controller = VideoController(_player); _init(); } Future _init() async { final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4'; final dir = await getTemporaryDirectory(); final file = File('${dir.path}/pyr_vfull_${widget.bytes.hashCode}.$ext'); await file.writeAsBytes(widget.bytes); await _player.open(Media('file://${file.path}'), play: true); if (mounted) setState(() => _loading = false); } @override void dispose() { _player.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (_loading) return const Center(child: CircularProgressIndicator(color: Colors.white)); return Video(controller: _controller, controls: AdaptiveVideoControls); } }