25ed765a03
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
478 lines
17 KiB
Dart
478 lines
17 KiB
Dart
// 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;
|
||
}
|