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
+136
View File
@@ -0,0 +1,136 @@
# Pyramid · Media Players (Flutter)
Player-Skelette im Pyramid-Stil — **Audio (4 Varianten)** und **Video (3 Varianten)** als wiederverwendbare Flutter-Widgets. Dies ist ein **UI-Gerüst ohne echte Medienwiedergabe**`position`, `duration`, `buffered`, `playing` sind reine Props. Zum Anschluss an echte Medien siehe „Mit echtem Playback verbinden" weiter unten.
---
## 📁 Dateien
```
flutter_export/
├── pubspec.yaml
└── lib/
├── pyramid_theme.dart ← Farb-, Typo-, Radius-Tokens (Single Source of Truth)
├── pyramid_audio_player.dart ← PyramidAudioPlayer + AudioPlayerVariant
├── pyramid_video_player.dart ← PyramidVideoPlayer + VideoPlayerVariant
└── example_app.dart ← Showcase mit allen Varianten (zum Reinkopieren)
```
Kopiere die drei `pyramid_*.dart` Dateien in dein Projekt (z. B. `lib/widgets/players/`) und importiere sie wie üblich.
---
## 🎨 Theme
Alle Player ziehen Farben/Typo aus `PyramidTheme`. Default ist **Dark + Amber**.
```dart
PyramidTheme.dark = true; // false → Light
PyramidTheme.setAccent(42, 95, 58); // HSL — Default Pyramid Amber
// Weitere Akzente: 280/70/60 (Violet), 160/65/50 (Mint), 0/75/60 (Coral)
```
**Fonts:** Theme erwartet `Geist` und `GeistMono`. Ohne Asset-Einbindung in `pubspec.yaml` fällt Flutter auf System-Sans/Mono zurück (immer noch ok). Die Font-Asset-Konfig ist in `pubspec.yaml` auskommentiert vorbereitet.
---
## 🎧 Audio Player
```dart
PyramidAudioPlayer(
variant: AudioPlayerVariant.gracile,
title: 'test track',
artist: 'pyramid · demo',
duration: const Duration(minutes: 3, seconds: 24),
position: _position, // ValueNotifier oder State
playing: _playing,
onTogglePlay: () => setState(() => _playing = !_playing),
onSeek: (Duration to) => setState(() => _position = to),
onSkipBack: () { /* 15s */ },
onSkipForward: () { /* +15s */ },
onSpeedChange: (double s) { /* 1.0 / 1.5 / 2.0 */ },
)
```
### Wann welche Variante?
| Variante | Wann benutzen | Größe |
|---|---|---|
| **`AudioPlayerVariant.defaultBars`** | Standard-Audio-Anhang in einer Nachricht. Robuste Bar-Waveform — wirkt „solide", für Musik-Snippets. | ≥ 380px breit |
| **`AudioPlayerVariant.gracile`** ⭐ | **Empfehlung für Voice-Notes und längere Audio-Dateien.** Filigrane Hairline-Waveform — passt am besten zum Pyramid-Stil. | ≥ 380px breit |
| **`AudioPlayerVariant.compact`** | **In-Chat Inline-Embed**, wenn Platz knapp ist (Reply-Quote, Thread-Vorschau, Side-Panel). Eine Zeile. | ≥ 320px breit |
| **`AudioPlayerVariant.mono`** | Spezielle Räume mit Terminal-/Editorial-Identität (z. B. `#announce`, „archive", Devs-Spaces). Mono-Font, harte Kanten. Sparsam einsetzen. | ≥ 380px breit |
---
## 🎬 Video Player
```dart
PyramidVideoPlayer(
variant: VideoPlayerVariant.defaultHover,
title: 'test clip',
subtitle: 'pyramid · 1080p',
duration: const Duration(minutes: 4, seconds: 12),
position: _videoPos,
buffered: 0.65, // 0..1
playing: _playing,
onTogglePlay: () => setState(() => _playing = !_playing),
onSeek: (Duration to) => setState(() => _videoPos = to),
onSpeedChange: (double s) { /* 0.5 / 1 / 1.25 / 1.5 / 2 */ },
onQualityChange: (String q) { /* Auto / 1080p / 720p / 480p / 360p */ },
onFullscreen: () { /* Navigator push fullscreen route */ },
)
```
### Wann welche Variante?
| Variante | Wann benutzen | Aspect | Chrome |
|---|---|---|---|
| **`VideoPlayerVariant.defaultHover`** ⭐ | **Standard-Video-Embed im Chat.** Player nimmt volle Breite ein, Controls erscheinen bei Hover/Tap. | 16:10 | hover/auto-hide |
| **`VideoPlayerVariant.minimal`** | **Inline-Vorschau** in dichten Layouts (Reply, Thread-Liste). Controls immer sichtbar, kompakter. | 16:9 | always-on |
| **`VideoPlayerVariant.framed`** | **Galerie-/Karten-Layouts** oder Posts mit Caption darunter. Stage in Card, Controls auf hellem Card-BG außerhalb des schwarzen Stages. | 16:9 + Card | always-on (Card) |
---
## 🔌 Mit echtem Playback verbinden
Diese Widgets sind reine UI-Mockups. Für echte Wiedergabe binde z. B. das offizielle [`video_player`](https://pub.dev/packages/video_player) (Video) oder [`just_audio`](https://pub.dev/packages/just_audio) (Audio) Package an:
```dart
// Video-Beispiel mit video_player
final controller = VideoPlayerController.networkUrl(Uri.parse(url))..initialize();
controller.addListener(() => setState(() {}));
// im build():
PyramidVideoPlayer(
variant: VideoPlayerVariant.defaultHover,
title: 'remote clip',
duration: controller.value.duration,
position: controller.value.position,
buffered: controller.value.buffered.isEmpty ? 0
: controller.value.buffered.last.end.inMilliseconds /
controller.value.duration.inMilliseconds,
playing: controller.value.isPlaying,
onTogglePlay: () => controller.value.isPlaying ? controller.pause() : controller.play(),
onSeek: (to) => controller.seekTo(to),
onSpeedChange: (s) => controller.setPlaybackSpeed(s),
)
```
**Stage rendern:** Aktuell zeigt der Player einen Pyramid-Triangle als Platzhalter. Ersetze in `pyramid_video_player.dart` die `_stage()`-Methode durch dein `VideoPlayer(controller)` Widget.
---
## 🧪 Showcase
`example_app.dart` zeigt alle 7 Player-Varianten in einem scrollbaren Dark-Layout. Direkt ausführbar via `flutter run -t lib/example_app.dart`.
---
## ✅ Checklist beim Einbau
- [ ] `pyramid_theme.dart` + Audio + Video kopiert
- [ ] `PyramidTheme.dark` und `setAccent(...)` in App-State gesetzt
- [ ] (optional) Geist + Geist Mono in `pubspec.yaml` eingebunden
- [ ] Player mit echtem Audio-/Video-Controller verdrahtet
- [ ] Skip-Intervall an Produkt-Konvention angepasst (Material hat `replay_10` / `forward_10` — wenn du wirklich ±15s willst, die Icons mit `Stack(text)` ersetzen)
+159
View File
@@ -0,0 +1,159 @@
// example_app.dart
// Pyramid Players · Showcase / Test-Harness
//
// Zeigt alle Player-Varianten in einem scrollbaren Dark-Layout.
// Drop-in-fähig: füge die drei Pyramid-Dateien (theme + audio + video)
// in dein Projekt und referenziere sie wie unten.
import 'package:flutter/material.dart';
import 'pyramid_theme.dart';
import 'pyramid_audio_player.dart';
import 'pyramid_video_player.dart';
void main() => runApp(const PyramidShowcaseApp());
class PyramidShowcaseApp extends StatelessWidget {
const PyramidShowcaseApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Pyramid · Players',
theme: ThemeData(
scaffoldBackgroundColor: PyramidTheme.bg0,
fontFamily: PyramidTheme.fontSans,
useMaterial3: true,
),
home: const _Showcase(),
);
}
}
class _Showcase extends StatefulWidget {
const _Showcase();
@override
State<_Showcase> createState() => _ShowcaseState();
}
class _ShowcaseState extends State<_Showcase> {
bool _playing = true;
Duration _audioPos = const Duration(minutes: 1, seconds: 11);
final _audioDur = const Duration(minutes: 3, seconds: 24);
Duration _videoPos = const Duration(minutes: 1, seconds: 28);
final _videoDur = const Duration(minutes: 4, seconds: 12);
void _toggle() => setState(() => _playing = !_playing);
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(48, 48, 48, 120),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('PYRAMID · MEDIA PLAYERS',
style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.4, color: PyramidTheme.fgDim)),
const SizedBox(height: 6),
Text('Audio + Video — Flutter', style: TextStyle(color: PyramidTheme.fg, fontSize: 28, fontWeight: FontWeight.w500)),
const SizedBox(height: 40),
_section('AUDIO'),
_grid([
_frame('01 · Default', 'chunky bars',
PyramidAudioPlayer(
variant: AudioPlayerVariant.defaultBars,
title: 'test track', artist: 'pyramid · demo',
duration: _audioDur, position: _audioPos,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _audioPos = p),
)),
_frame('02 · Gracile', 'hairline wave',
PyramidAudioPlayer(
variant: AudioPlayerVariant.gracile,
title: 'test track', artist: 'pyramid · demo',
duration: _audioDur, position: _audioPos,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _audioPos = p),
)),
_frame('03 · Compact', 'in-chat',
PyramidAudioPlayer(
variant: AudioPlayerVariant.compact,
title: 'test track', artist: 'pyramid · demo',
duration: _audioDur, position: _audioPos,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _audioPos = p),
)),
_frame('04 · Mono', 'terminal',
PyramidAudioPlayer(
variant: AudioPlayerVariant.mono,
title: 'test.wav', artist: 'pyramid · demo',
duration: _audioDur, position: _audioPos,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _audioPos = p),
)),
]),
const SizedBox(height: 40),
_section('VIDEO'),
_grid([
_frame('01 · Default', 'hover-chrome',
PyramidVideoPlayer(
variant: VideoPlayerVariant.defaultHover,
title: 'test clip', subtitle: 'pyramid · 1080p',
duration: _videoDur, position: _videoPos, buffered: 0.65,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _videoPos = p),
)),
_frame('02 · Minimal', 'always-visible',
PyramidVideoPlayer(
variant: VideoPlayerVariant.minimal,
title: 'test clip', subtitle: 'pyramid · 1080p',
duration: _videoDur, position: _videoPos, buffered: 0.65,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _videoPos = p),
)),
_frame('03 · Framed', 'editorial',
PyramidVideoPlayer(
variant: VideoPlayerVariant.framed,
title: 'test clip', subtitle: 'pyramid · 1080p',
duration: _videoDur, position: _videoPos, buffered: 0.65,
playing: _playing, onTogglePlay: _toggle,
onSeek: (p) => setState(() => _videoPos = p),
)),
]),
]),
),
),
);
}
Widget _section(String label) => Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Container(
padding: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: PyramidTheme.border))),
child: Text(label,
style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.8, color: PyramidTheme.fgDim)),
),
);
Widget _frame(String label, String sub, Widget child) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(label, style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.4, color: PyramidTheme.fgMuted)),
Text(sub, style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.4, color: PyramidTheme.fgDim)),
]),
),
child,
],
);
Widget _grid(List<Widget> items) => Wrap(
spacing: 28, runSpacing: 28,
children: items.map((i) => SizedBox(width: 420, child: i)).toList(),
);
}
@@ -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;
}
@@ -0,0 +1,76 @@
// pyramid_theme.dart
// Pyramid · Design Tokens für Flutter
//
// Zentrale Farb-, Typo- und Radius-Konstanten. Alle Player ziehen sich hier raus.
// Dark-first; Light-Modus umschaltbar via PyramidTheme.dark = false.
import 'package:flutter/material.dart';
class PyramidTheme {
PyramidTheme._();
/// Dark mode toggle. Beim Umschalten alle Player rebuilden.
static bool dark = true;
// ─── Accent (HSL → Color) ─────────────────────────────────────────────
// Default: Amber. Per setAccent(h,s,l) änderbar.
static double accentH = 42;
static double accentS = 95;
static double accentL = 58;
static Color get accent => HSLColor.fromAHSL(1, accentH, accentS / 100, accentL / 100).toColor();
static Color get accentSoft => HSLColor.fromAHSL(0.14, accentH, accentS / 100, accentL / 100).toColor();
static Color get accentGlow => HSLColor.fromAHSL(0.35, accentH, accentS / 100, accentL / 100).toColor();
static const Color accentFg = Color(0xFF0B0B0D);
static void setAccent(double h, double s, double l) {
accentH = h; accentS = s; accentL = l;
}
// ─── Backgrounds & Foregrounds ────────────────────────────────────────
static Color get bg0 => dark ? const Color(0xFF0A0A0C) : const Color(0xFFF5F4F0);
static Color get bg1 => dark ? const Color(0xFF111114) : const Color(0xFFFFFFFF);
static Color get bg2 => dark ? const Color(0xFF17171C) : const Color(0xFFFAF9F5);
static Color get bg3 => dark ? const Color(0xFF1E1E25) : const Color(0xFFF0EEEA);
static Color get bgHover => dark ? const Color(0xFF232330) : const Color(0xFFE9E7E1);
static Color get bgActive => dark ? const Color(0xFF2A2A38) : const Color(0xFFDFDCD3);
static Color get border => dark ? const Color(0xFF25252E) : const Color(0xFFE5E2DC);
static Color get borderStrong => dark ? const Color(0xFF32323D) : const Color(0xFFD3CFC6);
static Color get fg => dark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F);
static Color get fgMuted => dark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E);
static Color get fgDim => dark ? const Color(0xFF6F6F7D) : const Color(0xFF8A8A92);
// ─── Radii ────────────────────────────────────────────────────────────
static const double rBase = 12;
static const double rSm = 7.2; // base * 0.6
static const double rLg = 18; // base * 1.5
static const double rXl = 24; // base * 2
static const double rPill = 999;
// ─── Typography ───────────────────────────────────────────────────────
// Erfordert: Geist + Geist Mono in pubspec.yaml als fonts.
// (Alternativ: durch ein anderes Sans/Mono-Paar ersetzen.)
static const String fontSans = 'Geist';
static const String fontMono = 'GeistMono';
static TextStyle get title => TextStyle(
fontFamily: fontSans, fontSize: 14, fontWeight: FontWeight.w500, color: fg, letterSpacing: -0.1,
);
static TextStyle get subtitle => TextStyle(
fontFamily: fontSans, fontSize: 12, color: fgMuted,
);
static TextStyle get mono => TextStyle(
fontFamily: fontMono, fontSize: 11, color: fgDim, letterSpacing: 0.4,
);
static TextStyle get monoStrong => TextStyle(
fontFamily: fontMono, fontSize: 11, color: fg, fontWeight: FontWeight.w500, letterSpacing: 0.4,
);
// ─── Shadows ──────────────────────────────────────────────────────────
static List<BoxShadow> get shadowLg => [
BoxShadow(color: Colors.black.withOpacity(dark ? 0.6 : 0.12), blurRadius: 40, offset: const Offset(0, 12)),
BoxShadow(color: Colors.black.withOpacity(dark ? 0.4 : 0.06), blurRadius: 8, offset: const Offset(0, 2)),
];
}
@@ -0,0 +1,544 @@
// pyramid_video_player.dart
// Pyramid · Video Player
//
// Drei Varianten:
// VideoPlayerVariant.defaultHover → Chrome erscheint nur bei Hover/Tap (für Vollbild-Embeds)
// VideoPlayerVariant.minimal → Chrome immer sichtbar (für kleine Inline-Embeds)
// VideoPlayerVariant.framed → Editorial: Stage in Card, Controls außerhalb (für Galerie-Karten)
//
// Hinweis: Dies ist ein UI-Mockup. Für echte Wiedergabe diesen Widget mit
// `video_player`-Package verbinden (controller.value.position etc. an `position` mappen).
//
// Verwendung:
// PyramidVideoPlayer(
// variant: VideoPlayerVariant.defaultHover,
// title: 'test clip',
// subtitle: 'pyramid · 1080p',
// duration: Duration(minutes: 4, seconds: 12),
// position: Duration(minutes: 1, seconds: 28),
// buffered: 0.65,
// playing: true,
// onTogglePlay: () { ... },
// onSeek: (Duration to) { ... },
// onSpeedChange: (double speed) { ... },
// onQualityChange: (String quality) { ... },
// );
import 'package:flutter/material.dart';
import 'pyramid_theme.dart';
enum VideoPlayerVariant { defaultHover, minimal, framed }
class PyramidVideoPlayer extends StatefulWidget {
final VideoPlayerVariant variant;
final String title;
final String subtitle;
final Duration duration;
final Duration position;
final double buffered; // 0..1
final bool playing;
final double volume; // 0..1
final VoidCallback? onTogglePlay;
final ValueChanged<Duration>? onSeek;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<double>? onSpeedChange;
final ValueChanged<String>? onQualityChange;
final VoidCallback? onSkipBack;
final VoidCallback? onSkipForward;
final VoidCallback? onFullscreen;
final VoidCallback? onCaptions;
final VoidCallback? onMore;
const PyramidVideoPlayer({
super.key,
this.variant = VideoPlayerVariant.defaultHover,
required this.title,
this.subtitle = '',
required this.duration,
required this.position,
this.buffered = 0.0,
this.playing = false,
this.volume = 0.7,
this.onTogglePlay,
this.onSeek,
this.onVolumeChange,
this.onSpeedChange,
this.onQualityChange,
this.onSkipBack,
this.onSkipForward,
this.onFullscreen,
this.onCaptions,
this.onMore,
});
@override
State<PyramidVideoPlayer> createState() => _PyramidVideoPlayerState();
}
class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
bool _hovered = false;
String _quality = 'Auto';
double _speed = 1.0;
bool _settingsOpen = false;
static const _qualities = ['Auto', '1080p', '720p', '480p', '360p'];
static const _speeds = [0.5, 1.0, 1.25, 1.5, 2.0];
double get progress => widget.duration.inMilliseconds == 0
? 0
: widget.position.inMilliseconds / widget.duration.inMilliseconds;
bool get _showChrome {
if (widget.variant == VideoPlayerVariant.minimal) return true;
if (widget.variant == VideoPlayerVariant.framed) return true;
return _hovered || !widget.playing;
}
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 == VideoPlayerVariant.framed) return _buildFramed();
return _buildOverlay();
}
// ─── Default / Minimal ─────────────────────────────────────────────
Widget _buildOverlay() {
final aspect = widget.variant == VideoPlayerVariant.minimal ? 16 / 9 : 16 / 10;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() { _hovered = false; _settingsOpen = false; }),
child: GestureDetector(
onTap: widget.onTogglePlay,
child: ClipRRect(
borderRadius: BorderRadius.circular(PyramidTheme.rLg),
child: AspectRatio(
aspectRatio: aspect,
child: Stack(fit: StackFit.expand, children: [
_stage(),
_grain(),
if (!widget.playing) Center(child: _CenterPlay(onTap: widget.onTogglePlay)),
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showChrome ? 1 : 0,
child: _topBar(onDark: true),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showChrome ? 1 : 0,
child: Align(alignment: Alignment.bottomCenter, child: _bottomBar(onDark: true)),
),
]),
),
),
),
);
}
// ─── Framed ────────────────────────────────────────────────────────
Widget _buildFramed() {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: PyramidTheme.bg1,
border: Border.all(color: PyramidTheme.border),
borderRadius: BorderRadius.circular(PyramidTheme.rLg),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
ClipRRect(
borderRadius: BorderRadius.circular(PyramidTheme.rLg - 4),
child: AspectRatio(
aspectRatio: 16 / 9,
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTogglePlay,
child: Stack(fit: StackFit.expand, children: [
_stage(),
_grain(),
if (!widget.playing) Center(child: _CenterPlay(onTap: widget.onTogglePlay)),
_topBar(onDark: true),
]),
),
),
),
),
const SizedBox(height: 12),
_bottomBar(onDark: false),
]),
);
}
// ─── Stage (placeholder bg) ────────────────────────────────────────
Widget _stage() {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight,
colors: [const Color(0xFF1A1A22), const Color(0xFF0C0C10)],
),
),
alignment: Alignment.center,
child: Opacity(
opacity: 0.35,
child: CustomPaint(size: const Size(72, 60), painter: _BigTriPainter()),
),
);
}
Widget _grain() => IgnorePointer(child: Opacity(
opacity: 0.05,
child: Container(decoration: const BoxDecoration(color: Colors.white12)),
));
// ─── Top bar (title + captions + more) ─────────────────────────────
Widget _topBar({required bool onDark}) {
final fg = onDark ? Colors.white : PyramidTheme.fg;
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
gradient: onDark
? LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black.withOpacity(0.5), Colors.transparent])
: null,
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(widget.title, style: TextStyle(color: fg, fontSize: 13, fontFamily: PyramidTheme.fontSans, fontWeight: FontWeight.w500)),
if (widget.subtitle.isNotEmpty)
Text(widget.subtitle.toUpperCase(), style: TextStyle(color: fg.withOpacity(0.65), fontSize: 10, fontFamily: PyramidTheme.fontMono, letterSpacing: 1.2)),
])),
_OverlayBtn(icon: Icons.closed_caption_outlined, onTap: widget.onCaptions, onDark: onDark),
_OverlayBtn(icon: Icons.more_horiz, onTap: widget.onMore, onDark: onDark),
]),
);
}
// ─── Bottom bar (scrubber + controls) ──────────────────────────────
Widget _bottomBar({required bool onDark}) {
final iconColor = onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted;
final timeColor = onDark ? Colors.white.withOpacity(0.78) : PyramidTheme.fgDim;
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
gradient: onDark
? LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black.withOpacity(0.75), Colors.transparent])
: null,
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
_scrubber(onDark: onDark),
const SizedBox(height: 10),
Row(children: [
// Left side
_PlayMini(playing: widget.playing, onTap: widget.onTogglePlay, onDark: onDark),
const SizedBox(width: 4),
_OverlayBtn(icon: Icons.replay_10, onTap: widget.onSkipBack, onDark: onDark),
_OverlayBtn(icon: Icons.forward_10, onTap: widget.onSkipForward, onDark: onDark),
_OverlayBtn(icon: Icons.volume_up_outlined, onTap: null, onDark: onDark),
const SizedBox(width: 8),
Text('${_fmt(widget.position)} / ${_fmt(widget.duration)}',
style: TextStyle(color: timeColor, fontSize: 11, fontFamily: PyramidTheme.fontMono, letterSpacing: 0.4)),
const Spacer(),
// Right side
_QualityBadge(label: _quality == 'Auto' ? '1080p' : _quality, onDark: onDark),
const SizedBox(width: 4),
_SettingsButton(
open: _settingsOpen,
onTap: () => setState(() => _settingsOpen = !_settingsOpen),
speed: '${_speed == _speed.toInt() ? _speed.toInt() : _speed}x',
quality: _quality,
onDark: onDark,
onSpeed: (s) {
setState(() { _speed = s; _settingsOpen = false; });
widget.onSpeedChange?.call(s);
},
onQuality: (q) {
setState(() { _quality = q; _settingsOpen = false; });
widget.onQualityChange?.call(q);
},
speeds: _speeds, qualities: _qualities,
),
_OverlayBtn(icon: Icons.fullscreen, onTap: widget.onFullscreen, onDark: onDark),
]),
]),
);
}
Widget _scrubber({required bool onDark}) {
final trackBg = onDark ? Colors.white.withOpacity(0.18) : PyramidTheme.bg3;
final bufferedBg = onDark ? Colors.white.withOpacity(0.28) : PyramidTheme.borderStrong;
return LayoutBuilder(builder: (_, c) => GestureDetector(
onTapDown: (d) => widget.onSeek?.call(Duration(
milliseconds: (widget.duration.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round(),
)),
onPanUpdate: (d) => widget.onSeek?.call(Duration(
milliseconds: (widget.duration.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round(),
)),
child: SizedBox(
height: 14,
child: Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(color: trackBg, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(widthFactor: widget.buffered, child: Container(height: 3, decoration: BoxDecoration(color: bufferedBg, borderRadius: BorderRadius.circular(2)))),
FractionallySizedBox(widthFactor: progress, child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.accent, borderRadius: BorderRadius.circular(2)))),
Positioned(
left: c.maxWidth * progress - 5,
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: PyramidTheme.accent, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.35), blurRadius: 0, spreadRadius: 3)])),
),
]),
),
));
}
}
// ───────────────────────── Overlay sub-widgets ─────────────────────────
class _CenterPlay extends StatelessWidget {
final VoidCallback? onTap;
const _CenterPlay({this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 64, height: 64,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.08),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.15)),
),
child: const Icon(Icons.play_arrow, color: Colors.white, size: 28),
),
);
}
}
class _OverlayBtn extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
final bool onDark;
const _OverlayBtn({required this.icon, this.onTap, required this.onDark});
@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: 17, color: onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted),
),
);
}
}
class _PlayMini extends StatelessWidget {
final bool playing;
final VoidCallback? onTap;
final bool onDark;
const _PlayMini({required this.playing, this.onTap, required this.onDark});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: onDark ? Colors.white : PyramidTheme.accent,
shape: BoxShape.circle,
),
child: Icon(playing ? Icons.pause : Icons.play_arrow,
color: onDark ? const Color(0xFF0A0A0C) : PyramidTheme.accentFg, size: 16),
),
);
}
}
class _QualityBadge extends StatelessWidget {
final String label;
final bool onDark;
const _QualityBadge({required this.label, required this.onDark});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: onDark ? Colors.white.withOpacity(0.08) : PyramidTheme.bg2,
borderRadius: BorderRadius.circular(PyramidTheme.rSm),
),
child: Text(label.toUpperCase(),
style: TextStyle(
fontFamily: PyramidTheme.fontMono, fontSize: 10, fontWeight: FontWeight.w500, letterSpacing: 0.6,
color: onDark ? Colors.white.withOpacity(0.78) : PyramidTheme.fgMuted,
)),
);
}
}
// ───────────────────────── Settings menu ─────────────────────────
class _SettingsButton extends StatelessWidget {
final bool open;
final VoidCallback onTap;
final String speed;
final String quality;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
final List<double> speeds;
final List<String> qualities;
final bool onDark;
const _SettingsButton({
required this.open, required this.onTap,
required this.speed, required this.quality,
required this.onSpeed, required this.onQuality,
required this.speeds, required this.qualities,
required this.onDark,
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<void>(
tooltip: 'Einstellungen',
offset: const Offset(0, -12),
position: PopupMenuPosition.over,
color: onDark ? const Color(0xFF14141A).withOpacity(0.96) : PyramidTheme.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PyramidTheme.rBase),
side: BorderSide(color: onDark ? Colors.white.withOpacity(0.1) : PyramidTheme.border),
),
itemBuilder: (ctx) => [
PopupMenuItem<void>(
enabled: false,
padding: EdgeInsets.zero,
child: _SettingsMenu(
speed: speed, quality: quality,
onSpeed: onSpeed, onQuality: onQuality,
speeds: speeds, qualities: qualities,
onDark: onDark,
),
),
],
child: Container(
width: 32, height: 32,
alignment: Alignment.center,
child: Icon(Icons.settings_outlined, size: 17, color: onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted),
),
);
}
}
class _SettingsMenu extends StatefulWidget {
final String speed;
final String quality;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
final List<double> speeds;
final List<String> qualities;
final bool onDark;
const _SettingsMenu({
required this.speed, required this.quality,
required this.onSpeed, required this.onQuality,
required this.speeds, required this.qualities,
required this.onDark,
});
@override
State<_SettingsMenu> createState() => _SettingsMenuState();
}
class _SettingsMenuState extends State<_SettingsMenu> {
String? section; // null | 'speed' | 'quality'
@override
Widget build(BuildContext context) {
final fg = widget.onDark ? Colors.white.withOpacity(0.9) : PyramidTheme.fg;
final dim = widget.onDark ? Colors.white.withOpacity(0.6) : PyramidTheme.fgMuted;
Widget row(String label, String value, VoidCallback onTap) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(label, style: TextStyle(color: fg, fontSize: 12, fontFamily: PyramidTheme.fontSans)),
const SizedBox(width: 24),
Text(value, style: TextStyle(color: dim, fontSize: 11, fontFamily: PyramidTheme.fontMono)),
const SizedBox(width: 4),
Icon(Icons.chevron_right, size: 14, color: dim),
]),
),
);
Widget item(String label, bool active, VoidCallback onTap) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(width: 18, child: active ? Icon(Icons.check, size: 14, color: PyramidTheme.accent) : null),
Text(label, style: TextStyle(
color: active ? PyramidTheme.accent : fg,
fontSize: 12,
fontFamily: section == 'speed' ? PyramidTheme.fontMono : PyramidTheme.fontSans,
)),
]),
),
);
if (section == null) {
return SizedBox(
width: 200,
child: Column(mainAxisSize: MainAxisSize.min, children: [
row('Geschwindigkeit', widget.speed, () => setState(() => section = 'speed')),
row('Qualität', widget.quality, () => setState(() => section = 'quality')),
]),
);
}
final isSpeed = section == 'speed';
return SizedBox(
width: 180,
child: Column(mainAxisSize: MainAxisSize.min, children: [
InkWell(
onTap: () => setState(() => section = null),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.arrow_back, size: 12, color: dim),
const SizedBox(width: 8),
Text(isSpeed ? 'GESCHWINDIGKEIT' : 'QUALITÄT',
style: TextStyle(color: dim, fontSize: 10, fontFamily: PyramidTheme.fontMono, letterSpacing: 0.8)),
]),
),
),
Container(height: 1, color: widget.onDark ? Colors.white.withOpacity(0.08) : PyramidTheme.border),
if (isSpeed)
...widget.speeds.map((s) {
final lbl = '${s == s.toInt() ? s.toInt() : s}x';
return item(lbl, lbl == widget.speed, () => widget.onSpeed(s));
})
else
...widget.qualities.map((q) => item(q, q == widget.quality, () => widget.onQuality(q))),
]),
);
}
}
class _BigTriPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size s) {
final p = Paint()..color = PyramidTheme.accent;
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;
}
+32
View File
@@ -0,0 +1,32 @@
name: pyramid_players
description: Pyramid Audio + Video Player widgets (Mockup-Skelett, ohne echte Wiedergabe).
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.0.0 <4.0.0'
flutter: '>=3.16.0'
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
# Optional: Geist + Geist Mono einbinden, sonst Fallback auf System-Sans/Mono
# fonts:
# - family: Geist
# fonts:
# - asset: fonts/Geist-Regular.ttf
# - asset: fonts/Geist-Medium.ttf
# weight: 500
# - asset: fonts/Geist-SemiBold.ttf
# weight: 600
# - family: GeistMono
# fonts:
# - asset: fonts/GeistMono-Regular.ttf
# - asset: fonts/GeistMono-Medium.ttf
# weight: 500