Files
pyramid/mockup/flutter_export/lib/pyramid_audio_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

478 lines
17 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.
// pyramid_audio_player.dart
// Pyramid · Audio Player
//
// Vier Varianten:
// AudioPlayerVariant.defaultBars → Karte mit chunky-bar Waveform (wie HTML "01 · Default")
// AudioPlayerVariant.gracile → Karte mit hairline-Waveform (graziler, schicker)
// AudioPlayerVariant.compact → Eine Zeile, für In-Chat (Voice-Note Stil)
// AudioPlayerVariant.mono → Terminal-Look, Mono-Font, harte Kanten
//
// Verwendung:
// PyramidAudioPlayer(
// variant: AudioPlayerVariant.gracile,
// title: 'test track',
// artist: 'pyramid · demo',
// duration: Duration(minutes: 3, seconds: 24),
// position: Duration(minutes: 1, seconds: 11),
// playing: true,
// onTogglePlay: () { ... },
// onSeek: (Duration to) { ... },
// );
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'pyramid_theme.dart';
enum AudioPlayerVariant { defaultBars, gracile, compact, mono }
class PyramidAudioPlayer extends StatefulWidget {
final AudioPlayerVariant variant;
final String title;
final String artist;
final Duration duration;
final Duration position;
final bool playing;
final double volume; // 0..1
final VoidCallback? onTogglePlay;
final ValueChanged<Duration>? onSeek;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<double>? onSpeedChange; // 1.0, 1.5, 2.0
final VoidCallback? onSkipBack;
final VoidCallback? onSkipForward;
final VoidCallback? onLike;
final VoidCallback? onMore;
const PyramidAudioPlayer({
super.key,
this.variant = AudioPlayerVariant.gracile,
required this.title,
required this.artist,
required this.duration,
required this.position,
this.playing = false,
this.volume = 0.7,
this.onTogglePlay,
this.onSeek,
this.onVolumeChange,
this.onSpeedChange,
this.onSkipBack,
this.onSkipForward,
this.onLike,
this.onMore,
});
@override
State<PyramidAudioPlayer> createState() => _PyramidAudioPlayerState();
}
class _PyramidAudioPlayerState extends State<PyramidAudioPlayer> {
double speed = 1.0;
static const speeds = [1.0, 1.5, 2.0];
double get progress => widget.duration.inMilliseconds == 0
? 0
: widget.position.inMilliseconds / widget.duration.inMilliseconds;
String _fmt(Duration d) {
final m = d.inMinutes;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
if (widget.variant == AudioPlayerVariant.compact) return _buildCompact();
return _buildCard();
}
// ─── Compact (in-chat) ─────────────────────────────────────────────
Widget _buildCompact() {
return Container(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
decoration: BoxDecoration(
color: PyramidTheme.bg1,
border: Border.all(color: PyramidTheme.border),
borderRadius: BorderRadius.circular(PyramidTheme.rLg),
),
child: Row(
children: [
_Artwork(size: 36),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.title, style: PyramidTheme.title.copyWith(fontSize: 13), overflow: TextOverflow.ellipsis),
Text(widget.artist, style: PyramidTheme.subtitle.copyWith(fontSize: 11), overflow: TextOverflow.ellipsis),
],
),
),
const SizedBox(width: 12),
_PlayButton(playing: widget.playing, onTap: widget.onTogglePlay, size: 32),
const SizedBox(width: 12),
Text(_fmt(widget.position), style: PyramidTheme.mono),
const SizedBox(width: 8),
Expanded(child: _Scrubber(progress: progress, onSeek: _seekFromFraction)),
const SizedBox(width: 8),
Text(_fmt(widget.duration), style: PyramidTheme.mono),
],
),
);
}
// ─── Default / Gracile / Mono ──────────────────────────────────────
Widget _buildCard() {
final isMono = widget.variant == AudioPlayerVariant.mono;
final radius = isMono ? 0.0 : PyramidTheme.rLg;
return Container(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
decoration: BoxDecoration(
color: isMono ? PyramidTheme.bg0 : PyramidTheme.bg1,
border: Border.all(color: isMono ? PyramidTheme.borderStrong : PyramidTheme.border),
borderRadius: BorderRadius.circular(radius),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Head
Row(
children: [
_Artwork(size: 48, mono: isMono),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
isMono ? widget.title.toUpperCase() : widget.title,
style: isMono
? PyramidTheme.monoStrong.copyWith(fontSize: 12, color: PyramidTheme.fg, letterSpacing: 0.3)
: PyramidTheme.title,
overflow: TextOverflow.ellipsis,
),
Text(
isMono ? widget.artist.toUpperCase() : widget.artist,
style: isMono
? PyramidTheme.mono.copyWith(fontSize: 10, letterSpacing: 0.8)
: PyramidTheme.subtitle,
overflow: TextOverflow.ellipsis,
),
],
),
),
_IconBtn(icon: Icons.favorite_border, onTap: widget.onLike),
_IconBtn(icon: Icons.more_horiz, onTap: widget.onMore),
],
),
const SizedBox(height: 14),
// Waveform
GestureDetector(
onTapDown: (d) => _seekFromTap(d.localPosition.dx, context.size?.width ?? 1),
child: SizedBox(
height: widget.variant == AudioPlayerVariant.gracile ? 44 : 40,
child: widget.variant == AudioPlayerVariant.gracile
? CustomPaint(painter: _GracileWavePainter(progress: progress), size: Size.infinite)
: CustomPaint(painter: _ChunkyBarsPainter(progress: progress), size: Size.infinite),
),
),
const SizedBox(height: 8),
// Times
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_fmt(widget.position), style: PyramidTheme.mono),
Text('${_fmt(widget.duration - widget.position)}', style: PyramidTheme.mono),
],
),
const SizedBox(height: 14),
// Controls row
Row(
children: [
_IconBtn(icon: Icons.volume_up_outlined, onTap: null),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: _MiniBar(value: widget.volume),
),
const Spacer(),
_IconBtn(icon: Icons.replay_10, onTap: widget.onSkipBack), // ≈ 15 (Material has 10/30; close enough)
const SizedBox(width: 4),
_PlayButton(playing: widget.playing, onTap: widget.onTogglePlay, size: 40, mono: isMono),
const SizedBox(width: 4),
_IconBtn(icon: Icons.forward_10, onTap: widget.onSkipForward),
const Spacer(),
...speeds.map((s) => _SpeedPill(
label: '${s == s.toInt() ? s.toInt() : s}x',
active: speed == s,
mono: isMono,
onTap: () {
setState(() => speed = s);
widget.onSpeedChange?.call(s);
},
)),
],
),
],
),
);
}
void _seekFromFraction(double f) =>
widget.onSeek?.call(Duration(milliseconds: (widget.duration.inMilliseconds * f).round()));
void _seekFromTap(double x, double w) {
if (w <= 0) return;
_seekFromFraction((x / w).clamp(0.0, 1.0));
}
}
// ───────────────────────── Building blocks ─────────────────────────
class _Artwork extends StatelessWidget {
final double size;
final bool mono;
const _Artwork({required this.size, this.mono = false});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(mono ? 0 : PyramidTheme.rSm),
gradient: mono
? null
: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
HSLColor.fromAHSL(1, PyramidTheme.accentH, PyramidTheme.accentS / 100, (PyramidTheme.accentL - 10) / 100).toColor(),
HSLColor.fromAHSL(1, PyramidTheme.accentH + 30, PyramidTheme.accentS / 100, (PyramidTheme.accentL - 20) / 100).toColor(),
],
),
color: mono ? PyramidTheme.accent : null,
),
child: Center(
child: CustomPaint(
size: Size(size * 0.45, size * 0.42),
painter: _TrianglePainter(color: mono ? PyramidTheme.accentFg : Colors.white.withOpacity(0.9)),
),
),
);
}
}
class _TrianglePainter extends CustomPainter {
final Color color;
_TrianglePainter({required this.color});
@override
void paint(Canvas canvas, Size s) {
final p = Paint()..color = color;
final path = Path()
..moveTo(s.width / 2, 0)
..lineTo(s.width, s.height)
..lineTo(0, s.height)
..close();
canvas.drawPath(path, p);
}
@override
bool shouldRepaint(_) => false;
}
class _PlayButton extends StatelessWidget {
final bool playing;
final VoidCallback? onTap;
final double size;
final bool mono;
const _PlayButton({required this.playing, this.onTap, this.size = 40, this.mono = false});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: PyramidTheme.accent,
shape: mono ? BoxShape.rectangle : BoxShape.circle,
boxShadow: [BoxShadow(color: PyramidTheme.accentGlow, blurRadius: 18, offset: const Offset(0, 4))],
),
child: Icon(playing ? Icons.pause : Icons.play_arrow, color: PyramidTheme.accentFg, size: size * 0.45),
),
);
}
}
class _IconBtn extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
const _IconBtn({required this.icon, this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(PyramidTheme.rSm),
child: Container(
width: 32, height: 32,
alignment: Alignment.center,
child: Icon(icon, size: 18, color: PyramidTheme.fgMuted),
),
);
}
}
class _SpeedPill extends StatelessWidget {
final String label;
final bool active;
final bool mono;
final VoidCallback? onTap;
const _SpeedPill({required this.label, this.active = false, this.mono = false, this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: active ? PyramidTheme.accentSoft : PyramidTheme.bg2,
borderRadius: BorderRadius.circular(mono ? 0 : PyramidTheme.rSm),
border: Border.all(color: active ? PyramidTheme.accentSoft : Colors.transparent),
),
child: Text(label, style: TextStyle(
fontFamily: PyramidTheme.fontMono, fontSize: 11, fontWeight: FontWeight.w500,
color: active ? PyramidTheme.accent : PyramidTheme.fgMuted,
letterSpacing: 0.2,
)),
),
);
}
}
class _Scrubber extends StatelessWidget {
final double progress; // 0..1
final ValueChanged<double>? onSeek;
const _Scrubber({required this.progress, this.onSeek});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, c) {
return 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: PyramidTheme.bg3, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(
widthFactor: progress,
child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.accent, borderRadius: BorderRadius.circular(2))),
),
]),
),
);
});
}
}
class _MiniBar extends StatelessWidget {
final double value;
const _MiniBar({required this.value});
@override
Widget build(BuildContext context) {
return Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.bg3, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(
widthFactor: value,
child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.fgMuted, borderRadius: BorderRadius.circular(2))),
),
]);
}
}
// ───────────────────────── Wave painters ─────────────────────────
class _ChunkyBarsPainter extends CustomPainter {
final double progress;
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;
});
}
_ChunkyBarsPainter({required this.progress});
@override
void paint(Canvas canvas, Size s) {
final gap = 2.0;
final barW = (s.width - gap * (count - 1)) / count;
final cursor = (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 played = i < cursor;
final isCursor = i == cursor;
final paint = Paint()
..color = isCursor
? PyramidTheme.fg
: played
? PyramidTheme.accent
: PyramidTheme.bg3;
final r = RRect.fromRectAndRadius(Rect.fromLTWH(x, y, barW, h), const Radius.circular(1));
canvas.drawRRect(r, paint);
}
}
@override
bool shouldRepaint(_ChunkyBarsPainter old) => old.progress != progress;
}
class _GracileWavePainter extends CustomPainter {
final double progress;
static const int count = 72;
static final List<double> _heights = _seed();
static List<double> _seed() {
final r = math.Random(11);
return List.generate(count, (i) {
final env = math.sin((i / count) * math.pi) * 0.55 + 0.45;
return 0.18 + r.nextDouble() * 0.82 * env;
});
}
_GracileWavePainter({required this.progress});
@override
void paint(Canvas canvas, Size s) {
final cy = s.height / 2;
// Baseline
canvas.drawLine(
Offset(0, cy), Offset(s.width, cy),
Paint()..color = PyramidTheme.border..strokeWidth = 0.6,
);
final step = s.width / (count - 1);
for (int i = 0; i < count; i++) {
final x = i * step;
final h = _heights[i] * s.height * 0.88;
final played = (x / s.width) <= progress;
final p = Paint()
..color = (played ? PyramidTheme.accent : PyramidTheme.fgDim).withOpacity(played ? 0.95 : 0.42)
..strokeWidth = 1.0
..strokeCap = StrokeCap.round;
canvas.drawLine(Offset(x, cy - h / 2), Offset(x, cy + h / 2), p);
}
// Playhead
final cx = progress * s.width;
canvas.drawLine(Offset(cx, 4), Offset(cx, s.height - 4),
Paint()..color = PyramidTheme.fg..strokeWidth = 1.2);
canvas.drawCircle(Offset(cx, cy), 2.4, Paint()..color = PyramidTheme.accent);
}
@override
bool shouldRepaint(_GracileWavePainter old) => old.progress != progress;
}