057ac1e4ea
withOpacity/activeColor/cacheExtent/PublicRoomsChunk durch die neuen Flutter-3.44-Äquivalente ersetzt.
905 lines
33 KiB
Dart
905 lines
33 KiB
Dart
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.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<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.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<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.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<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.withValues(alpha: 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.withValues(alpha: 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.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<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);
|
||
}
|
||
}
|