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,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user