Protocol 10 presence gate, drop/paste anywhere, quick UI fixes
Ready gate: whoever is in the server while it is not playing counts (joining while playing does not pause anyone); a swap counts everyone present and keeps ready flags; the quorum rule is gone. Optional 'Unready when the video changes'. No 'Skip 1:30' guess when the media has chapters. Drag & drop and Ctrl+V anywhere (fox_utils): files, folders and links get a small card asking how to open them. Files series start collapsed. UI: Tab never moves focus and Space is always ready outside text fields; chat focuses its field; unread badge on uosc and a chip in fullscreen; people-only chat with an Activity toggle; skip pills show 5 s then with the controls; volume up to 150%; own avatar same size as the party. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f22be6c20
commit
8effa573a0
38 files changed
+1529
-303
No files matched your search
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -74,6 +74,12 @@ Future<void> openFolder(WidgetTester tester, String name) async {
|
||||
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() {
|
||||
setUp(() async {
|
||||
tmp = Directory.systemTemp.createTempSync('files_page_');
|
||||
@@ -147,6 +153,9 @@ void main() {
|
||||
expect(find.byType(SeriesHeader), findsOneWidget);
|
||||
expect(find.text('Show'), 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']) {
|
||||
expect(find.text(l), findsOneWidget);
|
||||
}
|
||||
@@ -157,6 +166,13 @@ void main() {
|
||||
// Durations come from the thumbnail.
|
||||
expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
|
||||
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 {
|
||||
@@ -180,6 +196,7 @@ void main() {
|
||||
store.addFolder(anime);
|
||||
await pumpPage(tester);
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
touch('$anime/[Grp] Show - 04.mkv');
|
||||
expect(find.text('E04'), findsNothing);
|
||||
await tester.tap(find.text('Rescan'));
|
||||
@@ -197,6 +214,7 @@ void main() {
|
||||
return null;
|
||||
});
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
await tester.tap(find.text('E01'));
|
||||
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);
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
|
||||
await tester.tap(find.text('E02'));
|
||||
await frames(tester);
|
||||
@@ -254,6 +273,7 @@ void main() {
|
||||
));
|
||||
await frames(tester);
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
expect(find.text('now playing'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.graphic_eq), findsOneWidget);
|
||||
});
|
||||
@@ -267,6 +287,7 @@ void main() {
|
||||
await tester.tap(find.widgetWithText(InputChip, 'anime'));
|
||||
await frames(tester);
|
||||
expect(find.text('$anime/'), findsOneWidget);
|
||||
await openSeries(tester);
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
// The chip's x removes the folder, like the sidebar's.
|
||||
await tester.tap(find.byTooltip('Remove folder'));
|
||||
@@ -287,6 +308,7 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('anime').first);
|
||||
await frames(tester);
|
||||
await openSeries(tester);
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('Show some'));
|
||||
@@ -312,6 +334,7 @@ void main() {
|
||||
return Completer<Thumbnail?>().future; // never finishes
|
||||
});
|
||||
await openFolder(tester, 'big');
|
||||
await openSeries(tester, 'Long Show');
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
final first = thumbRequests.toSet();
|
||||
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.relaying.peek(), isTrue);
|
||||
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();
|
||||
}
|
||||
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.calls.where((c) => c.path.startsWith('/Videos/')).every((c) =>
|
||||
_FakeJellyfin.authFields(c.auth)['Token'] == 'ATOKEN'), isTrue);
|
||||
for (final x in [a, b]) {
|
||||
for (final x in [a, b, d]) {
|
||||
x.toggleReady();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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/jellyfin/accounts.dart';
|
||||
@@ -278,7 +277,7 @@ void main() {
|
||||
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);
|
||||
expect(find.text('How the others get it'), findsOneWidget);
|
||||
const url = 'https://www.youtube.com/watch?v=abc';
|
||||
@@ -304,23 +303,6 @@ void main() {
|
||||
await settle(tester, 10);
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -111,8 +111,14 @@ void main() {
|
||||
group('skip offers', () {
|
||||
final op = SkipRange(s(30), m(2), SkipSource.chapter);
|
||||
final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter);
|
||||
SkipOffer? at(Duration p, {SkipRange? opening, SkipRange? ending, Duration? duration}) => skipOfferAt(
|
||||
position: p, duration: duration ?? m(24), opening: opening, ending: ending);
|
||||
SkipOffer? at(Duration p,
|
||||
{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', () {
|
||||
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)));
|
||||
// An opening known (but not playing) means no fallback.
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
final pushed = <String?>[];
|
||||
final p = Profile.instance;
|
||||
|
||||
Reference in new issue
Block a user