One app menu style for every picker; links go Through me by default
- AppSelect / AppMenuButton (widgets/app_menu.dart): glass menu anchored to its button, check on the current item, keyboard and Esc; replaces the Material dropdowns in Settings, player settings, Jellyfin and the member menu. Material menus elsewhere get the same surface by theme. - Link page: Through me is the main choice (you can't know which sites need cookies); Everyone / Server fetches it sit under "Other ways". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a7b553597
commit
95cd668cfb
17 files changed
+630
-125
No files matched your search
@@ -333,15 +333,15 @@ current season, else where you left it.
|
||||
Anyone can put up a file from their own computer, also on a dedicated
|
||||
server: it streams from your computer through the server to everyone (the
|
||||
server keeps a few minutes in memory, so seeks and skipping the opening
|
||||
are quick). Keep the app open while it plays. For a link you choose how
|
||||
the others get it:
|
||||
- Everyone fetches it (default): each app plays the link itself.
|
||||
are quick). Keep the app open while it plays. A link goes up "Through
|
||||
me": your computer opens it and passes it on, so it plays for everyone
|
||||
whenever it plays for you (you can't tell ahead which sites want cookies,
|
||||
and YouTube may start asking any day). Under "Other ways" there are two more:
|
||||
- Everyone fetches it: each app plays the link itself.
|
||||
- Server fetches it: the server downloads it once for everyone, for links
|
||||
that are slow or blocked for some. The server must be able to reach it,
|
||||
and it must have a password (an open server refuses: anyone could make
|
||||
it fetch addresses only it can reach).
|
||||
- Through me: your computer downloads it and passes it on, for links only
|
||||
you can open.
|
||||
|
||||
Web pages work in all three: the app and the dedicated server each
|
||||
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
|
||||
@@ -357,7 +357,7 @@ sites: keep the file private, and for YouTube consider a throwaway
|
||||
account (Google may flag accounts used with yt-dlp).
|
||||
YouTube works in all three too, and so do pages and links that only
|
||||
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
|
||||
The last two never show the link to anyone; they can't pass on live
|
||||
Through me and Server fetches it never show the link to anyone; they can't pass on live
|
||||
streams or DRM-protected video (use "Everyone fetches it" for live).
|
||||
Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is
|
||||
about 11 Mbit/s.
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/shell/app_shell.dart';
|
||||
import 'src/widgets/app_keys.dart';
|
||||
import 'src/widgets/app_menu.dart';
|
||||
|
||||
class WatchTogetherApp extends StatelessWidget {
|
||||
const WatchTogetherApp({super.key});
|
||||
@@ -11,7 +12,9 @@ class WatchTogetherApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: appName,
|
||||
theme: ThemeData.dark(),
|
||||
// Material's own menus (the player's track menus, sort) wear the
|
||||
// app menu's surface.
|
||||
theme: AppMenuStyle.theme(ThemeData.dark()),
|
||||
shortcuts: appShortcuts,
|
||||
home: const AppKeys(child: AppShell()),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../shell/motion.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'uosc/buttons.dart';
|
||||
import 'uosc/controls.dart';
|
||||
|
||||
@@ -56,27 +57,14 @@ class SettingRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
DropdownButton<T> _dropdown<T>({
|
||||
AppSelect<T> _dropdown<T>({
|
||||
required Key key,
|
||||
required T value,
|
||||
required List<(T, String)> items,
|
||||
required ValueChanged<T> onChanged,
|
||||
bool expanded = false,
|
||||
}) =>
|
||||
DropdownButton<T>(
|
||||
key: key,
|
||||
value: value,
|
||||
isExpanded: expanded,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
items: [
|
||||
for (final (v, label) in items)
|
||||
DropdownMenuItem(value: v, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
);
|
||||
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
|
||||
|
||||
/// Standard (our bars over media_kit's controls) or uosc. Live.
|
||||
class PlayerDesignSetting extends StatelessWidget {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../shell/motion.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'accounts.dart';
|
||||
import 'browse_scope.dart';
|
||||
import 'cards.dart' show doubleClickTime, kindIcon;
|
||||
@@ -392,17 +393,13 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
children: [
|
||||
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
|
||||
if (seasons.length > 1)
|
||||
DropdownButton<String>(
|
||||
key: const ValueKey('details-season'),
|
||||
value: seasonId,
|
||||
isDense: true,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
items: [
|
||||
for (final s in seasons)
|
||||
DropdownMenuItem(value: s.id, child: Text(s.name, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (id) => setState(() => _seasonId = id),
|
||||
Flexible(
|
||||
child: AppSelect<String?>(
|
||||
key: const ValueKey('details-season'),
|
||||
value: seasonId,
|
||||
items: [for (final s in seasons) (s.id, s.name)],
|
||||
onChanged: (id) => setState(() => _seasonId = id),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'browse_scope.dart';
|
||||
import 'cards.dart';
|
||||
import 'home_rows.dart';
|
||||
@@ -184,18 +185,18 @@ class _LatestRow extends StatelessWidget {
|
||||
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
|
||||
child: const Text('Show all'),
|
||||
),
|
||||
PopupMenuButton<void>(
|
||||
AppMenuButton<bool>(
|
||||
key: ValueKey('latest-menu-${view.id}'),
|
||||
tooltip: 'Row options',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
itemBuilder: (context) => [
|
||||
CheckedPopupMenuItem(
|
||||
items: () => [
|
||||
AppMenuItem(
|
||||
key: const ValueKey('hide-rereleases'),
|
||||
value: !hide,
|
||||
checked: hide,
|
||||
onTap: () => AppSettings.instance.setHideReReleases(!hide),
|
||||
child: const Text('Hide re-releases in Latest'),
|
||||
label: 'Hide re-releases in Latest',
|
||||
),
|
||||
],
|
||||
onSelected: AppSettings.instance.setHideReReleases,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -27,7 +27,8 @@ class MediaPickerController extends ChangeNotifier {
|
||||
final TextEditingController link;
|
||||
final TextEditingController file;
|
||||
MediaKind _source;
|
||||
LinkRelay _linkRelay = LinkRelay.direct;
|
||||
// Through me unless picked otherwise (Library → Link).
|
||||
LinkRelay _linkRelay = LinkRelay.me;
|
||||
JellyfinMedia? _jellyfin;
|
||||
JellyfinSelection? _selected;
|
||||
|
||||
|
||||
@@ -379,7 +379,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (error != null || !mounted) return error;
|
||||
}
|
||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway);
|
||||
if (media is LinkMedia && session.solo.peek()) {
|
||||
media = LinkMedia(media.url, relay: LinkRelay.direct, anyway: media.anyway);
|
||||
}
|
||||
final error = await session.coordinator.swapPicked(media);
|
||||
if (error != null || !mounted) return error;
|
||||
final f = session.lastForm;
|
||||
|
||||
@@ -178,7 +178,17 @@ String agoText(DateTime at, DateTime now) {
|
||||
|
||||
// -------------------------------------------------------------- widget
|
||||
|
||||
const _modes = [
|
||||
// Through me is the default and the main choice: it plays whatever this
|
||||
// computer can open, and nobody can tell ahead which sites will want
|
||||
// sign-in cookies (YouTube may start asking any day). The other two sit
|
||||
// behind "Other ways".
|
||||
const _main = (
|
||||
LinkRelay.me,
|
||||
Icons.cell_tower,
|
||||
'Through me',
|
||||
'Your computer opens it and passes it on, so it plays for everyone whenever it plays for you.'
|
||||
);
|
||||
const _others = [
|
||||
(LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'),
|
||||
(
|
||||
LinkRelay.server,
|
||||
@@ -186,7 +196,6 @@ const _modes = [
|
||||
'Server fetches it',
|
||||
'Downloaded once by the server. For links slow or blocked for some.'
|
||||
),
|
||||
(LinkRelay.me, Icons.cell_tower, 'Through me', 'Your computer passes it on. For links only you can open.'),
|
||||
];
|
||||
|
||||
String linkRelayHelp(LinkRelay r) => switch (r) {
|
||||
@@ -232,6 +241,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
String? _error;
|
||||
/// [_error] is the probe's problem: "Put it up anyway" is offered.
|
||||
bool _canForce = false;
|
||||
/// "Other ways" opened (always shown while one of them is picked).
|
||||
bool _otherWays = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -317,6 +328,16 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
});
|
||||
}
|
||||
|
||||
Widget _modeCard((LinkRelay, IconData, String, String) m, {bool compact = false}) => _ModeCard(
|
||||
key: ValueKey('link-mode-${m.$1.name}'),
|
||||
icon: m.$2,
|
||||
title: m.$3,
|
||||
sub: m.$4,
|
||||
compact: compact,
|
||||
selected: _c.linkRelay == m.$1,
|
||||
onTap: () => setState(() => _c.linkRelay = m.$1),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
@@ -421,33 +442,63 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
const SizedBox(height: 28),
|
||||
Text('How the others get it', style: t.titleSmall),
|
||||
const SizedBox(height: 10),
|
||||
LayoutBuilder(builder: (context, box) {
|
||||
final cards = [
|
||||
for (final m in _modes)
|
||||
_ModeCard(
|
||||
key: ValueKey('link-mode-${m.$1.name}'),
|
||||
icon: m.$2,
|
||||
title: m.$3,
|
||||
sub: m.$4,
|
||||
selected: _c.linkRelay == m.$1,
|
||||
onTap: () => setState(() => _c.linkRelay = m.$1),
|
||||
_modeCard(_main),
|
||||
const SizedBox(height: 6),
|
||||
Builder(builder: (context) {
|
||||
final open = _otherWays || _c.linkRelay != LinkRelay.me;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: const ValueKey('link-other-ways'),
|
||||
// Closing while one of them is picked goes back to Through me.
|
||||
onPressed: () => setState(() {
|
||||
_otherWays = !open;
|
||||
if (open) _c.linkRelay = LinkRelay.me;
|
||||
}),
|
||||
style: TextButton.styleFrom(foregroundColor: scheme.onSurfaceVariant),
|
||||
iconAlignment: IconAlignment.end,
|
||||
icon: AnimatedRotation(
|
||||
turns: open ? 0.5 : 0,
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: const Icon(Icons.keyboard_arrow_down_rounded, size: 18),
|
||||
),
|
||||
label: const Text('Other ways'),
|
||||
),
|
||||
];
|
||||
if (box.maxWidth < 560) {
|
||||
return Column(children: [
|
||||
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
|
||||
]);
|
||||
}
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, c) in cards.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 10),
|
||||
Expanded(child: c),
|
||||
],
|
||||
],
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: !open
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
final cards = [for (final m in _others) _modeCard(m, compact: true)];
|
||||
if (box.maxWidth < 560) {
|
||||
return Column(children: [
|
||||
for (final (i, c) in cards.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: 8),
|
||||
c,
|
||||
],
|
||||
]);
|
||||
}
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, c) in cards.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 10),
|
||||
Expanded(child: c),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
@@ -542,6 +593,8 @@ class _ModeCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title, sub;
|
||||
final bool selected;
|
||||
/// A secondary choice: icon beside the title, a quieter card.
|
||||
final bool compact;
|
||||
final VoidCallback onTap;
|
||||
const _ModeCard({
|
||||
super.key,
|
||||
@@ -550,6 +603,7 @@ class _ModeCard extends StatelessWidget {
|
||||
required this.sub,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -565,23 +619,31 @@ class _ModeCard extends StatelessWidget {
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: EdgeInsets.all(compact ? 10 : 14),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
crossAxisAlignment: compact ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||
const SizedBox(height: 8),
|
||||
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Icon(icon, size: compact ? 20 : 24, color: selected ? scheme.primary : scheme.onSurfaceVariant),
|
||||
SizedBox(width: compact ? 10 : 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
@@ -578,9 +579,8 @@ class _MemberTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
subtitle: Text(status),
|
||||
trailing: PopupMenuButton<String>(
|
||||
trailing: AppMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
case 'force':
|
||||
@@ -593,18 +593,13 @@ class _MemberTile extends StatelessWidget {
|
||||
coord.joinBack();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (!m.out)
|
||||
PopupMenuItem(
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self)
|
||||
const PopupMenuItem(value: 'nudge', child: Text('Nudge')),
|
||||
items: () => [
|
||||
if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
|
||||
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
|
||||
if (info.self && m.out)
|
||||
const PopupMenuItem(value: 'back', child: Text('Join back'))
|
||||
const AppMenuItem(value: 'back', label: 'Join back')
|
||||
else if (!m.out)
|
||||
const PopupMenuItem(value: 'out', child: Text('Sit out')),
|
||||
const AppMenuItem(value: 'out', label: 'Sit out'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import '../sfx/synth_sfx.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
@@ -622,11 +623,15 @@ class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _browser,
|
||||
decoration: const InputDecoration(labelText: 'Browser'),
|
||||
items: [for (final b in cookieBrowsers) DropdownMenuItem(value: b, child: Text(b))],
|
||||
onChanged: (v) => setState(() => _browser = v ?? _browser),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Browser')),
|
||||
AppSelect<String>(
|
||||
value: _browser,
|
||||
items: [for (final b in cookieBrowsers) (b, b)],
|
||||
onChanged: (v) => setState(() => _browser = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
@@ -762,15 +767,11 @@ class NotificationsInfo extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => DropdownButton<ChatPopups>(
|
||||
builder: (context) => AppSelect<ChatPopups>(
|
||||
key: const ValueKey('chat-popups'),
|
||||
value: AppSettings.instance.chatPopups.value,
|
||||
onChanged: (v) {
|
||||
if (v != null) AppSettings.instance.setChatPopups(v);
|
||||
},
|
||||
items: [
|
||||
for (final m in ChatPopups.values) DropdownMenuItem(value: m, child: Text(m.label)),
|
||||
],
|
||||
onChanged: AppSettings.instance.setChatPopups,
|
||||
items: [for (final m in ChatPopups.values) (m, m.label)],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -832,17 +833,15 @@ class SoundSettings extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Sound set')),
|
||||
DropdownButton<String>(
|
||||
AppSelect<String>(
|
||||
key: const ValueKey('sound-preset'),
|
||||
value: overall,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
s.setSoundPreset(v);
|
||||
_preview(SfxKind.ready);
|
||||
},
|
||||
items: [
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
|
||||
DropdownMenuItem(value: p.name, child: Text(name)),
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -867,19 +866,17 @@ class SoundSettings extends StatelessWidget {
|
||||
children: [Text(name), Text(help, style: muted)],
|
||||
),
|
||||
),
|
||||
DropdownButton<String?>(
|
||||
AppSelect<String?>(
|
||||
key: ValueKey('sound-preset-${kind.name}'),
|
||||
value: per[kind.name],
|
||||
underline: const SizedBox.shrink(),
|
||||
style: muted,
|
||||
onChanged: (v) {
|
||||
s.setSoundPresetFor(kind.name, v);
|
||||
if (volume > 0) _preview(kind);
|
||||
},
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(value: null, child: Text('Same as set')),
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
|
||||
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
|
||||
(null, 'Same as set'),
|
||||
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
|
||||
],
|
||||
),
|
||||
// A nudge always plays (an explicit poke): no switch,
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../shell/motion.dart';
|
||||
|
||||
// The app's one menu: a frosted dark panel anchored under (or over) the
|
||||
// widget that opens it, rows with a hover and keyboard highlight, a check
|
||||
// on the current one. [AppSelect] is the value picker built on it (in place
|
||||
// of a dropdown), [AppMenuButton] the "⋮" action menu. The menu is a popup
|
||||
// route: a click outside or Esc closes it, arrows and Enter pick, and the
|
||||
// player's keys stay off while it is open (in fullscreen too).
|
||||
|
||||
/// The menu surface, shared with Material's own menus via [appMenuTheme].
|
||||
abstract final class AppMenuStyle {
|
||||
/// Over a blur ([showAppMenu]).
|
||||
static const glass = Color(0xD918181C);
|
||||
/// Without a blur (Material menus).
|
||||
static const solid = Color(0xF518181C);
|
||||
static const border = Color(0x1FFFFFFF);
|
||||
static const hover = Color(0x14FFFFFF);
|
||||
static const focus = Color(0x24FFFFFF);
|
||||
static const radius = 12.0;
|
||||
static const rowRadius = 8.0;
|
||||
static const rowHeight = 36.0;
|
||||
static const maxWidth = 360.0;
|
||||
|
||||
static final shape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
side: const BorderSide(color: border, width: 0.5),
|
||||
);
|
||||
|
||||
/// The same surface for `showMenu`, `PopupMenuButton` and `MenuAnchor`.
|
||||
static ThemeData theme(ThemeData base) => base.copyWith(
|
||||
popupMenuTheme: PopupMenuThemeData(
|
||||
color: solid,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 8,
|
||||
shape: shape,
|
||||
menuPadding: const EdgeInsets.all(4),
|
||||
),
|
||||
menuTheme: MenuThemeData(
|
||||
style: MenuStyle(
|
||||
backgroundColor: const WidgetStatePropertyAll(solid),
|
||||
surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
elevation: const WidgetStatePropertyAll(8),
|
||||
shape: WidgetStatePropertyAll(shape),
|
||||
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One row: [label], a dim [hint] at the right, [checked] = the current
|
||||
/// one (null: the menu has no checks for this row).
|
||||
class AppMenuItem<T> {
|
||||
final T value;
|
||||
final String label;
|
||||
final String? hint;
|
||||
final bool? checked;
|
||||
final bool enabled;
|
||||
final Key? key;
|
||||
const AppMenuItem({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.hint,
|
||||
this.checked,
|
||||
this.enabled = true,
|
||||
this.key,
|
||||
});
|
||||
}
|
||||
|
||||
/// Opens the menu at [context]'s box (the button). Resolves to the picked
|
||||
/// value, or null when dismissed. [minWidth]: at least this wide (a picker
|
||||
/// is as wide as its button).
|
||||
Future<T?> showAppMenu<T>(
|
||||
BuildContext context, {
|
||||
required List<AppMenuItem<T>> items,
|
||||
double minWidth = 0,
|
||||
}) async {
|
||||
final i = await _openMenu(context, items, minWidth);
|
||||
return i == null ? null : items[i].value;
|
||||
}
|
||||
|
||||
/// The picked row's index (the value may itself be null).
|
||||
Future<int?> _openMenu(BuildContext context, List<AppMenuItem<Object?>> items, double minWidth) {
|
||||
final navigator = Navigator.of(context);
|
||||
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final anchor = Rect.fromPoints(
|
||||
box.localToGlobal(Offset.zero, ancestor: overlay),
|
||||
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
|
||||
);
|
||||
Future<int?> open() => navigator.push<int>(_AppMenuRoute(
|
||||
anchor: anchor,
|
||||
items: items,
|
||||
minWidth: minWidth,
|
||||
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
|
||||
));
|
||||
// Over the uosc controls: keep them up while the menu is open.
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
return controls == null ? open() : controls.holdDuring(open);
|
||||
}
|
||||
|
||||
class _AppMenuRoute extends PopupRoute<int> {
|
||||
final Rect anchor;
|
||||
final List<AppMenuItem<Object?>> items;
|
||||
final double minWidth;
|
||||
final CapturedThemes capturedThemes;
|
||||
_AppMenuRoute({required this.anchor, required this.items, required this.minWidth, required this.capturedThemes});
|
||||
|
||||
static const _gap = 4.0;
|
||||
static const _margin = 8.0;
|
||||
|
||||
// Opens down unless the room below is short and above is roomier.
|
||||
bool _below(Size screen) {
|
||||
final want = items.length * AppMenuStyle.rowHeight + 8;
|
||||
final below = screen.height - anchor.bottom - _gap - _margin;
|
||||
final above = anchor.top - _gap - _margin;
|
||||
return below >= want || below >= above;
|
||||
}
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => 'Close menu';
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
Duration get reverseTransitionDuration => const Duration(milliseconds: 120);
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final below = _below(constraints.biggest);
|
||||
final a = CurvedAnimation(parent: animation, curve: ease, reverseCurve: Curves.easeIn);
|
||||
return CustomSingleChildLayout(
|
||||
delegate: _MenuLayout(anchor: anchor, below: below, minWidth: minWidth),
|
||||
child: FadeTransition(
|
||||
opacity: a,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 0.96, end: 1.0).animate(a),
|
||||
alignment: below ? Alignment.topCenter : Alignment.bottomCenter,
|
||||
child: capturedThemes.wrap(_AppMenuPanel(
|
||||
items: items,
|
||||
onPick: (i) => Navigator.of(context).pop(i),
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuLayout extends SingleChildLayoutDelegate {
|
||||
final Rect anchor;
|
||||
final bool below;
|
||||
final double minWidth;
|
||||
const _MenuLayout({required this.anchor, required this.below, required this.minWidth});
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
const m = _AppMenuRoute._margin, gap = _AppMenuRoute._gap;
|
||||
final maxW = math.max(0.0, math.min(math.max(AppMenuStyle.maxWidth, minWidth), constraints.maxWidth - 2 * m));
|
||||
final maxH = below ? constraints.maxHeight - anchor.bottom - gap - m : anchor.top - gap - m;
|
||||
return BoxConstraints(
|
||||
minWidth: math.min(minWidth, maxW),
|
||||
maxWidth: maxW,
|
||||
maxHeight: math.max(AppMenuStyle.rowHeight + 8, maxH),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
const m = _AppMenuRoute._margin, gap = _AppMenuRoute._gap;
|
||||
final x = anchor.left.clamp(m, math.max(m, size.width - childSize.width - m)).toDouble();
|
||||
final y = below ? anchor.bottom + gap : anchor.top - gap - childSize.height;
|
||||
return Offset(x, y.clamp(m, math.max(m, size.height - childSize.height - m)).toDouble());
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_MenuLayout old) => old.anchor != anchor || old.below != below || old.minWidth != minWidth;
|
||||
}
|
||||
|
||||
class _AppMenuPanel extends StatefulWidget {
|
||||
final List<AppMenuItem<Object?>> items;
|
||||
final ValueChanged<int> onPick;
|
||||
const _AppMenuPanel({required this.items, required this.onPick});
|
||||
|
||||
@override
|
||||
State<_AppMenuPanel> createState() => _AppMenuPanelState();
|
||||
}
|
||||
|
||||
class _AppMenuPanelState extends State<_AppMenuPanel> {
|
||||
final _current = GlobalKey();
|
||||
|
||||
// Focus (and scroll to) the current row, else the first enabled one.
|
||||
late final int _start = () {
|
||||
final checked = widget.items.indexWhere((i) => i.checked == true && i.enabled);
|
||||
return checked >= 0 ? checked : widget.items.indexWhere((i) => i.enabled);
|
||||
}();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final c = _current.currentContext;
|
||||
if (c != null && c.mounted) Scrollable.ensureVisible(c, alignment: 0.5);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final checks = widget.items.any((i) => i.checked != null);
|
||||
final r = BorderRadius.circular(AppMenuStyle.radius);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: r,
|
||||
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: r,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||||
child: Material(
|
||||
key: const ValueKey('app-menu'),
|
||||
color: AppMenuStyle.glass,
|
||||
shape: AppMenuStyle.shape,
|
||||
child: IntrinsicWidth(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, item) in widget.items.indexed)
|
||||
_MenuRow(
|
||||
key: i == _start ? _current : null,
|
||||
item: item,
|
||||
checks: checks,
|
||||
autofocus: i == _start,
|
||||
onTap: () => widget.onPick(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuRow extends StatelessWidget {
|
||||
final AppMenuItem<Object?> item;
|
||||
final bool checks;
|
||||
final bool autofocus;
|
||||
final VoidCallback onTap;
|
||||
const _MenuRow({super.key, required this.item, required this.checks, required this.autofocus, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final on = item.checked == true;
|
||||
final color = item.enabled ? scheme.onSurface : scheme.onSurface.withValues(alpha: 0.38);
|
||||
return InkWell(
|
||||
key: item.key,
|
||||
autofocus: autofocus,
|
||||
onTap: item.enabled ? onTap : null,
|
||||
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
||||
hoverColor: AppMenuStyle.hover,
|
||||
focusColor: AppMenuStyle.focus,
|
||||
highlightColor: AppMenuStyle.focus,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
child: Container(
|
||||
height: AppMenuStyle.rowHeight,
|
||||
padding: EdgeInsets.only(left: checks ? 6 : 12, right: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (checks)
|
||||
SizedBox(
|
||||
width: 26,
|
||||
child: on ? Icon(Icons.check_rounded, size: 16, color: scheme.primary) : null,
|
||||
),
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 14, color: color, fontWeight: on ? FontWeight.w600 : FontWeight.normal),
|
||||
),
|
||||
),
|
||||
if (item.hint != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
Text(item.hint!, style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A value picker (in place of a dropdown): the current label and a small
|
||||
/// chevron on a soft rounded surface; the menu lists [items] (value,
|
||||
/// label) with the current one checked. [onChanged] null: disabled.
|
||||
/// [expanded]: fills the width it gets (the label ellipsizes).
|
||||
class AppSelect<T> extends StatefulWidget {
|
||||
final T value;
|
||||
final List<(T, String)> items;
|
||||
final ValueChanged<T>? onChanged;
|
||||
final bool expanded;
|
||||
final TextStyle? style;
|
||||
const AppSelect({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
this.expanded = false,
|
||||
this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppSelect<T>> createState() => _AppSelectState<T>();
|
||||
}
|
||||
|
||||
class _AppSelectState<T> extends State<AppSelect<T>> {
|
||||
bool _open = false;
|
||||
|
||||
Future<void> _show(BuildContext context) async {
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
setState(() => _open = true);
|
||||
final items = [
|
||||
for (final (v, label) in widget.items) AppMenuItem<T>(value: v, label: label, checked: v == widget.value),
|
||||
];
|
||||
final i = await _openMenu(context, items, box.size.width);
|
||||
if (!mounted) return;
|
||||
setState(() => _open = false);
|
||||
if (i != null) widget.onChanged?.call(widget.items[i].$1);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final enabled = widget.onChanged != null && widget.items.isNotEmpty;
|
||||
final label = widget.items.where((e) => e.$1 == widget.value).firstOrNull?.$2 ?? '';
|
||||
final base = Theme.of(context).textTheme.bodyMedium!.merge(widget.style);
|
||||
final style = enabled ? base : base.copyWith(color: base.color?.withValues(alpha: 0.38));
|
||||
final r = BorderRadius.circular(AppMenuStyle.rowRadius);
|
||||
final text = Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: style);
|
||||
return Semantics(
|
||||
button: true,
|
||||
value: label,
|
||||
child: Builder(
|
||||
builder: (context) => Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: enabled ? () => _show(context) : null,
|
||||
borderRadius: r,
|
||||
hoverColor: AppMenuStyle.hover,
|
||||
focusColor: AppMenuStyle.focus,
|
||||
highlightColor: Colors.transparent,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: ease,
|
||||
padding: const EdgeInsets.only(left: 10, right: 6, top: 6, bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: _open ? 0.10 : 0.05),
|
||||
borderRadius: r,
|
||||
border: Border.all(color: _open ? scheme.primary.withValues(alpha: 0.6) : AppMenuStyle.border, width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: widget.expanded ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.expanded) Expanded(child: text) else Flexible(child: text),
|
||||
const SizedBox(width: 4),
|
||||
AnimatedRotation(
|
||||
turns: _open ? 0.5 : 0,
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||
size: 18, color: enabled ? scheme.onSurfaceVariant : style.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "⋮" (or [icon]) opening an action menu of [items]; [onSelected] gets
|
||||
/// the picked value.
|
||||
class AppMenuButton<T> extends StatelessWidget {
|
||||
final List<AppMenuItem<T>> Function() items;
|
||||
final ValueChanged<T> onSelected;
|
||||
final String? tooltip;
|
||||
final Widget icon;
|
||||
const AppMenuButton({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.onSelected,
|
||||
this.tooltip,
|
||||
this.icon = const Icon(Icons.more_vert, size: 18),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: tooltip,
|
||||
icon: icon,
|
||||
onPressed: () async {
|
||||
final list = items();
|
||||
if (list.isEmpty) return;
|
||||
final i = await _openMenu(context, list, 0);
|
||||
if (i != null) onSelected(list[i].value);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ void main() {
|
||||
expect(c.source, MediaKind.jellyfin);
|
||||
c.pasteText(' https://example.com/a.mkv ');
|
||||
expect(c.source, MediaKind.link);
|
||||
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
|
||||
expect(c.selection, const LinkMedia('https://example.com/a.mkv', relay: LinkRelay.me));
|
||||
c.pasteText('/home/me/b.mkv');
|
||||
expect(c.source, MediaKind.file);
|
||||
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
||||
|
||||
@@ -307,7 +307,7 @@ void main() {
|
||||
final c = MediaPickerController();
|
||||
expect(c.selection, isNull);
|
||||
c.pasteText('https://example.com/a.mkv');
|
||||
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
|
||||
expect(c.selection, const LinkMedia('https://example.com/a.mkv', relay: LinkRelay.me));
|
||||
c.pasteText('/home/me/b.mkv');
|
||||
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
||||
c.source = MediaKind.jellyfin;
|
||||
|
||||
@@ -281,9 +281,18 @@ void main() {
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('link-preview')), matching: find.text('A video')),
|
||||
findsOneWidget);
|
||||
expect(find.text('youtube.com · Chan'), findsOneWidget);
|
||||
// Through me by default; the other two behind "Other ways".
|
||||
expect(h.controller.linkRelay, LinkRelay.me);
|
||||
expect(find.byKey(const ValueKey('link-mode-direct')), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('link-other-ways')));
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('link-mode-server')));
|
||||
await settle(tester);
|
||||
expect(h.controller.linkRelay, LinkRelay.server);
|
||||
await tester.tap(find.byKey(const ValueKey('link-mode-me')));
|
||||
await settle(tester);
|
||||
expect(h.controller.linkRelay, LinkRelay.me);
|
||||
expect(find.byKey(const ValueKey('link-mode-direct')), findsOneWidget, reason: 'stays open until closed');
|
||||
expect(find.text(linkRelayHelp(LinkRelay.me)), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('link-put-up')));
|
||||
await settle(tester, 10);
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:watch_together/src/shaders/shader_presets.dart';
|
||||
import 'package:watch_together/src/shell/settings_tab.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
import 'package:watch_together/src/widgets/app_menu.dart';
|
||||
|
||||
// The experimental mpv-style (uosc) controls, piece by piece (never the
|
||||
// session screen or a media_kit `Video`).
|
||||
@@ -466,12 +467,12 @@ void main() {
|
||||
));
|
||||
final design = find.byKey(const ValueKey('player-design'));
|
||||
expect(find.text('Player design'), findsOneWidget);
|
||||
expect(tester.widget<DropdownButton<bool>>(design).value, isFalse);
|
||||
expect(tester.widget<AppSelect<bool>>(design).value, isFalse);
|
||||
await tester.tap(design);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('uosc').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(s.mpvControls.value, isTrue);
|
||||
expect(tester.widget<DropdownButton<bool>>(design).value, isTrue);
|
||||
expect(tester.widget<AppSelect<bool>>(design).value, isTrue);
|
||||
});
|
||||
}
|
||||
+17
-4
@@ -463,9 +463,9 @@ What gets relayed (`SessionCoordinator.swapPicked`):
|
||||
|---|---|
|
||||
| File, by the host | `/m` from the hosting app (no relay) |
|
||||
| File, by anyone else | relayed from that member's computer |
|
||||
| Link, "Everyone fetches it" (default) | each app fetches it |
|
||||
| Link, "Everyone fetches it" | each app fetches it |
|
||||
| Link, "Server fetches it" | the server reads it once into the buffer; the link is never broadcast; refused on an open server (it would fetch any address it can reach for anyone) |
|
||||
| Link, "Through me" | relayed from that member's computer; for links only they can open |
|
||||
| Link, "Through me" (default) | relayed from that member's computer; plays for everyone whenever it plays for them (their cookies, their network) |
|
||||
| Jellyfin item (browser pick or pasted link) | own account first, then the server's login (`/j`), then relayed from the member who put it up (with their sign-in, or the pasted link's key, which stays on their machine) |
|
||||
|
||||
Relayed links are range-read: a media file as is, a web page as yt-dlp's
|
||||
@@ -781,7 +781,17 @@ 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 `HScroll` (horizontal rows: mouse drag or Shift+wheel; the plain wheel
|
||||
always scrolls the page). Space never presses a focused button
|
||||
(`appShortcuts`): it is the ready key.
|
||||
(`appShortcuts`): it is the ready key. Menus are one widget
|
||||
(`widgets/app_menu.dart`): `showAppMenu` is a popup route anchored under
|
||||
the button (over it when the room below is short), frosted dark glass
|
||||
with a check on the current row, fading and scaling in; arrows move,
|
||||
Enter picks, Esc or a click outside closes, and being a route it keeps
|
||||
the player's keys off (fullscreen too). `AppSelect` is the value picker
|
||||
on it (the current label and a chevron on a soft rounded surface; every
|
||||
setting that picks one of a few values), `AppMenuButton` the ⋮ action
|
||||
menu. Material's own menus (the standard controls' track, chapter and
|
||||
shader menus, the Jellyfin sort and server switch) get the same surface
|
||||
from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
|
||||
|
||||
- **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
|
||||
@@ -851,7 +861,10 @@ always scrolls the page). Space never presses a focused button
|
||||
(click picks the episode, double-click puts it up). A double-click on a
|
||||
card puts it up (a series: `playableFor`, its next episode). Link
|
||||
(`library_link.dart`): paste field, a preview (a page's yt-dlp title,
|
||||
uploader, length and thumbnail, else the file name; see "yt-dlp"), the three relay modes online, and the
|
||||
uploader, length and thumbnail, else the file name; see "yt-dlp"), the relay modes online ("Through me",
|
||||
the controller's default, as the main card; "Everyone fetches it" and
|
||||
"Server fetches it" under "Other ways", open while one of them is
|
||||
picked, closing it goes back to Through me), and the
|
||||
last 12 links put up (`recentLinks` in the `connect_form` box). Files
|
||||
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
|
||||
an isolate, videos grouped by series; series start collapsed, a header
|
||||
|
||||
@@ -17,7 +17,8 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
||||
+----------------------------------------+
|
||||
|
||||
video bytes come from somewhere else:
|
||||
- a link (any http/https URL): each app fetches it directly
|
||||
- a link (any http/https URL): the member who put it up passes it on
|
||||
through the relay (or each app, or the server, fetches it)
|
||||
- a Jellyfin item: each app plays it with its own Jellyfin account,
|
||||
friends without one through the server
|
||||
- a file on the host's disk: the server streams it (/m/... endpoint)
|
||||
@@ -87,9 +88,11 @@ for them until they join back.
|
||||
|
||||
## Where video comes from
|
||||
|
||||
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv;
|
||||
a web page (YouTube, X, ...) goes through yt-dlp, which the app and the
|
||||
server download and update by themselves.
|
||||
- **Link**: you paste an http(s) URL. By default your computer opens it and
|
||||
passes it on (it plays for everyone whenever it plays for you, cookies
|
||||
included); under "Other ways" every app plays it directly, or the server
|
||||
fetches it once. A web page (YouTube, X, ...) goes through yt-dlp, which
|
||||
the app and the server download and update by themselves.
|
||||
A pasted Jellyfin link becomes the Jellyfin item below when possible.
|
||||
- **Jellyfin item**: picked in the app's library browser. Shared as "this
|
||||
item on this Jellyfin server", never as a link with someone's sign-in.
|
||||
|
||||
Reference in new issue
Block a user