feat(ui): gemeinsame Chatliste, Gruppenanrufe mit Beitreten-Karte, Bubble-Anrufansicht
- Chats: Direktnachrichten + Gruppen ohne Server zusammen, Filter-Chips (Alle/Ungelesen/Personen/Gruppen), Leerzustaende mit Handlung; virtuelles 'Raeume'-Symbol entfaellt, Server-Kanaele nur noch im Server - Gruppen: runder Gruppen-Avatar, Kopf mit Mitgliederzahl, Anruf-/Video-Knopf - Gruppenanruf: LiveKit + Anwesenheit, Start-Nachricht (Push), Karte 'Gruppenanruf laeuft · Beitreten', Hinweis-Karte im Verlauf - Anrufansicht: aneinandergedrueckte Bubbles (Federphysik, Sprecher waechst), Kacheln umschaltbar, Bildschirm automatisch gross, Gespraechsdauer, Matrix-Namen/-Avatare statt LiveKit-IDs - Tests: test/bubble_physics_test.dart Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
87b472a373
commit
de24053167
@@ -0,0 +1,201 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
/// Physik der Bubble-Anrufansicht (Bernd 2026-10-09: „animierte dynamische
|
||||
/// Bubbles, die sich aneinander anpassen und aussehen, als würden sie
|
||||
/// aneinander gedrückt“).
|
||||
///
|
||||
/// Jede Person ist ein Kreis. Eine Feder zieht alle Kreise zur Mitte, Kreise
|
||||
/// stoßen sich ab, sobald sie sich mehr als [overlap] überlappen, und die
|
||||
/// Ränder halten sie im Bild. Im Gleichgewicht drücken sie also leicht
|
||||
/// gegeneinander; [clipPath] schneidet jeden Kreis an der Berührungslinie
|
||||
/// (Potenzgerade) gerade ab – wie Seifenblasen in Schaum.
|
||||
///
|
||||
/// Wer spricht, wird größer und schiebt die anderen sanft beiseite; neue
|
||||
/// Teilnehmer wachsen aus dem Nichts heraus, Gehende schrumpfen weg.
|
||||
class BubbleBody {
|
||||
final String id;
|
||||
Offset pos;
|
||||
Offset vel = Offset.zero;
|
||||
double r;
|
||||
double rVel = 0;
|
||||
double targetR = 0;
|
||||
bool leaving = false;
|
||||
|
||||
BubbleBody(this.id, this.pos, {this.r = 0});
|
||||
}
|
||||
|
||||
class BubbleSim {
|
||||
/// Anteil der Kreisdurchmesser, um den sich Nachbarn überlappen dürfen –
|
||||
/// daraus entstehen die flachen Berührungsflächen.
|
||||
static const overlap = 0.10;
|
||||
|
||||
/// Wie viel größer ein sprechender Kreis wird.
|
||||
static const speakingScale = 1.22;
|
||||
|
||||
final Map<String, BubbleBody> bodies = {};
|
||||
Size _size = Size.zero;
|
||||
Size get size => _size;
|
||||
Offset get _center => Offset(_size.width / 2, _size.height / 2);
|
||||
|
||||
/// Gleicht die Kreise mit den aktuellen Teilnehmern ab: neue kommen dazu,
|
||||
/// fehlende schrumpfen weg, die Zielgrößen werden neu verteilt.
|
||||
/// [speaking] = IDs, die gerade sprechen.
|
||||
void sync(List<String> ids, Set<String> speaking, Size size) {
|
||||
_size = size;
|
||||
final present = ids.toSet();
|
||||
for (final b in bodies.values) {
|
||||
b.leaving = !present.contains(b.id);
|
||||
}
|
||||
for (final id in ids) {
|
||||
if (bodies.containsKey(id)) continue;
|
||||
// Neu: nahe der Mitte (leicht versetzt, damit es die anderen
|
||||
// auseinanderschiebt statt exakt auf ihnen zu liegen).
|
||||
final h = id.hashCode;
|
||||
final angle = (h % 360) * math.pi / 180;
|
||||
final spread = math.min(size.width, size.height) * 0.08;
|
||||
bodies[id] = BubbleBody(
|
||||
id,
|
||||
_center + Offset(math.cos(angle), math.sin(angle)) * spread,
|
||||
);
|
||||
}
|
||||
_assignTargets(ids, speaking);
|
||||
}
|
||||
|
||||
void _assignTargets(List<String> ids, Set<String> speaking) {
|
||||
final w = _size.width, h = _size.height;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
final shortSide = math.min(w, h);
|
||||
double weight(String id) => speaking.contains(id) ? speakingScale : 1.0;
|
||||
final active = ids.where((id) => bodies.containsKey(id)).toList();
|
||||
double base;
|
||||
if (active.length <= 1) {
|
||||
base = shortSide * 0.30;
|
||||
} else {
|
||||
// Kreise sollen zusammen gut 40 % der Fläche bedecken.
|
||||
final sumW2 = active.fold<double>(0, (s, id) => s + weight(id) * weight(id));
|
||||
base = math.sqrt(0.42 * w * h / (math.pi * sumW2));
|
||||
}
|
||||
final maxR = shortSide * (active.length <= 2 ? 0.30 : 0.36);
|
||||
for (final b in bodies.values) {
|
||||
if (b.leaving) {
|
||||
b.targetR = 0;
|
||||
} else {
|
||||
b.targetR = (base * weight(b.id)).clamp(28.0, maxR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Zeitschritt in Sekunden. Liefert `true`, solange sich noch etwas
|
||||
/// bewegt (sonst kann die Animation pausieren).
|
||||
bool step(double dt) {
|
||||
if (_size.isEmpty) return false;
|
||||
dt = dt.clamp(0.0, 1 / 30);
|
||||
// Zwei Unterschritte: stabil auch bei großen Kräften.
|
||||
var moving = false;
|
||||
for (var i = 0; i < 2; i++) {
|
||||
moving = _substep(dt / 2) || moving;
|
||||
}
|
||||
bodies.removeWhere((_, b) => b.leaving && b.r < 1);
|
||||
return moving;
|
||||
}
|
||||
|
||||
bool _substep(double dt) {
|
||||
const centerK = 18.0; // Zug zur Mitte
|
||||
const pushK = 260.0; // Abstoßung bei zu starker Überlappung
|
||||
const wallK = 160.0;
|
||||
const damping = 9.0;
|
||||
const radiusK = 110.0;
|
||||
const radiusDamping = 13.0;
|
||||
const margin = 10.0;
|
||||
|
||||
final list = bodies.values.toList();
|
||||
final forces = List<Offset>.filled(list.length, Offset.zero);
|
||||
final c = _center;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
final a = list[i];
|
||||
// Die Mitte zieht, aber gestaucht in der langen Richtung, damit sich
|
||||
// die Kreise im Hochformat übereinander, im Querformat nebeneinander
|
||||
// anordnen.
|
||||
final d = a.pos - c;
|
||||
final ax = _size.width >= _size.height ? 0.55 : 1.0;
|
||||
final ay = _size.height > _size.width ? 0.55 : 1.0;
|
||||
forces[i] += Offset(-d.dx * centerK * ax, -d.dy * centerK * ay);
|
||||
|
||||
// Ränder
|
||||
final left = a.pos.dx - a.r - margin;
|
||||
final right = _size.width - margin - (a.pos.dx + a.r);
|
||||
final top = a.pos.dy - a.r - margin;
|
||||
final bottom = _size.height - margin - (a.pos.dy + a.r);
|
||||
if (left < 0) forces[i] += Offset(-left * wallK, 0);
|
||||
if (right < 0) forces[i] += Offset(right * wallK, 0);
|
||||
if (top < 0) forces[i] += Offset(0, -top * wallK);
|
||||
if (bottom < 0) forces[i] += Offset(0, bottom * wallK);
|
||||
|
||||
for (var j = i + 1; j < list.length; j++) {
|
||||
final b = list[j];
|
||||
var delta = b.pos - a.pos;
|
||||
var dist = delta.distance;
|
||||
if (dist < 0.01) {
|
||||
// exakt übereinander: in eine feste Richtung auseinander
|
||||
delta = Offset(math.cos(i + j * 1.7), math.sin(i + j * 1.7));
|
||||
dist = 0.01;
|
||||
}
|
||||
final rest = (a.r + b.r) * (1 - overlap);
|
||||
if (dist < rest) {
|
||||
final u = delta / dist;
|
||||
final f = u * ((rest - dist) * pushK);
|
||||
forces[i] -= f;
|
||||
forces[j] += f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var moving = false;
|
||||
final decay = math.exp(-damping * dt);
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
final b = list[i];
|
||||
b.vel = (b.vel + forces[i] * dt) * decay;
|
||||
b.pos += b.vel * dt;
|
||||
// Größe federt zum Ziel (leichtes Überschwingen beim Erscheinen).
|
||||
b.rVel += (radiusK * (b.targetR - b.r) - radiusDamping * b.rVel) * dt;
|
||||
b.r = math.max(0, b.r + b.rVel * dt);
|
||||
if (b.vel.distance > 2 ||
|
||||
b.rVel.abs() > 2 ||
|
||||
(b.targetR - b.r).abs() > 0.5) {
|
||||
moving = true;
|
||||
}
|
||||
}
|
||||
return moving;
|
||||
}
|
||||
|
||||
/// Umriss von [id] in Bühnen-Koordinaten: der Kreis, an jeder Berührung
|
||||
/// mit einem Nachbarn gerade abgeschnitten (mit [seam] Pixel Fuge).
|
||||
Path clipPath(String id, {double seam = 3}) {
|
||||
final a = bodies[id]!;
|
||||
final circle = Rect.fromCircle(center: a.pos, radius: math.max(a.r, 0.01));
|
||||
var path = Path()..addOval(circle);
|
||||
for (final b in bodies.values) {
|
||||
if (identical(a, b) || b.r < 1) continue;
|
||||
final delta = b.pos - a.pos;
|
||||
final d = delta.distance;
|
||||
if (d < 0.01 || d >= a.r + b.r) continue;
|
||||
final u = delta / d;
|
||||
// Abstand von a zur Potenzgerade der beiden Kreise
|
||||
final cut = (d * d + a.r * a.r - b.r * b.r) / (2 * d) - seam / 2;
|
||||
if (cut >= a.r) continue;
|
||||
final n = Offset(-u.dy, u.dx);
|
||||
final big = a.r * 4 + 10;
|
||||
final base = a.pos + u * cut;
|
||||
final half = Path()
|
||||
..addPolygon([
|
||||
base + n * big,
|
||||
base - n * big,
|
||||
base - n * big - u * big,
|
||||
base + n * big - u * big,
|
||||
], true);
|
||||
path = Path.combine(PathOperation.intersect, path, half);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
/// Anrufansicht als aneinandergedrückte Bubbles (Physik: bubble_physics.dart).
|
||||
/// Jede Person ist eine Blase mit Profilbild (oder Kamera-Bild), wer spricht,
|
||||
/// wächst und leuchtet; Blasen weichen sich federnd aus.
|
||||
class _BubbleStage extends StatefulWidget {
|
||||
final List<_ParticipantData> participants;
|
||||
final Client? client;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<_ParticipantData> onSelect;
|
||||
|
||||
const _BubbleStage({
|
||||
super.key,
|
||||
required this.participants,
|
||||
required this.client,
|
||||
required this.pt,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_BubbleStage> createState() => _BubbleStageState();
|
||||
}
|
||||
|
||||
class _BubbleStageState extends State<_BubbleStage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _sim = BubbleSim();
|
||||
// Letzter bekannter Stand je Person – auch für Gehende, die noch
|
||||
// wegschrumpfen.
|
||||
final Map<String, _ParticipantData> _data = {};
|
||||
late final Ticker _ticker = createTicker(_tick);
|
||||
Duration? _last;
|
||||
double _time = 0;
|
||||
bool _needsSync = true;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_BubbleStage old) {
|
||||
super.didUpdateWidget(old);
|
||||
_needsSync = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _tick(Duration elapsed) {
|
||||
final dt = _last == null
|
||||
? 1 / 60
|
||||
: (elapsed - _last!).inMicroseconds / Duration.microsecondsPerSecond;
|
||||
_last = elapsed;
|
||||
_time += dt;
|
||||
final moving = _sim.step(dt);
|
||||
_data.removeWhere((id, _) => !_sim.bodies.containsKey(id));
|
||||
final speaking = widget.participants.any((p) => p.isSpeaking);
|
||||
setState(() {});
|
||||
// Ruhe = keine Animation (spart Akku); Sprechen hält das Leuchten an.
|
||||
if (!moving && !speaking) {
|
||||
_ticker.stop();
|
||||
_last = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _sync(Size size) {
|
||||
for (final p in widget.participants) {
|
||||
_data[p.id] = p;
|
||||
}
|
||||
_sim.sync(
|
||||
widget.participants.map((p) => p.id).toList(),
|
||||
widget.participants.where((p) => p.isSpeaking).map((p) => p.id).toSet(),
|
||||
size,
|
||||
);
|
||||
if (!_ticker.isActive) {
|
||||
_last = null;
|
||||
_ticker.start();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final size = box.biggest;
|
||||
if (_needsSync || size != _sim.size) {
|
||||
_needsSync = false;
|
||||
_sync(size);
|
||||
}
|
||||
final glow = 0.5 + 0.5 * math.sin(_time * 6);
|
||||
final children = <Widget>[];
|
||||
for (final b in _sim.bodies.values) {
|
||||
final d = _data[b.id];
|
||||
if (d == null || b.r < 2) continue;
|
||||
final rect = Rect.fromCircle(center: b.pos, radius: b.r);
|
||||
children.add(Positioned.fromRect(
|
||||
key: ValueKey('bubble:${b.id}'),
|
||||
rect: rect,
|
||||
child: _Bubble(
|
||||
data: d,
|
||||
path: _sim.clipPath(b.id).shift(-rect.topLeft),
|
||||
radius: b.r,
|
||||
client: widget.client,
|
||||
pt: pt,
|
||||
glow: glow,
|
||||
onTap: () => widget.onSelect(d),
|
||||
),
|
||||
));
|
||||
}
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
colors: [pt.bg2, pt.bg1],
|
||||
radius: 0.9,
|
||||
),
|
||||
),
|
||||
child: Stack(clipBehavior: Clip.hardEdge, children: children),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _Bubble extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final Path path;
|
||||
final double radius;
|
||||
final Client? client;
|
||||
final PyramidTheme pt;
|
||||
final double glow;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _Bubble({
|
||||
required this.data,
|
||||
required this.path,
|
||||
required this.radius,
|
||||
required this.client,
|
||||
required this.pt,
|
||||
required this.glow,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showName = radius > 38;
|
||||
return Semantics(
|
||||
label: data.isMuted ? '${data.name}, stumm' : data.name,
|
||||
button: data.hasVideo,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: CustomPaint(
|
||||
foregroundPainter: _BubbleRingPainter(
|
||||
path: path,
|
||||
speaking: data.isSpeaking,
|
||||
glow: glow,
|
||||
color: pt.online,
|
||||
),
|
||||
child: ClipPath(
|
||||
clipper: _PathClipper(path),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_face(),
|
||||
if (data.hasVideo && data.coverVideoWidget != null)
|
||||
_MirrorWrapper(
|
||||
isLocal: data.isLocal,
|
||||
isScreenShare: data.isScreenShare,
|
||||
child: data.coverVideoWidget!,
|
||||
),
|
||||
if (showName)
|
||||
Align(
|
||||
alignment: const Alignment(0, 0.62),
|
||||
child: _nameTag(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Profilbild oder Farbverlauf mit Anfangsbuchstaben – fest in 256 px
|
||||
/// gebaut und skaliert, damit das Bild beim Wachsen nicht neu lädt.
|
||||
Widget _face() {
|
||||
Widget initial(BuildContext _) => Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [data.avatarColor, data.avatarColor.withAlpha(150)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
alignment: const Alignment(0, -0.12),
|
||||
child: Text(data.initial,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 104,
|
||||
fontWeight: FontWeight.w700)),
|
||||
);
|
||||
final c = client;
|
||||
final face = (c != null && data.avatarUrl != null)
|
||||
? MxcAvatar(
|
||||
mxcUri: data.avatarUrl,
|
||||
client: c,
|
||||
size: 256,
|
||||
borderRadius: BorderRadius.zero,
|
||||
placeholder: initial,
|
||||
)
|
||||
: Builder(builder: initial);
|
||||
return FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: SizedBox(width: 256, height: 256, child: face),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _nameTag() {
|
||||
final fontSize = (radius * 0.16).clamp(10.0, 15.0);
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: radius * 1.5),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: fontSize * 0.7, vertical: fontSize * 0.25),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (data.isMuted) ...[
|
||||
Icon(Icons.mic_off_rounded, size: fontSize + 1, color: pt.danger),
|
||||
SizedBox(width: fontSize * 0.3),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
data.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PathClipper extends CustomClipper<Path> {
|
||||
final Path path;
|
||||
_PathClipper(this.path);
|
||||
|
||||
@override
|
||||
Path getClip(Size size) => path;
|
||||
|
||||
@override
|
||||
bool shouldReclip(_PathClipper old) => !identical(old.path, path);
|
||||
}
|
||||
|
||||
class _BubbleRingPainter extends CustomPainter {
|
||||
final Path path;
|
||||
final bool speaking;
|
||||
final double glow;
|
||||
final Color color;
|
||||
|
||||
_BubbleRingPainter({
|
||||
required this.path,
|
||||
required this.speaking,
|
||||
required this.glow,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (speaking) {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = color.withValues(alpha: 0.30 + 0.30 * glow)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 7
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
|
||||
);
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3
|
||||
..strokeJoin = StrokeJoin.round,
|
||||
);
|
||||
} else {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.10)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_BubbleRingPainter old) => true;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
/// Darstellung der Teilnehmer: Bubbles (Standard), Kacheln, oder ein großes
|
||||
/// Bild (Bildschirm/Kamera) mit Leiste.
|
||||
enum _CallLayout { bubbles, grid, hero }
|
||||
|
||||
class _UnifiedCallView extends StatefulWidget {
|
||||
final List<_ParticipantData> participants;
|
||||
final Client? client;
|
||||
final DateTime startedAt;
|
||||
final bool isLiveKit;
|
||||
final String displayName;
|
||||
final bool isConnecting;
|
||||
@@ -22,6 +28,8 @@ class _UnifiedCallView extends StatefulWidget {
|
||||
|
||||
const _UnifiedCallView({
|
||||
required this.participants,
|
||||
required this.client,
|
||||
required this.startedAt,
|
||||
required this.isLiveKit,
|
||||
required this.displayName,
|
||||
required this.isConnecting,
|
||||
@@ -46,31 +54,49 @@ class _UnifiedCallView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
_CallLayout _mode = _CallLayout.bubbles;
|
||||
String? _heroId;
|
||||
bool _isGridView = false;
|
||||
bool _stripCollapsed = false;
|
||||
Set<String> _knownScreens = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_autoSelectHero();
|
||||
_followScreenShares();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_UnifiedCallView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_autoSelectHero();
|
||||
_followScreenShares();
|
||||
}
|
||||
|
||||
void _autoSelectHero() {
|
||||
if (_isGridView) return;
|
||||
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
|
||||
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
|
||||
_heroId = screenshare.id;
|
||||
} else if (_heroId == null && widget.participants.isNotEmpty) {
|
||||
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
|
||||
if (firstVideo != null) _heroId = firstVideo.id;
|
||||
/// Neue Bildschirmübertragung → groß zeigen. Verschwindet das große Bild
|
||||
/// (Person weg, Kamera aus), zurück zu den Bubbles.
|
||||
void _followScreenShares() {
|
||||
final screens = widget.participants
|
||||
.where((p) => p.isScreenShare)
|
||||
.map((p) => p.id)
|
||||
.toSet();
|
||||
final fresh = screens.difference(_knownScreens);
|
||||
if (fresh.isNotEmpty) {
|
||||
_mode = _CallLayout.hero;
|
||||
_heroId = fresh.first;
|
||||
}
|
||||
_knownScreens = screens;
|
||||
if (_mode == _CallLayout.hero &&
|
||||
!widget.participants.any((p) => p.id == _heroId && p.hasVideo)) {
|
||||
_mode = _CallLayout.bubbles;
|
||||
_heroId = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _showLarge(_ParticipantData p) {
|
||||
if (!p.hasVideo) return;
|
||||
setState(() {
|
||||
_mode = _CallLayout.hero;
|
||||
_heroId = p.id;
|
||||
});
|
||||
}
|
||||
|
||||
List<_ParticipantData> _sorted(List<_ParticipantData> all) {
|
||||
@@ -87,15 +113,10 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final all = _sorted(widget.participants);
|
||||
// Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt
|
||||
// das große Bild bei jedem Sprechwechsel hin und her.
|
||||
final stable = [...widget.participants]..sort((a, b) {
|
||||
int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2);
|
||||
return r(a).compareTo(r(b));
|
||||
});
|
||||
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ??
|
||||
(stable.isNotEmpty ? stable.first : null);
|
||||
|
||||
final hero = _mode == _CallLayout.hero
|
||||
? widget.participants.firstWhereOrNull((p) => p.id == _heroId)
|
||||
: null;
|
||||
|
||||
if (widget.isConnecting) {
|
||||
return Scaffold(
|
||||
backgroundColor: pt.bg1,
|
||||
@@ -126,23 +147,44 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
children: [
|
||||
_CallHeader(
|
||||
displayName: widget.displayName,
|
||||
isGridView: _isGridView,
|
||||
onToggleView: () => setState(() => _isGridView = !_isGridView),
|
||||
startedAt: widget.startedAt,
|
||||
count: widget.participants.length,
|
||||
layout: _mode,
|
||||
onToggleView: () => setState(() {
|
||||
_mode = _mode == _CallLayout.bubbles
|
||||
? _CallLayout.grid
|
||||
: _CallLayout.bubbles;
|
||||
_heroId = null;
|
||||
}),
|
||||
onMinimize: widget.onMinimize,
|
||||
pt: pt,
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _isGridView || hero == null
|
||||
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
|
||||
duration: motionDuration(context, 300),
|
||||
child: hero == null
|
||||
? (_mode == _CallLayout.grid
|
||||
? _ParticipantGrid(
|
||||
key: const ValueKey('grid'),
|
||||
participants: all,
|
||||
pt: pt,
|
||||
onSelect: _showLarge)
|
||||
: _BubbleStage(
|
||||
key: const ValueKey('bubbles'),
|
||||
participants: widget.participants,
|
||||
client: widget.client,
|
||||
pt: pt,
|
||||
onSelect: _showLarge))
|
||||
: _HeroLayout(
|
||||
key: const ValueKey('hero'),
|
||||
hero: hero,
|
||||
others: all.where((p) => p.id != hero.id).toList(),
|
||||
stripCollapsed: _stripCollapsed,
|
||||
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
|
||||
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
|
||||
onSelectOther: (p) => setState(() => _heroId = p.id),
|
||||
onSelectOther: (p) => p.hasVideo
|
||||
? setState(() => _heroId = p.id)
|
||||
: null,
|
||||
onQualityMenu: widget.onQualityMenu,
|
||||
pt: pt,
|
||||
),
|
||||
@@ -170,30 +212,55 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
|
||||
class _CallHeader extends StatelessWidget {
|
||||
final String displayName;
|
||||
final bool isGridView;
|
||||
final DateTime startedAt;
|
||||
final int count;
|
||||
final _CallLayout layout;
|
||||
final VoidCallback onToggleView;
|
||||
final VoidCallback onMinimize;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _CallHeader({required this.displayName, required this.isGridView, required this.onToggleView, required this.onMinimize, required this.pt});
|
||||
const _CallHeader({
|
||||
required this.displayName,
|
||||
required this.startedAt,
|
||||
required this.count,
|
||||
required this.layout,
|
||||
required this.onToggleView,
|
||||
required this.onMinimize,
|
||||
required this.pt,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final toBubbles = layout != _CallLayout.bubbles;
|
||||
return Container(
|
||||
height: 52,
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
|
||||
const SizedBox(width: 8),
|
||||
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
_LiveBadge(pt: pt),
|
||||
const Spacer(),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Row(children: [
|
||||
_LiveBadge(pt: pt),
|
||||
const SizedBox(width: 8),
|
||||
_CallDuration(startedAt: startedAt, pt: pt),
|
||||
Text(count == 1 ? ' · nur du' : ' · $count Personen',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 12)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
PyrIconBtn(
|
||||
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
|
||||
tooltip: isGridView ? 'Großansicht' : 'Kachelansicht',
|
||||
icon: Icon(toBubbles ? Icons.bubble_chart_rounded : Icons.grid_view_rounded, color: pt.fg),
|
||||
tooltip: toBubbles ? 'Bubble-Ansicht' : 'Kachelansicht',
|
||||
onPressed: onToggleView,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
@@ -208,6 +275,47 @@ class _CallHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gesprächsdauer „m:ss“ bzw. „h:mm:ss“, tickt jede Sekunde.
|
||||
class _CallDuration extends StatefulWidget {
|
||||
final DateTime startedAt;
|
||||
final PyramidTheme pt;
|
||||
const _CallDuration({required this.startedAt, required this.pt});
|
||||
|
||||
@override
|
||||
State<_CallDuration> createState() => _CallDurationState();
|
||||
}
|
||||
|
||||
class _CallDurationState extends State<_CallDuration> {
|
||||
late final Timer _timer =
|
||||
Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer; // starten
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = DateTime.now().difference(widget.startedAt);
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final text = d.inHours > 0
|
||||
? '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'
|
||||
: '${d.inMinutes}:${two(d.inSeconds % 60)}';
|
||||
return Text(text,
|
||||
style: TextStyle(
|
||||
color: widget.pt.fgMuted,
|
||||
fontSize: 12,
|
||||
fontFeatures: const [FontFeature.tabularFigures()]));
|
||||
}
|
||||
}
|
||||
|
||||
class _LiveBadge extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _LiveBadge({required this.pt});
|
||||
|
||||
@@ -5,7 +5,7 @@ class _ParticipantGrid extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final Function(_ParticipantData) onSelect;
|
||||
|
||||
const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect});
|
||||
const _ParticipantGrid({super.key, required this.participants, required this.pt, required this.onSelect});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -41,6 +41,7 @@ class _HeroLayout extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _HeroLayout({
|
||||
super.key,
|
||||
required this.hero,
|
||||
required this.others,
|
||||
required this.stripCollapsed,
|
||||
|
||||
@@ -14,6 +14,12 @@ class _ParticipantData {
|
||||
final double aspectRatio;
|
||||
/// Woraus der Anfangsbuchstabe im Avatar gebildet wird (Standard: [name]).
|
||||
final String? initialFrom;
|
||||
/// Profilbild (mxc://) der Person, falls bekannt.
|
||||
final Uri? avatarUrl;
|
||||
/// Bin das ich?
|
||||
final bool isLocal;
|
||||
/// Kamera-/Bildschirmbild formatfüllend (für die runden Bubbles).
|
||||
final Widget? coverVideoWidget;
|
||||
|
||||
String get initial {
|
||||
final src = (initialFrom ?? name).replaceFirst('@', '').trim();
|
||||
@@ -32,9 +38,21 @@ class _ParticipantData {
|
||||
this.originalParticipant,
|
||||
this.aspectRatio = 16.0 / 9.0,
|
||||
this.initialFrom,
|
||||
this.avatarUrl,
|
||||
this.isLocal = false,
|
||||
this.coverVideoWidget,
|
||||
});
|
||||
}
|
||||
|
||||
/// Startzeit je Anruf (für die Dauer im Kopf). Schlüssel bleibt beim
|
||||
/// Minimieren gleich, damit die Uhr nicht neu anfängt.
|
||||
final _callStarts = <String, DateTime>{};
|
||||
|
||||
DateTime _callStartFor(String key) {
|
||||
if (_callStarts.length > 20) _callStarts.clear();
|
||||
return _callStarts.putIfAbsent(key, DateTime.now);
|
||||
}
|
||||
|
||||
class VoiceChannelView extends ConsumerWidget {
|
||||
const VoiceChannelView({super.key});
|
||||
|
||||
@@ -43,10 +61,13 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
|
||||
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
|
||||
return _UnifiedCallView(
|
||||
participants: _getVoipParticipants(voip),
|
||||
client: client,
|
||||
startedAt: _callStartFor('voip:${voip.currentCall!.callId}'),
|
||||
isLiveKit: false,
|
||||
displayName: voip.currentCall!.room.getLocalizedDisplayname(),
|
||||
isConnecting: false,
|
||||
@@ -76,7 +97,9 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return _UnifiedCallView(
|
||||
participants: _getLiveKitParticipants(call),
|
||||
participants: _getLiveKitParticipants(call, client),
|
||||
client: client,
|
||||
startedAt: _callStartFor('lk:${call.currentRoomId ?? call.displayName}'),
|
||||
isLiveKit: true,
|
||||
displayName: call.displayName,
|
||||
isConnecting: call.isConnecting,
|
||||
@@ -101,9 +124,26 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) {
|
||||
List<_ParticipantData> _getLiveKitParticipants(
|
||||
VoiceChannelService call, Client? client) {
|
||||
final room = call.room;
|
||||
if (room == null) return [];
|
||||
// LiveKit-Identität = Matrix-ID (vom Token-Server gesetzt) → Name und
|
||||
// Profilbild aus dem Matrix-Raum des Anrufs.
|
||||
final matrixRoomId = call.currentRoomId;
|
||||
final matrixRoom =
|
||||
matrixRoomId == null ? null : client?.getRoomById(matrixRoomId);
|
||||
User? member(String id) {
|
||||
if (!id.startsWith('@')) return null;
|
||||
if (matrixRoom != null) {
|
||||
return matrixRoom.unsafeGetUserFromMemoryOrFallback(id);
|
||||
}
|
||||
for (final r in client?.rooms ?? const []) {
|
||||
final u = r.unsafeGetUserFromMemoryOrFallback(id);
|
||||
if (u.avatarUrl != null || u.displayName != null) return u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final all = <Participant>[
|
||||
if (room.localParticipant != null) room.localParticipant!,
|
||||
...room.remoteParticipants.values,
|
||||
@@ -127,10 +167,20 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final isLocal = p.identity == room.localParticipant?.identity;
|
||||
final user = member(p.identity);
|
||||
final name = isLocal
|
||||
? 'Du'
|
||||
: (user?.calcDisplayname() ??
|
||||
(p.name.isNotEmpty ? p.name : p.identity));
|
||||
return _ParticipantData(
|
||||
id: p.identity,
|
||||
name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity,
|
||||
name: name,
|
||||
initialFrom: isLocal ? user?.calcDisplayname() ?? p.identity : null,
|
||||
avatarUrl: user?.avatarUrl,
|
||||
isLocal: isLocal,
|
||||
videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null,
|
||||
coverVideoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.cover) : null,
|
||||
isSpeaking: p.isSpeaking,
|
||||
isMuted: !p.isMicrophoneEnabled(),
|
||||
hasVideo: videoTrack != null,
|
||||
@@ -151,6 +201,10 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
|
||||
}
|
||||
|
||||
final mroom = voip.currentCall?.room;
|
||||
final partnerId = mroom?.directChatMatrixID;
|
||||
final myId = mroom?.client.userID;
|
||||
|
||||
// Remote
|
||||
final isRemoteScreen = voip.isRemoteScreensharing;
|
||||
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
|
||||
@@ -158,6 +212,10 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
id: 'remote',
|
||||
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Gegenseite',
|
||||
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
coverVideoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
|
||||
avatarUrl: partnerId == null
|
||||
? mroom?.avatar
|
||||
: mroom?.unsafeGetUserFromMemoryOrFallback(partnerId).avatarUrl,
|
||||
isMuted: false,
|
||||
isSpeaking: voip.isRemoteSpeaking,
|
||||
hasVideo: hasRemoteVideo,
|
||||
@@ -174,6 +232,11 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
name: 'Du',
|
||||
initialFrom: voip.currentCall?.room.client.userID?.split(':').first,
|
||||
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
coverVideoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
|
||||
avatarUrl: myId == null
|
||||
? null
|
||||
: mroom?.unsafeGetUserFromMemoryOrFallback(myId).avatarUrl,
|
||||
isLocal: true,
|
||||
isMuted: voip.isMicMuted,
|
||||
isSpeaking: voip.isLocalSpeaking,
|
||||
hasVideo: hasLocalVideo,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart' show Ticker;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/features/call_ui/bubble_physics.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
@@ -17,3 +25,4 @@ part 'view/call_view.dart';
|
||||
part 'view/participant_layout.dart';
|
||||
part 'view/avatars_controls.dart';
|
||||
part 'view/quality_menu.dart';
|
||||
part 'view/bubble_stage.dart';
|
||||
|
||||
Reference in New Issue
Block a user