Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff51d52fbb | ||
|
|
f2272d7980 | ||
|
|
cf80bb2167 | ||
|
|
8effa573a0 | ||
|
|
5f22be6c20 | ||
|
|
740273745b | ||
|
|
27b4e1dc0c | ||
|
|
fce392e425 | ||
|
|
d8fd464e33 |
No files matched your search
@@ -62,8 +62,11 @@ a video first plays, its banner fills the picture until playback starts.
|
|||||||
One pill above the controls says what playback waits for ("Press Space
|
One pill above the controls says what playback waits for ("Press Space
|
||||||
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
||||||
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
||||||
Tab); joins, leaves, chat and other news also pop up bottom left with the
|
Tab), ready to type; it shows messages, and its "Activity" toggle adds
|
||||||
person's avatar.
|
joins, ready, seeks and the like between them. Joins, leaves, chat and
|
||||||
|
other news also pop up bottom left with the person's avatar; click a message to open the chat. Unread chat
|
||||||
|
shows as a count on the chat buttons and, in fullscreen with the controls
|
||||||
|
hidden, as a small chip top right.
|
||||||
|
|
||||||
Your name and picture change for everyone in the server right away; the
|
Your name and picture change for everyone in the server right away; the
|
||||||
colour is yours only.
|
colour is yours only.
|
||||||
@@ -83,8 +86,10 @@ disk. Nothing is uploaded or stored anywhere.
|
|||||||
|
|
||||||
## Library
|
## Library
|
||||||
|
|
||||||
What to watch, from three sources (the switch at the top; Ctrl+V outside
|
What to watch, from three sources (the switch at the top). Drop a video,
|
||||||
a field pastes a link into Link):
|
a folder or a link anywhere on the window, or press Ctrl+V outside a
|
||||||
|
text field, and a small card asks how to open it (put it up, add the
|
||||||
|
folder to Files, or fill Link).
|
||||||
|
|
||||||
- **Jellyfin**: your linked servers (see below). Library chips beside the
|
- **Jellyfin**: your linked servers (see below). Library chips beside the
|
||||||
switch, then one **Continue** row (what you were watching and what comes
|
switch, then one **Continue** row (what you were watching and what comes
|
||||||
@@ -102,8 +107,8 @@ a field pastes a link into Link):
|
|||||||
thumbnail for YouTube, else the file name); in a server, pick how the
|
thumbnail for YouTube, else the file name); in a server, pick how the
|
||||||
others get it (everyone fetches it, the server fetches it, or through
|
others get it (everyone fetches it, the server fetches it, or through
|
||||||
you). Your last 12 links are listed under it.
|
you). Your last 12 links are listed under it.
|
||||||
- **Files**: video folders you add on this computer, grouped by series,
|
- **Files**: video folders you add on this computer, grouped by series
|
||||||
or any file through Browse. The others stream it from you.
|
(click a series to list its episodes), or any file through Browse. The others stream it from you.
|
||||||
|
|
||||||
Put it up and the player opens. Offline the library browses but nothing
|
Put it up and the player opens. Offline the library browses but nothing
|
||||||
goes up until you join or host ("Join or host to watch together").
|
goes up until you join or host ("Join or host to watch together").
|
||||||
@@ -149,8 +154,8 @@ of help: Profile; Party & network (the Host card's default port and
|
|||||||
password, recent servers with remove); Player (below); Jellyfin &
|
password, recent servers with remove); Player (below); Jellyfin &
|
||||||
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
integrations (linked Jellyfin servers, below, the AnimeFlow connection:
|
||||||
"connected as <name>" with what you may do, or why not, and playing the
|
"connected as <name>" with what you may do, or why not, and playing the
|
||||||
next episode automatically); Notifications & sounds (what pops up; there
|
next episode automatically); Notifications & sounds (what pops up; the sound
|
||||||
is nothing to switch yet); Updates; Keyboard shortcuts (read-only); About
|
volume, and every sound with a preview and its own switch); Updates; Keyboard shortcuts (read-only); About
|
||||||
(the version).
|
(the version).
|
||||||
|
|
||||||
Player settings live in the player: ⚙ at the right end of its controls
|
Player settings live in the player: ⚙ at the right end of its controls
|
||||||
@@ -162,8 +167,9 @@ hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC`
|
|||||||
overrides it); in the player also the subtitle and audio track. Every
|
overrides it); in the player also the subtitle and audio track. Every
|
||||||
change applies right away.
|
change applies right away.
|
||||||
|
|
||||||
Keys while the player fills the window: Space = ready / unready, Tab =
|
Anywhere in a server (unless you are typing): Space = ready / unready,
|
||||||
chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30,
|
Tab = chat & activity (Tab never moves between buttons). Keys while the
|
||||||
|
player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30,
|
||||||
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
|
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
|
||||||
close the chat or fullscreen, else ask to leave, Ctrl+R = reload video,
|
close the chat or fullscreen, else ask to leave, Ctrl+R = reload video,
|
||||||
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
|
||||||
@@ -174,7 +180,9 @@ Controls: the seek bar marks chapter starts with a diamond; hovering it
|
|||||||
shows the time and the chapter title. The chapters button lists them
|
shows the time and the chapter title. The chapters button lists them
|
||||||
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
|
(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and
|
||||||
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
|
Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
|
||||||
2.0 · title"; subtitles have Off.
|
2.0 · title"; subtitles have Off. Volume (hover the speaker, scroll on
|
||||||
|
it, or Up/Down) goes to 150 % like mpv: past the tick at 100 % the bar
|
||||||
|
turns amber and mpv amplifies.
|
||||||
|
|
||||||
The uosc player design (⚙ → Player design, Standard by default,
|
The uosc player design (⚙ → Player design, Standard by default,
|
||||||
switches right away): video controls modeled on mpv's uosc theme. A thick
|
switches right away): video controls modeled on mpv's uosc theme. A thick
|
||||||
@@ -189,12 +197,14 @@ the same.
|
|||||||
|
|
||||||
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
||||||
jumps to the end of the opening), during the ending "Skip ED" (to the end
|
jumps to the end of the opening), during the ending "Skip ED" (to the end
|
||||||
of the ending only; what comes after is yours to skip). The opening and
|
of the ending only; what comes after is yours to skip). Any skip button
|
||||||
|
(also "Skip 1:30") shows for 5 seconds when it comes up, then only while
|
||||||
|
the controls are up (move the mouse). The opening and
|
||||||
ending come from Jellyfin's intro / outro segments when your Jellyfin has
|
ending come from Jellyfin's intro / outro segments when your Jellyfin has
|
||||||
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
|
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
|
||||||
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on
|
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
|
||||||
anything longer than 20 minutes, "Skip 1:30" floats there during the first
|
any chapters, on anything longer than 20 minutes, "Skip 1:30" floats there
|
||||||
6 minutes.
|
during the first 6 minutes.
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'src/screens/home/ui.dart';
|
import 'src/screens/home/ui.dart';
|
||||||
import 'src/shell/app_shell.dart';
|
import 'src/shell/app_shell.dart';
|
||||||
|
import 'src/widgets/app_keys.dart';
|
||||||
|
|
||||||
class WatchTogetherApp extends StatelessWidget {
|
class WatchTogetherApp extends StatelessWidget {
|
||||||
const WatchTogetherApp({super.key});
|
const WatchTogetherApp({super.key});
|
||||||
@@ -11,7 +12,8 @@ class WatchTogetherApp extends StatelessWidget {
|
|||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: appName,
|
title: appName,
|
||||||
theme: ThemeData.dark(),
|
theme: ThemeData.dark(),
|
||||||
home: const AppShell(),
|
shortcuts: appShortcuts,
|
||||||
|
home: const AppKeys(child: AppShell()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import '../../screens/session/player_controls.dart' show TrackPick;
|
|||||||
import '../../shaders/shader_menu.dart';
|
import '../../shaders/shader_menu.dart';
|
||||||
import '../../shaders/shader_presets.dart';
|
import '../../shaders/shader_presets.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
|
import '../volume.dart';
|
||||||
import 'controls.dart';
|
import 'controls.dart';
|
||||||
import 'menu.dart';
|
import 'menu.dart';
|
||||||
import 'style.dart';
|
import 'style.dart';
|
||||||
@@ -166,9 +167,10 @@ class _UoscBadgeButtonState extends State<UoscBadgeButton> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Volume: click mutes / unmutes, the wheel steps 5 %.
|
/// Volume: click mutes / unmutes, hovering shows a [VolumeBar] up to
|
||||||
|
/// [maxVolume], the wheel steps 5 %.
|
||||||
class UoscVolumeButton extends StatefulWidget {
|
class UoscVolumeButton extends StatefulWidget {
|
||||||
/// 0..100.
|
/// 0..[maxVolume].
|
||||||
final double volume;
|
final double volume;
|
||||||
final ValueChanged<double> onChanged;
|
final ValueChanged<double> onChanged;
|
||||||
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
const UoscVolumeButton({super.key, required this.volume, required this.onChanged});
|
||||||
@@ -179,20 +181,43 @@ class UoscVolumeButton extends StatefulWidget {
|
|||||||
|
|
||||||
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
||||||
double _beforeMute = 100;
|
double _beforeMute = 100;
|
||||||
|
bool _hover = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final v = widget.volume;
|
final v = widget.volume;
|
||||||
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
final icon = v <= 0 ? Icons.volume_off_rounded : (v < 50 ? Icons.volume_down_rounded : Icons.volume_up_rounded);
|
||||||
return Listener(
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
|
onExit: (_) => setState(() => _hover = false),
|
||||||
|
child: Listener(
|
||||||
onPointerSignal: (e) {
|
onPointerSignal: (e) {
|
||||||
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||||
widget.onChanged((v + (e.scrollDelta.dy < 0 ? 5 : -5)).clamp(0.0, 100.0));
|
widget.onChanged(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: UoscButton(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: _hover
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 6, right: 2),
|
||||||
|
child: VolumeBar(
|
||||||
|
key: const ValueKey('uosc-volume-bar'),
|
||||||
|
volume: v,
|
||||||
|
onChanged: widget.onChanged,
|
||||||
|
color: UoscStyle.text,
|
||||||
|
trackColor: UoscStyle.dim.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
UoscButton(
|
||||||
key: const ValueKey('uosc-volume'),
|
key: const ValueKey('uosc-volume'),
|
||||||
icon: icon,
|
icon: icon,
|
||||||
|
color: v > 100 ? boostColor : null,
|
||||||
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
tooltip: 'Volume ${v.round()} % (scroll to change)',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (v > 0) {
|
if (v > 0) {
|
||||||
@@ -203,6 +228,9 @@ class _UoscVolumeButtonState extends State<UoscVolumeButton> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class UoscControls extends StatefulWidget {
|
|||||||
final Widget? topLeft;
|
final Widget? topLeft;
|
||||||
final VoidCallback? onDoubleTap;
|
final VoidCallback? onDoubleTap;
|
||||||
final Duration hideAfter;
|
final Duration hideAfter;
|
||||||
|
/// Follows whether the controls are up (for overlays outside them).
|
||||||
|
final ValueNotifier<bool>? shown;
|
||||||
const UoscControls({
|
const UoscControls({
|
||||||
super.key,
|
super.key,
|
||||||
required this.timeline,
|
required this.timeline,
|
||||||
@@ -35,6 +37,7 @@ class UoscControls extends StatefulWidget {
|
|||||||
this.topLeft,
|
this.topLeft,
|
||||||
this.onDoubleTap,
|
this.onDoubleTap,
|
||||||
this.hideAfter = const Duration(seconds: 3),
|
this.hideAfter = const Duration(seconds: 3),
|
||||||
|
this.shown,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const buttonRowHeight = 40.0;
|
static const buttonRowHeight = 40.0;
|
||||||
@@ -58,6 +61,10 @@ class UoscControlsState extends State<UoscControls> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Not during this build: an overlay listening to [shown] may be built.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) widget.shown?.value = true;
|
||||||
|
});
|
||||||
_hideLater();
|
_hideLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +76,10 @@ class UoscControlsState extends State<UoscControls> {
|
|||||||
|
|
||||||
/// Show the controls and restart the hide countdown.
|
/// Show the controls and restart the hide countdown.
|
||||||
void wake() {
|
void wake() {
|
||||||
if (!_visible && mounted) setState(() => _visible = true);
|
if (!_visible && mounted) {
|
||||||
|
setState(() => _visible = true);
|
||||||
|
widget.shown?.value = true;
|
||||||
|
}
|
||||||
_hideLater();
|
_hideLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +108,7 @@ class UoscControlsState extends State<UoscControls> {
|
|||||||
void _hide() {
|
void _hide() {
|
||||||
if (!mounted || _holds > 0 || !_visible) return;
|
if (!mounted || _holds > 0 || !_visible) return;
|
||||||
setState(() => _visible = false);
|
setState(() => _visible = false);
|
||||||
|
widget.shown?.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
|
||||||
|
import '../playback/playback_controller.dart' show PlaybackController;
|
||||||
|
|
||||||
|
/// The player's top volume (mpv's `volume-max`): past 100 % mpv amplifies.
|
||||||
|
const maxVolume = PlaybackController.maxVolume * 1.0;
|
||||||
|
|
||||||
|
/// The part of the volume bar above 100 %.
|
||||||
|
const boostColor = Color(0xFFFFB74D);
|
||||||
|
|
||||||
|
/// [volume] moved by [delta] (wheel, keys), kept within 0..[maxVolume].
|
||||||
|
double stepVolume(double volume, double delta) => (volume + delta).clamp(0.0, maxVolume);
|
||||||
|
|
||||||
|
/// A thin volume track over 0..[maxVolume]: filled up to the volume, the
|
||||||
|
/// part above 100 % in [boostColor], a tick at 100 %. Click or drag sets
|
||||||
|
/// it.
|
||||||
|
class VolumeBar extends StatelessWidget {
|
||||||
|
final double volume;
|
||||||
|
final ValueChanged<double> onChanged;
|
||||||
|
final double width;
|
||||||
|
final Color color;
|
||||||
|
final Color trackColor;
|
||||||
|
const VolumeBar({
|
||||||
|
super.key,
|
||||||
|
required this.volume,
|
||||||
|
required this.onChanged,
|
||||||
|
this.width = 64,
|
||||||
|
this.color = Colors.white,
|
||||||
|
this.trackColor = Colors.white30,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
void at(double x) => onChanged((x / width * maxVolume).clamp(0.0, maxVolume).roundToDouble());
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapDown: (e) => at(e.localPosition.dx),
|
||||||
|
onHorizontalDragUpdate: (e) => at(e.localPosition.dx),
|
||||||
|
child: SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: 24,
|
||||||
|
child: CustomPaint(painter: _VolumePainter(volume.clamp(0.0, maxVolume), color, trackColor)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VolumePainter extends CustomPainter {
|
||||||
|
final double volume;
|
||||||
|
final Color color, trackColor;
|
||||||
|
_VolumePainter(this.volume, this.color, this.trackColor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final y = size.height / 2;
|
||||||
|
double x(double v) => size.width * v / maxVolume;
|
||||||
|
final line = Paint()
|
||||||
|
..strokeWidth = 3
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), line..color = trackColor);
|
||||||
|
final full = x(100);
|
||||||
|
canvas.drawLine(Offset(full, y - 5), Offset(full, y + 5), Paint()
|
||||||
|
..color = trackColor
|
||||||
|
..strokeWidth = 1.5);
|
||||||
|
if (volume > 0) canvas.drawLine(Offset(0, y), Offset(x(volume.clamp(0, 100)), y), line..color = color);
|
||||||
|
if (volume > 100) canvas.drawLine(Offset(full, y), Offset(x(volume), y), line..color = boostColor);
|
||||||
|
canvas.drawCircle(Offset(x(volume), y), 5, Paint()..color = volume > 100 ? boostColor : color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_VolumePainter old) =>
|
||||||
|
old.volume != volume || old.color != color || old.trackColor != trackColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume for the standard bar (instead of media_kit's, which stops at
|
||||||
|
/// 100): the icon mutes / unmutes, hovering shows a [VolumeBar], the
|
||||||
|
/// wheel steps 5 %.
|
||||||
|
class VolumeButton extends StatefulWidget {
|
||||||
|
final Player player;
|
||||||
|
final FocusNode? focusNode;
|
||||||
|
const VolumeButton({super.key, required this.player, this.focusNode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VolumeButton> createState() => _VolumeButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VolumeButtonState extends State<VolumeButton> {
|
||||||
|
bool _hover = false;
|
||||||
|
double _beforeMute = 100;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final player = widget.player;
|
||||||
|
return StreamBuilder<double>(
|
||||||
|
stream: player.stream.volume,
|
||||||
|
initialData: player.state.volume,
|
||||||
|
builder: (context, snap) {
|
||||||
|
final v = snap.data ?? 100;
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
|
onExit: (_) => setState(() => _hover = false),
|
||||||
|
child: Listener(
|
||||||
|
onPointerSignal: (e) {
|
||||||
|
if (e is PointerScrollEvent && e.scrollDelta.dy != 0) {
|
||||||
|
player.setVolume(stepVolume(v, e.scrollDelta.dy < 0 ? 5 : -5));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton(
|
||||||
|
key: const ValueKey('volume-button'),
|
||||||
|
focusNode: widget.focusNode,
|
||||||
|
tooltip: 'Volume ${v.round()} %',
|
||||||
|
iconSize: 22,
|
||||||
|
color: Colors.white,
|
||||||
|
onPressed: () {
|
||||||
|
if (v > 0) {
|
||||||
|
_beforeMute = v;
|
||||||
|
player.setVolume(0);
|
||||||
|
} else {
|
||||||
|
player.setVolume(_beforeMute <= 0 ? 100 : _beforeMute);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: Icon(v <= 0 ? Icons.volume_off : (v < 50 ? Icons.volume_down : Icons.volume_up)),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: _hover
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, right: 12),
|
||||||
|
child: VolumeBar(volume: v, onChanged: player.setVolume),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,11 @@ Widget _offlineTip(bool offline, Widget child) =>
|
|||||||
class SeriesHeader extends StatelessWidget {
|
class SeriesHeader extends StatelessWidget {
|
||||||
final SeriesGroup group;
|
final SeriesGroup group;
|
||||||
final ThumbCache thumbs;
|
final ThumbCache thumbs;
|
||||||
const SeriesHeader({super.key, required this.group, required this.thumbs});
|
/// Its episodes are listed below; [onTap] opens or closes them.
|
||||||
|
final bool expanded;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
const SeriesHeader(
|
||||||
|
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -49,7 +53,12 @@ class SeriesHeader extends StatelessWidget {
|
|||||||
?first.parsed.group,
|
?first.parsed.group,
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 18, bottom: 4),
|
padding: const EdgeInsets.only(top: 14),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
@@ -76,8 +85,17 @@ class SeriesHeader extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (onTap != null)
|
||||||
|
AnimatedRotation(
|
||||||
|
turns: expanded ? 0.5 : 0,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export 'thumb.dart' show ThumbnailLoader;
|
|||||||
// The Files source: watched folders on the left (chips on top when
|
// The Files source: watched folders on the left (chips on top when
|
||||||
// narrow), the open one on the right grouped into series and loose
|
// narrow), the open one on the right grouped into series and loose
|
||||||
// videos, or Recent. A folder is scanned when first opened and on Rescan;
|
// videos, or Recent. A folder is scanned when first opened and on Rescan;
|
||||||
// scans are kept for the app session. Playing goes through [onPlay].
|
// scans are kept for the app session. Series start collapsed; the ones
|
||||||
|
// opened stay open for the app session. Playing goes through [onPlay].
|
||||||
|
|
||||||
/// Below this width the sidebar turns into chips above the list.
|
/// Below this width the sidebar turns into chips above the list.
|
||||||
const filesNarrowWidth = 700.0;
|
const filesNarrowWidth = 700.0;
|
||||||
@@ -26,12 +27,23 @@ const filesNarrowWidth = 700.0;
|
|||||||
final _scans = <String, FolderScan>{};
|
final _scans = <String, FolderScan>{};
|
||||||
final _running = <String, Future<FolderScan>>{};
|
final _running = <String, Future<FolderScan>>{};
|
||||||
String? _lastOpen;
|
String? _lastOpen;
|
||||||
|
/// Series opened this session, by `folder\nseries key`.
|
||||||
|
final _expanded = <String>{};
|
||||||
|
final _showRequests = ValueNotifier(0);
|
||||||
|
|
||||||
/// Forgets the session's scans and the open folder.
|
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||||
|
/// else when it next opens.
|
||||||
|
void showFilesFolder(String? dir) {
|
||||||
|
_lastOpen = dir;
|
||||||
|
_showRequests.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets the session's scans, open series and the open folder.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void resetFilesPageSession() {
|
void resetFilesPageSession() {
|
||||||
_scans.clear();
|
_scans.clear();
|
||||||
_running.clear();
|
_running.clear();
|
||||||
|
_expanded.clear();
|
||||||
_lastOpen = null;
|
_lastOpen = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,22 +85,21 @@ class FilesPage extends StatefulWidget {
|
|||||||
/// A scan grouped for display, made once per scan.
|
/// A scan grouped for display, made once per scan.
|
||||||
class _Library {
|
class _Library {
|
||||||
final GroupedVideos grouped;
|
final GroupedVideos grouped;
|
||||||
/// Series rows in order: a header (video null), then its episodes and
|
|
||||||
/// extras.
|
|
||||||
final List<(SeriesGroup, VideoFile?)> rows;
|
|
||||||
final int subtitleFiles;
|
final int subtitleFiles;
|
||||||
|
|
||||||
_Library(FolderScan scan)
|
_Library(FolderScan scan)
|
||||||
: grouped = groupBySeries(scan.videos),
|
: grouped = groupBySeries(scan.videos),
|
||||||
rows = [],
|
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
|
||||||
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length {
|
|
||||||
for (final g in grouped.series) {
|
/// Series rows in order: a header (video null), then, for the series
|
||||||
rows.add((g, null));
|
/// [open] says, its episodes and extras.
|
||||||
for (final v in [...g.episodes, ...g.extras]) {
|
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
|
||||||
rows.add((g, v));
|
for (final g in grouped.series) ...[
|
||||||
}
|
(g, null),
|
||||||
}
|
if (open(g))
|
||||||
}
|
for (final v in [...g.episodes, ...g.extras]) (g, v),
|
||||||
|
],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
final _libraries = Expando<_Library>();
|
final _libraries = Expando<_Library>();
|
||||||
@@ -107,6 +118,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_showRequests.addListener(_shown);
|
||||||
_store.ensureLoaded().then((_) {
|
_store.ensureLoaded().then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final open = _open;
|
final open = _open;
|
||||||
@@ -118,6 +130,14 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_showRequests.removeListener(_shown);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shown() => _select(_lastOpen);
|
||||||
|
|
||||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
Future<FolderScan> _runScan(String dir) => (widget.scan ?? scanFolder)(dir);
|
||||||
|
|
||||||
void _select(String? dir) {
|
void _select(String? dir) {
|
||||||
@@ -477,6 +497,8 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
final loose = lib.grouped.loose;
|
final loose = lib.grouped.loose;
|
||||||
|
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
|
||||||
|
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
|
||||||
return [
|
return [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
@@ -547,13 +569,21 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
'It may have been moved, renamed or be on a drive that is not connected.',
|
'It may have been moved, renamed or be on a drive that is not connected.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Series: files whose names carry an episode number.
|
// Series: files whose names carry an episode number; a header
|
||||||
|
// click opens or closes its episodes.
|
||||||
SliverList.builder(
|
SliverList.builder(
|
||||||
itemCount: lib.rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final (group, v) = lib.rows[i];
|
final (group, v) = rows[i];
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
return SeriesHeader(key: ValueKey('series:${group.key}'), group: group, thumbs: _thumbs);
|
final id = seriesId(group);
|
||||||
|
return SeriesHeader(
|
||||||
|
key: ValueKey('series:${group.key}'),
|
||||||
|
group: group,
|
||||||
|
thumbs: _thumbs,
|
||||||
|
expanded: _expanded.contains(id),
|
||||||
|
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return FileRow(
|
return FileRow(
|
||||||
key: ValueKey(v.path),
|
key: ValueKey(v.path),
|
||||||
|
|||||||
@@ -243,18 +243,20 @@ const _skipTail = Duration(seconds: 1);
|
|||||||
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
|
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
|
||||||
/// inside the ending "Skip ED" (to its end only: what follows, preview or
|
/// inside the ending "Skip ED" (to its end only: what follows, preview or
|
||||||
/// more episode, is the viewer's call). Without any opening info on media
|
/// more episode, is the viewer's call). Without any opening info on media
|
||||||
/// longer than [fallbackMinDuration], "Skip 1:30" during the first
|
/// longer than [fallbackMinDuration] and without chapters ([chaptered]: a
|
||||||
/// [fallbackWindow]. Null otherwise.
|
/// chaptered file without an OP chapter has none on purpose), "Skip 1:30"
|
||||||
|
/// during the first [fallbackWindow]. Null otherwise.
|
||||||
SkipOffer? skipOfferAt({
|
SkipOffer? skipOfferAt({
|
||||||
required Duration position,
|
required Duration position,
|
||||||
required Duration duration,
|
required Duration duration,
|
||||||
required SkipRange? opening,
|
required SkipRange? opening,
|
||||||
required SkipRange? ending,
|
required SkipRange? ending,
|
||||||
|
bool chaptered = false,
|
||||||
}) {
|
}) {
|
||||||
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
|
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
|
||||||
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
|
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
|
||||||
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
|
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
|
||||||
if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) {
|
if (opening == null && !chaptered && duration > fallbackMinDuration && position < fallbackWindow) {
|
||||||
final to = position + fallbackSkip;
|
final to = position + fallbackSkip;
|
||||||
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
|
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class PlaybackController {
|
|||||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||||
/// seek back without holding another 256 MiB.
|
/// seek back without holding another 256 MiB.
|
||||||
static const maxBackBytes = 64 * 1024 * 1024;
|
static const maxBackBytes = 64 * 1024 * 1024;
|
||||||
|
/// mpv's `volume-max` (its default is 130): the volume controls go
|
||||||
|
/// this far, past 100 % amplified.
|
||||||
|
static const maxVolume = 150;
|
||||||
|
|
||||||
SyncPlayer? _player;
|
SyncPlayer? _player;
|
||||||
VideoController? _video;
|
VideoController? _video;
|
||||||
@@ -39,7 +42,7 @@ class PlaybackController {
|
|||||||
bool _probing = false;
|
bool _probing = false;
|
||||||
final FrameWatchdog _watchdog = FrameWatchdog();
|
final FrameWatchdog _watchdog = FrameWatchdog();
|
||||||
StreamSubscription<PlayerLog>? _logSub;
|
StreamSubscription<PlayerLog>? _logSub;
|
||||||
final List<StreamSubscription<Duration>> _timeSubs = [];
|
final List<StreamSubscription<Object?>> _timeSubs = [];
|
||||||
String _chapterJson = '';
|
String _chapterJson = '';
|
||||||
// `glsl-shaders` as last set on the current player ('' = none).
|
// `glsl-shaders` as last set on the current player ('' = none).
|
||||||
String _shaders = '';
|
String _shaders = '';
|
||||||
@@ -163,6 +166,7 @@ class PlaybackController {
|
|||||||
_logSub = _player!.stream.log.listen(_onLog);
|
_logSub = _player!.stream.log.listen(_onLog);
|
||||||
_watchMarks(_player!);
|
_watchMarks(_player!);
|
||||||
await _pinBufferProps();
|
await _pinBufferProps();
|
||||||
|
await _pin('volume-max', '$maxVolume');
|
||||||
await applyShaders();
|
await applyShaders();
|
||||||
_watchdog.reset();
|
_watchdog.reset();
|
||||||
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
||||||
@@ -426,9 +430,18 @@ class PlaybackController {
|
|||||||
// track-list observed in mpv (read back as JSON).
|
// track-list observed in mpv (read back as JSON).
|
||||||
void _watchMarks(SyncPlayer p) {
|
void _watchMarks(SyncPlayer p) {
|
||||||
_timeSubs.add(p.stream.position.listen((d) {
|
_timeSubs.add(p.stream.position.listen((d) {
|
||||||
|
// At the end the bar shows full: mpv's last position is the last
|
||||||
|
// frame's start (and the 250 ms steps round down), short of duration.
|
||||||
|
if (p.state.completed && duration.value > Duration.zero) {
|
||||||
|
position.value = duration.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
final ms = d.inMilliseconds;
|
final ms = d.inMilliseconds;
|
||||||
position.value = Duration(milliseconds: ms - ms % 250);
|
position.value = Duration(milliseconds: ms - ms % 250);
|
||||||
}));
|
}));
|
||||||
|
_timeSubs.add(p.stream.completed.listen((done) {
|
||||||
|
if (done && duration.value > Duration.zero) position.value = duration.value;
|
||||||
|
}));
|
||||||
_timeSubs.add(p.stream.buffer.listen((d) {
|
_timeSubs.add(p.stream.buffer.listen((d) {
|
||||||
final ms = d.inMilliseconds;
|
final ms = d.inMilliseconds;
|
||||||
buffered.value = Duration(milliseconds: ms - ms % 250);
|
buffered.value = Duration(milliseconds: ms - ms % 250);
|
||||||
|
|||||||
@@ -501,7 +501,6 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
|
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
|
||||||
}
|
}
|
||||||
return HScroll(
|
return HScroll(
|
||||||
wheel: false,
|
|
||||||
controller: _scroll,
|
controller: _scroll,
|
||||||
builder: (c) => ListView.separated(
|
builder: (c) => ListView.separated(
|
||||||
key: const ValueKey('schedule-board'),
|
key: const ValueKey('schedule-board'),
|
||||||
|
|||||||
@@ -1,920 +0,0 @@
|
|||||||
part of 'shell_demo.dart';
|
|
||||||
|
|
||||||
// DEBUG ONLY: the Files source for real. Folders you add are scanned for
|
|
||||||
// videos (everything else is counted and hidden), grouped into series by
|
|
||||||
// a small anitomy-style name parser; a click plays the file in the demo
|
|
||||||
// player. Kept in memory for the demo session.
|
|
||||||
|
|
||||||
const _videoExt = {
|
|
||||||
'mkv',
|
|
||||||
'mp4',
|
|
||||||
'webm',
|
|
||||||
'avi',
|
|
||||||
'mov',
|
|
||||||
'm4v',
|
|
||||||
'ts',
|
|
||||||
'm2ts',
|
|
||||||
'wmv',
|
|
||||||
'flv',
|
|
||||||
};
|
|
||||||
const _subExt = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
|
||||||
|
|
||||||
class _VideoFile {
|
|
||||||
final String path;
|
|
||||||
final int size;
|
|
||||||
final _ParsedName parsed;
|
|
||||||
const _VideoFile(this.path, this.size, this.parsed);
|
|
||||||
String get name => _baseName(path);
|
|
||||||
String get folder => _baseName(File(path).parent.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FolderScan {
|
|
||||||
final List<_VideoFile> videos;
|
|
||||||
final int hidden;
|
|
||||||
final int subtitles;
|
|
||||||
final List<String> hiddenExamples;
|
|
||||||
final bool truncated;
|
|
||||||
const _FolderScan(
|
|
||||||
this.videos,
|
|
||||||
this.hidden,
|
|
||||||
this.subtitles,
|
|
||||||
this.hiddenExamples,
|
|
||||||
this.truncated,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final _folders = ValueNotifier<List<String>>([]);
|
|
||||||
final _scans = <String, _FolderScan>{};
|
|
||||||
final _recentFiles = ValueNotifier<List<_VideoFile>>([]);
|
|
||||||
|
|
||||||
/// What a release file name says: series, season, episode, group.
|
|
||||||
class _ParsedName {
|
|
||||||
final String series;
|
|
||||||
final int? season;
|
|
||||||
final int? episode;
|
|
||||||
final String? group;
|
|
||||||
const _ParsedName(this.series, {this.season, this.episode, this.group});
|
|
||||||
|
|
||||||
String get label {
|
|
||||||
if (episode == null) return series;
|
|
||||||
final ep = 'E${episode.toString().padLeft(2, '0')}';
|
|
||||||
return season == null ? ep : 'S${season.toString().padLeft(2, '0')}$ep';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ParsedName _parseName(String file) {
|
|
||||||
var s = file;
|
|
||||||
final dot = s.lastIndexOf('.');
|
|
||||||
if (dot > 0) s = s.substring(0, dot);
|
|
||||||
final group = RegExp(r'^\s*\[([^\]]+)\]').firstMatch(s)?.group(1);
|
|
||||||
// Brackets/parens hold group, resolution, codec, CRC.
|
|
||||||
s = s.replaceAll(RegExp(r'\[[^\]]*\]|\([^)]*\)'), ' ');
|
|
||||||
// Dots/underscores as separators (scene style), unless it has spaces.
|
|
||||||
if (!s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2) {
|
|
||||||
s = s.replaceAll(RegExp(r'[._]'), ' ');
|
|
||||||
}
|
|
||||||
int? season, episode;
|
|
||||||
var cut = s.length;
|
|
||||||
final se = RegExp(
|
|
||||||
r'\bS(\d{1,2})\s?E(\d{1,4})\b',
|
|
||||||
caseSensitive: false,
|
|
||||||
).firstMatch(s);
|
|
||||||
if (se != null) {
|
|
||||||
season = int.parse(se.group(1)!);
|
|
||||||
episode = int.parse(se.group(2)!);
|
|
||||||
cut = se.start;
|
|
||||||
} else {
|
|
||||||
final dash = RegExp(r'\s-\s(\d{1,4})(?:v\d)?\b').firstMatch(s);
|
|
||||||
final ep = RegExp(
|
|
||||||
r'\b(?:E|EP|Episode)\s?(\d{1,4})\b',
|
|
||||||
caseSensitive: false,
|
|
||||||
).firstMatch(s);
|
|
||||||
final m = dash ?? ep;
|
|
||||||
if (m != null) {
|
|
||||||
episode = int.parse(m.group(1)!);
|
|
||||||
cut = m.start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var series = s.substring(0, cut);
|
|
||||||
// Season markers inside the title ("Title S2", "Title 2nd Season").
|
|
||||||
final sInTitle = RegExp(
|
|
||||||
r'\s(?:S(\d{1,2})|(\d)(?:st|nd|rd|th) Season)\s*$',
|
|
||||||
caseSensitive: false,
|
|
||||||
).firstMatch(series);
|
|
||||||
if (sInTitle != null && season == null) {
|
|
||||||
season = int.parse(sInTitle.group(1) ?? sInTitle.group(2)!);
|
|
||||||
}
|
|
||||||
// Release junk after the title.
|
|
||||||
series = series
|
|
||||||
.replaceAll(
|
|
||||||
RegExp(
|
|
||||||
r'\b(1080p|720p|2160p|480p|x26[45]|HEVC|WEB(-?DL|Rip)?|BD(Rip)?|BluRay|AAC|FLAC|10bit)\b.*',
|
|
||||||
caseSensitive: false,
|
|
||||||
),
|
|
||||||
'',
|
|
||||||
)
|
|
||||||
.replaceAll(RegExp(r'\s+'), ' ')
|
|
||||||
.replaceAll(RegExp(r'[\s\-–:]+$'), '')
|
|
||||||
.trim();
|
|
||||||
if (series.isEmpty) series = file;
|
|
||||||
return _ParsedName(series, season: season, episode: episode, group: group);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<_FolderScan> _scanFolder(String dir) async {
|
|
||||||
final videos = <_VideoFile>[];
|
|
||||||
var hidden = 0, subs = 0;
|
|
||||||
final examples = <String>[];
|
|
||||||
var seen = 0;
|
|
||||||
var truncated = false;
|
|
||||||
try {
|
|
||||||
await for (final e in Directory(
|
|
||||||
dir,
|
|
||||||
).list(recursive: true, followLinks: false)) {
|
|
||||||
if (e is! File) continue;
|
|
||||||
if (++seen > 20000) {
|
|
||||||
truncated = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
final name = e.path.split(Platform.pathSeparator).last;
|
|
||||||
if (name.startsWith('.')) continue;
|
|
||||||
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
|
||||||
if (_subExt.contains(ext)) {
|
|
||||||
subs++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final isVideo = _videoExt.contains(ext);
|
|
||||||
final lower = name.toLowerCase();
|
|
||||||
int size = 0;
|
|
||||||
try {
|
|
||||||
size = await e.length();
|
|
||||||
} catch (_) {}
|
|
||||||
// Samples and trailers next to the real thing.
|
|
||||||
final junk =
|
|
||||||
isVideo &&
|
|
||||||
(lower.contains('sample') || lower.contains('trailer')) &&
|
|
||||||
size < 300 << 20;
|
|
||||||
if (!isVideo || junk) {
|
|
||||||
hidden++;
|
|
||||||
if (examples.length < 6) examples.add(name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
videos.add(_VideoFile(e.path, size, _parseName(name)));
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Unreadable subfolders: show what was found.
|
|
||||||
}
|
|
||||||
return _FolderScan(videos, hidden, subs, examples, truncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _size(int b) => b >= 1 << 30
|
|
||||||
? '${(b / (1 << 30)).toStringAsFixed(1)} GB'
|
|
||||||
: '${(b / (1 << 20)).toStringAsFixed(0)} MB';
|
|
||||||
|
|
||||||
class _RealFilesTab extends StatefulWidget {
|
|
||||||
final bool online;
|
|
||||||
final VoidCallback onPlay;
|
|
||||||
const _RealFilesTab({required this.online, required this.onPlay});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_RealFilesTab> createState() => _RealFilesTabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RealFilesTabState extends State<_RealFilesTab> {
|
|
||||||
String? _open; // null = Recent
|
|
||||||
bool _scanning = false;
|
|
||||||
bool _showOther = false;
|
|
||||||
|
|
||||||
Future<void> _addFolder() async {
|
|
||||||
String? dir;
|
|
||||||
try {
|
|
||||||
dir = await FilePicker.getDirectoryPath(
|
|
||||||
dialogTitle: 'Add a folder with videos',
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
if (dir == null || _folders.value.contains(dir)) return;
|
|
||||||
_folders.value = [..._folders.value, dir];
|
|
||||||
await _select(dir, rescan: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _select(String? dir, {bool rescan = false}) async {
|
|
||||||
setState(() {
|
|
||||||
_open = dir;
|
|
||||||
_showOther = false;
|
|
||||||
});
|
|
||||||
if (dir == null || (_scans.containsKey(dir) && !rescan)) return;
|
|
||||||
setState(() => _scanning = true);
|
|
||||||
final scan = await _scanFolder(dir);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_scans[dir] = scan;
|
|
||||||
_scanning = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _browse() async {
|
|
||||||
List<PlatformFile> files = const [];
|
|
||||||
try {
|
|
||||||
files = await FilePicker.pickFiles(
|
|
||||||
dialogTitle: 'Pick a video',
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: _videoExt.toList(),
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
final path = files.isEmpty ? null : files.first.path;
|
|
||||||
if (path == null) return;
|
|
||||||
final f = File(path);
|
|
||||||
await _play(
|
|
||||||
_VideoFile(path, await f.length(), _parseName(f.uri.pathSegments.last)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _play(_VideoFile v) async {
|
|
||||||
_recentFiles.value = [
|
|
||||||
v,
|
|
||||||
..._recentFiles.value.where((x) => x.path != v.path),
|
|
||||||
].take(30).toList();
|
|
||||||
// Structured names read as "Series · S01E03"; anything else as the
|
|
||||||
// cleaned name and the folder it is in.
|
|
||||||
await _playMedia(
|
|
||||||
_DemoMedia(
|
|
||||||
v.parsed.series,
|
|
||||||
v.parsed.episode == null ? v.folder : v.parsed.label,
|
|
||||||
v.path,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
widget.onPlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, box) {
|
|
||||||
final narrow = box.maxWidth < 700;
|
|
||||||
final list = _list(context, narrow);
|
|
||||||
if (narrow) return list;
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(width: 260, child: _sidebar(context)),
|
|
||||||
Expanded(child: list),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _sidebar(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return ValueListenableBuilder(
|
|
||||||
valueListenable: _folders,
|
|
||||||
builder: (context, folders, _) => ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
|
||||||
children: [
|
|
||||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ListTile(
|
|
||||||
dense: true,
|
|
||||||
selected: _open == null,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
leading: const Icon(Icons.history, size: 18),
|
|
||||||
title: const Text('Recent'),
|
|
||||||
onTap: () => _select(null),
|
|
||||||
),
|
|
||||||
for (final f in folders)
|
|
||||||
ListTile(
|
|
||||||
dense: true,
|
|
||||||
selected: _open == f,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
leading: const Icon(Icons.folder_outlined, size: 18),
|
|
||||||
title: Text(
|
|
||||||
_baseName(f),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
_scans[f] == null
|
|
||||||
? 'scanning…'
|
|
||||||
: '${_scans[f]!.videos.length} videos',
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11),
|
|
||||||
),
|
|
||||||
trailing: IconButton(
|
|
||||||
tooltip: 'Remove folder',
|
|
||||||
icon: const Icon(Icons.close, size: 16),
|
|
||||||
onPressed: () {
|
|
||||||
_folders.value = [...folders]..remove(f);
|
|
||||||
_scans.remove(f);
|
|
||||||
if (_open == f) _select(null);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onTap: () => _select(f),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: _addFolder,
|
|
||||||
icon: const Icon(Icons.add, size: 18),
|
|
||||||
label: const Text('Add folder'),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _browse,
|
|
||||||
icon: const Icon(Icons.folder_open, size: 18),
|
|
||||||
label: const Text('Browse…'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
const Text(
|
|
||||||
'The system file picker, for anything outside your folders.',
|
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _list(BuildContext context, bool narrow) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final open = _open;
|
|
||||||
final scan = open == null ? null : _scans[open];
|
|
||||||
final children = <Widget>[
|
|
||||||
// Where you are: Recent, or the folder and its path.
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
open == null ? Icons.history : Icons.folder,
|
|
||||||
color: scheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
open == null ? 'Recent' : _baseName(open),
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
if (open != null)
|
|
||||||
Text(
|
|
||||||
open,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (narrow)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
|
||||||
child: ValueListenableBuilder(
|
|
||||||
valueListenable: _folders,
|
|
||||||
builder: (context, folders, _) => Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
ChoiceChip(
|
|
||||||
label: const Text('Recent'),
|
|
||||||
selected: open == null,
|
|
||||||
onSelected: (_) => _select(null),
|
|
||||||
),
|
|
||||||
for (final f in folders)
|
|
||||||
ChoiceChip(
|
|
||||||
label: Text(_baseName(f)),
|
|
||||||
selected: open == f,
|
|
||||||
onSelected: (_) => _select(f),
|
|
||||||
),
|
|
||||||
ActionChip(
|
|
||||||
avatar: const Icon(Icons.add, size: 16),
|
|
||||||
label: const Text('Folder'),
|
|
||||||
onPressed: _addFolder,
|
|
||||||
),
|
|
||||||
ActionChip(
|
|
||||||
avatar: const Icon(Icons.folder_open, size: 16),
|
|
||||||
label: const Text('Browse…'),
|
|
||||||
onPressed: _browse,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.online)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.lightBlueAccent.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.cell_tower, size: 18, color: Colors.lightBlueAccent),
|
|
||||||
SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Files stream from this computer through the server. Keep the app open while it plays.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (open == null) {
|
|
||||||
children.add(
|
|
||||||
ValueListenableBuilder(
|
|
||||||
valueListenable: _recentFiles,
|
|
||||||
builder: (context, recent, _) => recent.isEmpty
|
|
||||||
? _empty(
|
|
||||||
context,
|
|
||||||
Icons.video_library_outlined,
|
|
||||||
'Nothing played yet',
|
|
||||||
'Add a folder with your videos, or Browse for a single file.',
|
|
||||||
action: FilledButton.icon(
|
|
||||||
onPressed: _addFolder,
|
|
||||||
icon: const Icon(Icons.create_new_folder_outlined),
|
|
||||||
label: const Text('Add folder'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Column(
|
|
||||||
children: [
|
|
||||||
for (final v in recent)
|
|
||||||
_episodeTile(context, v, showSeries: true),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (_scanning || scan == null) {
|
|
||||||
children.add(
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 60),
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
final groups = <String, List<_VideoFile>>{};
|
|
||||||
for (final v in scan.videos) {
|
|
||||||
groups.putIfAbsent(v.parsed.series, () => []).add(v);
|
|
||||||
}
|
|
||||||
final names = groups.keys.toList()
|
|
||||||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
|
||||||
for (final g in groups.values) {
|
|
||||||
g.sort((a, b) {
|
|
||||||
final s = (a.parsed.season ?? 0).compareTo(b.parsed.season ?? 0);
|
|
||||||
if (s != 0) return s;
|
|
||||||
final e = (a.parsed.episode ?? 0).compareTo(b.parsed.episode ?? 0);
|
|
||||||
return e != 0 ? e : a.name.compareTo(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
children.add(
|
|
||||||
Wrap(
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.filter_alt_outlined,
|
|
||||||
size: 16,
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'${scan.videos.length} videos in ${groups.length} series'
|
|
||||||
'${scan.hidden > 0 ? ' · ${scan.hidden} other files hidden' : ''}'
|
|
||||||
'${scan.subtitles > 0 ? ' · ${scan.subtitles} subtitle files' : ''}'
|
|
||||||
'${scan.truncated ? ' · stopped after 20 000 files' : ''}',
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
|
||||||
),
|
|
||||||
if (scan.hidden > 0) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => setState(() => _showOther = !_showOther),
|
|
||||||
child: Text(_showOther ? 'Hide them' : 'Show some'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => _select(open, rescan: true),
|
|
||||||
icon: const Icon(Icons.refresh, size: 16),
|
|
||||||
label: const Text('Rescan'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
children.add(
|
|
||||||
AnimatedSize(
|
|
||||||
duration: _easeTime,
|
|
||||||
curve: _ease,
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
child: !_showOther
|
|
||||||
? const SizedBox(width: double.infinity)
|
|
||||||
: Column(
|
|
||||||
children: [
|
|
||||||
for (final f in scan.hiddenExamples)
|
|
||||||
ListTile(
|
|
||||||
dense: true,
|
|
||||||
enabled: false,
|
|
||||||
leading: const Icon(
|
|
||||||
Icons.insert_drive_file_outlined,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
title: Text(f),
|
|
||||||
trailing: const Text(
|
|
||||||
'not a video',
|
|
||||||
style: TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (scan.videos.isEmpty) {
|
|
||||||
children.add(
|
|
||||||
_empty(
|
|
||||||
context,
|
|
||||||
Icons.search_off,
|
|
||||||
'No videos here',
|
|
||||||
'Only video files are listed (mkv, mp4, webm…).',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Series: files whose names carry an episode number. Everything
|
|
||||||
// else is a loose video, shown as a thumbnail grid.
|
|
||||||
final loose = <_VideoFile>[];
|
|
||||||
for (final name in names) {
|
|
||||||
final eps = groups[name]!;
|
|
||||||
if (eps.every((e) => e.parsed.episode == null)) {
|
|
||||||
loose.addAll(eps);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
children.add(
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 18, bottom: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: _Thumb(eps.first.path, width: 96, height: 54),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${eps.length} ${eps.length == 1 ? 'episode' : 'episodes'}'
|
|
||||||
'${eps.first.parsed.group == null ? '' : ' · ${eps.first.parsed.group}'}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (final v in eps) {
|
|
||||||
children.add(_episodeTile(context, v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (loose.isNotEmpty) {
|
|
||||||
children.add(
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
|
||||||
child: Text(
|
|
||||||
names.length == loose.length ? 'Videos' : 'Other videos',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
children.add(
|
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, box) {
|
|
||||||
const gap = 12.0;
|
|
||||||
final cols = (box.maxWidth / 240).floor().clamp(2, 8);
|
|
||||||
final w = (box.maxWidth - gap * (cols - 1)) / cols;
|
|
||||||
return Wrap(
|
|
||||||
spacing: gap,
|
|
||||||
runSpacing: gap,
|
|
||||||
children: [
|
|
||||||
for (final v in loose)
|
|
||||||
SizedBox(width: w, child: _videoCard(context, v)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ListView(
|
|
||||||
padding: EdgeInsets.fromLTRB(narrow ? 20 : 12, 20, 20, 20),
|
|
||||||
children: children,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _episodeTile(
|
|
||||||
BuildContext context,
|
|
||||||
_VideoFile v, {
|
|
||||||
bool showSeries = false,
|
|
||||||
}) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return ValueListenableBuilder(
|
|
||||||
valueListenable: _nowPlaying,
|
|
||||||
builder: (context, now, _) {
|
|
||||||
final playing = now?.path == v.path;
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.only(left: showSeries ? 0 : 20),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
selected: playing,
|
|
||||||
leading: SizedBox(
|
|
||||||
width: 112,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: _Thumb(v.path, width: 80, height: 45),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
playing ? Icons.graphic_eq : Icons.play_circle_outline,
|
|
||||||
size: 20,
|
|
||||||
color: scheme.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
minLeadingWidth: 112,
|
|
||||||
title: Text(
|
|
||||||
showSeries
|
|
||||||
? '${v.parsed.series} · ${v.parsed.label}'
|
|
||||||
: v.parsed.label,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
showSeries && v.parsed.episode == null ? v.folder : v.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: Text(
|
|
||||||
_size(v.size),
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
|
||||||
),
|
|
||||||
onTap: () => _play(v),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _videoCard(BuildContext context, _VideoFile v) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return ValueListenableBuilder(
|
|
||||||
valueListenable: _nowPlaying,
|
|
||||||
builder: (context, now, _) {
|
|
||||||
final playing = now?.path == v.path;
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => _play(v),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
AspectRatio(
|
|
||||||
aspectRatio: 16 / 9,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: playing ? scheme.primary : Colors.transparent,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
_Thumb(v.path),
|
|
||||||
Positioned(
|
|
||||||
right: 6,
|
|
||||||
bottom: 6,
|
|
||||||
child: _DurationBadge(v.path),
|
|
||||||
),
|
|
||||||
if (playing)
|
|
||||||
const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.graphic_eq,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
v.parsed.series,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: playing ? scheme.primary : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_size(v.size),
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _empty(
|
|
||||||
BuildContext context,
|
|
||||||
IconData icon,
|
|
||||||
String title,
|
|
||||||
String sub, {
|
|
||||||
Widget? action,
|
|
||||||
}) => Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 60),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: 44,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(sub, style: const TextStyle(color: Colors.grey)),
|
|
||||||
if (action != null) ...[const SizedBox(height: 14), action],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _baseName(String path) {
|
|
||||||
final parts = path
|
|
||||||
.split(RegExp(r'[/\\]'))
|
|
||||||
.where((p) => p.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
return parts.isEmpty ? path : parts.last;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ thumbnails
|
|
||||||
//
|
|
||||||
// Demo: ffmpeg/ffprobe when installed (a frame at 25%, cached in the temp
|
|
||||||
// dir). The real app would ask mpv for the frame instead (no extra tool).
|
|
||||||
|
|
||||||
class _ThumbInfo {
|
|
||||||
final File? image;
|
|
||||||
final Duration? duration;
|
|
||||||
const _ThumbInfo(this.image, this.duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
final _thumbs = <String, Future<_ThumbInfo>>{};
|
|
||||||
var _thumbRunning = 0;
|
|
||||||
final _thumbQueue = <Completer<void>>[];
|
|
||||||
|
|
||||||
Future<_ThumbInfo> _thumbFor(String path) =>
|
|
||||||
_thumbs.putIfAbsent(path, () async {
|
|
||||||
// At most 3 ffmpeg runs at a time.
|
|
||||||
if (_thumbRunning >= 3) {
|
|
||||||
final c = Completer<void>();
|
|
||||||
_thumbQueue.add(c);
|
|
||||||
await c.future;
|
|
||||||
}
|
|
||||||
_thumbRunning++;
|
|
||||||
try {
|
|
||||||
return await _makeThumb(path);
|
|
||||||
} finally {
|
|
||||||
_thumbRunning--;
|
|
||||||
if (_thumbQueue.isNotEmpty) _thumbQueue.removeAt(0).complete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<_ThumbInfo> _makeThumb(String path) async {
|
|
||||||
Duration? dur;
|
|
||||||
try {
|
|
||||||
final r = await Process.run('ffprobe', [
|
|
||||||
'-v',
|
|
||||||
'error',
|
|
||||||
'-show_entries',
|
|
||||||
'format=duration',
|
|
||||||
'-of',
|
|
||||||
'csv=p=0',
|
|
||||||
path,
|
|
||||||
]);
|
|
||||||
final secs = double.tryParse('${r.stdout}'.trim());
|
|
||||||
if (secs != null) dur = Duration(milliseconds: (secs * 1000).round());
|
|
||||||
} catch (_) {
|
|
||||||
return const _ThumbInfo(null, null); // no ffmpeg on this machine
|
|
||||||
}
|
|
||||||
final dir = Directory('${Directory.systemTemp.path}/wt-demo-thumbs')
|
|
||||||
..createSync(recursive: true);
|
|
||||||
final key = path.hashCode.toUnsigned(32).toRadixString(16);
|
|
||||||
final out = File('${dir.path}/$key.jpg');
|
|
||||||
if (out.existsSync() && out.lengthSync() > 0) return _ThumbInfo(out, dur);
|
|
||||||
final at = dur == null ? 5.0 : dur.inMilliseconds / 1000 * 0.25;
|
|
||||||
try {
|
|
||||||
final r = await Process.run('ffmpeg', [
|
|
||||||
'-v',
|
|
||||||
'error',
|
|
||||||
'-y',
|
|
||||||
'-ss',
|
|
||||||
at.toStringAsFixed(2),
|
|
||||||
'-i',
|
|
||||||
path,
|
|
||||||
'-frames:v',
|
|
||||||
'1',
|
|
||||||
'-vf',
|
|
||||||
'scale=480:-2',
|
|
||||||
'-q:v',
|
|
||||||
'4',
|
|
||||||
out.path,
|
|
||||||
]);
|
|
||||||
if (r.exitCode == 0 && out.existsSync()) return _ThumbInfo(out, dur);
|
|
||||||
} catch (_) {}
|
|
||||||
return _ThumbInfo(null, dur);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Thumb extends StatelessWidget {
|
|
||||||
final String path;
|
|
||||||
final double? width, height;
|
|
||||||
const _Thumb(this.path, {this.width, this.height});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final placeholder = Container(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
color: scheme.surfaceContainerHighest,
|
|
||||||
child: Icon(
|
|
||||||
Icons.movie_outlined,
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return FutureBuilder<_ThumbInfo>(
|
|
||||||
future: _thumbFor(path),
|
|
||||||
builder: (context, snap) {
|
|
||||||
final img = snap.data?.image;
|
|
||||||
return AnimatedSwitcher(
|
|
||||||
duration: _easeTime,
|
|
||||||
child: img == null
|
|
||||||
? placeholder
|
|
||||||
: Image.file(
|
|
||||||
img,
|
|
||||||
key: ValueKey(img.path),
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DurationBadge extends StatelessWidget {
|
|
||||||
final String path;
|
|
||||||
const _DurationBadge(this.path);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => FutureBuilder<_ThumbInfo>(
|
|
||||||
future: _thumbFor(path),
|
|
||||||
builder: (context, snap) {
|
|
||||||
final d = snap.data?.duration;
|
|
||||||
if (d == null) return const SizedBox.shrink();
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black87,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_fmt(d),
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
part of 'shell_demo.dart';
|
|
||||||
|
|
||||||
// DEBUG ONLY: a real media_kit player for the layout demo. One Player and
|
|
||||||
// VideoController, shown by whichever view is active (mini, full or
|
|
||||||
// fullscreen); only one Video widget is mounted at a time.
|
|
||||||
|
|
||||||
class _DemoMedia {
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final String? path;
|
|
||||||
const _DemoMedia(this.title, this.subtitle, [this.path]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What is playing; null = nothing.
|
|
||||||
final _nowPlaying = ValueNotifier<_DemoMedia?>(null);
|
|
||||||
Player? _demoPlayer;
|
|
||||||
VideoController? _demoVideo;
|
|
||||||
|
|
||||||
Future<void> _playMedia(_DemoMedia m) async {
|
|
||||||
_nowPlaying.value = m;
|
|
||||||
if (m.path == null) return;
|
|
||||||
final p = _demoPlayer ??= Player();
|
|
||||||
_demoVideo ??= VideoController(p);
|
|
||||||
await p.open(Media(m.path!));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _disposeDemoPlayer() async {
|
|
||||||
final p = _demoPlayer;
|
|
||||||
_demoPlayer = null;
|
|
||||||
_demoVideo = null;
|
|
||||||
_nowPlaying.value = null;
|
|
||||||
await p?.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _fmt(Duration d) {
|
|
||||||
final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60;
|
|
||||||
String two(int n) => n.toString().padLeft(2, '0');
|
|
||||||
return h > 0 ? '$h:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The video itself, or a placeholder when nothing real is open.
|
|
||||||
class _VideoSurface extends StatelessWidget {
|
|
||||||
final double placeholderSize;
|
|
||||||
const _VideoSurface({this.placeholderSize = 32});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final v = _demoVideo;
|
|
||||||
if (v == null || _nowPlaying.value?.path == null) {
|
|
||||||
return Container(
|
|
||||||
color: Colors.black,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
'video · play something from Files',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white24,
|
|
||||||
fontSize: placeholderSize * 0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Video(controller: v, controls: NoVideoControls, fill: Colors.black);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Position bar + play/pause + time, bound to the real player (inert
|
|
||||||
/// when nothing is open).
|
|
||||||
class _Transport extends StatelessWidget {
|
|
||||||
final bool compact;
|
|
||||||
final List<Widget> trailing;
|
|
||||||
const _Transport({this.compact = false, this.trailing = const []});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final p = _demoPlayer;
|
|
||||||
if (p == null) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
const IconButton(
|
|
||||||
onPressed: null,
|
|
||||||
icon: Icon(Icons.play_arrow, color: Colors.white38),
|
|
||||||
),
|
|
||||||
const Text('--:-- / --:--', style: TextStyle(color: Colors.white38)),
|
|
||||||
const Spacer(),
|
|
||||||
...trailing,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return StreamBuilder<Duration>(
|
|
||||||
stream: p.stream.position,
|
|
||||||
builder: (context, _) {
|
|
||||||
final pos = p.state.position, dur = p.state.duration;
|
|
||||||
final frac = dur.inMilliseconds == 0
|
|
||||||
? 0.0
|
|
||||||
: pos.inMilliseconds / dur.inMilliseconds;
|
|
||||||
final bar = SliderTheme(
|
|
||||||
data: SliderTheme.of(context).copyWith(
|
|
||||||
trackHeight: compact ? 3 : 4,
|
|
||||||
thumbShape: RoundSliderThumbShape(
|
|
||||||
enabledThumbRadius: compact ? 0 : 7,
|
|
||||||
),
|
|
||||||
overlayShape: SliderComponentShape.noOverlay,
|
|
||||||
),
|
|
||||||
child: Slider(
|
|
||||||
value: frac.clamp(0.0, 1.0),
|
|
||||||
onChanged: dur == Duration.zero ? null : (v) => p.seek(dur * v),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final row = Row(
|
|
||||||
children: [
|
|
||||||
StreamBuilder<bool>(
|
|
||||||
stream: p.stream.playing,
|
|
||||||
builder: (context, _) => IconButton(
|
|
||||||
onPressed: p.playOrPause,
|
|
||||||
icon: Icon(
|
|
||||||
p.state.playing ? Icons.pause : Icons.play_arrow,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${_fmt(pos)} / ${_fmt(dur)}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white70,
|
|
||||||
fontSize: compact ? 12 : 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
...trailing,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [if (!compact) bar, row, if (compact) bar],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full and fullscreen player. Controls fade out after 2.5 s without the
|
|
||||||
/// mouse moving; in fullscreen the party sits top-right and toasts stay.
|
|
||||||
class _RealFullPlayer extends StatefulWidget {
|
|
||||||
final bool fullscreen;
|
|
||||||
final VoidCallback onCollapse, onToggleFullscreen;
|
|
||||||
final Widget? partyOverlay;
|
|
||||||
const _RealFullPlayer({
|
|
||||||
required this.fullscreen,
|
|
||||||
required this.onCollapse,
|
|
||||||
required this.onToggleFullscreen,
|
|
||||||
this.partyOverlay,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_RealFullPlayer> createState() => _RealFullPlayerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RealFullPlayerState extends State<_RealFullPlayer> {
|
|
||||||
bool _shown = true;
|
|
||||||
Timer? _hide;
|
|
||||||
|
|
||||||
void _poke() {
|
|
||||||
if (!_shown) setState(() => _shown = true);
|
|
||||||
_hide?.cancel();
|
|
||||||
_hide = Timer(const Duration(milliseconds: 2500), () {
|
|
||||||
if (mounted) setState(() => _shown = false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_poke();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_hide?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final m = _nowPlaying.value;
|
|
||||||
final controls = AnimatedOpacity(
|
|
||||||
duration: _easeTime,
|
|
||||||
curve: _ease,
|
|
||||||
opacity: _shown ? 1 : 0,
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: !_shown,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 28),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Colors.black87, Colors.transparent],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 34,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white12,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
m?.title ?? 'Nothing playing',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
m?.subtitle ?? '',
|
|
||||||
style: const TextStyle(color: Colors.white60),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.partyOverlay != null) ...[
|
|
||||||
widget.partyOverlay!,
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
if (!widget.fullscreen)
|
|
||||||
IconButton(
|
|
||||||
onPressed: widget.onCollapse,
|
|
||||||
tooltip: 'Back to the library (keeps playing)',
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.close_fullscreen,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 32, 16, 10),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.bottomCenter,
|
|
||||||
end: Alignment.topCenter,
|
|
||||||
colors: [Colors.black87, Colors.transparent],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: _Transport(
|
|
||||||
trailing: [
|
|
||||||
Builder(
|
|
||||||
builder: (context) => IconButton(
|
|
||||||
tooltip: 'Player settings',
|
|
||||||
onPressed: () => _popover(
|
|
||||||
context,
|
|
||||||
at: const Alignment(0.97, 0.78),
|
|
||||||
child: const _PlayerSettingsCard(),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.tune, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: widget.fullscreen
|
|
||||||
? 'Exit fullscreen (Esc)'
|
|
||||||
: 'Fullscreen (F)',
|
|
||||||
onPressed: widget.onToggleFullscreen,
|
|
||||||
icon: Icon(
|
|
||||||
widget.fullscreen
|
|
||||||
? Icons.fullscreen_exit
|
|
||||||
: Icons.fullscreen,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return CallbackShortcuts(
|
|
||||||
bindings: {
|
|
||||||
const SingleActivator(LogicalKeyboardKey.keyF):
|
|
||||||
widget.onToggleFullscreen,
|
|
||||||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
|
||||||
if (widget.fullscreen) widget.onToggleFullscreen();
|
|
||||||
},
|
|
||||||
const SingleActivator(LogicalKeyboardKey.space): () =>
|
|
||||||
_demoPlayer?.playOrPause(),
|
|
||||||
},
|
|
||||||
child: Focus(
|
|
||||||
autofocus: true,
|
|
||||||
child: MouseRegion(
|
|
||||||
onHover: (_) => _poke(),
|
|
||||||
cursor: _shown ? MouseCursor.defer : SystemMouseCursors.none,
|
|
||||||
child: GestureDetector(
|
|
||||||
onDoubleTap: widget.onToggleFullscreen,
|
|
||||||
onTap: () => _demoPlayer?.playOrPause(),
|
|
||||||
child: Container(
|
|
||||||
color: Colors.black,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
const Positioned.fill(child: _VideoSurface()),
|
|
||||||
Positioned.fill(child: controls),
|
|
||||||
// A thin progress line stays when the controls hide.
|
|
||||||
if (!_shown && _demoPlayer != null)
|
|
||||||
const Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: _ThinProgress(),
|
|
||||||
),
|
|
||||||
// Paused: who everyone is waiting for, always visible.
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: _shown ? 96 : 28,
|
|
||||||
child: const Center(child: _WaitingPill()),
|
|
||||||
),
|
|
||||||
// Fullscreen with the controls hidden: the party stays
|
|
||||||
// in the corner while paused.
|
|
||||||
if (widget.fullscreen &&
|
|
||||||
!_shown &&
|
|
||||||
widget.partyOverlay != null)
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
top: 14,
|
|
||||||
child: _PausedOnly(child: widget.partyOverlay!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ThinProgress extends StatelessWidget {
|
|
||||||
const _ThinProgress();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final p = _demoPlayer!;
|
|
||||||
return StreamBuilder<Duration>(
|
|
||||||
stream: p.stream.position,
|
|
||||||
builder: (context, _) {
|
|
||||||
final d = p.state.duration.inMilliseconds;
|
|
||||||
return LinearProgressIndicator(
|
|
||||||
value: d == 0 ? 0 : p.state.position.inMilliseconds / d,
|
|
||||||
minHeight: 2,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The gear in the player: everything about the player lives here.
|
|
||||||
class _PlayerSettingsCard extends StatefulWidget {
|
|
||||||
const _PlayerSettingsCard();
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_PlayerSettingsCard> createState() => _PlayerSettingsCardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PlayerSettingsCardState extends State<_PlayerSettingsCard> {
|
|
||||||
String _design = 'Standard';
|
|
||||||
bool _line = true;
|
|
||||||
String _shader = 'Off';
|
|
||||||
String _hwdec = 'Auto';
|
|
||||||
double _subSize = 1;
|
|
||||||
|
|
||||||
Widget _row(String label, Widget control, {String? help}) => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label),
|
|
||||||
if (help != null)
|
|
||||||
Text(
|
|
||||||
help,
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
control,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _drop(String value, List<String> items, ValueChanged<String> on) =>
|
|
||||||
DropdownButton<String>(
|
|
||||||
value: value,
|
|
||||||
underline: const SizedBox.shrink(),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
items: [
|
|
||||||
for (final i in items) DropdownMenuItem(value: i, child: Text(i)),
|
|
||||||
],
|
|
||||||
onChanged: (v) => setState(() => on(v!)),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Material(
|
|
||||||
elevation: 12,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 380,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Player',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_row(
|
|
||||||
'Player design',
|
|
||||||
_drop(_design, const ['Standard', 'uosc'], (v) => _design = v),
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Progress line when hidden',
|
|
||||||
Switch(
|
|
||||||
value: _line,
|
|
||||||
onChanged: (v) => setState(() => _line = v),
|
|
||||||
),
|
|
||||||
help: 'A thin bar at the bottom while the controls are hidden',
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Upscaling shader',
|
|
||||||
_drop(_shader, const [
|
|
||||||
'Off',
|
|
||||||
'Anime4K A (HQ)',
|
|
||||||
'Anime4K B (HQ)',
|
|
||||||
'Anime4K A (Fast)',
|
|
||||||
], (v) => _shader = v),
|
|
||||||
help: 'Runs on your GPU; only you see it',
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Hardware decoding',
|
|
||||||
_drop(_hwdec, const ['Auto', 'On', 'Off'], (v) => _hwdec = v),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
_row(
|
|
||||||
'Subtitles',
|
|
||||||
_drop('English · ASS', const [
|
|
||||||
'Off',
|
|
||||||
'English · ASS',
|
|
||||||
'Signs only',
|
|
||||||
], (_) {}),
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Subtitle size',
|
|
||||||
SizedBox(
|
|
||||||
width: 140,
|
|
||||||
child: Slider(
|
|
||||||
value: _subSize,
|
|
||||||
min: 0.6,
|
|
||||||
max: 1.6,
|
|
||||||
onChanged: (v) => setState(() => _subSize = v),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_row(
|
|
||||||
'Audio',
|
|
||||||
_drop('Japanese', const ['Japanese', 'English'], (_) {}),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
const Text(
|
|
||||||
'Track choices carry to the next episode.',
|
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows [child] only while the demo player is paused (or has nothing).
|
|
||||||
class _PausedOnly extends StatelessWidget {
|
|
||||||
final Widget child;
|
|
||||||
const _PausedOnly({required this.child});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final p = _demoPlayer;
|
|
||||||
return StreamBuilder<bool>(
|
|
||||||
stream: p?.stream.playing,
|
|
||||||
builder: (context, _) {
|
|
||||||
final paused = p == null || !p.state.playing;
|
|
||||||
return AnimatedOpacity(
|
|
||||||
duration: _easeTime,
|
|
||||||
curve: _ease,
|
|
||||||
opacity: paused ? 1 : 0,
|
|
||||||
child: IgnorePointer(ignoring: !paused, child: child),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "Waiting for Alex, Mika" or "Waiting for you · press Space", with
|
|
||||||
/// their avatars; only while paused.
|
|
||||||
class _WaitingPill extends StatelessWidget {
|
|
||||||
const _WaitingPill();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final notReady = _members.where((m) => !m.ready && !m.stale).toList();
|
|
||||||
final meWaiting = !_me.value.ready;
|
|
||||||
final text = meWaiting
|
|
||||||
? 'Waiting for you · press Space when ready'
|
|
||||||
: notReady.isEmpty
|
|
||||||
? 'Paused'
|
|
||||||
: 'Waiting for ${notReady.map((m) => m.name).join(', ')}';
|
|
||||||
return _PausedOnly(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.72),
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
border: Border.all(color: Colors.white12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
for (final m in (meWaiting ? [_me.value] : notReady).take(4))
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 4),
|
|
||||||
child: _Avatar(member: m, size: 28),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,355 +0,0 @@
|
|||||||
part of 'shell_demo.dart';
|
|
||||||
|
|
||||||
// DEBUG ONLY: the Settings tab. App-wide things only; everything about
|
|
||||||
// the player lives behind the gear in the player (pointer card at the top
|
|
||||||
// of every page). Controls are placeholders that hold their own state.
|
|
||||||
|
|
||||||
class _SettingsTab extends StatefulWidget {
|
|
||||||
final VoidCallback onOpenPlayerSettings;
|
|
||||||
const _SettingsTab({required this.onOpenPlayerSettings});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_SettingsTab> createState() => _SettingsTabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsTabState extends State<_SettingsTab> {
|
|
||||||
static const _sections = [
|
|
||||||
(Icons.person_outline, 'Profile'),
|
|
||||||
(Icons.hub_outlined, 'Party & network'),
|
|
||||||
(Icons.extension_outlined, 'Jellyfin & integrations'),
|
|
||||||
(Icons.notifications_none, 'Notifications & sounds'),
|
|
||||||
(Icons.palette_outlined, 'Appearance'),
|
|
||||||
(Icons.system_update_alt, 'Updates'),
|
|
||||||
(Icons.info_outline, 'About'),
|
|
||||||
];
|
|
||||||
int _at = 0;
|
|
||||||
final _bools = <String, bool>{
|
|
||||||
'toast-chat': true,
|
|
||||||
'toast-ready': true,
|
|
||||||
'toast-seek': false,
|
|
||||||
'unfocused': true,
|
|
||||||
'sounds': true,
|
|
||||||
'reduce-motion': false,
|
|
||||||
'auto-update': true,
|
|
||||||
'auto-next': true,
|
|
||||||
'remember-folders': true,
|
|
||||||
};
|
|
||||||
|
|
||||||
Widget _switch(String key, String title, [String? help]) => SwitchListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: help == null ? null : Text(help),
|
|
||||||
value: _bools[key]!,
|
|
||||||
onChanged: (v) => setState(() => _bools[key] = v),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Material, not a coloured box: list tiles paint their ripple on it.
|
|
||||||
Widget _card(String title, List<Widget> children) => Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
|
||||||
child: Material(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
...children,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
List<Widget> _page() {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
switch (_at) {
|
|
||||||
case 0:
|
|
||||||
return [
|
|
||||||
_card('You', [
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: _Avatar(member: _me.value, size: 48, badges: false),
|
|
||||||
title: Text(_me.value.name),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Name, picture and colour: click your avatar in the top left',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 1:
|
|
||||||
return [
|
|
||||||
_card('Hosting', [
|
|
||||||
const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text('Default port'),
|
|
||||||
trailing: Text('8080'),
|
|
||||||
),
|
|
||||||
const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text('Password'),
|
|
||||||
subtitle: Text(
|
|
||||||
'Asked when you host; blank = anyone who reaches the port can join',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text('Relay buffer'),
|
|
||||||
subtitle: Text('Memory the server keeps for relayed videos'),
|
|
||||||
trailing: Text('256 MB ahead · 64 MB back'),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('Recent servers', [
|
|
||||||
for (final s in [
|
|
||||||
'VPS · vps.example:8080',
|
|
||||||
'Bob · 192.168.1.20:8080',
|
|
||||||
])
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(Icons.dns_outlined),
|
|
||||||
title: Text(s),
|
|
||||||
trailing: IconButton(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.close, size: 18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 2:
|
|
||||||
return [
|
|
||||||
_card('Jellyfin', [
|
|
||||||
for (final (name, user) in [
|
|
||||||
('Senko Jellyfin', 'foxyz'),
|
|
||||||
('Bob\'s box', 'guest'),
|
|
||||||
])
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(Icons.video_library_outlined),
|
|
||||||
title: Text(name),
|
|
||||||
subtitle: Text('signed in as $user'),
|
|
||||||
trailing: TextButton(
|
|
||||||
onPressed: () {},
|
|
||||||
child: const Text('Sign out'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('Add a Jellyfin server'),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('AnimeFlow', [
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(Icons.downloading_outlined),
|
|
||||||
title: const Text('anime.senkochan.moe'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Uses your Senko Jellyfin sign-in · can view, request, approve',
|
|
||||||
),
|
|
||||||
trailing: _Pill('connected', Colors.greenAccent.shade400),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Powers the Schedule tab: what is downloaded, downloading or missing, and requests.',
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('Local files', [
|
|
||||||
_switch(
|
|
||||||
'remember-folders',
|
|
||||||
'Remember folders',
|
|
||||||
'Folders added in Files stay between launches',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 3:
|
|
||||||
return [
|
|
||||||
_card('Pop-ups', [
|
|
||||||
_switch('toast-chat', 'Chat messages'),
|
|
||||||
_switch('toast-ready', 'Ready / joined / left'),
|
|
||||||
_switch(
|
|
||||||
'toast-seek',
|
|
||||||
'Seeks and skips',
|
|
||||||
'Always in the activity log',
|
|
||||||
),
|
|
||||||
_switch(
|
|
||||||
'unfocused',
|
|
||||||
'Desktop notification when the app is in the background',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('Sounds', [
|
|
||||||
_switch(
|
|
||||||
'sounds',
|
|
||||||
'Play sounds',
|
|
||||||
'Soft cues for ready, join and chat; seeks stay silent',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 4:
|
|
||||||
return [
|
|
||||||
_card('Look', [
|
|
||||||
const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text('Theme'),
|
|
||||||
trailing: Text('Dark'),
|
|
||||||
),
|
|
||||||
_switch(
|
|
||||||
'reduce-motion',
|
|
||||||
'Reduce motion',
|
|
||||||
'Fades instead of slides, no spreading avatars',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
_card('Playback', [
|
|
||||||
_switch(
|
|
||||||
'auto-next',
|
|
||||||
'Play the next episode automatically',
|
|
||||||
'10 s countdown at the end',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
case 5:
|
|
||||||
return [
|
|
||||||
_card('Updates', [
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('Watch together 0.2.2'),
|
|
||||||
subtitle: const Text('0.3.0 is available'),
|
|
||||||
trailing: FilledButton(
|
|
||||||
onPressed: () {},
|
|
||||||
child: const Text('Update and restart'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_switch('auto-update', 'Check for updates on launch'),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
default:
|
|
||||||
return [
|
|
||||||
_card('About', [
|
|
||||||
const Text(
|
|
||||||
'Watch together for friends: synced playback, Jellyfin, relay.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'mpv · media_kit · Flutter',
|
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final pointer = Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: scheme.primary.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.tune, color: scheme.primary),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Player design, shaders, subtitles and audio live in the player: the ⚙ in its controls.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: widget.onOpenPlayerSettings,
|
|
||||||
child: const Text('Open player settings'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, box) {
|
|
||||||
final narrow = box.maxWidth < 760;
|
|
||||||
final nav = narrow
|
|
||||||
? SizedBox(
|
|
||||||
height: 52,
|
|
||||||
child: _HScroll(
|
|
||||||
builder: (c) => ListView(
|
|
||||||
controller: c,
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < _sections.length; i++)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: ChoiceChip(
|
|
||||||
label: Text(_sections[i].$2),
|
|
||||||
selected: _at == i,
|
|
||||||
onSelected: (_) => setState(() => _at = i),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: SizedBox(
|
|
||||||
width: 250,
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < _sections.length; i++)
|
|
||||||
ListTile(
|
|
||||||
selected: _at == i,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
leading: Icon(_sections[i].$1, size: 20),
|
|
||||||
title: Text(_sections[i].$2),
|
|
||||||
onTap: () => setState(() => _at = i),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final page = _fadeSwitch(
|
|
||||||
KeyedSubtree(
|
|
||||||
key: ValueKey(_at),
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 760),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_sections[_at].$2,
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
pointer,
|
|
||||||
..._page(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return narrow
|
|
||||||
? Column(
|
|
||||||
children: [
|
|
||||||
nav,
|
|
||||||
Expanded(child: page),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Row(
|
|
||||||
children: [
|
|
||||||
nav,
|
|
||||||
Expanded(child: page),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -7,11 +7,12 @@ import '../../update/update_controller.dart';
|
|||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
import 'update_indicator.dart';
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
/// Player keys, as handled by the session player's key handler (while
|
/// Session keys: Space and Tab anywhere in a server (`AppKeys`), the
|
||||||
/// the player fills the window).
|
/// rest by the session player's key handler (while it fills the window).
|
||||||
const sessionShortcuts = [
|
const sessionShortcuts = [
|
||||||
('Space', 'Toggle ready'),
|
('Space', 'Toggle ready'),
|
||||||
('← / →', 'Seek back / forward 10 s'),
|
('← / →', 'Seek back / forward 10 s'),
|
||||||
|
('↑ / ↓', 'Volume up / down (up to 150 %)'),
|
||||||
('Shift+→', 'Skip 1:30 (for everyone)'),
|
('Shift+→', 'Skip 1:30 (for everyone)'),
|
||||||
('PgUp / PgDn', 'Previous / next chapter'),
|
('PgUp / PgDn', 'Previous / next chapter'),
|
||||||
('F', 'Toggle fullscreen'),
|
('F', 'Toggle fullscreen'),
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ class ToastItem {
|
|||||||
|
|
||||||
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
||||||
|
|
||||||
|
/// "Unready when the video changes": a state with a newer [mediaId] than
|
||||||
|
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
||||||
|
/// self is ready (or a reconnect is restoring it). A swap keeps the
|
||||||
|
/// server's ready flags, so the client unreadies itself.
|
||||||
|
bool unreadyForNewMedia(
|
||||||
|
{required bool enabled,
|
||||||
|
required int lastMediaId,
|
||||||
|
required int mediaId,
|
||||||
|
required bool ready}) =>
|
||||||
|
enabled && lastMediaId >= 0 && mediaId != lastMediaId && ready;
|
||||||
|
|
||||||
/// `media.relay` of a state, or null when the media is not relayed.
|
/// `media.relay` of a state, or null when the media is not relayed.
|
||||||
MediaRelay? stateMediaRelay(StateBroadcast state) {
|
MediaRelay? stateMediaRelay(StateBroadcast state) {
|
||||||
final r = state.media?['relay'];
|
final r = state.media?['relay'];
|
||||||
@@ -184,7 +195,7 @@ class SessionCoordinator {
|
|||||||
final autoAdvance = signal<AutoAdvance?>(null);
|
final autoAdvance = signal<AutoAdvance?>(null);
|
||||||
|
|
||||||
/// The skip pill for the current position ([skipOfferAt]: Skip OP,
|
/// The skip pill for the current position ([skipOfferAt]: Skip OP,
|
||||||
/// Skip ED, or Skip 1:30 without opening info); null while nothing
|
/// Skip ED, or Skip 1:30 without opening info or chapters); null while nothing
|
||||||
/// is open or at `finished`. Press it with [skip].
|
/// is open or at `finished`. Press it with [skip].
|
||||||
late final Computed<SkipOffer?> skipOffer = computed(() {
|
late final Computed<SkipOffer?> skipOffer = computed(() {
|
||||||
final r = serverState.value;
|
final r = serverState.value;
|
||||||
@@ -195,6 +206,7 @@ class SessionCoordinator {
|
|||||||
duration: duration,
|
duration: duration,
|
||||||
opening: playback.opening.value,
|
opening: playback.opening.value,
|
||||||
ending: playback.ending.value,
|
ending: playback.ending.value,
|
||||||
|
chaptered: playback.chapters.value.isNotEmpty,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,6 +235,9 @@ class SessionCoordinator {
|
|||||||
// Set after a reconnect until the server echoes the restored ready, so
|
// Set after a reconnect until the server echoes the restored ready, so
|
||||||
// the fresh member's ready=false doesn't overwrite [_lastReady].
|
// the fresh member's ready=false doesn't overwrite [_lastReady].
|
||||||
bool _restoringReady = false;
|
bool _restoringReady = false;
|
||||||
|
// Last mediaId seen in a state; unlike [_mediaId] it survives a
|
||||||
|
// reconnect, so only a real swap counts as a new video.
|
||||||
|
int _seenMediaId = -1;
|
||||||
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
|
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
|
||||||
int _epoch = 0;
|
int _epoch = 0;
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
@@ -260,6 +275,8 @@ class SessionCoordinator {
|
|||||||
int _toastId = 0;
|
int _toastId = 0;
|
||||||
ConnectParams? _params;
|
ConnectParams? _params;
|
||||||
bool _everConnected = false;
|
bool _everConnected = false;
|
||||||
|
/// The connection-lost sound played; the reconnected one answers it once.
|
||||||
|
bool _lostSounded = false;
|
||||||
Map<String, String>? _openHeaders;
|
Map<String, String>? _openHeaders;
|
||||||
|
|
||||||
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
|
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
|
||||||
@@ -354,8 +371,10 @@ class SessionCoordinator {
|
|||||||
_openUrl = null;
|
_openUrl = null;
|
||||||
_lastReady = false;
|
_lastReady = false;
|
||||||
_restoringReady = false;
|
_restoringReady = false;
|
||||||
|
_seenMediaId = -1;
|
||||||
_wasPlayAllowed = false;
|
_wasPlayAllowed = false;
|
||||||
_everConnected = false;
|
_everConnected = false;
|
||||||
|
_lostSounded = false;
|
||||||
_resumeToken = '';
|
_resumeToken = '';
|
||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
@@ -423,6 +442,7 @@ class SessionCoordinator {
|
|||||||
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) {
|
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) {
|
||||||
health.value = const HealthStatus(
|
health.value = const HealthStatus(
|
||||||
HealthSeverity.warn, 'connection lost — retrying…');
|
HealthSeverity.warn, 'connection lost — retrying…');
|
||||||
|
_soundLost();
|
||||||
_scheduleReconnect();
|
_scheduleReconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -664,12 +684,27 @@ class SessionCoordinator {
|
|||||||
void _trackSelfReady(StateBroadcast r) {
|
void _trackSelfReady(StateBroadcast r) {
|
||||||
final self = selfMember.peek();
|
final self = selfMember.peek();
|
||||||
if (self == null) return;
|
if (self == null) return;
|
||||||
if (r.mediaId != _mediaId) _restoringReady = false;
|
// [_seenMediaId], not [_mediaId]: that one is -1 after a lost
|
||||||
|
// connection, which would end the restore before its echo.
|
||||||
|
if (_seenMediaId >= 0 && r.mediaId != _seenMediaId) _restoringReady = false;
|
||||||
if (_restoringReady) {
|
if (_restoringReady) {
|
||||||
if (self.ready) _restoringReady = false;
|
if (self.ready) _restoringReady = false;
|
||||||
} else {
|
} else {
|
||||||
_lastReady = self.ready;
|
_lastReady = self.ready;
|
||||||
}
|
}
|
||||||
|
final last = _seenMediaId;
|
||||||
|
_seenMediaId = r.mediaId;
|
||||||
|
// A ready restored by a reconnect may not have echoed yet: unready
|
||||||
|
// anyway (it was sent first, so this one lands last).
|
||||||
|
if (unreadyForNewMedia(
|
||||||
|
enabled: AppSettings.instance.unreadyOnSwap.peek(),
|
||||||
|
lastMediaId: last,
|
||||||
|
mediaId: r.mediaId,
|
||||||
|
ready: self.ready || _restoringReady)) {
|
||||||
|
_lastReady = false;
|
||||||
|
_restoringReady = false;
|
||||||
|
sync.sendReady(false, source: 'auto');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
||||||
@@ -682,7 +717,7 @@ class SessionCoordinator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (final m in r.members) {
|
for (final m in r.members) {
|
||||||
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
|
if (m.inSet && m.buffered < r.bufferNeed) {
|
||||||
starving.value =
|
starving.value =
|
||||||
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
|
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
|
||||||
return;
|
return;
|
||||||
@@ -727,7 +762,9 @@ class SessionCoordinator {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
final kind = sfxForFeedAction(f.action);
|
final kind = sfxForFeedAction(f.action);
|
||||||
if (kind != null) {
|
// Your own message needs no sound.
|
||||||
|
final own = f.action == 'chat' && f.actor == selfMember.peek()?.name;
|
||||||
|
if (kind != null && !own) {
|
||||||
var readyIndex = 0;
|
var readyIndex = 0;
|
||||||
if (f.action == 'ready') {
|
if (f.action == 'ready') {
|
||||||
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
|
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
|
||||||
@@ -813,6 +850,7 @@ class SessionCoordinator {
|
|||||||
if (!reconnecting.peek()) {
|
if (!reconnecting.peek()) {
|
||||||
health.value = const HealthStatus(
|
health.value = const HealthStatus(
|
||||||
HealthSeverity.warn, 'connection lost — retrying…');
|
HealthSeverity.warn, 'connection lost — retrying…');
|
||||||
|
_soundLost();
|
||||||
_scheduleReconnect();
|
_scheduleReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1490,7 +1528,8 @@ class SessionCoordinator {
|
|||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final wantReady = _lastReady;
|
var wantReady = _lastReady;
|
||||||
|
final seenBefore = _seenMediaId;
|
||||||
try {
|
try {
|
||||||
health.value = const HealthStatus(
|
health.value = const HealthStatus(
|
||||||
HealthSeverity.info, 'reconnecting…');
|
HealthSeverity.info, 'reconnecting…');
|
||||||
@@ -1507,6 +1546,10 @@ class SessionCoordinator {
|
|||||||
if (stale()) return;
|
if (stale()) return;
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
_everConnected = true;
|
_everConnected = true;
|
||||||
|
if (_lostSounded) {
|
||||||
|
_lostSounded = false;
|
||||||
|
unawaited(sfx.play(const SfxCue(SfxKind.back)));
|
||||||
|
}
|
||||||
_resumeToken = sync.token;
|
_resumeToken = sync.token;
|
||||||
_lastStateAt = DateTime.now().toUtc();
|
_lastStateAt = DateTime.now().toUtc();
|
||||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||||
@@ -1517,6 +1560,15 @@ class SessionCoordinator {
|
|||||||
if (stale()) return;
|
if (stale()) return;
|
||||||
// The server forgot the dropped member; restore the ready flag it
|
// The server forgot the dropped member; restore the ready flag it
|
||||||
// last reported (not a local guess: others may have forced it).
|
// last reported (not a local guess: others may have forced it).
|
||||||
|
// Not when the video changed meanwhile and the user unreadies then.
|
||||||
|
if (unreadyForNewMedia(
|
||||||
|
enabled: AppSettings.instance.unreadyOnSwap.peek(),
|
||||||
|
lastMediaId: seenBefore,
|
||||||
|
mediaId: sync.lastState?.mediaId ?? seenBefore,
|
||||||
|
ready: wantReady)) {
|
||||||
|
wantReady = false;
|
||||||
|
_lastReady = false;
|
||||||
|
}
|
||||||
if (wantReady) {
|
if (wantReady) {
|
||||||
_lastReady = true;
|
_lastReady = true;
|
||||||
_restoringReady = true;
|
_restoringReady = true;
|
||||||
@@ -1531,6 +1583,12 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _soundLost() {
|
||||||
|
if (!_everConnected || _lostSounded) return;
|
||||||
|
_lostSounded = true;
|
||||||
|
unawaited(sfx.play(const SfxCue(SfxKind.lost)));
|
||||||
|
}
|
||||||
|
|
||||||
void _invalidateAttempts() {
|
void _invalidateAttempts() {
|
||||||
_epoch++;
|
_epoch++;
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
|
/// The app's sounds. Only events that carry news get one: no sound for
|
||||||
|
/// what the video itself makes obvious (start, pause, end, seeks).
|
||||||
enum SfxKind {
|
enum SfxKind {
|
||||||
|
/// Someone readies up; pitch climbs with [SfxCue.readyIndex].
|
||||||
ready,
|
ready,
|
||||||
unready,
|
unready,
|
||||||
|
/// Someone marks everyone ready.
|
||||||
forceReady,
|
forceReady,
|
||||||
forceUnready,
|
|
||||||
forcePlay,
|
|
||||||
forceHold,
|
|
||||||
kick,
|
|
||||||
join,
|
join,
|
||||||
leave,
|
leave,
|
||||||
stale,
|
kick,
|
||||||
|
/// A new video or episode is put up.
|
||||||
swap,
|
swap,
|
||||||
seek,
|
|
||||||
pause,
|
|
||||||
resume,
|
|
||||||
chat,
|
chat,
|
||||||
|
/// Idle warning: auto-unready soon.
|
||||||
warn,
|
warn,
|
||||||
|
/// The connection to the server dropped / came back.
|
||||||
|
lost,
|
||||||
|
back,
|
||||||
}
|
}
|
||||||
|
|
||||||
class SfxCue {
|
class SfxCue {
|
||||||
@@ -23,42 +25,17 @@ class SfxCue {
|
|||||||
const SfxCue(this.kind, {this.readyIndex = 0});
|
const SfxCue(this.kind, {this.readyIndex = 0});
|
||||||
}
|
}
|
||||||
|
|
||||||
SfxKind? sfxForFeedAction(String action) {
|
SfxKind? sfxForFeedAction(String action) => switch (action) {
|
||||||
switch (action) {
|
'ready' => SfxKind.ready,
|
||||||
case 'ready':
|
'unready' => SfxKind.unready,
|
||||||
return SfxKind.ready;
|
'force-ready' => SfxKind.forceReady,
|
||||||
case 'unready':
|
'kick' => SfxKind.kick,
|
||||||
return SfxKind.unready;
|
'join' => SfxKind.join,
|
||||||
case 'force-ready':
|
'leave' => SfxKind.leave,
|
||||||
return SfxKind.forceReady;
|
'swap' => SfxKind.swap,
|
||||||
case 'force-unready':
|
'chat' => SfxKind.chat,
|
||||||
return SfxKind.forceUnready;
|
_ => null,
|
||||||
case 'force-play':
|
};
|
||||||
return SfxKind.forcePlay;
|
|
||||||
case 'force-hold':
|
|
||||||
return SfxKind.forceHold;
|
|
||||||
case 'kick':
|
|
||||||
return SfxKind.kick;
|
|
||||||
case 'join':
|
|
||||||
return SfxKind.join;
|
|
||||||
case 'leave':
|
|
||||||
return SfxKind.leave;
|
|
||||||
case 'stale':
|
|
||||||
return SfxKind.stale;
|
|
||||||
case 'swap':
|
|
||||||
return SfxKind.swap;
|
|
||||||
case 'seek':
|
|
||||||
return SfxKind.seek;
|
|
||||||
case 'pause':
|
|
||||||
return SfxKind.pause;
|
|
||||||
case 'resume':
|
|
||||||
return SfxKind.resume;
|
|
||||||
case 'chat':
|
|
||||||
return SfxKind.chat;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class SfxService {
|
abstract class SfxService {
|
||||||
Future<void> play(SfxCue cue);
|
Future<void> play(SfxCue cue);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import 'sfx.dart';
|
||||||
|
import 'synth.dart';
|
||||||
|
|
||||||
|
// The sound of the house: koto for readiness, the wind chime for people
|
||||||
|
// arriving and leaving, the shoji door for a new video, bamboo for chat,
|
||||||
|
// clappers for a kick, the singing bowl for the idle warning, a string
|
||||||
|
// going slack and re-tuned for the connection. All in the Yo scale on D
|
||||||
|
// (D E G A B), so overlapping sounds agree; quiet and short.
|
||||||
|
|
||||||
|
const _d4 = -7, _a4 = 0, _b4 = 2;
|
||||||
|
const _d5 = 5, _g5 = 10, _a5 = 12, _b5 = 14;
|
||||||
|
const _d6 = 17, _e6 = 19, _g6 = 22, _a6 = 24, _b6 = 26;
|
||||||
|
|
||||||
|
double _p(int s) => semi(s);
|
||||||
|
|
||||||
|
/// The ready pluck's answer climbs with each person ready.
|
||||||
|
const _readyLadder = [_g5, _a5, _b5, _d6, _e6, _g6];
|
||||||
|
|
||||||
|
SoundSpec readySound(int readyIndex) {
|
||||||
|
final top = _readyLadder[(readyIndex - 1).clamp(0, _readyLadder.length - 1)];
|
||||||
|
return SoundSpec([
|
||||||
|
Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55),
|
||||||
|
Note(0.085, _p(top), 1.1, bright: 0.65),
|
||||||
|
], peakDb: -17);
|
||||||
|
}
|
||||||
|
|
||||||
|
SoundSpec soundFor(SfxKind kind, {int readyIndex = 1}) => switch (kind) {
|
||||||
|
SfxKind.ready => readySound(readyIndex),
|
||||||
|
// The same strings, muted and stepping down.
|
||||||
|
SfxKind.unready => SoundSpec([
|
||||||
|
Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2),
|
||||||
|
Note(0.09, _p(_g5), 0.45, bright: 0.15),
|
||||||
|
], cutoff: 3800, peakDb: -20, room: 0.1),
|
||||||
|
// Just the bell: playback starting is the rest of the cue.
|
||||||
|
SfxKind.forceReady => SoundSpec([
|
||||||
|
Note(0, _p(_d6) * 2, 1.6, voice: Voice.chime),
|
||||||
|
], cutoff: 7000, peakDb: -18, room: 0.18),
|
||||||
|
// Wind through the furin: three uneven tinkles.
|
||||||
|
SfxKind.join => SoundSpec([
|
||||||
|
Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8),
|
||||||
|
Note(0.11, _p(_d6) * 2, 1.2, voice: Voice.chime, gain: 0.55),
|
||||||
|
Note(0.26, _p(_e6) * 2, 1.4, voice: Voice.chime, gain: 0.45),
|
||||||
|
], cutoff: 7000, peakDb: -19, room: 0.18),
|
||||||
|
// The wind settling: two softer, lower tinkles.
|
||||||
|
SfxKind.leave => SoundSpec([
|
||||||
|
Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6),
|
||||||
|
Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5),
|
||||||
|
], cutoff: 5000, peakDb: -22, room: 0.18),
|
||||||
|
// Hyoshigi: clack… clack, then a low muted string.
|
||||||
|
SfxKind.kick => SoundSpec([
|
||||||
|
Note(0, 1900, 0.09, voice: Voice.clack),
|
||||||
|
Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9),
|
||||||
|
Note(0.3, _p(_d4), 0.7, bright: 0.15, gain: 0.7),
|
||||||
|
], cutoff: 5000, peakDb: -19, room: 0.12),
|
||||||
|
// The door slides open on something new: air, then a fifth.
|
||||||
|
SfxKind.swap => SoundSpec([
|
||||||
|
Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5),
|
||||||
|
Note(0.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8),
|
||||||
|
Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7),
|
||||||
|
], peakDb: -18),
|
||||||
|
// Bamboo: a hollow tok-tok, the second higher.
|
||||||
|
SfxKind.chat => SoundSpec([
|
||||||
|
Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85),
|
||||||
|
Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo),
|
||||||
|
], cutoff: 5500, peakDb: -19, room: 0.1),
|
||||||
|
// The singing bowl: one soft strike that hums.
|
||||||
|
SfxKind.warn => SoundSpec([
|
||||||
|
Note(0, _p(_d5), 2.6, voice: Voice.rin),
|
||||||
|
], cutoff: 4500, peakDb: -20, room: 0.1),
|
||||||
|
// A string going slack: plucked, its pitch sagging away.
|
||||||
|
SfxKind.lost => SoundSpec([
|
||||||
|
Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4),
|
||||||
|
Note(0.05, _p(_d5), 1.0, to: _p(_a4), bright: 0.3, gain: 0.6),
|
||||||
|
], cutoff: 3500, peakDb: -19),
|
||||||
|
// Re-tuned: a quick bend up that lands, and a tinkle.
|
||||||
|
SfxKind.back => SoundSpec([
|
||||||
|
Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5),
|
||||||
|
Note(0.2, _p(_a5), 1.0, bright: 0.6, gain: 0.7),
|
||||||
|
Note(0.3, _p(_d6) * 2, 1.0, voice: Voice.chime, gain: 0.2),
|
||||||
|
], peakDb: -18),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shown in Settings → Notifications & sounds: name and when it plays.
|
||||||
|
const soundInfo = {
|
||||||
|
SfxKind.ready: ('Ready', 'Someone presses Space; climbs with each ready person'),
|
||||||
|
SfxKind.unready: ('Not ready', 'Someone takes their ready back'),
|
||||||
|
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
|
||||||
|
SfxKind.join: ('Joined', 'Someone joins the server'),
|
||||||
|
SfxKind.leave: ('Left', 'Someone leaves'),
|
||||||
|
SfxKind.kick: ('Removed', 'Someone is kicked'),
|
||||||
|
SfxKind.swap: ('New video', 'A different video or episode is put up'),
|
||||||
|
SfxKind.chat: ('Message', 'A chat message from someone else'),
|
||||||
|
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
|
||||||
|
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
|
||||||
|
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
// A tiny offline synthesizer for UI sounds, voiced as a Japanese house:
|
||||||
|
// koto strings (Karplus-Strong), a glass wind chime (furin), a singing
|
||||||
|
// bowl (rin), bamboo knocks, wooden clappers (hyoshigi) and the air of a
|
||||||
|
// sliding door (shoji). Renders to 16-bit mono WAV bytes. Pure Dart.
|
||||||
|
|
||||||
|
const sampleRate = 44100;
|
||||||
|
|
||||||
|
enum Voice {
|
||||||
|
/// Plucked string; [Note.to] bends it (the delay line stretches).
|
||||||
|
koto,
|
||||||
|
/// Small glass bell: inharmonic partials, a slow shimmer.
|
||||||
|
chime,
|
||||||
|
/// Singing bowl: a soft strike and a long beating hum.
|
||||||
|
rin,
|
||||||
|
/// Hollow bamboo knock, pitched.
|
||||||
|
bamboo,
|
||||||
|
/// Two hardwood blocks struck together.
|
||||||
|
clack,
|
||||||
|
/// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the
|
||||||
|
/// sweep of its band.
|
||||||
|
slide,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Note {
|
||||||
|
final double at;
|
||||||
|
final double hz;
|
||||||
|
final double? to;
|
||||||
|
/// Length (s) to about -40 dB.
|
||||||
|
final double len;
|
||||||
|
final double gain;
|
||||||
|
final Voice voice;
|
||||||
|
/// Koto only: 0 = dark/muted pluck, 1 = bright.
|
||||||
|
final double bright;
|
||||||
|
const Note(this.at, this.hz, this.len,
|
||||||
|
{this.to, this.gain = 1, this.voice = Voice.koto, this.bright = 0.6});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SoundSpec {
|
||||||
|
final List<Note> notes;
|
||||||
|
/// Gentle low-pass cutoff (Hz) over the mix.
|
||||||
|
final double cutoff;
|
||||||
|
/// Peak level after normalising, dBFS (quiet by design).
|
||||||
|
final double peakDb;
|
||||||
|
/// Room tail mix (0..1): the veranda, not a hall.
|
||||||
|
final double room;
|
||||||
|
const SoundSpec(this.notes, {this.cutoff = 6000, this.peakDb = -17, this.room = 0.14});
|
||||||
|
|
||||||
|
double get length =>
|
||||||
|
notes.fold<double>(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? 0.25 : 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Semitones from A4 (440 Hz) to Hz.
|
||||||
|
double semi(num n) => 440 * math.pow(2, n / 12).toDouble();
|
||||||
|
|
||||||
|
Float64List render(SoundSpec spec, {int seed = 7}) {
|
||||||
|
final total = (spec.length * sampleRate).ceil();
|
||||||
|
final out = Float64List(total);
|
||||||
|
final rnd = math.Random(seed);
|
||||||
|
for (final n in spec.notes) {
|
||||||
|
final start = (n.at * sampleRate).round();
|
||||||
|
if (start >= total) continue;
|
||||||
|
final s = switch (n.voice) {
|
||||||
|
Voice.koto => _koto(n, rnd),
|
||||||
|
Voice.chime => _bell(n, const [(1.0, 1.0, 1.0), (2.76, 0.45, 1.9), (5.40, 0.2, 3.2), (8.93, 0.08, 4.5)], 1.6, 0.003),
|
||||||
|
Voice.rin => _bell(n, const [(1.0, 1.0, 1.0), (2.71, 0.35, 1.5), (5.15, 0.12, 2.4)], 2.4, 0.012),
|
||||||
|
Voice.bamboo => _bamboo(n, rnd),
|
||||||
|
Voice.clack => _clack(n, rnd),
|
||||||
|
Voice.slide => _slide(n, rnd),
|
||||||
|
};
|
||||||
|
for (var i = 0; i < s.length && start + i < total; i++) {
|
||||||
|
out[start + i] += n.gain * s[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lowpass(out, spec.cutoff);
|
||||||
|
if (spec.room > 0) _room(out, spec.room);
|
||||||
|
// 1 ms in, 10 ms out: no clicks at either end.
|
||||||
|
final fadeIn = (0.001 * sampleRate).round();
|
||||||
|
for (var i = 0; i < fadeIn && i < total; i++) {
|
||||||
|
out[i] *= i / fadeIn;
|
||||||
|
}
|
||||||
|
final fade = (0.01 * sampleRate).round();
|
||||||
|
for (var i = 0; i < fade && i < total; i++) {
|
||||||
|
out[total - 1 - i] *= i / fade;
|
||||||
|
}
|
||||||
|
var peak = 0.0;
|
||||||
|
for (final v in out) {
|
||||||
|
peak = math.max(peak, v.abs());
|
||||||
|
}
|
||||||
|
if (peak > 0) {
|
||||||
|
final g = math.pow(10, spec.peakDb / 20) / peak;
|
||||||
|
for (var i = 0; i < total; i++) {
|
||||||
|
out[i] *= g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _n(double secs) => (secs * sampleRate).round();
|
||||||
|
double _smooth(double t) => t * t * (3 - 2 * t);
|
||||||
|
|
||||||
|
/// Karplus-Strong with a fractional, movable delay: a noise burst in a
|
||||||
|
/// loop that loses a little of its highs each pass.
|
||||||
|
Float64List _koto(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final maxPeriod = sampleRate / math.min(n.hz, n.to ?? n.hz) + 4;
|
||||||
|
final buf = Float64List(maxPeriod.ceil() + 4);
|
||||||
|
final size = buf.length;
|
||||||
|
// Excitation: noise, smoothed more for a darker pluck.
|
||||||
|
final period0 = sampleRate / n.hz;
|
||||||
|
var prev = 0.0;
|
||||||
|
final smooth = 1 - (0.25 + 0.7 * n.bright);
|
||||||
|
for (var i = 0; i < period0.ceil(); i++) {
|
||||||
|
final x = rnd.nextDouble() * 2 - 1;
|
||||||
|
prev = smooth * prev + (1 - smooth) * x;
|
||||||
|
buf[i % size] = prev;
|
||||||
|
}
|
||||||
|
// Loop gain so the note reaches -40 dB at len.
|
||||||
|
final loss = math.pow(0.01, 1 / (n.len * n.hz)).toDouble();
|
||||||
|
var w = period0.ceil();
|
||||||
|
var last = 0.0;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final t = i / len;
|
||||||
|
final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6)));
|
||||||
|
// The two-point average below delays half a sample: take it off.
|
||||||
|
final period = sampleRate / hz - 0.5;
|
||||||
|
final r = w - period;
|
||||||
|
final ri = r.floor();
|
||||||
|
final f = r - ri;
|
||||||
|
final a = buf[(ri % size + size) % size];
|
||||||
|
final b = buf[((ri + 1) % size + size) % size];
|
||||||
|
final y = a + (b - a) * f;
|
||||||
|
// Two-point average = the string's damping; the 0.5 mix keeps it bright.
|
||||||
|
final v = loss * (0.5 * y + 0.5 * last);
|
||||||
|
last = y;
|
||||||
|
buf[w % size] = v;
|
||||||
|
w++;
|
||||||
|
// A 3 ms attack takes the click off the pluck.
|
||||||
|
out[i] = v * (i < 132 ? i / 132 : 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inharmonic partials (ratio, amplitude, decay multiplier), each doubled
|
||||||
|
/// with a slightly detuned twin so the tail shimmers.
|
||||||
|
Float64List _bell(Note n, List<(double, double, double)> partials, double beatHz, double attackSecs) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final k = math.log(100) / len;
|
||||||
|
final attack = _n(attackSecs);
|
||||||
|
for (final (ratio, amp, decay) in partials) {
|
||||||
|
final f1 = n.hz * ratio, f2 = f1 + beatHz * ratio;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final env = math.exp(-k * decay * i) * (i < attack ? _smooth(i / attack) : 1);
|
||||||
|
final ph = 2 * math.pi * i / sampleRate;
|
||||||
|
out[i] += amp * env * 0.5 * (math.sin(ph * f1) + math.sin(ph * f2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hollow tube: a pitched thump that drops into its note, an overtone
|
||||||
|
/// that dies fast, and a short click of air.
|
||||||
|
Float64List _bamboo(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final k = math.log(100) / len;
|
||||||
|
var ph = 0.0, ph2 = 0.0;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
final drop = math.exp(-i / _n(0.012));
|
||||||
|
final hz = n.hz * (1 + 0.5 * drop);
|
||||||
|
ph += 2 * math.pi * hz / sampleRate;
|
||||||
|
ph2 += 2 * math.pi * hz * 2.31 / sampleRate;
|
||||||
|
out[i] = math.exp(-k * i) * (math.sin(ph) + 0.35 * math.sin(ph2) * math.exp(-k * 3 * i));
|
||||||
|
}
|
||||||
|
final click = _bandNoise(_n(0.006), n.hz * 3, 2.0, rnd);
|
||||||
|
for (var i = 0; i < click.length && i < len; i++) {
|
||||||
|
out[i] += 0.5 * click[i] * (1 - i / click.length);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hardwood: two resonances excited by a very short noise burst.
|
||||||
|
Float64List _clack(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final out = Float64List(len);
|
||||||
|
final burst = Float64List(len);
|
||||||
|
for (var i = 0; i < _n(0.002) && i < len; i++) {
|
||||||
|
burst[i] = rnd.nextDouble() * 2 - 1;
|
||||||
|
}
|
||||||
|
for (final (f, q, g) in [(n.hz, 18.0, 1.0), (n.hz * 1.47, 14.0, 0.6), (n.hz * 2.6, 10.0, 0.25)]) {
|
||||||
|
final r = _biquadBand(burst, f, q);
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
out[i] += g * r[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final k = math.log(100) / len;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
out[i] *= math.exp(-k * i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Air through a band that sweeps from [Note.hz] to [Note.to], swelling in
|
||||||
|
/// and out (a paper door sliding along its track).
|
||||||
|
Float64List _slide(Note n, math.Random rnd) {
|
||||||
|
final len = _n(n.len);
|
||||||
|
final noise = Float64List(len);
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
noise[i] = rnd.nextDouble() * 2 - 1;
|
||||||
|
}
|
||||||
|
final out = Float64List(len);
|
||||||
|
// Sweep in short blocks (a fixed band per block is plenty).
|
||||||
|
const block = 256;
|
||||||
|
var z1 = 0.0, z2 = 0.0;
|
||||||
|
for (var b0 = 0; b0 < len; b0 += block) {
|
||||||
|
final t = b0 / len;
|
||||||
|
final f = n.hz * math.pow((n.to ?? n.hz) / n.hz, t);
|
||||||
|
final c = _bandCoeffs(f.toDouble(), 2.5);
|
||||||
|
for (var i = b0; i < math.min(len, b0 + block); i++) {
|
||||||
|
final y = c.$1 * noise[i] + z1;
|
||||||
|
z1 = c.$2 * noise[i] - c.$4 * y + z2;
|
||||||
|
z2 = c.$3 * noise[i] - c.$5 * y;
|
||||||
|
final tt = i / len;
|
||||||
|
// Rises, holds, eases off: sin^2 swell with a tail.
|
||||||
|
final env = math.pow(math.sin(math.pi * math.min(1, tt * 1.25)), 2).toDouble();
|
||||||
|
// The track's grain: a slow flutter.
|
||||||
|
final grain = 1 + 0.25 * math.sin(2 * math.pi * 23 * i / sampleRate);
|
||||||
|
out[i] = y * env * grain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Float64List _bandNoise(int len, double f, double q, math.Random rnd) {
|
||||||
|
final x = Float64List(len);
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
x[i] = rnd.nextDouble() * 2 - 1;
|
||||||
|
}
|
||||||
|
return _biquadBand(x, f, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RBJ band-pass (constant 0 dB peak): (b0, b1, b2, a1, a2), b1 = 0.
|
||||||
|
(double, double, double, double, double) _bandCoeffs(double f, double q) {
|
||||||
|
final w = 2 * math.pi * math.min(f, sampleRate * 0.45) / sampleRate;
|
||||||
|
final alpha = math.sin(w) / (2 * q);
|
||||||
|
final a0 = 1 + alpha;
|
||||||
|
return (alpha / a0, 0, -alpha / a0, -2 * math.cos(w) / a0, (1 - alpha) / a0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Float64List _biquadBand(Float64List x, double f, double q) {
|
||||||
|
final c = _bandCoeffs(f, q);
|
||||||
|
final y = Float64List(x.length);
|
||||||
|
var z1 = 0.0, z2 = 0.0;
|
||||||
|
for (var i = 0; i < x.length; i++) {
|
||||||
|
final v = c.$1 * x[i] + z1;
|
||||||
|
z1 = c.$2 * x[i] - c.$4 * v + z2;
|
||||||
|
z2 = c.$3 * x[i] - c.$5 * v;
|
||||||
|
y[i] = v;
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pole, twice: a soft 12 dB/oct roll-off.
|
||||||
|
void _lowpass(Float64List out, double cutoff) {
|
||||||
|
final a = math.exp(-2 * math.pi * cutoff / sampleRate);
|
||||||
|
for (var pass = 0; pass < 2; pass++) {
|
||||||
|
var y = 0.0;
|
||||||
|
for (var i = 0; i < out.length; i++) {
|
||||||
|
y = (1 - a) * out[i] + a * y;
|
||||||
|
out[i] = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A few feedback delays: wood and paper walls, a short tail.
|
||||||
|
void _room(Float64List out, double mix) {
|
||||||
|
const taps = [0.029, 0.041, 0.059];
|
||||||
|
final dry = Float64List.fromList(out);
|
||||||
|
for (final d in taps) {
|
||||||
|
final delay = _n(d);
|
||||||
|
final buf = Float64List(out.length);
|
||||||
|
for (var i = delay; i < out.length; i++) {
|
||||||
|
buf[i] = dry[i - delay] + 0.42 * buf[i - delay];
|
||||||
|
}
|
||||||
|
for (var i = 0; i < out.length; i++) {
|
||||||
|
out[i] += mix / taps.length * buf[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 16-bit PCM mono WAV.
|
||||||
|
Uint8List wav(Float64List samples) {
|
||||||
|
final data = samples.length * 2;
|
||||||
|
final b = ByteData(44 + data);
|
||||||
|
void str(int o, String s) {
|
||||||
|
for (var i = 0; i < s.length; i++) {
|
||||||
|
b.setUint8(o + i, s.codeUnitAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
str(0, 'RIFF');
|
||||||
|
b.setUint32(4, 36 + data, Endian.little);
|
||||||
|
str(8, 'WAVE');
|
||||||
|
str(12, 'fmt ');
|
||||||
|
b.setUint32(16, 16, Endian.little);
|
||||||
|
b.setUint16(20, 1, Endian.little);
|
||||||
|
b.setUint16(22, 1, Endian.little);
|
||||||
|
b.setUint32(24, sampleRate, Endian.little);
|
||||||
|
b.setUint32(28, sampleRate * 2, Endian.little);
|
||||||
|
b.setUint16(32, 2, Endian.little);
|
||||||
|
b.setUint16(34, 16, Endian.little);
|
||||||
|
str(36, 'data');
|
||||||
|
b.setUint32(40, data, Endian.little);
|
||||||
|
for (var i = 0; i < samples.length; i++) {
|
||||||
|
b.setInt16(44 + i * 2, (samples[i].clamp(-1.0, 1.0) * 32767).round(), Endian.little);
|
||||||
|
}
|
||||||
|
return b.buffer.asUint8List();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'sfx.dart';
|
||||||
|
import 'sound_bank.dart';
|
||||||
|
import 'synth.dart';
|
||||||
|
|
||||||
|
/// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and
|
||||||
|
/// played on a small pool of audio-only mpv players (so two sounds can
|
||||||
|
/// overlap). [volume] (0..1) is read per play and applied on a square
|
||||||
|
/// curve (ears are log); [enabled] filters cues.
|
||||||
|
class SynthSfx implements SfxService {
|
||||||
|
final double Function() volume;
|
||||||
|
final bool Function(SfxKind kind) enabled;
|
||||||
|
SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled}) : enabled = enabled ?? ((_) => true);
|
||||||
|
|
||||||
|
static const _poolSize = 3;
|
||||||
|
final List<Player> _pool = [];
|
||||||
|
var _next = 0;
|
||||||
|
final Map<String, Future<String>> _files = {};
|
||||||
|
Directory? _dir;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> play(SfxCue cue) async {
|
||||||
|
if (!enabled(cue.kind)) return;
|
||||||
|
await playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays [kind] even when it is switched off (Settings' preview).
|
||||||
|
Future<void> preview(SfxKind kind, {int readyIndex = 1}) =>
|
||||||
|
playSpec('${kind.name}-$readyIndex', soundFor(kind, readyIndex: readyIndex));
|
||||||
|
|
||||||
|
/// Plays [spec], cached under [key] (same key = same sound).
|
||||||
|
Future<void> playSpec(String key, SoundSpec spec) async {
|
||||||
|
final v = volume();
|
||||||
|
if (v <= 0) return;
|
||||||
|
try {
|
||||||
|
final path = await (_files[key] ??= _write(key, spec));
|
||||||
|
final player = await _player();
|
||||||
|
await player.setVolume(100 * v * v);
|
||||||
|
await player.open(Media(path));
|
||||||
|
} catch (e) {
|
||||||
|
_files.remove(key);
|
||||||
|
debugPrint('sfx $key: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _write(String key, SoundSpec spec) async {
|
||||||
|
_dir ??= await Directory('${(await getTemporaryDirectory()).path}/watch_together_sfx').create(recursive: true);
|
||||||
|
final f = File('${_dir!.path}/$key.wav');
|
||||||
|
await f.writeAsBytes(wav(render(spec)));
|
||||||
|
return f.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Player> _player() async {
|
||||||
|
if (_pool.length < _poolSize) {
|
||||||
|
final p = Player(
|
||||||
|
configuration: const PlayerConfiguration(vo: 'null', osc: false, libass: false, title: 'sfx'),
|
||||||
|
);
|
||||||
|
await (p.platform as NativePlayer).setProperty('audio-display', 'no');
|
||||||
|
_pool.add(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
return _pool[_next++ % _poolSize];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
for (final p in _pool) {
|
||||||
|
await p.dispose();
|
||||||
|
}
|
||||||
|
_pool.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io' show File;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTarget;
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
import '../files/folder_store.dart';
|
||||||
|
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
@@ -18,6 +22,7 @@ import '../state/settings.dart';
|
|||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
import 'chat_panel.dart';
|
import 'chat_panel.dart';
|
||||||
import 'connect_cards.dart';
|
import 'connect_cards.dart';
|
||||||
|
import 'handoff.dart';
|
||||||
import 'library_tab.dart';
|
import 'library_tab.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
import 'party_strip.dart';
|
import 'party_strip.dart';
|
||||||
@@ -36,6 +41,9 @@ import 'toasts.dart';
|
|||||||
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
|
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
|
||||||
/// fades the layer out, moves it while invisible and fades it back in, so
|
/// fades the layer out, moves it while invisible and fades it back in, so
|
||||||
/// the video is never mounted twice nor remounted.
|
/// the video is never mounted twice nor remounted.
|
||||||
|
///
|
||||||
|
/// A drop anywhere on the window or Ctrl+V outside a text field is a
|
||||||
|
/// [Handoff]: a card near the pointer asks how to open it.
|
||||||
class AppShell extends StatefulWidget {
|
class AppShell extends StatefulWidget {
|
||||||
final UpdateController? updates;
|
final UpdateController? updates;
|
||||||
final bool autoUpdateCheck;
|
final bool autoUpdateCheck;
|
||||||
@@ -88,6 +96,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
Offset? _drag; // mini top-left while dragging
|
Offset? _drag; // mini top-left while dragging
|
||||||
bool _snapping = false;
|
bool _snapping = false;
|
||||||
|
|
||||||
|
/// Last pointer position in the window (where a paste's card opens).
|
||||||
|
Offset? _pointer;
|
||||||
|
bool _dragging = false;
|
||||||
|
bool _handoffOpen = false;
|
||||||
|
|
||||||
final _flow = ConnectFlow();
|
final _flow = ConnectFlow();
|
||||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||||
late final _host = HostFields(SessionState.instance.lastForm);
|
late final _host = HostFields(SessionState.instance.lastForm);
|
||||||
@@ -128,6 +141,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
_jellyfin.ensureLoaded();
|
_jellyfin.ensureLoaded();
|
||||||
if (widget.autoUpdateCheck) _autoCheck();
|
if (widget.autoUpdateCheck) _autoCheck();
|
||||||
_flow.onKicked = _showKicked;
|
_flow.onKicked = _showKicked;
|
||||||
|
HardwareKeyboard.instance.addHandler(_onKey);
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
final coord = session.coordinator;
|
final coord = session.coordinator;
|
||||||
_feedSeen = coord.feedLog.peek().length;
|
_feedSeen = coord.feedLog.peek().length;
|
||||||
@@ -195,6 +209,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
for (final stop in _stops) {
|
for (final stop in _stops) {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
|
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||||
_fade.dispose();
|
_fade.dispose();
|
||||||
_online.dispose();
|
_online.dispose();
|
||||||
_hasMedia.dispose();
|
_hasMedia.dispose();
|
||||||
@@ -368,6 +383,122 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- handoff
|
||||||
|
|
||||||
|
/// Nothing (a dialog, a popover) is over the shell.
|
||||||
|
bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||||
|
|
||||||
|
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||||
|
bool _onKey(KeyEvent e) {
|
||||||
|
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||||
|
final keys = HardwareKeyboard.instance;
|
||||||
|
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
|
||||||
|
if (!mounted || !_onTop) return false;
|
||||||
|
final inField =
|
||||||
|
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
||||||
|
if (inField) return false;
|
||||||
|
unawaited(_paste());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clipboard files first, then text (a link or absolute paths). A pasted
|
||||||
|
/// image has no taker yet, so the clipboard's image data is not read.
|
||||||
|
Future<void> _paste() async {
|
||||||
|
var files = const <String>[];
|
||||||
|
String? text;
|
||||||
|
try {
|
||||||
|
files = await ClipboardX.readFiles();
|
||||||
|
} catch (_) {}
|
||||||
|
if (files.isEmpty) {
|
||||||
|
try {
|
||||||
|
text = await ClipboardX.readText();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
await _handOff(classifyHandoff(paths: files, text: text), _pointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handOff(Handoff item, Offset? at) async {
|
||||||
|
if (!mounted || !_onTop || item is HandoffNothing || item is HandoffImage) return;
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final align = at == null || size.isEmpty
|
||||||
|
? Alignment.center
|
||||||
|
: Alignment((at.dx / size.width * 2 - 1).clamp(-1, 1), (at.dy / size.height * 2 - 1).clamp(-1, 1));
|
||||||
|
final online = _online.peek();
|
||||||
|
_handoffOpen = true;
|
||||||
|
final HandoffAction? action;
|
||||||
|
try {
|
||||||
|
action = await showPopover<HandoffAction>(context, at: align, child: HandoffCard(item: item, online: online));
|
||||||
|
} finally {
|
||||||
|
_handoffOpen = false;
|
||||||
|
}
|
||||||
|
if (action == null || !mounted) return;
|
||||||
|
switch ((item, action)) {
|
||||||
|
case (HandoffVideos(:final paths), HandoffAction.putUp):
|
||||||
|
final error = await _putUp(FileMedia(paths.first));
|
||||||
|
if (error != null) {
|
||||||
|
SessionState.instance.coordinator.showToast(error);
|
||||||
|
} else {
|
||||||
|
await FolderStore.instance.ensureLoaded();
|
||||||
|
FolderStore.instance.addRecent(paths.first);
|
||||||
|
}
|
||||||
|
case (HandoffVideos(:final paths), HandoffAction.openInFiles):
|
||||||
|
await FolderStore.instance.ensureLoaded();
|
||||||
|
FolderStore.instance.addRecent(paths.first);
|
||||||
|
_openFiles(null);
|
||||||
|
case (HandoffVideos(:final paths), HandoffAction.addFolder):
|
||||||
|
await _addFolder(File(paths.first).parent.path);
|
||||||
|
case (HandoffFolder(:final path), _):
|
||||||
|
await _addFolder(path);
|
||||||
|
case (HandoffLink(:final url), _):
|
||||||
|
_picker.link.text = url;
|
||||||
|
_openLibrary(MediaKind.link);
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addFolder(String dir) async {
|
||||||
|
await FolderStore.instance.ensureLoaded();
|
||||||
|
FolderStore.instance.addFolder(dir);
|
||||||
|
_openFiles(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Library → Files on [dir] (null: Recent).
|
||||||
|
void _openFiles(String? dir) {
|
||||||
|
showFilesFolder(dir);
|
||||||
|
_openLibrary(MediaKind.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openLibrary(MediaKind source) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_picker.source = source;
|
||||||
|
_selectTab(ShellTab.library);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dropTarget(Widget child) => DropTarget(
|
||||||
|
onDragEntered: (_) => setState(() => _dragging = _onTop),
|
||||||
|
onDragExited: (_) => setState(() => _dragging = false),
|
||||||
|
onDragDone: (d) {
|
||||||
|
setState(() => _dragging = false);
|
||||||
|
_handOff(classifyHandoff(paths: DesktopDropX.pathsOf(d.files)), d.localPosition);
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
opacity: _dragging ? 1 : 0,
|
||||||
|
child: const _DropOverlay(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// --------------------------------------------------------------- dialogs
|
// --------------------------------------------------------------- dialogs
|
||||||
|
|
||||||
Future<void> _confirmLeave() async {
|
Future<void> _confirmLeave() async {
|
||||||
@@ -426,7 +557,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return CallbackShortcuts(
|
final shell = CallbackShortcuts(
|
||||||
bindings: {
|
bindings: {
|
||||||
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
|
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
|
||||||
},
|
},
|
||||||
@@ -474,6 +605,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return Listener(
|
||||||
|
onPointerHover: (e) => _pointer = e.localPosition,
|
||||||
|
onPointerDown: (e) => _pointer = e.localPosition,
|
||||||
|
child: _dropTarget(shell),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _body(bool online) {
|
Widget _body(bool online) {
|
||||||
@@ -533,7 +669,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||||
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: const PartyToasts()),
|
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
|
||||||
|
onOpenChat: () {
|
||||||
|
if (!_chatOpen) _toggleChat();
|
||||||
|
},
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -619,3 +759,39 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Over the window while something is dragged over it.
|
||||||
|
class _DropOverlay extends StatelessWidget {
|
||||||
|
const _DropOverlay();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
key: const ValueKey('drop-overlay'),
|
||||||
|
margin: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surface.withValues(alpha: 0.55),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: scheme.primary.withValues(alpha: 0.7), width: 2),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Material(
|
||||||
|
color: scheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
elevation: 8,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.file_download_outlined, color: scheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text('Drop to open', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
|||||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||||
image: memberAvatarImage(m, coord),
|
image: memberAvatarImage(m, coord),
|
||||||
ready: m.ready,
|
ready: m.ready,
|
||||||
buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs,
|
buffering: !m.stale && m.ready && playing && m.buffered < r!.bufferNeed,
|
||||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||||
stale: m.stale,
|
stale: m.stale,
|
||||||
// The state marks the host (`members[].host`); your own app also
|
// The state marks the host (`members[].host`); your own app also
|
||||||
|
|||||||
@@ -1,22 +1,70 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../widgets/text_focus.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
|
|
||||||
/// Side panel: chat with avatars, other activity as quiet lines, and the
|
/// Activity lines (joins, ready, seeks...) between the messages: off by
|
||||||
/// message field. Width is fixed; the shell slides it in and out.
|
/// default, kept for the app's run.
|
||||||
class ChatPanel extends StatelessWidget {
|
final showActivity = ValueNotifier(false);
|
||||||
|
|
||||||
|
/// Side panel: chat with avatars, other activity as quiet lines (behind
|
||||||
|
/// the "Activity" toggle), and the message field. Width is fixed; the
|
||||||
|
/// shell slides it in and out. The field takes focus on open; Tab and Esc
|
||||||
|
/// in it close the panel.
|
||||||
|
class ChatPanel extends StatefulWidget {
|
||||||
static const width = 340.0;
|
static const width = 340.0;
|
||||||
final TextEditingController chat;
|
final TextEditingController chat;
|
||||||
final FocusNode chatFocus;
|
final FocusNode chatFocus;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
|
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatPanel> createState() => _ChatPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatPanelState extends State<ChatPanel> {
|
||||||
|
// Where focus was before the field took it; it goes back there on close
|
||||||
|
// (else a field behind could take it, and Space with it).
|
||||||
|
FocusNode? _before;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final before = FocusManager.instance.primaryFocus;
|
||||||
|
if (before != widget.chatFocus) _before = before;
|
||||||
|
widget.chatFocus.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
final before = _before;
|
||||||
|
if (before != null && before.context != null && before.canRequestFocus && !textInputFocused(before)) {
|
||||||
|
before.requestFocus();
|
||||||
|
} else if (textInputFocused()) {
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _send() {
|
void _send() {
|
||||||
SessionState.instance.coordinator.sendChat(chat.text);
|
SessionState.instance.coordinator.sendChat(widget.chat.text);
|
||||||
chat.clear();
|
widget.chat.clear();
|
||||||
chatFocus.requestFocus();
|
widget.chatFocus.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEventResult _fieldKey(FocusNode node, KeyEvent e) {
|
||||||
|
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||||
|
if (e.logicalKey != LogicalKeyboardKey.tab && e.logicalKey != LogicalKeyboardKey.escape) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
widget.onClose();
|
||||||
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -24,7 +72,7 @@ class ChatPanel extends StatelessWidget {
|
|||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Container(
|
return Container(
|
||||||
key: const ValueKey('chat-panel'),
|
key: const ValueKey('chat-panel'),
|
||||||
width: width,
|
width: ChatPanel.width,
|
||||||
color: scheme.surfaceContainerLow,
|
color: scheme.surfaceContainerLow,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -32,18 +80,35 @@ class ChatPanel extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)),
|
const Text('Chat', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)),
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: showActivity,
|
||||||
|
builder: (context, on, _) => FilterChip(
|
||||||
|
key: const ValueKey('chat-activity'),
|
||||||
|
label: const Text('Activity', style: TextStyle(fontSize: 12)),
|
||||||
|
tooltip: 'Show joins, ready, seeks and other activity',
|
||||||
|
selected: on,
|
||||||
|
showCheckmark: false,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
onSelected: (v) => showActivity.value = v,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(tooltip: 'Close (Tab)', onPressed: widget.onClose, icon: const Icon(Icons.close, size: 18)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: PartyWatch(builder: (context) {
|
child: ValueListenableBuilder<bool>(
|
||||||
final log = SessionState.instance.coordinator.feedLog.value;
|
valueListenable: showActivity,
|
||||||
|
builder: (context, activity, _) => PartyWatch(builder: (context) {
|
||||||
|
final all = SessionState.instance.coordinator.feedLog.value;
|
||||||
|
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
|
||||||
if (log.isEmpty) {
|
if (log.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
child: Text(activity ? 'Nothing yet' : 'No messages yet',
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
@@ -54,12 +119,17 @@ class ChatPanel extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Focus(
|
||||||
|
canRequestFocus: false,
|
||||||
|
skipTraversal: true,
|
||||||
|
onKeyEvent: _fieldKey,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
key: const ValueKey('chat-field'),
|
key: const ValueKey('chat-field'),
|
||||||
controller: chat,
|
controller: widget.chat,
|
||||||
focusNode: chatFocus,
|
focusNode: widget.chatFocus,
|
||||||
onSubmitted: (_) => _send(),
|
onSubmitted: (_) => _send(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Message (Enter)',
|
hintText: 'Message (Enter)',
|
||||||
@@ -70,6 +140,7 @@ class ChatPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../files/scanner.dart' show FileKind, kindOf;
|
||||||
|
import '../files/ui/file_tiles.dart' show baseName;
|
||||||
|
import 'library_tab.dart' show offlinePutUpHint;
|
||||||
|
|
||||||
|
// Something handed to the app, by a drop or by Ctrl+V outside a text
|
||||||
|
// field: what it is ([classifyHandoff], pure) and the small card that asks
|
||||||
|
// how to open it ([HandoffCard]). The shell wires both.
|
||||||
|
|
||||||
|
/// What was dropped or pasted.
|
||||||
|
sealed class Handoff {
|
||||||
|
const Handoff();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Video files (by the Files page's extensions), in the order given.
|
||||||
|
final class HandoffVideos extends Handoff {
|
||||||
|
final List<String> paths;
|
||||||
|
const HandoffVideos(this.paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HandoffFolder extends Handoff {
|
||||||
|
final String path;
|
||||||
|
const HandoffFolder(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An http(s) link.
|
||||||
|
final class HandoffLink extends Handoff {
|
||||||
|
final String url;
|
||||||
|
const HandoffLink(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An image file. Nothing takes one yet; the shell ignores it.
|
||||||
|
final class HandoffImage extends Handoff {
|
||||||
|
final String path;
|
||||||
|
const HandoffImage(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HandoffNothing extends Handoff {
|
||||||
|
const HandoffNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
const _imageExtensions = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'};
|
||||||
|
|
||||||
|
final _link = RegExp(r'^https?://\S+$', caseSensitive: false);
|
||||||
|
final _windowsAbsolute = RegExp(r'^([A-Za-z]:[\\/]|\\\\)');
|
||||||
|
|
||||||
|
bool _absolute(String p) => p.startsWith('/') || _windowsAbsolute.hasMatch(p);
|
||||||
|
|
||||||
|
/// A `file://` URI as a path; anything else as is.
|
||||||
|
String _pathOf(String p) {
|
||||||
|
if (!p.startsWith('file://')) return p;
|
||||||
|
try {
|
||||||
|
return Uri.parse(p).toFilePath();
|
||||||
|
} catch (_) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies dropped or pasted [paths] (files first) or, when there are
|
||||||
|
/// none, pasted [text]: a link, or one absolute path (or `file://` URI) per
|
||||||
|
/// line. Videos win over folders, folders over images; paths that do not
|
||||||
|
/// exist ([typeOf], defaults to the file system) count for nothing.
|
||||||
|
Handoff classifyHandoff({
|
||||||
|
List<String> paths = const [],
|
||||||
|
String? text,
|
||||||
|
FileSystemEntityType Function(String path)? typeOf,
|
||||||
|
}) {
|
||||||
|
final type = typeOf ?? (p) => FileSystemEntity.typeSync(p);
|
||||||
|
var list = [for (final p in paths) if (p.trim().isNotEmpty) _pathOf(p.trim())];
|
||||||
|
if (list.isEmpty && text != null) {
|
||||||
|
final t = text.trim();
|
||||||
|
if (_link.hasMatch(t)) return HandoffLink(t);
|
||||||
|
final lines = [for (final l in t.split(RegExp(r'[\r\n]+'))) if (l.trim().isNotEmpty) _pathOf(l.trim())];
|
||||||
|
if (lines.isEmpty || !lines.every(_absolute)) return const HandoffNothing();
|
||||||
|
list = lines;
|
||||||
|
}
|
||||||
|
final types = {for (final p in list) p: type(p)};
|
||||||
|
bool file(String p) => types[p] == FileSystemEntityType.file;
|
||||||
|
final videos = [for (final p in list) if (file(p) && kindOf(p) == FileKind.video) p];
|
||||||
|
if (videos.isNotEmpty) return HandoffVideos(videos);
|
||||||
|
for (final p in list) {
|
||||||
|
if (types[p] == FileSystemEntityType.directory) return HandoffFolder(p);
|
||||||
|
}
|
||||||
|
for (final p in list) {
|
||||||
|
final dot = p.lastIndexOf('.');
|
||||||
|
if (file(p) && dot >= 0 && _imageExtensions.contains(p.substring(dot + 1).toLowerCase())) {
|
||||||
|
return HandoffImage(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const HandoffNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the card's buttons ask the shell to do.
|
||||||
|
enum HandoffAction {
|
||||||
|
/// Put the (first) video up for everyone.
|
||||||
|
putUp,
|
||||||
|
/// Offline: list the video in Files → Recent and open it there.
|
||||||
|
openInFiles,
|
||||||
|
/// Add the video's folder (or the folder) to Files and open it.
|
||||||
|
addFolder,
|
||||||
|
/// Open Library → Link with the URL filled.
|
||||||
|
openLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card asking how to open a [Handoff] (videos, a folder or a link);
|
||||||
|
/// pops the [HandoffAction] chosen.
|
||||||
|
class HandoffCard extends StatelessWidget {
|
||||||
|
final Handoff item;
|
||||||
|
final bool online;
|
||||||
|
const HandoffCard({super.key, required this.item, required this.online});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
void pick(HandoffAction a) => Navigator.of(context).pop(a);
|
||||||
|
Widget button(HandoffAction a, IconData icon, String label, {bool primary = false}) => primary
|
||||||
|
? FilledButton.icon(
|
||||||
|
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label))
|
||||||
|
: OutlinedButton.icon(
|
||||||
|
key: ValueKey('handoff-${a.name}'), onPressed: () => pick(a), icon: Icon(icon, size: 18), label: Text(label));
|
||||||
|
|
||||||
|
final (IconData icon, String title, String detail, List<Widget> buttons) = switch (item) {
|
||||||
|
HandoffVideos(:final paths) => (
|
||||||
|
Icons.movie_outlined,
|
||||||
|
baseName(paths.first),
|
||||||
|
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
|
||||||
|
[
|
||||||
|
if (online)
|
||||||
|
button(HandoffAction.putUp, Icons.play_arrow, 'Put it up', primary: true)
|
||||||
|
else
|
||||||
|
button(HandoffAction.openInFiles, Icons.history, 'Open in Files', primary: true),
|
||||||
|
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
HandoffFolder(:final path) => (
|
||||||
|
Icons.folder_outlined,
|
||||||
|
baseName(path),
|
||||||
|
path,
|
||||||
|
[button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add to Files', primary: true)],
|
||||||
|
),
|
||||||
|
HandoffLink(:final url) => (
|
||||||
|
Icons.link,
|
||||||
|
url,
|
||||||
|
online ? 'Pick how the others get it on the Link page' : offlinePutUpHint,
|
||||||
|
[
|
||||||
|
button(HandoffAction.openLink, Icons.link, online ? 'Put it up as a link…' : 'Open in Link',
|
||||||
|
primary: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
HandoffImage() || HandoffNothing() => (Icons.help_outline, '', '', const <Widget>[]),
|
||||||
|
};
|
||||||
|
return Material(
|
||||||
|
key: const ValueKey('handoff-card'),
|
||||||
|
elevation: 12,
|
||||||
|
color: scheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 380),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: scheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
Text(detail,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Wrap(spacing: 8, runSpacing: 8, children: buttons),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -346,7 +346,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'A video file, YouTube, a Jellyfin link… Ctrl+V anywhere pastes it here.',
|
'A video file, YouTube, a Jellyfin link… Ctrl+V or drop it anywhere to bring it here.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
|
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
|
||||||
|
|
||||||
@@ -189,32 +188,6 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- paste
|
|
||||||
|
|
||||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
|
||||||
final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed;
|
|
||||||
if (e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
// A focused text field handles its own paste.
|
|
||||||
final inField =
|
|
||||||
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
|
||||||
if (inField) return KeyEventResult.ignored;
|
|
||||||
unawaited(_pasteLink());
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ctrl+V outside a field: an http(s) link goes to the Link source.
|
|
||||||
Future<void> _pasteLink() async {
|
|
||||||
String? text;
|
|
||||||
try {
|
|
||||||
text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
|
||||||
} catch (_) {}
|
|
||||||
if (text == null || !(text.startsWith('http://') || text.startsWith('https://'))) return;
|
|
||||||
_c.link.text = text;
|
|
||||||
_c.source = MediaKind.link;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------- build
|
// -------------------------------------------------------------- build
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -306,10 +279,10 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
|
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Holds focus so the shell's shortcuts (Ctrl+,) work.
|
||||||
return Focus(
|
return Focus(
|
||||||
key: const ValueKey('library-tab'),
|
key: const ValueKey('library-tab'),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onKeyEvent: _onKey,
|
|
||||||
child: LayoutBuilder(builder: (context, box) {
|
child: LayoutBuilder(builder: (context, box) {
|
||||||
if (box.maxWidth >= detailsDockFrom) {
|
if (box.maxWidth >= detailsDockFrom) {
|
||||||
return Row(
|
return Row(
|
||||||
|
|||||||
@@ -90,17 +90,17 @@ Future<T?> showPopover<T>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Horizontal lists for mouse users: drag with the mouse, and the plain
|
/// Horizontal lists for mouse users: drag with the mouse or Shift+wheel;
|
||||||
/// (vertical) wheel moves them sideways. At either end the wheel is left
|
/// the plain wheel stays vertical (the page scrolls under the cursor, not
|
||||||
/// to the page, so a row never traps vertical scrolling.
|
/// the row).
|
||||||
class HScroll extends StatefulWidget {
|
class HScroll extends StatefulWidget {
|
||||||
final ScrollController? controller;
|
final ScrollController? controller;
|
||||||
final Widget Function(ScrollController controller) builder;
|
final Widget Function(ScrollController controller) builder;
|
||||||
/// Map the plain (vertical) wheel to sideways scrolling. Off where the
|
/// Also map the plain (vertical) wheel to sideways scrolling (at either
|
||||||
/// children scroll vertically themselves (the schedule's day columns):
|
/// end the wheel goes to the page). Off by default: one wheel doing two
|
||||||
/// there only mouse drag and Shift+wheel move sideways.
|
/// things was confusing.
|
||||||
final bool wheel;
|
final bool wheel;
|
||||||
const HScroll({super.key, this.controller, required this.builder, this.wheel = true});
|
const HScroll({super.key, this.controller, required this.builder, this.wheel = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HScroll> createState() => _HScrollState();
|
State<HScroll> createState() => _HScrollState();
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
import '../screens/demo/shell_demo.dart';
|
|
||||||
import '../screens/home/update_indicator.dart';
|
import '../screens/home/update_indicator.dart';
|
||||||
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/dialogs.dart';
|
import '../widgets/dialogs.dart';
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
@@ -29,6 +28,9 @@ enum ShellTab {
|
|||||||
|
|
||||||
const partyStripHeight = 60.0;
|
const partyStripHeight = 60.0;
|
||||||
|
|
||||||
|
/// Every avatar in the strip, yours and the party's.
|
||||||
|
const stripAvatarSize = 42.0;
|
||||||
|
|
||||||
/// Below this window width the strip shows 3 avatars and no server text,
|
/// Below this window width the strip shows 3 avatars and no server text,
|
||||||
/// so the tabs keep their room.
|
/// so the tabs keep their room.
|
||||||
const stripCompactBelow = 1100.0;
|
const stripCompactBelow = 1100.0;
|
||||||
@@ -90,6 +92,7 @@ class PartyStrip extends StatelessWidget {
|
|||||||
message: 'Your profile',
|
message: 'Your profile',
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
key: const ValueKey('strip-profile'),
|
key: const ValueKey('strip-profile'),
|
||||||
|
canRequestFocus: false,
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
onTap: onProfile,
|
onTap: onProfile,
|
||||||
child: StatusAvatar(
|
child: StatusAvatar(
|
||||||
@@ -97,7 +100,7 @@ class PartyStrip extends StatelessWidget {
|
|||||||
ready: self?.ready ?? false,
|
ready: self?.ready ?? false,
|
||||||
host: online && coord.hostFile != null,
|
host: online && coord.hostFile != null,
|
||||||
),
|
),
|
||||||
size: 32,
|
size: stripAvatarSize,
|
||||||
ring: online,
|
ring: online,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -126,18 +129,6 @@ class PartyStrip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (kDebugMode)
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Layout demo (debug builds only)',
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.bug_report_outlined,
|
|
||||||
size: 20,
|
|
||||||
color: Colors.amber,
|
|
||||||
),
|
|
||||||
onPressed: () => Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(builder: (_) => const ShellDemo()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
UpdateIndicator(controller: updates),
|
UpdateIndicator(controller: updates),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (online) ...[
|
if (online) ...[
|
||||||
@@ -153,18 +144,22 @@ class PartyStrip extends StatelessWidget {
|
|||||||
key: const ValueKey('party-avatars'),
|
key: const ValueKey('party-avatars'),
|
||||||
members: partyAvatars(coord),
|
members: partyAvatars(coord),
|
||||||
shown: compact ? 3 : 5,
|
shown: compact ? 3 : 5,
|
||||||
|
size: stripAvatarSize,
|
||||||
onTap: () => showMembersCard(context),
|
onTap: () => showMembersCard(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
if (!compact) _ServerLabel(scheme: scheme),
|
if (!compact) _ServerLabel(scheme: scheme),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Badge(
|
// Tab toggles the chat while the strip is shown.
|
||||||
|
ChatKey(
|
||||||
|
onToggle: onChat,
|
||||||
|
child: Badge(
|
||||||
isLabelVisible: unread > 0,
|
isLabelVisible: unread > 0,
|
||||||
label: Text('$unread'),
|
label: Text('$unread'),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
key: const ValueKey('strip-chat'),
|
key: const ValueKey('strip-chat'),
|
||||||
tooltip: 'Chat & activity',
|
tooltip: 'Chat & activity (Tab)',
|
||||||
isSelected: chatOpen,
|
isSelected: chatOpen,
|
||||||
onPressed: onChat,
|
onPressed: onChat,
|
||||||
icon: const Icon(Icons.chat_bubble_outline, size: 20),
|
icon: const Icon(Icons.chat_bubble_outline, size: 20),
|
||||||
@@ -175,6 +170,7 @@ class PartyStrip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
key: const ValueKey('strip-leave'),
|
key: const ValueKey('strip-leave'),
|
||||||
|
|||||||
@@ -87,16 +87,18 @@ StatusView? playerStatusFor({
|
|||||||
}
|
}
|
||||||
// As the server holds playback: someone in the ready set short of the
|
// As the server holds playback: someone in the ready set short of the
|
||||||
// minimum buffer.
|
// minimum buffer.
|
||||||
|
final need = r.bufferNeed;
|
||||||
for (final m in r.members) {
|
for (final m in r.members) {
|
||||||
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
|
if (m.inSet && m.buffered < need) {
|
||||||
final you = self != null && m.id == self.id;
|
final you = self != null && m.id == self.id;
|
||||||
|
final p = (m.buffered / need).clamp(0.0, 1.0);
|
||||||
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
||||||
who: [m], self: you, progress: (m.buffered / RelayConstants.minBufferSecs).clamp(0.0, 1.0));
|
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (self != null && !self.ready) {
|
if (self != null && !self.ready) {
|
||||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||||
who: [self], self: true, action: StatusAction.ready, short: 'Press Space when ready');
|
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||||
}
|
}
|
||||||
final active = [for (final m in r.members) if (!m.stale) m];
|
final active = [for (final m in r.members) if (!m.stale) m];
|
||||||
final others = [
|
final others = [
|
||||||
@@ -193,7 +195,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
key: const ValueKey('player-status'),
|
key: const ValueKey('player-status'),
|
||||||
onTap: ready ? coord.toggleReady : null,
|
onTap: ready ? coord.toggleReady : null,
|
||||||
child: _Glass(
|
child: PlayerGlass(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
@@ -217,9 +219,10 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Glass extends StatelessWidget {
|
/// Frosted dark glass in a pill: the status pill, the unread chip.
|
||||||
|
class PlayerGlass extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
const _Glass({required this.child});
|
const PlayerGlass({super.key, required this.child});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -250,7 +253,7 @@ class _StatusLine extends StatelessWidget {
|
|||||||
final ValueChanged<StatusAction> onAction;
|
final ValueChanged<StatusAction> onAction;
|
||||||
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
||||||
|
|
||||||
static const _avatar = 24.0;
|
static const _avatar = 34.0;
|
||||||
|
|
||||||
Widget? _lead() {
|
Widget? _lead() {
|
||||||
final v = view;
|
final v = view;
|
||||||
@@ -259,7 +262,7 @@ class _StatusLine extends StatelessWidget {
|
|||||||
case StatusKind.problem:
|
case StatusKind.problem:
|
||||||
final error = v.tone == HealthSeverity.error;
|
final error = v.tone == HealthSeverity.error;
|
||||||
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
||||||
size: 18, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
||||||
case StatusKind.reconnecting:
|
case StatusKind.reconnecting:
|
||||||
return const _Spinner(color: Colors.amber);
|
return const _Spinner(color: Colors.amber);
|
||||||
case StatusKind.connecting:
|
case StatusKind.connecting:
|
||||||
@@ -275,9 +278,26 @@ class _StatusLine extends StatelessWidget {
|
|||||||
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
|
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
|
||||||
];
|
];
|
||||||
if (infos.isEmpty) return null;
|
if (infos.isEmpty) return null;
|
||||||
return infos.length == 1
|
final avatar = infos.length == 1
|
||||||
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
|
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
|
||||||
: AvatarStack(members: infos, shown: 4, size: _avatar);
|
: AvatarStack(members: infos, shown: 4, size: _avatar);
|
||||||
|
if (v.progress == null) return avatar;
|
||||||
|
// Loading: the progress runs round the avatar.
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
avatar,
|
||||||
|
SizedBox.square(
|
||||||
|
dimension: _avatar + 6,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: v.progress,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: Colors.white,
|
||||||
|
backgroundColor: Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,43 +305,45 @@ class _StatusLine extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final v = view;
|
final v = view;
|
||||||
final lead = _lead();
|
final lead = _lead();
|
||||||
|
final avatars = v.who.isNotEmpty;
|
||||||
final action = v.action == StatusAction.ready ? null : v.action;
|
final action = v.action == StatusAction.ready ? null : v.action;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.fromLTRB(lead == null ? 16 : 6, 6, action == null ? 16 : 6, 6),
|
padding: EdgeInsets.fromLTRB(lead == null ? 18 : (avatars ? 5 : 14), 5, action == null ? 18 : 6, 5),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (lead != null) ...[
|
if (lead != null) ...[
|
||||||
SizedBox(height: _avatar, child: Center(child: lead)),
|
SizedBox(height: _avatar + 6, child: Center(child: lead)),
|
||||||
const SizedBox(width: 8),
|
SizedBox(width: avatars ? 10 : 8),
|
||||||
],
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minHeight: _avatar),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
v.text,
|
v.text,
|
||||||
key: const ValueKey('player-status-text'),
|
key: const ValueKey('player-status-text'),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2),
|
||||||
),
|
|
||||||
),
|
|
||||||
if (v.progress != null) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
SizedBox.square(
|
|
||||||
dimension: 14,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: v.progress,
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
backgroundColor: Colors.white24,
|
|
||||||
),
|
),
|
||||||
|
if (v.detail != null)
|
||||||
|
Text(
|
||||||
|
v.detail!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white60, fontSize: 12, fontWeight: FontWeight.w500, height: 1.2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (v.detail != null) ...[
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Text(v.detail!, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.w500)),
|
),
|
||||||
],
|
|
||||||
if (action != null) ...[
|
if (action != null) ...[
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 12),
|
||||||
_PillButton(
|
_PillButton(
|
||||||
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
|
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
|
||||||
onPressed: () => onAction(action),
|
onPressed: () => onAction(action),
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -10,7 +13,8 @@ import '../controls/player_settings.dart';
|
|||||||
import '../controls/uosc/buttons.dart';
|
import '../controls/uosc/buttons.dart';
|
||||||
import '../controls/uosc/controls.dart';
|
import '../controls/uosc/controls.dart';
|
||||||
import '../controls/uosc/timeline.dart';
|
import '../controls/uosc/timeline.dart';
|
||||||
import '../playback/media_marks.dart' show SkipOffer, formatClock;
|
import '../controls/volume.dart';
|
||||||
|
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||||
import '../screens/session/jellyfin_widgets.dart';
|
import '../screens/session/jellyfin_widgets.dart';
|
||||||
import '../screens/session/player_controls.dart';
|
import '../screens/session/player_controls.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
@@ -18,6 +22,7 @@ import '../shaders/shader_menu.dart';
|
|||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/shown_frame_probe.dart';
|
import '../widgets/shown_frame_probe.dart';
|
||||||
import '../widgets/text_focus.dart';
|
import '../widgets/text_focus.dart';
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
@@ -96,9 +101,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
int _toastSeq = 0;
|
int _toastSeq = 0;
|
||||||
int _shaderNoticeSeen = 0;
|
int _shaderNoticeSeen = 0;
|
||||||
bool _miniHover = false;
|
bool _miniHover = false;
|
||||||
// Whether the standard controls are up (tracked like media_kit does:
|
// Whether the controls are up: the standard ones tracked like media_kit
|
||||||
// shown on mount and on mouse movement, hidden 3 s later or on exit).
|
// does (shown on mount and on mouse movement, hidden 3 s later or on
|
||||||
final _standardShown = ValueNotifier(true);
|
// exit), the uosc ones reporting it.
|
||||||
|
final _controlsShown = ValueNotifier(true);
|
||||||
|
|
||||||
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
||||||
bool get _full => widget.mode == PlayerMode.full;
|
bool get _full => widget.mode == PlayerMode.full;
|
||||||
@@ -109,6 +115,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_coord.jellyfin.ensureLoaded();
|
_coord.jellyfin.ensureLoaded();
|
||||||
_coord.playback.shownFrame = _frameProbe.sample;
|
_coord.playback.shownFrame = _frameProbe.sample;
|
||||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||||
|
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||||
|
AppKeys.addChatToggle(_toggleChat);
|
||||||
// Shader messages (preset changes, load failures) become toasts.
|
// Shader messages (preset changes, load failures) become toasts.
|
||||||
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
|
_shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0;
|
||||||
_stopShaderWatch = effect(() {
|
_stopShaderWatch = effect(() {
|
||||||
@@ -122,11 +130,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||||
|
AppKeys.removeChatToggle(_toggleChat);
|
||||||
final playback = _coord.playback;
|
final playback = _coord.playback;
|
||||||
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
||||||
_stopShaderWatch();
|
_stopShaderWatch();
|
||||||
_noFocus.dispose();
|
_noFocus.dispose();
|
||||||
_standardShown.dispose();
|
_controlsShown.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +161,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The player keys, while the player fills the body and nothing (a
|
/// The player keys, while the player fills the body and nothing (a
|
||||||
/// dialog, a card, a text field) is in front of it.
|
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||||
|
/// app-wide ([AppKeys]).
|
||||||
bool _globalKey(KeyEvent event) {
|
bool _globalKey(KeyEvent event) {
|
||||||
if (event is! KeyDownEvent || !mounted || !_full) return false;
|
if (event is! KeyDownEvent || !mounted || !_full) return false;
|
||||||
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
||||||
@@ -184,11 +194,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_reloadVideo();
|
_reloadVideo();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.space) {
|
|
||||||
_lastKeyAt = now;
|
|
||||||
coord.toggleReady();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||||
@@ -213,9 +218,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
coord.skipForward();
|
coord.skipForward();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.tab) {
|
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
||||||
_lastKeyAt = now;
|
final player = coord.playback.player;
|
||||||
_toggleChat();
|
player.setVolume(stepVolume(player.state.volume, key == LogicalKeyboardKey.arrowUp ? 5 : -5));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key == LogicalKeyboardKey.arrowRight) {
|
if (key == LogicalKeyboardKey.arrowRight) {
|
||||||
@@ -282,7 +287,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
_status(),
|
_status(),
|
||||||
_progressLine(),
|
_progressLine(),
|
||||||
_jellyfinOverlay(),
|
_jellyfinOverlay(),
|
||||||
if (widget.fullscreen) _pausedParty(),
|
if (widget.fullscreen) ...[_pausedParty(), _unreadChip()],
|
||||||
] else
|
] else
|
||||||
_miniOverlay(),
|
_miniOverlay(),
|
||||||
],
|
],
|
||||||
@@ -340,7 +345,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
final s = AppSettings.instance;
|
final s = AppSettings.instance;
|
||||||
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
|
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
valueListenable: _standardShown,
|
valueListenable: _controlsShown,
|
||||||
builder: (context, shown, _) => shown
|
builder: (context, shown, _) => shown
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
|
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
|
||||||
@@ -437,7 +442,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
onPressed: coord.playNextEpisode,
|
onPressed: coord.playNextEpisode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const MaterialDesktopVolumeButton(),
|
VolumeButton(player: playback.player, focusNode: _noFocus),
|
||||||
_ReadyControlButton(focusNode: _noFocus),
|
_ReadyControlButton(focusNode: _noFocus),
|
||||||
_BarTextButton(
|
_BarTextButton(
|
||||||
focusNode: _noFocus,
|
focusNode: _noFocus,
|
||||||
@@ -504,7 +509,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
Widget _controlsSwitch(VideoState state) {
|
Widget _controlsSwitch(VideoState state) {
|
||||||
return SessionControlsSwitch(
|
return SessionControlsSwitch(
|
||||||
material: (_) => ControlsHoverProbe(
|
material: (_) => ControlsHoverProbe(
|
||||||
shown: _standardShown,
|
shown: _controlsShown,
|
||||||
hideAfter: _controlsHide,
|
hideAfter: _controlsHide,
|
||||||
child: DoubleClickListener(
|
child: DoubleClickListener(
|
||||||
onDoubleClick: widget.onToggleFullscreen,
|
onDoubleClick: widget.onToggleFullscreen,
|
||||||
@@ -536,12 +541,19 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
return UoscControls(
|
return UoscControls(
|
||||||
|
shown: _controlsShown,
|
||||||
onDoubleTap: widget.onToggleFullscreen,
|
onDoubleTap: widget.onToggleFullscreen,
|
||||||
topLeft: const NowPlayingTitle(),
|
topLeft: const NowPlayingTitle(),
|
||||||
top: Row(
|
top: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
UoscButton(icon: Icons.chat_bubble_outline_rounded, tooltip: 'Chat & activity (Tab)', onPressed: _toggleChat),
|
UoscButton(
|
||||||
|
key: const ValueKey('uosc-chat'),
|
||||||
|
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
||||||
|
tooltip: 'Chat & activity (Tab)',
|
||||||
|
badge: widget.unread,
|
||||||
|
onPressed: _toggleChat,
|
||||||
|
),
|
||||||
UoscButton(
|
UoscButton(
|
||||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||||
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||||
@@ -653,7 +665,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
|
|
||||||
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
||||||
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
||||||
/// 1:30) above it. Outside the auto-hiding controls, so they stay up.
|
/// 1:30) above it ([SkipPillGate]). Outside the auto-hiding controls, so
|
||||||
|
/// they stay up.
|
||||||
Widget _jellyfinOverlay() {
|
Widget _jellyfinOverlay() {
|
||||||
final coord = _coord;
|
final coord = _coord;
|
||||||
return Positioned.fill(
|
return Positioned.fill(
|
||||||
@@ -695,7 +708,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
spacing: 10,
|
spacing: 10,
|
||||||
children: [
|
children: [
|
||||||
if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
if (skip != null)
|
||||||
|
SkipPillGate(
|
||||||
|
key: const ValueKey('skip-gate'),
|
||||||
|
offer: skip,
|
||||||
|
controlsShown: _controlsShown,
|
||||||
|
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||||
|
),
|
||||||
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -723,6 +742,40 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Fullscreen hides the strip and its chat badge: while there is unread
|
||||||
|
/// chat and the controls are down, a small glass chip says so top right.
|
||||||
|
Widget _unreadChip() => Positioned(
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _controlsShown,
|
||||||
|
builder: (context, shown, _) {
|
||||||
|
final on = widget.unread > 0 && !widget.chatOpen && !shown;
|
||||||
|
return AnimatedOpacity(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
opacity: on ? 1 : 0,
|
||||||
|
child: PlayerGlass(
|
||||||
|
key: const ValueKey('unread-chip'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.chat_bubble_rounded, size: 14, color: Colors.white70),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('${widget.unread}', style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Mini: title, expand and close on hover; ready, time and a seekable
|
/// Mini: title, expand and close on hover; ready, time and a seekable
|
||||||
/// bar at the bottom. A thin progress line stays while not hovered.
|
/// bar at the bottom. A thin progress line stays while not hovered.
|
||||||
Widget _miniOverlay() {
|
Widget _miniOverlay() {
|
||||||
@@ -809,6 +862,75 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The skip pill's visibility: up for [firstShown] after an offer
|
||||||
|
/// appears (a new kind: OP, ED, 1:30), then only while the controls are
|
||||||
|
/// up or the mouse is on it; gone with the offer (the parent drops it).
|
||||||
|
class SkipPillGate extends StatefulWidget {
|
||||||
|
static const firstShown = Duration(seconds: 5);
|
||||||
|
final SkipOffer offer;
|
||||||
|
final ValueListenable<bool> controlsShown;
|
||||||
|
final Widget child;
|
||||||
|
const SkipPillGate({super.key, required this.offer, required this.controlsShown, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SkipPillGate> createState() => _SkipPillGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SkipPillGateState extends State<SkipPillGate> {
|
||||||
|
SkipKind? _kind;
|
||||||
|
bool _fresh = false;
|
||||||
|
bool _hover = false;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_arm();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SkipPillGate old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.offer.kind != _kind) _arm();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _arm() {
|
||||||
|
_kind = widget.offer.kind;
|
||||||
|
_fresh = true;
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = Timer(SkipPillGate.firstShown, () {
|
||||||
|
if (mounted) setState(() => _fresh = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// The pill covers the controls' hover area: on it, they may hide while
|
||||||
|
// it stays.
|
||||||
|
return ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: widget.controlsShown,
|
||||||
|
builder: (context, shown, child) {
|
||||||
|
final on = _fresh || shown || _hover;
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: !on,
|
||||||
|
child: MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hover = true),
|
||||||
|
onExit: (_) => setState(() => _hover = false),
|
||||||
|
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: on ? 1 : 0, child: child),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const _shaderKeys = [
|
const _shaderKeys = [
|
||||||
LogicalKeyboardKey.digit0,
|
LogicalKeyboardKey.digit0,
|
||||||
LogicalKeyboardKey.digit1,
|
LogicalKeyboardKey.digit1,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
@@ -7,7 +9,11 @@ import '../jellyfin/accounts_panel.dart';
|
|||||||
import '../schedule/animeflow.dart';
|
import '../schedule/animeflow.dart';
|
||||||
import '../screens/home/about_page.dart';
|
import '../screens/home/about_page.dart';
|
||||||
import '../screens/home/settings_page.dart';
|
import '../screens/home/settings_page.dart';
|
||||||
|
import '../sfx/sfx.dart';
|
||||||
|
import '../sfx/sound_bank.dart';
|
||||||
|
import '../sfx/synth_sfx.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../state/settings.dart';
|
||||||
import '../update/update_controller.dart';
|
import '../update/update_controller.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -73,7 +79,23 @@ class SettingsTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
case SettingsSection.network:
|
case SettingsSection.network:
|
||||||
return const [HostDefaultsPanel(), RecentServersPanel()];
|
return [
|
||||||
|
const HostDefaultsPanel(),
|
||||||
|
const RecentServersPanel(),
|
||||||
|
SettingsPanel(
|
||||||
|
title: 'Watching',
|
||||||
|
child: SignalBuilder(
|
||||||
|
builder: (context) => SwitchListTile(
|
||||||
|
key: const ValueKey('unready-on-swap'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Unready when the video changes'),
|
||||||
|
subtitle: const Text('Otherwise you stay ready and the next video starts once everyone ready has buffered'),
|
||||||
|
value: AppSettings.instance.unreadyOnSwap.value,
|
||||||
|
onChanged: AppSettings.instance.setUnreadyOnSwap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
case SettingsSection.player:
|
case SettingsSection.player:
|
||||||
return [
|
return [
|
||||||
const SettingsHintCard(
|
const SettingsHintCard(
|
||||||
@@ -431,11 +453,78 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsPanel(
|
const SettingsPanel(title: 'Sounds', child: SoundSettings()),
|
||||||
title: 'Sounds',
|
|
||||||
child: line('No sounds yet', 'The app is silent; only the video makes noise'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Volume, then every sound with a preview button and its own switch.
|
||||||
|
class SoundSettings extends StatelessWidget {
|
||||||
|
const SoundSettings({super.key});
|
||||||
|
|
||||||
|
void _preview(SfxKind kind) {
|
||||||
|
final sfx = SessionState.instance.sfx;
|
||||||
|
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final volume = s.soundVolume.value;
|
||||||
|
final off = s.soundsOff.value;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
|
||||||
|
color: scheme.onSurfaceVariant),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
key: const ValueKey('sound-volume'),
|
||||||
|
value: volume,
|
||||||
|
onChanged: s.setSoundVolume,
|
||||||
|
onChangeEnd: (_) => _preview(SfxKind.ready),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text('Soft cues for people and readiness; the video itself covers play, pause and the end. '
|
||||||
|
'Letting go of the slider plays the ready sound.', style: muted),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
key: ValueKey('sound-play-${kind.name}'),
|
||||||
|
tooltip: 'Play',
|
||||||
|
onPressed: volume == 0 ? null : () => _preview(kind),
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [Text(name), Text(help, style: muted)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
key: ValueKey('sound-on-${kind.name}'),
|
||||||
|
value: !off.contains(kind.name),
|
||||||
|
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,9 +26,11 @@ class StartCard extends StatelessWidget {
|
|||||||
final episode = now.episode;
|
final episode = now.episode;
|
||||||
return LayoutBuilder(builder: (context, box) {
|
return LayoutBuilder(builder: (context, box) {
|
||||||
final h = box.maxHeight;
|
final h = box.maxHeight;
|
||||||
|
// Small (the mini player): no cover, a smaller title near the bottom.
|
||||||
|
final small = h < 320;
|
||||||
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
|
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
|
||||||
final titleSize = (h * 0.05).clamp(22.0, 44.0);
|
final titleSize = small ? (h * 0.08).clamp(13.0, 18.0) : (h * 0.05).clamp(22.0, 44.0);
|
||||||
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0);
|
final side = small ? 12.0 : (box.maxWidth * 0.05).clamp(20.0, 72.0);
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -76,12 +78,14 @@ class StartCard extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: side,
|
left: side,
|
||||||
right: side,
|
right: side,
|
||||||
bottom: startCardBottom,
|
bottom: small ? 12 : startCardBottom,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
|
if (!small) ...[
|
||||||
ArtCover(hash: now.posterHash, width: coverWidth),
|
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
||||||
/// slides in, stays [life], then slides out and collapses its gap.
|
/// slides in, stays [life], then slides out and collapses its gap. A click
|
||||||
|
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
|
||||||
|
/// through.
|
||||||
class PartyToasts extends StatefulWidget {
|
class PartyToasts extends StatefulWidget {
|
||||||
final Duration life;
|
final Duration life;
|
||||||
const PartyToasts({super.key, this.life = const Duration(seconds: 5)});
|
final VoidCallback? onOpenChat;
|
||||||
|
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PartyToasts> createState() => _PartyToastsState();
|
State<PartyToasts> createState() => _PartyToastsState();
|
||||||
@@ -101,13 +104,37 @@ class _PartyToastsState extends State<PartyToasts> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _open(ShellToast t) {
|
||||||
|
widget.onOpenChat?.call();
|
||||||
|
setState(() => t.leaving = true);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => IgnorePointer(child: ToastColumn(toasts: _shown.reversed.take(6).toList().reversed.toList()));
|
Widget build(BuildContext context) => ToastColumn(
|
||||||
|
toasts: _shown.reversed.take(6).toList().reversed.toList(),
|
||||||
|
onTap: widget.onOpenChat == null ? null : _open,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ToastColumn extends StatelessWidget {
|
class ToastColumn extends StatelessWidget {
|
||||||
final List<ShellToast> toasts;
|
final List<ShellToast> toasts;
|
||||||
const ToastColumn({super.key, required this.toasts});
|
/// Clicks on chat toasts; null (or any other toast): clicks pass through.
|
||||||
|
final ValueChanged<ShellToast>? onTap;
|
||||||
|
const ToastColumn({super.key, required this.toasts, this.onTap});
|
||||||
|
|
||||||
|
Widget _card(ShellToast t) {
|
||||||
|
final card = ToastCard(toast: t);
|
||||||
|
final tap = onTap;
|
||||||
|
if (!t.chat || tap == null || t.leaving) return IgnorePointer(child: card);
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
key: ValueKey('toast-open-chat-${t.id}'),
|
||||||
|
onTap: () => tap(t),
|
||||||
|
child: card,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -139,7 +166,7 @@ class ToastColumn extends StatelessWidget {
|
|||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
opacity: t.leaving ? 0 : 1,
|
opacity: t.leaving ? 0 : 1,
|
||||||
child: Padding(padding: const EdgeInsets.only(top: 8), child: ToastCard(toast: t)),
|
child: Padding(padding: const EdgeInsets.only(top: 8), child: _card(t)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
|
import '../sfx/synth_sfx.dart';
|
||||||
import '../session/health.dart';
|
import '../session/health.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../widgets/avatar_pipeline.dart';
|
import '../widgets/avatar_pipeline.dart';
|
||||||
import 'connect_prefs.dart';
|
import 'connect_prefs.dart';
|
||||||
import 'server_isolate.dart';
|
import 'server_isolate.dart';
|
||||||
|
import 'settings.dart';
|
||||||
|
|
||||||
enum AppRoute {
|
enum AppRoute {
|
||||||
home,
|
home,
|
||||||
@@ -153,7 +155,10 @@ class SessionState {
|
|||||||
final route = signal(AppRoute.home);
|
final route = signal(AppRoute.home);
|
||||||
final recents = signal<List<RecentServer>>(const []);
|
final recents = signal<List<RecentServer>>(const []);
|
||||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||||
final SfxService sfx = const NoopSfxService();
|
final SfxService sfx = SynthSfx(
|
||||||
|
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||||
|
enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||||
|
);
|
||||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||||
final lastForm = ConnectForm();
|
final lastForm = ConnectForm();
|
||||||
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
JellyfinAccounts get jellyfin => JellyfinAccounts.instance;
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const _keyAutoAdvance = 'autoAdvance';
|
|||||||
const _keyMpvControls = 'mpvControls';
|
const _keyMpvControls = 'mpvControls';
|
||||||
const _keyHideReReleases = 'hideReReleases';
|
const _keyHideReReleases = 'hideReReleases';
|
||||||
const _keyProgressLine = 'progressLine';
|
const _keyProgressLine = 'progressLine';
|
||||||
|
const _keySoundVolume = 'soundVolume';
|
||||||
|
const _keySoundsOff = 'soundsOff';
|
||||||
|
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||||
|
|
||||||
enum HwdecMode { auto, off, custom }
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
@@ -31,6 +34,9 @@ class AppSettings {
|
|||||||
final autoCheckUpdates = signal(true);
|
final autoCheckUpdates = signal(true);
|
||||||
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
|
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
|
||||||
final autoAdvance = signal(true);
|
final autoAdvance = signal(true);
|
||||||
|
/// Session: send ready=false when the video changes (a swap keeps the
|
||||||
|
/// server's ready flags).
|
||||||
|
final unreadyOnSwap = signal(false);
|
||||||
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
/// Player design: the uosc controls (`controls/uosc/`) instead of the
|
||||||
/// standard ones (media_kit's with our bars). Applies live.
|
/// standard ones (media_kit's with our bars). Applies live.
|
||||||
final mpvControls = signal(false);
|
final mpvControls = signal(false);
|
||||||
@@ -40,6 +46,10 @@ class AppSettings {
|
|||||||
/// A 2 px progress line at the bottom of the video while the controls
|
/// A 2 px progress line at the bottom of the video while the controls
|
||||||
/// are hidden (both designs). Applies live.
|
/// are hidden (both designs). Applies live.
|
||||||
final progressLine = signal(true);
|
final progressLine = signal(true);
|
||||||
|
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||||
|
final soundVolume = signal(1.0);
|
||||||
|
/// `SfxKind` names switched off one by one.
|
||||||
|
final soundsOff = signal<Set<String>>(const {});
|
||||||
|
|
||||||
/// Video shaders: one choice, used on join and changed both here and
|
/// Video shaders: one choice, used on join and changed both here and
|
||||||
/// in the session (Ctrl+0..6, the shader button).
|
/// in the session (Ctrl+0..6, the shader button).
|
||||||
@@ -76,12 +86,18 @@ class AppSettings {
|
|||||||
if (auto is bool) autoCheckUpdates.value = auto;
|
if (auto is bool) autoCheckUpdates.value = auto;
|
||||||
final advance = box.get(_keyAutoAdvance);
|
final advance = box.get(_keyAutoAdvance);
|
||||||
if (advance is bool) autoAdvance.value = advance;
|
if (advance is bool) autoAdvance.value = advance;
|
||||||
|
final unready = box.get(_keyUnreadyOnSwap);
|
||||||
|
if (unready is bool) unreadyOnSwap.value = unready;
|
||||||
final mpv = box.get(_keyMpvControls);
|
final mpv = box.get(_keyMpvControls);
|
||||||
if (mpv is bool) mpvControls.value = mpv;
|
if (mpv is bool) mpvControls.value = mpv;
|
||||||
final hideRe = box.get(_keyHideReReleases);
|
final hideRe = box.get(_keyHideReReleases);
|
||||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||||
final line = box.get(_keyProgressLine);
|
final line = box.get(_keyProgressLine);
|
||||||
if (line is bool) progressLine.value = line;
|
if (line is bool) progressLine.value = line;
|
||||||
|
final vol = box.get(_keySoundVolume);
|
||||||
|
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||||
|
final off = box.get(_keySoundsOff);
|
||||||
|
if (off is List) soundsOff.value = Set.unmodifiable(off.whereType<String>());
|
||||||
final preset = box.get(_keyShaderPreset);
|
final preset = box.get(_keyShaderPreset);
|
||||||
shaderPreset.value =
|
shaderPreset.value =
|
||||||
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
|
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
|
||||||
@@ -119,6 +135,11 @@ class AppSettings {
|
|||||||
_put(_keyAutoAdvance, on);
|
_put(_keyAutoAdvance, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setUnreadyOnSwap(bool on) {
|
||||||
|
unreadyOnSwap.value = on;
|
||||||
|
_put(_keyUnreadyOnSwap, on);
|
||||||
|
}
|
||||||
|
|
||||||
void setMpvControls(bool on) {
|
void setMpvControls(bool on) {
|
||||||
mpvControls.value = on;
|
mpvControls.value = on;
|
||||||
_put(_keyMpvControls, on);
|
_put(_keyMpvControls, on);
|
||||||
@@ -129,6 +150,16 @@ class AppSettings {
|
|||||||
_put(_keyProgressLine, on);
|
_put(_keyProgressLine, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setSoundVolume(double v) {
|
||||||
|
soundVolume.value = v.clamp(0, 1);
|
||||||
|
_put(_keySoundVolume, soundVolume.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSoundOn(String kind, bool on) {
|
||||||
|
soundsOff.value = Set.unmodifiable(on ? ({...soundsOff.value}..remove(kind)) : {...soundsOff.value, kind});
|
||||||
|
_put(_keySoundsOff, soundsOff.value.toList());
|
||||||
|
}
|
||||||
|
|
||||||
void setHideReReleases(bool on) {
|
void setHideReReleases(bool on) {
|
||||||
hideReReleases.value = on;
|
hideReReleases.value = on;
|
||||||
_put(_keyHideReReleases, on);
|
_put(_keyHideReReleases, on);
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ bool localPlayAllowed(StateBroadcast state, String memberId) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// At `finished`, a player at most this far behind the end keeps playing
|
||||||
|
/// to it.
|
||||||
|
const finishSlackSecs = 3.0;
|
||||||
|
|
||||||
class StateDiff {
|
class StateDiff {
|
||||||
static const debounceWindow = Duration(seconds: 2);
|
static const debounceWindow = Duration(seconds: 2);
|
||||||
|
|
||||||
@@ -73,7 +77,9 @@ class StateDiff {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// End of media: everyone stops where they are (at or just before the
|
// End of media: everyone stops where they are (at or just before the
|
||||||
// end). No seek to the very end and no drift correction.
|
// end). No seek to the very end and no drift correction. A player a
|
||||||
|
// little behind the server's clock plays its last moments out (mpv
|
||||||
|
// stops at the end by itself) instead of being cut short.
|
||||||
if (state.transport == 'finished') {
|
if (state.transport == 'finished') {
|
||||||
final first = _lastEventId == -1;
|
final first = _lastEventId == -1;
|
||||||
final changed = first ||
|
final changed = first ||
|
||||||
@@ -87,7 +93,9 @@ class StateDiff {
|
|||||||
if (!changed) return out;
|
if (!changed) return out;
|
||||||
out.add(const RateAction(1.0));
|
out.add(const RateAction(1.0));
|
||||||
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
|
if (first) out.add(SeekAction(serverPos > 1 ? serverPos - 1 : 0));
|
||||||
|
if (first || state.position - ownPosition > finishSlackSecs) {
|
||||||
out.add(const TransportAction(false));
|
out.add(const TransportAction(false));
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
import 'text_focus.dart';
|
||||||
|
|
||||||
|
/// Tab and Shift+Tab never move focus, and Space never presses the
|
||||||
|
/// focused button (the app's [Shortcuts] override): Space is the ready key,
|
||||||
|
/// and a button keeps focus after a click. Text fields still type spaces
|
||||||
|
/// (their own shortcuts come first).
|
||||||
|
Map<ShortcutActivator, Intent> get appShortcuts => {
|
||||||
|
...WidgetsApp.defaultShortcuts,
|
||||||
|
const SingleActivator(LogicalKeyboardKey.tab): const DoNothingIntent(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingIntent(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.space): const DoNothingIntent(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The window's two app-wide keys, ahead of whatever has focus: while in a
|
||||||
|
/// server, Space is ready / not ready and Tab opens or closes the chat,
|
||||||
|
/// on every tab and in every player mode. Not while a text field has
|
||||||
|
/// focus (the chat field handles Tab and Esc itself) nor while a dialog or
|
||||||
|
/// menu is in front of [child].
|
||||||
|
class AppKeys extends StatefulWidget {
|
||||||
|
/// Who toggles the chat, latest first: the strip while it is shown, the
|
||||||
|
/// full player in fullscreen (both hold the shell's toggle).
|
||||||
|
static final _chatToggles = <VoidCallback>[];
|
||||||
|
|
||||||
|
static void addChatToggle(VoidCallback f) => _chatToggles.add(f);
|
||||||
|
static void removeChatToggle(VoidCallback f) => _chatToggles.remove(f);
|
||||||
|
|
||||||
|
/// Space; tests count presses instead of sending them.
|
||||||
|
final VoidCallback? onToggleReady;
|
||||||
|
final Widget child;
|
||||||
|
const AppKeys({super.key, this.onToggleReady, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AppKeys> createState() => _AppKeysState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppKeysState extends State<AppKeys> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
HardwareKeyboard.instance.addHandler(_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
HardwareKeyboard.instance.removeHandler(_key);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _key(KeyEvent event) {
|
||||||
|
if (event is! KeyDownEvent || !mounted) return false;
|
||||||
|
final key = event.logicalKey;
|
||||||
|
if (key != LogicalKeyboardKey.space && key != LogicalKeyboardKey.tab) return false;
|
||||||
|
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
||||||
|
if (ModalRoute.of(context)?.isCurrent == false) return false;
|
||||||
|
if (textInputFocused()) return false;
|
||||||
|
final keys = HardwareKeyboard.instance;
|
||||||
|
if (keys.isControlPressed || keys.isAltPressed || keys.isMetaPressed) return false;
|
||||||
|
if (key == LogicalKeyboardKey.space) {
|
||||||
|
(widget.onToggleReady ?? SessionState.instance.coordinator.toggleReady)();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final toggles = AppKeys._chatToggles;
|
||||||
|
if (toggles.isEmpty) return false;
|
||||||
|
toggles.last();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers [onToggle] with [AppKeys] while mounted.
|
||||||
|
class ChatKey extends StatefulWidget {
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final Widget child;
|
||||||
|
const ChatKey({super.key, required this.onToggle, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatKey> createState() => _ChatKeyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatKeyState extends State<ChatKey> {
|
||||||
|
void _toggle() => widget.onToggle();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
AppKeys.addChatToggle(_toggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
AppKeys.removeChatToggle(_toggle);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
@@ -6,12 +6,16 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <fox_utils/fox_utils_plugin.h>
|
||||||
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
||||||
#include <media_kit_video/media_kit_video_plugin.h>
|
#include <media_kit_video/media_kit_video_plugin.h>
|
||||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) fox_utils_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FoxUtilsPlugin");
|
||||||
|
fox_utils_plugin_register_with_registrar(fox_utils_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
|
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
|
||||||
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
|
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
fox_utils
|
||||||
media_kit_libs_linux
|
media_kit_libs_linux
|
||||||
media_kit_video
|
media_kit_video
|
||||||
screen_retriever_linux
|
screen_retriever_linux
|
||||||
|
|||||||
@@ -105,6 +105,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.15"
|
version: "0.7.15"
|
||||||
|
dio:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.11.1"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -208,6 +224,15 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
fox_utils:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "."
|
||||||
|
ref: "v0.1.7"
|
||||||
|
resolved-ref: dc16bba05491f54daf528ed83484ea9b6eab8825
|
||||||
|
url: "https://git.senkochan.moe/Foxyz/fox_utils.git"
|
||||||
|
source: git
|
||||||
|
version: "0.1.7"
|
||||||
hive_ce:
|
hive_ce:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -723,14 +748,14 @@ packages:
|
|||||||
path: "../../packages/core"
|
path: "../../packages/core"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.2.2"
|
version: "1.0.0"
|
||||||
sync_relay_server:
|
sync_relay_server:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "../../packages/server"
|
path: "../../packages/server"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.2.2"
|
version: "1.0.0"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: watch_together
|
name: watch_together
|
||||||
description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
|
description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 1.0.0
|
version: 1.0.1
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.13.0-0 <4.0.0'
|
sdk: '>=3.13.0-0 <4.0.0'
|
||||||
|
|
||||||
@@ -25,6 +25,11 @@ dependencies:
|
|||||||
window_manager: ^0.5.2
|
window_manager: ^0.5.2
|
||||||
archive: ^4.0.7
|
archive: ^4.0.7
|
||||||
crypto: ^3.0.6
|
crypto: ^3.0.6
|
||||||
|
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
|
||||||
|
fox_utils:
|
||||||
|
git:
|
||||||
|
url: https://git.senkochan.moe/Foxyz/fox_utils.git
|
||||||
|
ref: v0.1.7
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:watch_together/src/shell/app_shell.dart';
|
||||||
|
import 'package:watch_together/src/state/session_state.dart';
|
||||||
|
import 'package:watch_together/src/update/update_controller.dart';
|
||||||
|
import 'package:watch_together/src/widgets/app_keys.dart';
|
||||||
|
import 'package:watch_together/src/widgets/text_focus.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final session = SessionState.instance;
|
||||||
|
final coord = session.coordinator;
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
session.route.value = AppRoute.home;
|
||||||
|
coord.serverState.value = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: Tab moved focus (out of the chat field onto a button) and
|
||||||
|
// Space, the ready key, then pressed that button.
|
||||||
|
testWidgets('Tab opens and closes chat without moving focus; Space is ready', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1280, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
var readies = 0;
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
shortcuts: appShortcuts,
|
||||||
|
home: AppKeys(
|
||||||
|
onToggleReady: () => readies++,
|
||||||
|
child: AppShell(
|
||||||
|
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
||||||
|
autoUpdateCheck: false,
|
||||||
|
windowControls: false,
|
||||||
|
videoBuilder: (context, controls) => const SizedBox(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
coord.serverState.value = StateBroadcast(
|
||||||
|
eventId: 1,
|
||||||
|
transport: 'held',
|
||||||
|
position: 0,
|
||||||
|
serverTime: DateTime.now().toUtc(),
|
||||||
|
mediaId: 0,
|
||||||
|
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0, stale: false)],
|
||||||
|
);
|
||||||
|
session.route.value = AppRoute.session;
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final panel = find.byKey(const ValueKey('chat-panel'));
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(panel, findsOneWidget);
|
||||||
|
expect(textInputFocused(), isTrue, reason: 'opening the chat focuses its field');
|
||||||
|
|
||||||
|
// From the field: closes the chat, focus goes back, not to a button.
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(panel, findsNothing);
|
||||||
|
expect(textInputFocused(), isFalse);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(readies, 1);
|
||||||
|
expect(panel, findsNothing, reason: 'Space pressed a focused chat button');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
|
import 'package:watch_together/src/sfx/sfx.dart';
|
||||||
import 'package:watch_together/src/state/session_state.dart';
|
import 'package:watch_together/src/state/session_state.dart';
|
||||||
import 'package:watch_together/src/sync/sync_client.dart';
|
import 'package:watch_together/src/sync/sync_client.dart';
|
||||||
|
|
||||||
@@ -214,7 +215,8 @@ void main() {
|
|||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
|
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
|
||||||
await server.start();
|
await server.start();
|
||||||
final coord = SessionCoordinator();
|
final sounds = _RecordingSfx();
|
||||||
|
final coord = SessionCoordinator(sfx: sounds);
|
||||||
final friend = SyncClient();
|
final friend = SyncClient();
|
||||||
try {
|
try {
|
||||||
await coord.connect(ConnectParams(
|
await coord.connect(ConnectParams(
|
||||||
@@ -227,6 +229,12 @@ void main() {
|
|||||||
final toasts = coord.toasts.peek();
|
final toasts = coord.toasts.peek();
|
||||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
|
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
|
||||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr');
|
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr');
|
||||||
|
// Others' chat makes a sound, your own does not.
|
||||||
|
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||||
|
coord.sendChat('mine');
|
||||||
|
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
|
||||||
|
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||||
|
expect(sounds.kinds, contains(SfxKind.join));
|
||||||
} finally {
|
} finally {
|
||||||
await friend.dispose();
|
await friend.dispose();
|
||||||
await coord.dispose();
|
await coord.dispose();
|
||||||
@@ -235,3 +243,9 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RecordingSfx implements SfxService {
|
||||||
|
final kinds = <SfxKind>[];
|
||||||
|
@override
|
||||||
|
Future<void> play(SfxCue cue) async => kinds.add(cue.kind);
|
||||||
|
}
|
||||||
@@ -74,6 +74,12 @@ Future<void> openFolder(WidgetTester tester, String name) async {
|
|||||||
await frames(tester);
|
await frames(tester);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Series start collapsed: a click on the header lists the episodes.
|
||||||
|
Future<void> openSeries(WidgetTester tester, [String title = 'Show']) async {
|
||||||
|
await tester.tap(find.descendant(of: find.byType(SeriesHeader), matching: find.text(title)));
|
||||||
|
await frames(tester);
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
tmp = Directory.systemTemp.createTempSync('files_page_');
|
tmp = Directory.systemTemp.createTempSync('files_page_');
|
||||||
@@ -147,6 +153,9 @@ void main() {
|
|||||||
expect(find.byType(SeriesHeader), findsOneWidget);
|
expect(find.byType(SeriesHeader), findsOneWidget);
|
||||||
expect(find.text('Show'), findsOneWidget);
|
expect(find.text('Show'), findsOneWidget);
|
||||||
expect(find.text('3 episodes · 1 extra · Grp'), findsOneWidget);
|
expect(find.text('3 episodes · 1 extra · Grp'), findsOneWidget);
|
||||||
|
// Collapsed until clicked; open for the rest of the session.
|
||||||
|
expect(find.text('E01'), findsNothing);
|
||||||
|
await openSeries(tester);
|
||||||
for (final l in ['E01', 'E02', 'E03', 'NCOP1']) {
|
for (final l in ['E01', 'E02', 'E03', 'NCOP1']) {
|
||||||
expect(find.text(l), findsOneWidget);
|
expect(find.text(l), findsOneWidget);
|
||||||
}
|
}
|
||||||
@@ -157,6 +166,13 @@ void main() {
|
|||||||
// Durations come from the thumbnail.
|
// Durations come from the thumbnail.
|
||||||
expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
|
expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
|
||||||
expect(find.text('24:00'), findsOneWidget); // card badge
|
expect(find.text('24:00'), findsOneWidget); // card badge
|
||||||
|
|
||||||
|
await openFolder(tester, 'Recent');
|
||||||
|
await openFolder(tester, 'anime');
|
||||||
|
expect(find.text('E01'), findsOneWidget);
|
||||||
|
await openSeries(tester);
|
||||||
|
expect(find.text('E01'), findsNothing);
|
||||||
|
expect(find.byType(VideoCard), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('hidden files are counted and some can be shown', (tester) async {
|
testWidgets('hidden files are counted and some can be shown', (tester) async {
|
||||||
@@ -180,6 +196,7 @@ void main() {
|
|||||||
store.addFolder(anime);
|
store.addFolder(anime);
|
||||||
await pumpPage(tester);
|
await pumpPage(tester);
|
||||||
await openFolder(tester, 'anime');
|
await openFolder(tester, 'anime');
|
||||||
|
await openSeries(tester);
|
||||||
touch('$anime/[Grp] Show - 04.mkv');
|
touch('$anime/[Grp] Show - 04.mkv');
|
||||||
expect(find.text('E04'), findsNothing);
|
expect(find.text('E04'), findsNothing);
|
||||||
await tester.tap(find.text('Rescan'));
|
await tester.tap(find.text('Rescan'));
|
||||||
@@ -197,6 +214,7 @@ void main() {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
await openFolder(tester, 'anime');
|
await openFolder(tester, 'anime');
|
||||||
|
await openSeries(tester);
|
||||||
expect(find.text('E01'), findsOneWidget);
|
expect(find.text('E01'), findsOneWidget);
|
||||||
await tester.tap(find.text('E01'));
|
await tester.tap(find.text('E01'));
|
||||||
await tester.tap(find.text('Some Movie (2019)'));
|
await tester.tap(find.text('Some Movie (2019)'));
|
||||||
@@ -216,6 +234,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
expect(find.textContaining('Keep the app open while it plays'), findsOneWidget);
|
expect(find.textContaining('Keep the app open while it plays'), findsOneWidget);
|
||||||
await openFolder(tester, 'anime');
|
await openFolder(tester, 'anime');
|
||||||
|
await openSeries(tester);
|
||||||
|
|
||||||
await tester.tap(find.text('E02'));
|
await tester.tap(find.text('E02'));
|
||||||
await frames(tester);
|
await frames(tester);
|
||||||
@@ -254,6 +273,7 @@ void main() {
|
|||||||
));
|
));
|
||||||
await frames(tester);
|
await frames(tester);
|
||||||
await openFolder(tester, 'anime');
|
await openFolder(tester, 'anime');
|
||||||
|
await openSeries(tester);
|
||||||
expect(find.text('now playing'), findsOneWidget);
|
expect(find.text('now playing'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.graphic_eq), findsOneWidget);
|
expect(find.byIcon(Icons.graphic_eq), findsOneWidget);
|
||||||
});
|
});
|
||||||
@@ -267,6 +287,7 @@ void main() {
|
|||||||
await tester.tap(find.widgetWithText(InputChip, 'anime'));
|
await tester.tap(find.widgetWithText(InputChip, 'anime'));
|
||||||
await frames(tester);
|
await frames(tester);
|
||||||
expect(find.text('$anime/'), findsOneWidget);
|
expect(find.text('$anime/'), findsOneWidget);
|
||||||
|
await openSeries(tester);
|
||||||
expect(find.text('E01'), findsOneWidget);
|
expect(find.text('E01'), findsOneWidget);
|
||||||
// The chip's x removes the folder, like the sidebar's.
|
// The chip's x removes the folder, like the sidebar's.
|
||||||
await tester.tap(find.byTooltip('Remove folder'));
|
await tester.tap(find.byTooltip('Remove folder'));
|
||||||
@@ -287,6 +308,7 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
await tester.tap(find.text('anime').first);
|
await tester.tap(find.text('anime').first);
|
||||||
await frames(tester);
|
await frames(tester);
|
||||||
|
await openSeries(tester);
|
||||||
expect(find.text('E01'), findsOneWidget);
|
expect(find.text('E01'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
await tester.tap(find.text('Show some'));
|
await tester.tap(find.text('Show some'));
|
||||||
@@ -312,6 +334,7 @@ void main() {
|
|||||||
return Completer<Thumbnail?>().future; // never finishes
|
return Completer<Thumbnail?>().future; // never finishes
|
||||||
});
|
});
|
||||||
await openFolder(tester, 'big');
|
await openFolder(tester, 'big');
|
||||||
|
await openSeries(tester, 'Long Show');
|
||||||
expect(find.text('E01'), findsOneWidget);
|
expect(find.text('E01'), findsOneWidget);
|
||||||
final first = thumbRequests.toSet();
|
final first = thumbRequests.toSet();
|
||||||
expect(first, isNotEmpty);
|
expect(first, isNotEmpty);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/shell/handoff.dart';
|
||||||
|
|
||||||
|
// What a drop or a paste is: decided from paths and text alone (the file
|
||||||
|
// system stands behind a fake).
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const fs = {
|
||||||
|
'/v/a.mkv': FileSystemEntityType.file,
|
||||||
|
'/v/b.MP4': FileSystemEntityType.file,
|
||||||
|
'/v/notes.txt': FileSystemEntityType.file,
|
||||||
|
'/v/cover.jpg': FileSystemEntityType.file,
|
||||||
|
'/v/Season 1': FileSystemEntityType.directory,
|
||||||
|
'/v/folder.mkv': FileSystemEntityType.directory,
|
||||||
|
r'C:\Videos\c.mkv': FileSystemEntityType.file,
|
||||||
|
};
|
||||||
|
Handoff of({List<String> paths = const [], String? text}) =>
|
||||||
|
classifyHandoff(paths: paths, text: text, typeOf: (p) => fs[p] ?? FileSystemEntityType.notFound);
|
||||||
|
|
||||||
|
test('videos by extension, in order; others dropped with them are left out', () {
|
||||||
|
final h = of(paths: ['/v/notes.txt', '/v/b.MP4', '/v/Season 1', '/v/a.mkv']);
|
||||||
|
expect((h as HandoffVideos).paths, ['/v/b.MP4', '/v/a.mkv']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a folder (even one named like a video), else an image, else nothing', () {
|
||||||
|
expect((of(paths: ['/v/notes.txt', '/v/folder.mkv']) as HandoffFolder).path, '/v/folder.mkv');
|
||||||
|
expect((of(paths: ['/v/cover.jpg']) as HandoffImage).path, '/v/cover.jpg');
|
||||||
|
expect(of(paths: ['/v/notes.txt']), isA<HandoffNothing>());
|
||||||
|
expect(of(paths: ['/gone/x.mkv']), isA<HandoffNothing>());
|
||||||
|
expect(of(), isA<HandoffNothing>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pasted text: a link, absolute paths or file URIs; files win over text', () {
|
||||||
|
expect(of(text: ' https://cdn.example/a b'), isA<HandoffNothing>());
|
||||||
|
expect((of(text: ' https://youtu.be/x?t=1 \n') as HandoffLink).url, 'https://youtu.be/x?t=1');
|
||||||
|
expect((of(text: 'HTTP://x.example/v.mp4') as HandoffLink).url, 'HTTP://x.example/v.mp4');
|
||||||
|
expect((of(text: '/v/a.mkv\n/v/b.MP4') as HandoffVideos).paths, ['/v/a.mkv', '/v/b.MP4']);
|
||||||
|
expect((of(text: 'file:///v/Season%201') as HandoffFolder).path, '/v/Season 1');
|
||||||
|
expect((of(text: r'C:\Videos\c.mkv') as HandoffVideos).paths, [r'C:\Videos\c.mkv']);
|
||||||
|
expect(of(text: 'v/a.mkv'), isA<HandoffNothing>(), reason: 'relative');
|
||||||
|
expect(of(text: 'hello'), isA<HandoffNothing>());
|
||||||
|
expect((of(paths: ['/v/Season 1'], text: 'https://x.example') as HandoffFolder).path, '/v/Season 1');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -501,7 +501,8 @@ void main() {
|
|||||||
expect(a.jellyfinPlan.peek(), isA<OwnPlay>());
|
expect(a.jellyfinPlan.peek(), isA<OwnPlay>());
|
||||||
expect(a.relaying.peek(), isTrue);
|
expect(a.relaying.peek(), isTrue);
|
||||||
expect(b.jellyfinBlocked.peek(), isNull);
|
expect(b.jellyfinBlocked.peek(), isNull);
|
||||||
for (final x in [a, b]) {
|
// Everyone present counts for the gate, Dave too.
|
||||||
|
for (final x in [a, b, d]) {
|
||||||
x.toggleReady();
|
x.toggleReady();
|
||||||
}
|
}
|
||||||
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'playing');
|
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'playing');
|
||||||
@@ -511,7 +512,7 @@ void main() {
|
|||||||
expect(jelly.streams('ATOKEN'), isNotEmpty);
|
expect(jelly.streams('ATOKEN'), isNotEmpty);
|
||||||
expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) =>
|
expect(jelly.calls.where((c) => c.path.startsWith('/Videos/')).every((c) =>
|
||||||
_FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue);
|
_FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue);
|
||||||
for (final x in [a, b]) {
|
for (final x in [a, b, d]) {
|
||||||
x.toggleReady();
|
x.toggleReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||||
@@ -278,7 +277,7 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('link: preview, how the others get it, put up, recent links, Ctrl+V', (tester) async {
|
testWidgets('link: preview, how the others get it, put up, recent links', (tester) async {
|
||||||
final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link);
|
final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link);
|
||||||
expect(find.text('How the others get it'), findsOneWidget);
|
expect(find.text('How the others get it'), findsOneWidget);
|
||||||
const url = 'https://www.youtube.com/watch?v=abc';
|
const url = 'https://www.youtube.com/watch?v=abc';
|
||||||
@@ -304,23 +303,6 @@ void main() {
|
|||||||
await settle(tester, 10);
|
await settle(tester, 10);
|
||||||
expect(h.controller.link.text, url);
|
expect(h.controller.link.text, url);
|
||||||
|
|
||||||
// Ctrl+V outside a field, on another source: the link lands in Link.
|
|
||||||
await tester.tap(find.text('Jellyfin'));
|
|
||||||
await settle(tester, 10);
|
|
||||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
|
||||||
if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'};
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
|
||||||
Focus.of(tester.element(find.byKey(const ValueKey('media-source')))).requestFocus();
|
|
||||||
await tester.pump();
|
|
||||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
|
||||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
|
||||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
|
||||||
await settle(tester, 10);
|
|
||||||
expect(h.controller.source, MediaKind.link);
|
|
||||||
expect(h.controller.link.text, 'https://cdn.example/b.mkv');
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -111,8 +111,14 @@ void main() {
|
|||||||
group('skip offers', () {
|
group('skip offers', () {
|
||||||
final op = SkipRange(s(30), m(2), SkipSource.chapter);
|
final op = SkipRange(s(30), m(2), SkipSource.chapter);
|
||||||
final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter);
|
final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter);
|
||||||
SkipOffer? at(Duration p, {SkipRange? opening, SkipRange? ending, Duration? duration}) => skipOfferAt(
|
SkipOffer? at(Duration p,
|
||||||
position: p, duration: duration ?? m(24), opening: opening, ending: ending);
|
{SkipRange? opening, SkipRange? ending, Duration? duration, bool chaptered = false}) =>
|
||||||
|
skipOfferAt(
|
||||||
|
position: p,
|
||||||
|
duration: duration ?? m(24),
|
||||||
|
opening: opening,
|
||||||
|
ending: ending,
|
||||||
|
chaptered: chaptered);
|
||||||
|
|
||||||
test('Skip OP inside the opening, to its end', () {
|
test('Skip OP inside the opening, to its end', () {
|
||||||
expect(at(s(29), opening: op, ending: ed), isNull);
|
expect(at(s(29), opening: op, ending: ed), isNull);
|
||||||
@@ -141,6 +147,9 @@ void main() {
|
|||||||
expect(at(m(1), ending: ed), SkipOffer(SkipKind.fixed, m(2, 30)));
|
expect(at(m(1), ending: ed), SkipOffer(SkipKind.fixed, m(2, 30)));
|
||||||
// An opening known (but not playing) means no fallback.
|
// An opening known (but not playing) means no fallback.
|
||||||
expect(at(m(3), opening: op), isNull);
|
expect(at(m(3), opening: op), isNull);
|
||||||
|
// Chapters without an OP chapter: no opening on purpose.
|
||||||
|
expect(at(m(1), chaptered: true), isNull);
|
||||||
|
expect(at(m(20, 10), ending: ed, chaptered: true), SkipOffer(SkipKind.ending, m(21, 30)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -91,4 +91,14 @@ void main() {
|
|||||||
expect(n.contains('SECRET'), isFalse);
|
expect(n.contains('SECRET'), isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('unreadyForNewMedia: only a real swap while ready, setting on', () {
|
||||||
|
bool u({bool enabled = true, int last = 2, int id = 3, bool ready = true}) =>
|
||||||
|
unreadyForNewMedia(enabled: enabled, lastMediaId: last, mediaId: id, ready: ready);
|
||||||
|
expect(u(), isTrue);
|
||||||
|
expect(u(enabled: false), isFalse);
|
||||||
|
expect(u(ready: false), isFalse);
|
||||||
|
expect(u(id: 2), isFalse, reason: 'same media');
|
||||||
|
expect(u(last: -1), isFalse, reason: 'first state of a session is no swap');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/sfx/sfx.dart';
|
||||||
|
import 'package:watch_together/src/sfx/sound_bank.dart';
|
||||||
|
import 'package:watch_together/src/sfx/synth.dart';
|
||||||
|
|
||||||
|
double _pitch(List<double> x, int from) {
|
||||||
|
final c = <double>[];
|
||||||
|
for (var l = 0; l < 700; l++) {
|
||||||
|
var s = 0.0;
|
||||||
|
for (var i = from; i < from + 2048; i++) {
|
||||||
|
s += x[i] * x[i + l];
|
||||||
|
}
|
||||||
|
c.add(s);
|
||||||
|
}
|
||||||
|
var best = 0.0, lag = 0.0;
|
||||||
|
for (var l = 30; l < 699; l++) {
|
||||||
|
if (c[l] > best && c[l] >= c[l - 1] && c[l] >= c[l + 1]) {
|
||||||
|
best = c[l];
|
||||||
|
lag = l + (c[l - 1] - c[l + 1]) / (2 * (c[l - 1] - 2 * c[l] + c[l + 1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sampleRate / lag;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('every sound is described, quiet, short and clean', () {
|
||||||
|
for (final k in SfxKind.values) {
|
||||||
|
expect(soundInfo[k], isNotNull, reason: k.name);
|
||||||
|
final r = render(soundFor(k));
|
||||||
|
final peak = r.fold<double>(0, (m, v) => math.max(m, v.abs()));
|
||||||
|
expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '${k.name} peak');
|
||||||
|
expect(r.length / sampleRate, lessThan(3), reason: '${k.name} length');
|
||||||
|
expect(r.every((v) => v.isFinite), isTrue, reason: k.name);
|
||||||
|
expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '${k.name} clicks');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the ready ladder climbs and stays in range', () {
|
||||||
|
expect(render(readySound(0)), render(readySound(1)));
|
||||||
|
expect(render(readySound(99)), render(readySound(6)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('koto strings are in tune', () {
|
||||||
|
for (final hz in [293.66, 587.33, 880.0, 1174.66]) {
|
||||||
|
final r = render(SoundSpec([Note(0, hz, 1.0)], room: 0, cutoff: 20000));
|
||||||
|
final cents = 1200 * math.log(_pitch(r, 2000) / hz) / math.ln2;
|
||||||
|
expect(cents.abs(), lessThan(5), reason: '$hz');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wav header and length', () {
|
||||||
|
final b = wav(render(soundFor(SfxKind.chat)));
|
||||||
|
expect(String.fromCharCodes(b.sublist(0, 4)), 'RIFF');
|
||||||
|
expect(String.fromCharCodes(b.sublist(8, 12)), 'WAVE');
|
||||||
|
expect(b.length, 44 + render(soundFor(SfxKind.chat)).length * 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -216,6 +216,31 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Ctrl+V outside a field: a link asks, then fills Library → Link', (tester) async {
|
||||||
|
await pumpShell(tester, const Size(1280, 800));
|
||||||
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||||
|
if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'};
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||||
|
// fox_utils' clipboard files: none.
|
||||||
|
const pasteboard = MethodChannel('pasteboard');
|
||||||
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pasteboard, (call) async => const <String>[]);
|
||||||
|
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pasteboard, null));
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('handoff-card')), findsOneWidget);
|
||||||
|
expect(find.text('Open in Link'), findsOneWidget); // offline
|
||||||
|
await tester.tap(find.byKey(const ValueKey('handoff-openLink')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('handoff-card')), findsNothing);
|
||||||
|
final field = tester.widget<TextField>(find.byKey(const ValueKey('link-field')));
|
||||||
|
expect(field.controller!.text, 'https://cdn.example/b.mkv');
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('profile rename goes to the server through the hook', (tester) async {
|
testWidgets('profile rename goes to the server through the hook', (tester) async {
|
||||||
final pushed = <String?>[];
|
final pushed = <String?>[];
|
||||||
final p = Profile.instance;
|
final p = Profile.instance;
|
||||||
|
|||||||
@@ -158,12 +158,15 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('finished', () {
|
group('finished', () {
|
||||||
test('stops in place: pause, no seek to the end, no drift seeks', () {
|
test('a player just behind plays out; far behind pauses; no seeks', () {
|
||||||
final diff = StateDiff();
|
|
||||||
final t = DateTime.now().toUtc();
|
final t = DateTime.now().toUtc();
|
||||||
diff.diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
|
|
||||||
final end = _state(eventId: 2, transport: 'finished', position: 120);
|
final end = _state(eventId: 2, transport: 'finished', position: 120);
|
||||||
final actions = diff.diff(end, 119.4, t);
|
final near = StateDiff()..diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
|
||||||
|
final nearActions = near.diff(end, 119.4, t);
|
||||||
|
expect(nearActions.whereType<SeekAction>(), isEmpty);
|
||||||
|
expect(nearActions.whereType<TransportAction>(), isEmpty);
|
||||||
|
final diff = StateDiff()..diff(_state(eventId: 1, transport: 'playing', position: 100), 100, t);
|
||||||
|
final actions = diff.diff(end, 110, t);
|
||||||
expect(actions.whereType<SeekAction>(), isEmpty);
|
expect(actions.whereType<SeekAction>(), isEmpty);
|
||||||
expect(actions.whereType<TransportAction>().single.playing, isFalse);
|
expect(actions.whereType<TransportAction>().single.playing, isFalse);
|
||||||
// Far off and later: still nothing.
|
// Far off and later: still nothing.
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <fox_utils/fox_utils_plugin.h>
|
||||||
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
||||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FoxUtilsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FoxUtilsPlugin"));
|
||||||
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
|
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
||||||
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
fox_utils
|
||||||
media_kit_libs_windows_video
|
media_kit_libs_windows_video
|
||||||
media_kit_video
|
media_kit_video
|
||||||
screen_retriever_windows
|
screen_retriever_windows
|
||||||
|
|||||||
+91
-31
@@ -80,20 +80,30 @@ Loosely modeled on Syncplay, with a different readiness design.
|
|||||||
server stores `buffered` and liveness, and takes the first reported
|
server stores `buffered` and liveness, and takes the first reported
|
||||||
duration for the current media (swap resets it); a report with `eventId`
|
duration for the current media (swap resets it); a report with `eventId`
|
||||||
older than current has its position ignored.
|
older than current has its position ignored.
|
||||||
- **Ready gate** (HELD to PLAYING): the in-set is non-empty, every in-set
|
- **Ready gate** (HELD to PLAYING): the in-set is non-empty and every
|
||||||
member is ready, fresh and has `buffered >= need`, and quorum holds: in-set
|
in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s
|
||||||
size >= 2, unless at most one member is fresh. `need` is 4 s
|
(`minBufferSecs`), or near the end what is left minus 2 s
|
||||||
(`minBufferSecs`), or near the end what is left minus 2 s (`endSlackSecs`,
|
(`endSlackSecs`, floor 0), since mpv cannot buffer past the end. So a
|
||||||
floor 0), since mpv cannot buffer past the end. So a lone viewer plays
|
lone viewer plays alone; with others present everyone must ready.
|
||||||
alone; with a second viewer present one Space waits for the other. Joining
|
- **In-set** (who the gate waits for): a member joins it on joining, or
|
||||||
members start with `inSet=false, ready=false` and never block. Disconnect
|
on coming back from stale, while not PLAYING (any media, the one the
|
||||||
removes from the set; swap clears everyone.
|
server started with included); joining while PLAYING it stays out
|
||||||
|
(`ready=false`) until it readies: a late joiner never pauses anyone. A
|
||||||
|
swap puts every fresh member in the set and keeps everyone's ready flag,
|
||||||
|
so the next video starts once everyone ready has buffered (the client
|
||||||
|
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
|
||||||
|
default off, sends `ready=false` itself on a new `mediaId`; not for the
|
||||||
|
first state of a session, and a reconnect does not restore a ready that
|
||||||
|
a swap meanwhile would have dropped). Stale, leave and kick remove from
|
||||||
|
the set.
|
||||||
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
|
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
|
||||||
`need` buffered or stale flips the server to HELD (feed action `pause` by
|
`need` buffered or stale flips the server to HELD (feed action `pause` by
|
||||||
`server`). Recovery resumes by itself when the gate passes.
|
`server`). Recovery resumes by itself when the gate passes.
|
||||||
- **End**: while PLAYING with a known duration, reaching it sets FINISHED
|
- **End**: while PLAYING with a known duration, reaching it sets FINISHED
|
||||||
(feed `finished` by `server`, position = duration). Clients pause where they
|
(feed `finished` by `server`, position = duration). Clients pause where they
|
||||||
are; FINISHED never auto-resumes. A seek turns it into HELD at the new
|
are, except one within 3 s of the end (`finishSlackSecs`), which plays
|
||||||
|
out to mpv's own stop; at the end the position shows the full duration.
|
||||||
|
FINISHED never auto-resumes. A seek turns it into HELD at the new
|
||||||
position (resuming at once if the gate passes); a swap resets to HELD.
|
position (resuming at once if the gate passes); a swap resets to HELD.
|
||||||
- **Seek**: allowed from any member in any transport; the server writes the
|
- **Seek**: allowed from any member in any transport; the server writes the
|
||||||
position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in
|
position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in
|
||||||
@@ -119,7 +129,8 @@ Loosely modeled on Syncplay, with a different readiness design.
|
|||||||
user is not seeking.
|
user is not seeking.
|
||||||
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
|
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
|
||||||
gate, `stale` feed); over 30 s the socket is closed and the member removed.
|
gate, `stale` feed); over 30 s the socket is closed and the member removed.
|
||||||
A stale member that was ready goes back into the set with its next frame.
|
A stale member goes back into the set with its next frame if it is ready
|
||||||
|
(also one stale at a swap) or the server is not PLAYING.
|
||||||
Closed sockets are polled with `isClosed` every second: Relic's server-side
|
Closed sockets are polled with `isClosed` every second: Relic's server-side
|
||||||
`onDone` does not fire for dead peers, so presence never relies on it.
|
`onDone` does not fire for dead peers, so presence never relies on it.
|
||||||
- **Resume**: a reconnecting client sends its previous token as
|
- **Resume**: a reconnecting client sends its previous token as
|
||||||
@@ -137,7 +148,7 @@ Loosely modeled on Syncplay, with a different readiness design.
|
|||||||
moment playback stopped. While HELD and self ready: warning at 8 min,
|
moment playback stopped. While HELD and self ready: warning at 8 min,
|
||||||
auto-unready at 10 min, and never less than 2 min after the warning.
|
auto-unready at 10 min, and never less than 2 min after the warning.
|
||||||
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
|
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
|
||||||
pause-on-leave; anyone can force ready/unready (all or one); quorum gate;
|
pause-on-leave; anyone can force ready/unready (all or one); presence gate;
|
||||||
the clock is server time (clients correct for their offset to it).
|
the clock is server time (clients correct for their offset to it).
|
||||||
|
|
||||||
Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
|
Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
|
||||||
@@ -285,8 +296,9 @@ Skip rules (pure, `media_marks.dart`):
|
|||||||
- `skipOfferAt` (the coordinator's `skipOffer`, null at `finished`): inside
|
- `skipOfferAt` (the coordinator's `skipOffer`, null at `finished`): inside
|
||||||
the opening (up to its last second) "Skip OP" to its end; inside the
|
the opening (up to its last second) "Skip OP" to its end; inside the
|
||||||
ending "Skip ED" to its end only (no guessing about previews: the viewer
|
ending "Skip ED" to its end only (no guessing about previews: the viewer
|
||||||
decides). Without any opening and with media over 20 min, "Skip 1:30"
|
decides). Without any opening, without chapters (a chaptered file
|
||||||
during the first 6 min.
|
without an OP chapter has none on purpose) and with media over 20 min,
|
||||||
|
"Skip 1:30" during the first 6 min.
|
||||||
- +1:30 (`skipForward`: the pill, the controls' "1:30" button,
|
- +1:30 (`skipForward`: the pill, the controls' "1:30" button,
|
||||||
Shift+Right) is a `nudge`, so repeated presses add up.
|
Shift+Right) is a `nudge`, so repeated presses add up.
|
||||||
- Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next
|
- Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next
|
||||||
@@ -513,10 +525,11 @@ One window, `AppShell` (`lib/src/shell/`), whatever the connection state
|
|||||||
through for views that replace each other (the old one gone after 35%, the
|
through for views that replace each other (the old one gone after 35%, the
|
||||||
new one fading and scaling in from 0.985), `showPopover` (cards that fade
|
new one fading and scaling in from 0.985), `showPopover` (cards that fade
|
||||||
and scale in from where they open; they scroll when the window is short)
|
and scale in from where they open; they scroll when the window is short)
|
||||||
and `HScroll` (horizontal rows: mouse drag, the vertical wheel scrolls them
|
and `HScroll` (horizontal rows: mouse drag or Shift+wheel; the plain wheel
|
||||||
sideways until an end, then the page).
|
always scrolls the page). Space never presses a focused button
|
||||||
|
(`appShortcuts`): it is the ready key.
|
||||||
|
|
||||||
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card),
|
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's),
|
||||||
the Library / Schedule / Settings tabs with a growing underline (an
|
the Library / Schedule / Settings tabs with a growing underline (an
|
||||||
`HScroll` when narrow), the update indicator, then offline Join and Host
|
`HScroll` when narrow), the update indicator, then offline Join and Host
|
||||||
(connect cards), online the party: `AvatarStack` (host first, then
|
(connect cards), online the party: `AvatarStack` (host first, then
|
||||||
@@ -528,8 +541,7 @@ sideways until an end, then the page).
|
|||||||
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
|
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
|
||||||
ranges first) with the Host port, and a note that friends outside the
|
ranges first) with the Host port, and a note that friends outside the
|
||||||
LAN need a forwarded port or a reachable server). Empty strip space is the window
|
LAN need a forwarded port or a reachable server). Empty strip space is the window
|
||||||
drag area; the window buttons sit at its end. Debug builds add a button
|
drag area; the window buttons sit at its end.
|
||||||
that opens the layout mock (`screens/demo/`).
|
|
||||||
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
||||||
picture with a gap in the background colour (both scale with the size):
|
picture with a gap in the background colour (both scale with the size):
|
||||||
green ready, amber buffering (with a spinner), grey not ready, dimmed
|
green ready, amber buffering (with a spinner), grey not ready, dimmed
|
||||||
@@ -572,8 +584,10 @@ sideways until an end, then the page).
|
|||||||
(`library_link.dart`): paste field, a preview (YouTube oEmbed title and
|
(`library_link.dart`): paste field, a preview (YouTube oEmbed title and
|
||||||
thumbnail, else the file name), the three relay modes online, and the
|
thumbnail, else the file name), the three relay modes online, and the
|
||||||
last 12 links put up (`recentLinks` in the `connect_form` box). Files
|
last 12 links put up (`recentLinks` in the `connect_form` box). Files
|
||||||
is `FilesPage` (`files/`). Ctrl+V outside a field pastes a link into
|
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
|
||||||
Link. Offline everything browses; the buttons say "Join or host to
|
an isolate, videos grouped by series; series start collapsed, a header
|
||||||
|
click opens one and open ones stay open for the app session
|
||||||
|
(`showFilesFolder` opens a folder from outside). Offline everything browses; the buttons say "Join or host to
|
||||||
watch together". Schedule (`schedule/`) is a week of day columns built
|
watch together". Schedule (`schedule/`) is a week of day columns built
|
||||||
from AniList's airing schedule (`anilist.dart`: pages cached 10 min in
|
from AniList's airing schedule (`anilist.dart`: pages cached 10 min in
|
||||||
memory and 1 h on disk, stale data shown on 429 or failure) matched
|
memory and 1 h on disk, stale data shown on 429 or failure) matched
|
||||||
@@ -590,6 +604,30 @@ sideways until an end, then the page).
|
|||||||
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
|
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
|
||||||
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds
|
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds
|
||||||
(what pops up; no switches), updates, shortcuts, about.
|
(what pops up; no switches), updates, shortcuts, about.
|
||||||
|
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
|
||||||
|
`DropTarget` (a "Drop to open" overlay while dragging) and a
|
||||||
|
`HardwareKeyboard` handler for Ctrl+V outside a text field (clipboard
|
||||||
|
files first, then text). `classifyHandoff` (pure) turns paths or text
|
||||||
|
into videos (the Files extensions, `kindOf`), a folder, an http(s) link,
|
||||||
|
an image or nothing; videos win over folders, folders over images.
|
||||||
|
`HandoffCard` opens as a popover at the pointer: a video goes up through
|
||||||
|
the shell's put-up (offline: into Files → Recent) or adds its folder to
|
||||||
|
Files; a folder is added to Files and opened; a link fills Library →
|
||||||
|
Link (online the relay mode is picked there). Images are ignored.
|
||||||
|
Nothing happens while a dialog or popover is over the shell.
|
||||||
|
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
|
||||||
|
unready, force-ready, join, leave, kick, swap and others' chat (feed
|
||||||
|
events), the idle warning, and connection lost / back (once per drop).
|
||||||
|
Nothing for what the video already says (play, pause, seeks, the end).
|
||||||
|
The set is synthesized, not sampled: `synth.dart` renders Japanese-house
|
||||||
|
voices (Karplus-Strong koto with bends, furin chime, rin bowl, bamboo,
|
||||||
|
hyoshigi clappers, a shoji slide of filtered noise) through a low-pass
|
||||||
|
and a short room to quiet WAVs; `sound_bank.dart` voices each event in
|
||||||
|
the Yo scale on D. `SynthSfx` renders a sound on first use into the temp
|
||||||
|
dir and plays it on a pool of three audio-only mpv players, at
|
||||||
|
`AppSettings.soundVolume` (square curve) unless switched off in
|
||||||
|
`soundsOff`. Settings → Notifications & sounds has the volume and a
|
||||||
|
preview and switch per sound.
|
||||||
- **Player** (`session_player.dart`): `SessionPlayer` holds the one
|
- **Player** (`session_player.dart`): `SessionPlayer` holds the one
|
||||||
`Video` of the current player (a new `GlobalKey` per player generation).
|
`Video` of the current player (a new `GlobalKey` per player generation).
|
||||||
The shell keeps it in one layer of the body at one place in the element
|
The shell keeps it in one layer of the body at one place in the element
|
||||||
@@ -602,7 +640,13 @@ sideways until an end, then the page).
|
|||||||
is `windowManager.setFullScreen` with the strip hidden (party avatars in
|
is `windowManager.setFullScreen` with the strip hidden (party avatars in
|
||||||
a corner while paused); F, Esc and double-click on the picture toggle it.
|
a corner while paused); F, Esc and double-click on the picture toggle it.
|
||||||
The player keys (Settings → Keyboard shortcuts) work while the player is
|
The player keys (Settings → Keyboard shortcuts) work while the player is
|
||||||
full and nothing is in front of it. Something coming up while the player
|
full and nothing is in front of it; ↑ / ↓ step the volume. Space (ready)
|
||||||
|
and Tab (chat) are app-wide: `AppKeys` (`widgets/app_keys.dart`, around
|
||||||
|
the shell) takes them first while in a server, unless a text field has
|
||||||
|
focus or a dialog or menu is in front. Tab reaches the shell's chat
|
||||||
|
toggle through `ChatKey` (the strip while shown, the player in
|
||||||
|
fullscreen). The app's shortcuts map Tab / Shift+Tab to nothing, so focus
|
||||||
|
never lands on a button Space would press. Something coming up while the player
|
||||||
is closed opens it: full right after joining or after putting it up
|
is closed opens it: full right after joining or after putting it up
|
||||||
here, else mini. Both control designs show `NowPlayingTitle` top left
|
here, else mini. Both control designs show `NowPlayingTitle` top left
|
||||||
(`now_playing_title.dart`: 2:3 cover, title, episode line from
|
(`now_playing_title.dart`: 2:3 cover, title, episode line from
|
||||||
@@ -612,13 +656,16 @@ sideways until an end, then the page).
|
|||||||
under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls:
|
under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls:
|
||||||
the banner (else a blur of the cover, else dark), darkened, with cover,
|
the banner (else a blur of the cover, else dark), darkened, with cover,
|
||||||
big title and episode; it fades out on play and stays gone for that
|
big title and episode; it fades out on play and stays gone for that
|
||||||
media. One status pill (`player_status.dart`) sits bottom centre above
|
media; under 320 px tall (the mini) it drops the cover and shrinks the
|
||||||
|
title. One status pill (`player_status.dart`) sits bottom centre above
|
||||||
the controls: `playerStatusFor` (pure) picks the most pressing of
|
the controls: `playerStatusFor` (pure) picks the most pressing of
|
||||||
removed, reconnecting (Cancel), a warn/error health message (Retry),
|
removed, reconnecting (Cancel), a warn/error health message (Retry),
|
||||||
connecting (only after 2 s), finished, someone loading (their avatar
|
connecting (only after 2 s), finished, someone loading (below
|
||||||
and buffer progress), you not ready (a click marks you ready) and who
|
`StateBroadcast.bufferNeed`, the gate's own need: less near the end,
|
||||||
everyone waits for (avatars, "2/3 ready"); nothing otherwise, info
|
none at finished; their avatar with the progress round it), you not
|
||||||
never. Frosted glass, colour only for problems; it fades through
|
ready (a click marks you ready) and who everyone waits for (avatars,
|
||||||
|
"2/3 ready"); nothing otherwise, info never. 34 px avatars, a main line
|
||||||
|
and a muted detail line. Frosted glass, colour only for problems; it fades through
|
||||||
between kinds. The mini shows its short line in its hover bar. ⚙ in both
|
between kinds. The mini shows its short line in its hover bar. ⚙ in both
|
||||||
control designs opens the player settings card (`controls/
|
control designs opens the player settings card (`controls/
|
||||||
player_settings.dart`, `showPopover`): player design, progress line
|
player_settings.dart`, `showPopover`): player design, progress line
|
||||||
@@ -632,11 +679,17 @@ sideways until an end, then the page).
|
|||||||
`WindowButtons` to the top bar while the controls are up (restore
|
`WindowButtons` to the top bar while the controls are up (restore
|
||||||
leaves fullscreen).
|
leaves fullscreen).
|
||||||
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
|
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
|
||||||
(strip button or Tab): chat with avatars, other activity as quiet lines.
|
(strip button or Tab): chat with avatars; the "Activity" chip (off by
|
||||||
|
default, `showActivity`, kept for the app's run only) adds the other
|
||||||
|
feed lines between them. Opening focuses the field; Tab or Esc in it
|
||||||
|
close the panel and focus goes back where it was. Unread chat badges
|
||||||
|
the strip's and both player designs' chat buttons; in fullscreen with
|
||||||
|
the controls down a glass chip (`PlayerGlass`) top right shows it.
|
||||||
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
|
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
|
||||||
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
|
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
|
||||||
own have none); each
|
own have none); each
|
||||||
slides in, stays 5 s, then slides out and collapses its gap.
|
slides in, stays 5 s, then slides out and collapses its gap. A click on
|
||||||
|
a chat toast opens the chat; the others let clicks through.
|
||||||
|
|
||||||
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
||||||
servers and links, the profile picture and colour; saved even when a join
|
servers and links, the profile picture and colour; saved even when a join
|
||||||
@@ -707,8 +760,14 @@ sits in the bottom bar, which is taller by the bar's height (the top bar
|
|||||||
keeps its row at the top). A drag sends no hover events, so the bar keeps
|
keeps its row at the top). A drag sends no hover events, so the bar keeps
|
||||||
the controls up through a guarded dynamic `onHover()` on media_kit's
|
the controls up through a guarded dynamic `onHover()` on media_kit's
|
||||||
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
|
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
|
||||||
1:30, Next episode) and cards live outside the auto-hiding controls, so
|
1:30, Next episode) and cards live outside the auto-hiding controls. The
|
||||||
they stay up when the controls hide.
|
Next episode pill and the cards stay up; the skip pill (`SkipPillGate`)
|
||||||
|
shows for 5 s when an offer of a new kind appears, then only while the
|
||||||
|
controls are up (`ControlsHoverProbe` for this design, `UoscControls.shown`
|
||||||
|
for uosc) or the mouse is on it. Volume (`controls/volume.dart`, both
|
||||||
|
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
|
||||||
|
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part
|
||||||
|
above in amber; the wheel and ↑ / ↓ step 5 %, clamped.
|
||||||
|
|
||||||
The uosc player design (`controls/uosc/`, after mpv's uosc theme;
|
The uosc player design (`controls/uosc/`, after mpv's uosc theme;
|
||||||
"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s
|
"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s
|
||||||
@@ -787,7 +846,8 @@ this repo, written by `tool/release.sh`.
|
|||||||
- Host by key, not by address: loopback detection broke behind a
|
- Host by key, not by address: loopback detection broke behind a
|
||||||
same-machine reverse proxy or tunnel.
|
same-machine reverse proxy or tunnel.
|
||||||
- Server owns the clock; reports never move it: avoids ping-pong between clients.
|
- Server owns the clock; reports never move it: avoids ping-pong between clients.
|
||||||
- Quorum gate: a lone viewer must be able to watch; two or more must start together.
|
- Presence gate: a lone viewer must be able to watch; everyone present
|
||||||
|
while the video is stopped must ready before it starts.
|
||||||
- Relic for the server, `web_socket_channel` for the client; packages are
|
- Relic for the server, `web_socket_channel` for the client; packages are
|
||||||
chosen on merit, not locked.
|
chosen on merit, not locked.
|
||||||
- Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS
|
- Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS
|
||||||
|
|||||||
+5
-1
@@ -6,7 +6,11 @@ Layout and rules: [ARCHITECTURE.md](ARCHITECTURE.md).
|
|||||||
|
|
||||||
Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop
|
Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop
|
||||||
build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev).
|
build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev).
|
||||||
mpv libraries come from `media_kit_libs_video`.
|
mpv libraries come from `media_kit_libs_video`. `fox_utils` (drag & drop,
|
||||||
|
clipboard files and images; native Linux/Windows plugin) is the owner's
|
||||||
|
shared package, a git dependency pinned to a tag of
|
||||||
|
`git.senkochan.moe/Foxyz/fox_utils`: bump it by tagging there and changing
|
||||||
|
`ref` in the desktop pubspec.
|
||||||
|
|
||||||
```
|
```
|
||||||
cd apps/desktop && flutter pub get && flutter run -d linux
|
cd apps/desktop && flutter pub get && flutter run -d linux
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ Friends join either way with Join in the strip at the top of the window.
|
|||||||
(version, password, name, avatar). Wrong password or version: refused with
|
(version, password, name, avatar). Wrong password or version: refused with
|
||||||
a reason. Otherwise the server answers with a welcome and the current state.
|
a reason. Otherwise the server answers with a welcome and the current state.
|
||||||
2. **Catch up.** The app opens the current video, jumps to where the server
|
2. **Catch up.** The app opens the current video, jumps to where the server
|
||||||
says we are, and buffers. A new arrival is not ready yet and never holds
|
says we are, and buffers. Arriving while the video is stopped, you are
|
||||||
anyone back.
|
waited for; arriving while it plays, you never hold anyone back.
|
||||||
3. **Ready gate.** Press Space to say you are ready. The server starts
|
3. **Ready gate.** Press Space to say you are ready. The server starts
|
||||||
playback only when everyone who is ready has at least 4 seconds buffered
|
playback only when everyone who is ready has at least 4 seconds buffered
|
||||||
(or what is left, near the end). At the end everyone stops and the server
|
(or what is left, near the end). At the end everyone stops and the server
|
||||||
stays finished until someone seeks back or changes the video.
|
stays finished until someone seeks back or changes the video.
|
||||||
If two or more people are ready, one person alone cannot start it for the
|
Everyone who was there before it started must be ready, so one person
|
||||||
others. A lone viewer can play alone.
|
alone cannot start it for the others. A lone viewer can play alone.
|
||||||
4. **Play.** The server owns the clock. Each app compares its mpv position
|
4. **Play.** The server owns the clock. Each app compares its mpv position
|
||||||
to the clock: a small lead slows mpv a little (0.95x), a big gap (over 5 s)
|
to the clock: a small lead slows mpv a little (0.95x), a big gap (over 5 s)
|
||||||
jumps. If someone's buffer runs dry the server pauses everyone and resumes
|
jumps. If someone's buffer runs dry the server pauses everyone and resumes
|
||||||
@@ -67,7 +67,8 @@ Friends join either way with Join in the strip at the top of the window.
|
|||||||
5. **Seek.** Anyone can seek, anytime. The server moves the clock and
|
5. **Seek.** Anyone can seek, anytime. The server moves the clock and
|
||||||
everybody follows. Seeking does not change anyone's ready flag.
|
everybody follows. Seeking does not change anyone's ready flag.
|
||||||
6. **Swap.** Anyone can change the video by pasting another link. Everybody
|
6. **Swap.** Anyone can change the video by pasting another link. Everybody
|
||||||
stops at 0, ready flags clear, and the gate runs again.
|
stops at 0 and the gate runs again for everyone there; ready flags stay
|
||||||
|
as they were (a setting unreadies you instead).
|
||||||
7. **Leave.** Closing or leaving drops you from the server; the others keep
|
7. **Leave.** Closing or leaving drops you from the server; the others keep
|
||||||
going. If you vanish without saying goodbye, you are marked stale after 15 s
|
going. If you vanish without saying goodbye, you are marked stale after 15 s
|
||||||
(stops blocking) and removed after 30 s. The app reconnects by itself and
|
(stops blocking) and removed after 30 s. The app reconnects by itself and
|
||||||
|
|||||||
+8
-4
@@ -1,4 +1,4 @@
|
|||||||
# Protocol (proto = 9)
|
# Protocol (proto = 10)
|
||||||
|
|
||||||
Wire format only. Behavior (gate, clock, timeouts) is in
|
Wire format only. Behavior (gate, clock, timeouts) is in
|
||||||
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
||||||
@@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
|||||||
## Client to server
|
## Client to server
|
||||||
|
|
||||||
```
|
```
|
||||||
{"t":"hello","proto":9,"secret":"","user":{"name":"","avatar":""},
|
{"t":"hello","proto":10,"secret":"","user":{"name":"","avatar":""},
|
||||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
||||||
"resume":"<token>"}
|
"resume":"<token>"}
|
||||||
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
||||||
@@ -84,6 +84,9 @@ Field notes:
|
|||||||
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
||||||
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||||
dropped. `swap.media.relay` makes it relayed media (see "Relay").
|
dropped. `swap.media.relay` makes it relayed media (see "Relay").
|
||||||
|
A swap keeps every member's `ready` and puts every fresh member in the
|
||||||
|
set (`inSet`); clients that want to be unready for the new media send
|
||||||
|
`ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
|
||||||
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
|
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
|
||||||
unknown frames are ignored silently.
|
unknown frames are ignored silently.
|
||||||
|
|
||||||
@@ -258,8 +261,9 @@ server -> source {"t":"pull","id":1,"start":0,"end":1048575} inclusive
|
|||||||
## Join and reconnect
|
## Join and reconnect
|
||||||
|
|
||||||
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
|
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
|
||||||
position and buffers. A joining member is `ready=false, inSet=false` until
|
position and buffers. A joining member is `ready=false`; it is `inSet`
|
||||||
the first `ready`. A socket the server sees close loses its token; reconnect
|
at once unless the server is `playing`, then from its first `ready`
|
||||||
|
(members present at a swap are put in the set by it). A socket the server sees close loses its token; reconnect
|
||||||
is a fresh `hello` (with `resume` = the old token), after which the client
|
is a fresh `hello` (with `resume` = the old token), after which the client
|
||||||
re-sends its prior ready flag. If the old member was still there (unclean
|
re-sends its prior ready flag. If the old member was still there (unclean
|
||||||
drop), `resume` takes it over and the token and media URL stay the same.
|
drop), `resume` takes it over and the token and media URL stay the same.
|
||||||
|
|||||||
+28
-7
@@ -6,13 +6,33 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
1. **Windows: first real use.** CI builds and publishes
|
1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the
|
||||||
|
server, personal, per show) is open. Constraint: a skip is a seek and
|
||||||
|
seeks are shared, and only real ranges (segments, OP/ED chapters) should
|
||||||
|
ever be skipped.
|
||||||
|
|
||||||
|
2. **Files**: next episode from Files (by parsed episode, with the
|
||||||
|
countdown). Later: tracking for file series.
|
||||||
|
|
||||||
|
3. **yt-dlp** (epic): players that open links themselves use mpv's ytdl
|
||||||
|
hook (bundled on Windows); then relay of single-file formats; then a
|
||||||
|
resolver for the Link preview (title, thumbnail, duration).
|
||||||
|
|
||||||
|
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
|
||||||
|
filter on the Schedule; today's "Following" (AnimeFlow's monitored
|
||||||
|
shows) becomes "On the server".
|
||||||
|
|
||||||
|
5. **Ops**: `watch-together-server --upgrade` (a VPS has no GTK for the
|
||||||
|
app's updater); check shaders on Windows; the relay e2e "source drops
|
||||||
|
uncleanly and resumes" fails about half the time under the full suite.
|
||||||
|
|
||||||
|
6. **Windows: first real use.** CI builds and publishes
|
||||||
`watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the
|
`watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the
|
||||||
updater tests on NTFS; a friend has joined from it. Still unchecked:
|
updater tests on NTFS; a friend has joined from it. Still unchecked:
|
||||||
fullscreen controls, hosting (firewall prompt), in-app update from one
|
fullscreen controls, hosting (firewall prompt), in-app update from one
|
||||||
release to the next. The rest of the desktop tests are not in CI.
|
release to the next. The rest of the desktop tests are not in CI.
|
||||||
|
|
||||||
2. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's
|
7. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's
|
||||||
PC -> VPS -> friend, Jellyfin and a file. Then:
|
PC -> VPS -> friend, Jellyfin and a file. Then:
|
||||||
- Source takeover: when the source leaves, another member with an
|
- Source takeover: when the source leaves, another member with an
|
||||||
account on that Jellyfin (or the same file: name, size, hash) takes
|
account on that Jellyfin (or the same file: name, size, hash) takes
|
||||||
@@ -22,9 +42,9 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
- Show who is relaying and how fast in the member list; warn the source
|
- Show who is relaying and how fast in the member list; warn the source
|
||||||
when its upload is below the media's bitrate.
|
when its upload is below the media's bitrate.
|
||||||
|
|
||||||
3. **Jellyfin** (epic, below).
|
8. **Jellyfin** (epic, below).
|
||||||
|
|
||||||
4. **Smaller items**
|
9. **Smaller items**
|
||||||
- Video output robustness: if media_kit's hardware path regresses to
|
- Video output robustness: if media_kit's hardware path regresses to
|
||||||
software (see ARCHITECTURE, Media: render mode), vendor a
|
software (see ARCHITECTURE, Media: render mode), vendor a
|
||||||
media_kit_video fix that builds the EGL display from GDK's native
|
media_kit_video fix that builds the EGL display from GDK's native
|
||||||
@@ -35,13 +55,14 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
show the open link with an open-in-browser button.
|
show the open link with an open-in-browser button.
|
||||||
- Time UI: remaining time, scrubber preview.
|
- Time UI: remaining time, scrubber preview.
|
||||||
- mpv / mpv.net shortcut parity.
|
- mpv / mpv.net shortcut parity.
|
||||||
- Sounds: replace `NoopSfxService` (cues already fire). Candidates:
|
|
||||||
Kenney Interface Sounds (CC0), ObsydianX Pack 1 (CC0), akx/Notifications
|
|
||||||
(CC0); playback via flutter_soloud. Keep seek/pause/resume near silent.
|
|
||||||
- Chaos suite: host drop 5 s / 30 s, viewer stall, 20-seek storm, long
|
- Chaos suite: host drop 5 s / 30 s, viewer stall, 20-seek storm, long
|
||||||
outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no
|
outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no
|
||||||
OOM, no stuck session, drift recovers.
|
OOM, no stuck session, drift recovers.
|
||||||
- Chat history pane (today: activity log only).
|
- Chat history pane (today: activity log only).
|
||||||
|
- Images in chat (paste a screenshot; server memory only): possible,
|
||||||
|
not wanted yet.
|
||||||
|
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
|
||||||
|
to dio from `fox_utils` when touching them, for retries/interceptors.
|
||||||
- The `state` broadcast carries the full media URL, which can include an
|
- The `state` broadcast carries the full media URL, which can include an
|
||||||
`api_key` (recognised Jellyfin links become refs; other links do not).
|
`api_key` (recognised Jellyfin links become refs; other links do not).
|
||||||
- TLS: document a reverse proxy in front of the server.
|
- TLS: document a reverse proxy in front of the server.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
abstract class RelayConstants {
|
abstract class RelayConstants {
|
||||||
static const int protoVersion = 9;
|
static const int protoVersion = 10;
|
||||||
|
|
||||||
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
||||||
static const int relaySliceBytes = 1 << 20;
|
static const int relaySliceBytes = 1 << 20;
|
||||||
|
|||||||
@@ -556,6 +556,17 @@ class MemberView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StateBroadcast {
|
class StateBroadcast {
|
||||||
|
/// Buffer an in-set member needs before playback can go on, as the
|
||||||
|
/// server's gate counts it: [RelayConstants.minBufferSecs], less near the
|
||||||
|
/// end (mpv cannot buffer past it), none once finished.
|
||||||
|
double get bufferNeed {
|
||||||
|
if (transport == 'finished') return 0;
|
||||||
|
final d = (media?['duration'] as num?)?.toDouble() ?? 0;
|
||||||
|
const full = RelayConstants.minBufferSecs;
|
||||||
|
if (d <= 0) return full;
|
||||||
|
return (d - position - RelayConstants.endSlackSecs).clamp(0.0, full).toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
final int eventId;
|
final int eventId;
|
||||||
final String transport;
|
final String transport;
|
||||||
final double position;
|
final double position;
|
||||||
|
|||||||
@@ -186,12 +186,17 @@ class SyncEngine {
|
|||||||
return left.clamp(0.0, full).toDouble();
|
return left.clamp(0.0, full).toDouble();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Any frame from a member: fresh again, and a ready member the stale
|
/// Any frame from a member: fresh again. Back in the set: a ready member
|
||||||
/// sweep took out of the set goes back in (ready && !inSet only arises
|
/// the stale sweep (or a swap while it was stale) took out (ready &&
|
||||||
/// from that sweep).
|
/// !inSet only arises from those), and any member back from stale while
|
||||||
|
/// not playing (like a join then).
|
||||||
void _seen(EngineMember member) {
|
void _seen(EngineMember member) {
|
||||||
|
final wasStale = isStale(member);
|
||||||
member.lastSeen = now;
|
member.lastSeen = now;
|
||||||
if (member.ready && !member.inSet) member.inSet = true;
|
if (!member.inSet &&
|
||||||
|
(member.ready || (wasStale && transport != Transport.playing))) {
|
||||||
|
member.inSet = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Heartbeat: stale sweep, end of media.
|
/// Heartbeat: stale sweep, end of media.
|
||||||
@@ -202,8 +207,11 @@ class SyncEngine {
|
|||||||
|
|
||||||
String applyJoin(String token, String name, String avatar) {
|
String applyJoin(String token, String name, String avatar) {
|
||||||
final id = 'm${_nextId++}';
|
final id = 'm${_nextId++}';
|
||||||
|
// Joining while not playing: in the set, so the start waits for them;
|
||||||
|
// while playing, out until they ready (a late joiner never pauses).
|
||||||
members[token] =
|
members[token] =
|
||||||
EngineMember(id: id, token: token, name: name, avatar: avatar, now: now);
|
EngineMember(id: id, token: token, name: name, avatar: avatar, now: now)
|
||||||
|
..inSet = transport != Transport.playing;
|
||||||
feed.add(FeedMessage(actor: name, action: 'join', at: now));
|
feed.add(FeedMessage(actor: name, action: 'join', at: now));
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -367,9 +375,11 @@ class SyncEngine {
|
|||||||
serverTime = now;
|
serverTime = now;
|
||||||
eventId++;
|
eventId++;
|
||||||
transport = Transport.held;
|
transport = Transport.held;
|
||||||
|
// Ready flags stay; everyone here now waits for everyone else here now
|
||||||
|
// (a stale member rejoins the set with its next frame if still ready).
|
||||||
|
final t = now;
|
||||||
for (final member in members.values) {
|
for (final member in members.values) {
|
||||||
member.ready = false;
|
member.inSet = !isStale(member, t);
|
||||||
member.inSet = false;
|
|
||||||
member.buffered = 0;
|
member.buffered = 0;
|
||||||
}
|
}
|
||||||
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
|
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
|
||||||
@@ -448,12 +458,9 @@ class SyncEngine {
|
|||||||
}
|
}
|
||||||
final inSet = members.values.where((m) => m.inSet).toList();
|
final inSet = members.values.where((m) => m.inSet).toList();
|
||||||
if (inSet.isEmpty) return;
|
if (inSet.isEmpty) return;
|
||||||
final freshCount =
|
|
||||||
members.values.where((m) => !isStale(m, t)).length;
|
|
||||||
final quorum = inSet.length >= 2 || freshCount <= 1;
|
|
||||||
final allGood = inSet.every(
|
final allGood = inSet.every(
|
||||||
(m) => m.ready && m.buffered >= need && !isStale(m, t));
|
(m) => m.ready && m.buffered >= need && !isStale(m, t));
|
||||||
if (allGood && quorum) {
|
if (allGood) {
|
||||||
serverTime = t;
|
serverTime = t;
|
||||||
eventId++;
|
eventId++;
|
||||||
transport = Transport.playing;
|
transport = Transport.playing;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: sync_relay_core
|
name: sync_relay_core
|
||||||
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
|
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
|
||||||
version: 1.0.0
|
version: 1.0.1
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.0.0
|
sdk: ^3.0.0
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('membership + gate', () {
|
group('membership + gate', () {
|
||||||
test('join lands out-of-set, playback stays held', () {
|
test('join while held lands in the set, playback stays held', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
expect(engine.transport, Transport.held);
|
expect(engine.transport, Transport.held);
|
||||||
expect(engine.members['a']!.inSet, isFalse);
|
expect(engine.members['a']!.inSet, isTrue);
|
||||||
final state = engine.broadcast();
|
final state = engine.broadcast();
|
||||||
expect(state.members.single.inSet, isFalse);
|
expect(state.members.single.inSet, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('single ready + buffered auto-plays', () {
|
test('single ready + buffered auto-plays', () {
|
||||||
@@ -126,7 +126,22 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('quorum gate', () {
|
group('presence gate', () {
|
||||||
|
test('initial media: three join while held, two ready stays held', () {
|
||||||
|
engine.media = const HelloMedia(name: 'f', size: 1, duration: 0);
|
||||||
|
join('a', 'alice');
|
||||||
|
join('b', 'bob');
|
||||||
|
join('c', 'cara');
|
||||||
|
engine.applyReady('a', true);
|
||||||
|
engine.applyReady('b', true);
|
||||||
|
buffer('a', 6.0);
|
||||||
|
buffer('b', 6.0);
|
||||||
|
buffer('c', 6.0);
|
||||||
|
expect(engine.transport, Transport.held);
|
||||||
|
engine.applyReady('c', true);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
});
|
||||||
|
|
||||||
test('one ready of two fresh stays held', () {
|
test('one ready of two fresh stays held', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
join('b', 'bob');
|
join('b', 'bob');
|
||||||
@@ -153,6 +168,7 @@ void main() {
|
|||||||
t = t.add(const Duration(seconds: 16));
|
t = t.add(const Duration(seconds: 16));
|
||||||
engine.applyReady('a', true);
|
engine.applyReady('a', true);
|
||||||
buffer('a', 6.0);
|
buffer('a', 6.0);
|
||||||
|
engine.tick();
|
||||||
expect(engine.transport, Transport.playing);
|
expect(engine.transport, Transport.playing);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,19 +273,66 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('swap resets everything', () {
|
group('swap', () {
|
||||||
test('swap bumps mediaId, clears sets, holds', () {
|
const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0);
|
||||||
|
|
||||||
|
test('swap bumps mediaId, holds, keeps ready, fresh members in set', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
engine.applyReady('a', true);
|
engine.applyReady('a', true);
|
||||||
buffer('a', 4.0);
|
buffer('a', 4.0);
|
||||||
expect(engine.transport, Transport.playing);
|
expect(engine.transport, Transport.playing);
|
||||||
engine.applySwap('a',
|
engine.applySwap('a', ep2);
|
||||||
const HelloMedia(name: 'Ep02.mkv', size: 9, duration: 9.0));
|
|
||||||
expect(engine.mediaId, 2);
|
expect(engine.mediaId, 2);
|
||||||
expect(engine.transport, Transport.held);
|
expect(engine.transport, Transport.held);
|
||||||
expect(engine.positionAt(), 0);
|
expect(engine.positionAt(), 0);
|
||||||
expect(engine.members['a']!.inSet, isFalse);
|
expect(engine.members['a']!.inSet, isTrue);
|
||||||
expect(engine.members['a']!.ready, isFalse);
|
expect(engine.members['a']!.ready, isTrue);
|
||||||
|
expect(engine.members['a']!.buffered, 0);
|
||||||
|
buffer('a', 4.0);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a member present at the swap blocks until ready', () {
|
||||||
|
join('a', 'alice');
|
||||||
|
join('b', 'bob');
|
||||||
|
join('c', 'cara');
|
||||||
|
engine.applySwap('a', ep2);
|
||||||
|
engine.applyReady('a', true);
|
||||||
|
engine.applyReady('b', true);
|
||||||
|
buffer('a', 6.0);
|
||||||
|
buffer('b', 6.0);
|
||||||
|
buffer('c', 6.0);
|
||||||
|
expect(engine.transport, Transport.held);
|
||||||
|
expect(engine.members['c']!.inSet, isTrue);
|
||||||
|
engine.applyReady('c', true);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a joiner while playing never holds anyone back', () {
|
||||||
|
join('a', 'alice');
|
||||||
|
engine.applySwap('a', ep2);
|
||||||
|
engine.applyReady('a', true);
|
||||||
|
buffer('a', 6.0);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
join('b', 'bob');
|
||||||
|
buffer('b', 0);
|
||||||
|
expect(engine.members['b']!.inSet, isFalse);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale member stays out, rejoins with its next frame if ready', () {
|
||||||
|
join('a', 'alice');
|
||||||
|
join('b', 'bob');
|
||||||
|
engine.applyReady('b', true);
|
||||||
|
t = t.add(const Duration(seconds: 16));
|
||||||
|
engine.applyReady('a', true);
|
||||||
|
engine.applySwap('a', ep2);
|
||||||
|
expect(engine.members['b']!.inSet, isFalse);
|
||||||
|
buffer('a', 6.0);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
buffer('b', 0);
|
||||||
|
expect(engine.members['b']!.inSet, isTrue);
|
||||||
|
expect(engine.transport, Transport.held);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,10 +490,13 @@ void main() {
|
|||||||
isTrue);
|
isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unready member does not rejoin on reports', () {
|
test('unready late joiner does not join on reports', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
|
engine.applyReady('a', true);
|
||||||
buffer('a', 6.0);
|
buffer('a', 6.0);
|
||||||
expect(engine.members['a']!.inSet, isFalse);
|
join('b', 'bob');
|
||||||
|
buffer('b', 6.0);
|
||||||
|
expect(engine.members['b']!.inSet, isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: sync_relay_server
|
name: sync_relay_server
|
||||||
description: Pure-Dart sync + media server. Embeddable, no Flutter.
|
description: Pure-Dart sync + media server. Embeddable, no Flutter.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 1.0.0
|
version: 1.0.1
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.0.0
|
sdk: ^3.0.0
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('core welcome + proto', () {
|
group('core welcome + proto', () {
|
||||||
test('proto 9: avatars by hash, profile intent', () {
|
test('proto 10: avatars by hash, profile intent', () {
|
||||||
expect(RelayConstants.protoVersion, 9);
|
expect(RelayConstants.protoVersion, 10);
|
||||||
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
|
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
|
||||||
expect(v.toJson().containsKey('avatarHash'), isFalse);
|
expect(v.toJson().containsKey('avatarHash'), isFalse);
|
||||||
expect(v.toJson().containsKey('avatar'), isFalse);
|
expect(v.toJson().containsKey('avatar'), isFalse);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"protocol": 9,
|
"protocol": 10,
|
||||||
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.0/watch-together-1.0.0-linux-x64.tar.gz",
|
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.1/watch-together-1.0.1-linux-x64.tar.gz",
|
||||||
"notes": "- Player: now-playing title with cover, start banner, one status pill; media art for everyone",
|
"notes": "- Space never presses a focused button; chat toasts open the chat; plain wheel always scrolls the page",
|
||||||
"assets": {
|
"assets": {
|
||||||
"linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.0/watch-together-1.0.0-linux-x64.tar.gz",
|
"linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.1/watch-together-1.0.1-linux-x64.tar.gz",
|
||||||
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.0/watch-together-1.0.0-windows-x64.zip"
|
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.0.1/watch-together-1.0.1-windows-x64.zip"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user