Protocol 12: Up next (a shared queue) and where the video comes from

Up next: a server-side queue anyone edits by item id (add at end or
next, move, remove, play now, skip); each item says who added it and
how it plays. At the real end the first item swaps in after the end
card's countdown, put up by whoever can (its adder for through-me links
and files, someone with access for Jellyfin, else anyone); items that
can't play are skipped with the reason. The next item's link is
resolved ahead. Greyed "Continues with" episodes below. A chip with the
controls opens it as a popover (leaf_widgets, local path dependency for
now); "Add to queue" next to Put it up everywhere.

Delivery: the server measures the source's rate, the media's bitrate
and each member's read rate; the members card shows a source line
(who passes it on, through what, keeping up or not, source left) and
each member's route and buffer. A slow source is told once per media.
A failed server fetch is announced to everyone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 06:40:26 +03:00
1 parent 95cd668cfb
commit 6b10cae4e6
50 files changed
+4239 -157

No files matched your search

@@ -0,0 +1,176 @@
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 => 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,
_ => 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 or waiting, 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 ? 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),
],
),
);
}
}