Files
sync_replay_project/apps/desktop/lib/src/shell/delivery_line.dart
T
foxyzandClaude Opus 5.5 629be0d9ed
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m11s
ci / updater-windows (push) Failing after 6m16s
Restructure step 2: Status as one computed level, the Notifier's real policy, system notifications
- Status: computed from connection, presence, hold, relay, player facts;
  no setters (health writes and HealthStatus gone); pill, strip and
  source line read it; transitions go to the Notifier.
- Notifier: noticeFor(event, context) with self presence, alone, window
  activity, playing and prefs; toasts for joins/leaves/returns/swaps/
  Up next adds (with picture)/problems/chat, the rest logged; while
  sitting out only chat, nudges, starting, Up next (+ joins if chosen);
  alone no social notices; replayed lines logged; owns its toast timer.
- System notifications (flutter_local_notifications: D-Bus on Linux,
  WinRT on Windows) when the window isn't focused; a click focuses it.
- Toasts sit above the control bar and move with it.
- Versions: each member's in the members card and avatar tooltip,
  the server's in the card header; the strip no longer shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:11:08 +03:00

178 lines
6.3 KiB
Dart

import 'package:flutter/material.dart';
import '../session/delivery.dart';
import 'motion.dart';
// The members card's view of [Delivery]: one source line on top, a buffer
// dot on each member. Nothing of it sits on the picture.
Color _tone(SourceState s, ColorScheme scheme) => switch (s) {
SourceState.ok => scheme.primary,
SourceState.slow || SourceState.waiting || SourceState.gone => Colors.amber,
SourceState.left => Colors.redAccent.shade200,
};
IconData _icon(Delivery d) => switch (d.state) {
SourceState.left => Icons.link_off_rounded,
SourceState.waiting => Icons.hourglass_top_rounded,
SourceState.gone => Icons.wifi_off_rounded,
_ => switch (d.kind) {
DeliveryKind.memberRelay => Icons.cell_tower,
DeliveryKind.serverFetch => Icons.cloud_download_outlined,
DeliveryKind.hostFile => Icons.folder_shared_outlined,
DeliveryKind.serverFile => Icons.dns_outlined,
DeliveryKind.link => Icons.public,
DeliveryKind.jellyfin => Icons.video_library_outlined,
},
};
/// Where the current media comes from and how it reaches the others:
/// "Foxyz passes it on through the server", "3 through the server ·
/// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the
/// video's (past the mark: keeping up). Amber when slow, waiting or the
/// source's connection dropped, red when the source left.
class SourceLine extends StatelessWidget {
final Delivery delivery;
const SourceLine({super.key, required this.delivery});
@override
Widget build(BuildContext context) {
final d = delivery;
final scheme = Theme.of(context).colorScheme;
final tone = _tone(d.state, scheme);
final detail = d.detail;
final headroom = d.state == SourceState.left || d.state == SourceState.gone ? null : d.headroom;
return Tooltip(
message: d.explain,
waitDuration: const Duration(milliseconds: 400),
child: Container(
key: const ValueKey('source-line'),
margin: const EdgeInsets.fromLTRB(12, 0, 12, 6),
padding: const EdgeInsets.fromLTRB(10, 8, 12, 8),
decoration: BoxDecoration(
color: d.state == SourceState.ok ? scheme.surfaceContainerHigh : tone.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(_icon(d), size: 18, color: tone),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(d.title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
if (detail.isNotEmpty) ...[
const SizedBox(height: 2),
Text(detail, style: TextStyle(fontSize: 11.5, color: scheme.onSurfaceVariant)),
],
if (headroom != null) ...[
const SizedBox(height: 6),
_HeadroomBar(headroom: headroom, slow: d.state == SourceState.slow),
],
],
),
),
],
),
),
);
}
}
/// The source's speed on a scale to twice the video's bitrate; the mark
/// in the middle is what the video needs.
class _HeadroomBar extends StatelessWidget {
final double headroom;
final bool slow;
const _HeadroomBar({required this.headroom, required this.slow});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final fill = (headroom / 2).clamp(0.04, 1.0);
final color = slow ? Colors.amber : Colors.greenAccent.shade400;
return SizedBox(
key: const ValueKey('headroom-bar'),
height: 4,
child: LayoutBuilder(
builder: (context, box) => Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.outlineVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
AnimatedContainer(
duration: easeTime,
curve: ease,
width: box.maxWidth * fill,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2)),
),
Positioned(
left: box.maxWidth / 2 - 0.75,
top: -1,
bottom: -1,
child: Container(width: 1.5, color: scheme.onSurface.withValues(alpha: 0.7)),
),
],
),
),
);
}
}
/// A member's buffer at a glance: green enough to play on, amber low,
/// red empty while playing.
Color healthColor(BufferHealth h) => switch (h) {
BufferHealth.good => Colors.greenAccent.shade400,
BufferHealth.low => Colors.amber,
BufferHealth.starving => Colors.redAccent.shade200,
};
/// The members card's subtitle: [status] ("ready"), then how the member
/// gets the media (the source's own pill says it serves it), after a dot
/// coloured by its buffer; the hover says how much it has buffered.
class DeliveryStatus extends StatelessWidget {
final String status;
final MemberDelivery? delivery;
const DeliveryStatus({super.key, required this.status, this.delivery});
@override
Widget build(BuildContext context) {
final d = delivery;
final route = d == null || d.route == DeliveryRoute.source ? '' : d.short;
final text = Text([status, if (route.isNotEmpty) route].join(' · '),
maxLines: 1, overflow: TextOverflow.ellipsis);
if (d == null) return text;
final health = d.health;
return Tooltip(
message: d.detail,
waitDuration: const Duration(milliseconds: 400),
child: Row(
children: [
if (health != null) ...[
AnimatedContainer(
key: const ValueKey('health-dot'),
duration: easeTime,
curve: ease,
width: 7,
height: 7,
decoration: BoxDecoration(color: healthColor(health), shape: BoxShape.circle),
),
const SizedBox(width: 6),
],
Flexible(child: text),
],
),
);
}
}