// 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? onSeek; final ValueChanged? onVolumeChange; final ValueChanged? 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 createState() => _PyramidAudioPlayerState(); } class _PyramidAudioPlayerState extends State { 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? 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 _heights = _seed(); static List _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 _heights = _seed(); static List _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; }