Files
pyramid/lib/features/chat/media_player.dart
T
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

905 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PyramidAudioPlayer> createState() => _PyramidAudioPlayerState();
}
class _PyramidAudioPlayerState extends State<PyramidAudioPlayer> {
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<void> _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<bool>(
stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>(
stream: _player.stream.position,
builder: (_, posSnap) => StreamBuilder<Duration>(
stream: _player.stream.duration,
builder: (_, durSnap) => StreamBuilder<double>(
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.withOpacity(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<double>? 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<double> _heights = _seed();
static List<double> _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<PyramidVideoPlayer> createState() => _PyramidVideoPlayerState();
}
class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
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<void> _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<bool>(
stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>(
stream: _player.stream.position,
builder: (_, posSnap) => StreamBuilder<Duration>(
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.withOpacity(0.10),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(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<double> speeds;
final List<String> qualities;
final String Function(Duration) fmt;
final PyramidTheme pt;
final VoidCallback onTogglePlay, onSkipBack, onSkipForward, onFullscreen;
final ValueChanged<Duration> onSeek;
final ValueChanged<double> onSpeed;
final ValueChanged<String> 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<Duration> 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.withOpacity(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.withOpacity(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.withOpacity(0.85))),
);
}
class _VSettingsButton extends StatelessWidget {
final double speed;
final String quality;
final List<double> speeds;
final List<String> qualities;
final ValueChanged<double> onSpeed;
final ValueChanged<String> 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<void>(
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.withOpacity(0.1)),
),
itemBuilder: (_) => [PopupMenuItem<void>(
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.withOpacity(0.85))),
);
}
class _VSettingsMenu extends StatefulWidget {
final String speed, quality;
final List<double> speeds;
final List<String> qualities;
final ValueChanged<double> onSpeed;
final ValueChanged<String> 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.withOpacity(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<FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState();
}
class _FullscreenVideoPlayerState extends State<FullscreenVideoPlayer> {
late final Player _player;
late final VideoController _controller;
bool _loading = true;
@override
void initState() {
super.initState();
_player = Player();
_controller = VideoController(_player);
_init();
}
Future<void> _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);
}
}