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:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
@@ -0,0 +1,477 @@
// 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;
}