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
This commit is contained in:
@@ -0,0 +1,904 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user