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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user