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