diff --git a/README.md b/README.md index ebac1c6..3c71424 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/desktop/lib/app.dart b/apps/desktop/lib/app.dart index 2f9a8cb..086d338 100644 --- a/apps/desktop/lib/app.dart +++ b/apps/desktop/lib/app.dart @@ -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()), ); diff --git a/apps/desktop/lib/src/controls/player_settings.dart b/apps/desktop/lib/src/controls/player_settings.dart index e86b1ae..165ebea 100644 --- a/apps/desktop/lib/src/controls/player_settings.dart +++ b/apps/desktop/lib/src/controls/player_settings.dart @@ -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 _dropdown({ +AppSelect _dropdown({ required Key key, required T value, required List<(T, String)> items, required ValueChanged onChanged, bool expanded = false, }) => - DropdownButton( - 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(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 { diff --git a/apps/desktop/lib/src/jellyfin/details_panel.dart b/apps/desktop/lib/src/jellyfin/details_panel.dart index a597c57..a3cc625 100644 --- a/apps/desktop/lib/src/jellyfin/details_panel.dart +++ b/apps/desktop/lib/src/jellyfin/details_panel.dart @@ -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 { children: [ const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))), if (seasons.length > 1) - DropdownButton( - 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( + key: const ValueKey('details-season'), + value: seasonId, + items: [for (final s in seasons) (s.id, s.name)], + onChanged: (id) => setState(() => _seasonId = id), + ), ), ], ); diff --git a/apps/desktop/lib/src/jellyfin/home_page.dart b/apps/desktop/lib/src/jellyfin/home_page.dart index f17a0c1..798facc 100644 --- a/apps/desktop/lib/src/jellyfin/home_page.dart +++ b/apps/desktop/lib/src/jellyfin/home_page.dart @@ -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( + AppMenuButton( 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, ), ], ), diff --git a/apps/desktop/lib/src/screens/home/media_picker.dart b/apps/desktop/lib/src/screens/home/media_picker.dart index d50f2eb..9d13dc2 100644 --- a/apps/desktop/lib/src/screens/home/media_picker.dart +++ b/apps/desktop/lib/src/screens/home/media_picker.dart @@ -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; diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 3b3ca58..075f750 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -379,7 +379,9 @@ class AppShellState extends State 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; diff --git a/apps/desktop/lib/src/shell/library_link.dart b/apps/desktop/lib/src/shell/library_link.dart index bfb1845..b3ccb76 100644 --- a/apps/desktop/lib/src/shell/library_link.dart +++ b/apps/desktop/lib/src/shell/library_link.dart @@ -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 { 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 { }); } + 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 { 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)), + ], + ), + ), ], ), ), diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index 6f10725..f681132 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -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( + trailing: AppMenuButton( 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'), ], ), ); diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index f26ded7..7c4a0cc 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -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( - 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( + 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( + builder: (context) => AppSelect( 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( + AppSelect( 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( + AppSelect( 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(value: null, child: Text('Same as set')), - for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) - DropdownMenuItem(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, diff --git a/apps/desktop/lib/src/widgets/app_menu.dart b/apps/desktop/lib/src/widgets/app_menu.dart new file mode 100644 index 0000000..61d0411 --- /dev/null +++ b/apps/desktop/lib/src/widgets/app_menu.dart @@ -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 { + 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 showAppMenu( + BuildContext context, { + required List> 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 _openMenu(BuildContext context, List> 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 open() => navigator.push(_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 { + final Rect anchor; + final List> 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 animation, Animation 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> items; + final ValueChanged 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 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 extends StatefulWidget { + final T value; + final List<(T, String)> items; + final ValueChanged? 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> createState() => _AppSelectState(); +} + +class _AppSelectState extends State> { + bool _open = false; + + Future _show(BuildContext context) async { + final box = context.findRenderObject() as RenderBox; + setState(() => _open = true); + final items = [ + for (final (v, label) in widget.items) AppMenuItem(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 extends StatelessWidget { + final List> Function() items; + final ValueChanged 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); + }, + ); + } +} diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart index 34726a7..d18d123 100644 --- a/apps/desktop/test/home_test.dart +++ b/apps/desktop/test/home_test.dart @@ -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')); diff --git a/apps/desktop/test/jellyfin_accounts_test.dart b/apps/desktop/test/jellyfin_accounts_test.dart index 841d794..7118e93 100644 --- a/apps/desktop/test/jellyfin_accounts_test.dart +++ b/apps/desktop/test/jellyfin_accounts_test.dart @@ -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; diff --git a/apps/desktop/test/library_tab_test.dart b/apps/desktop/test/library_tab_test.dart index 6d74345..9bf4c44 100644 --- a/apps/desktop/test/library_tab_test.dart +++ b/apps/desktop/test/library_tab_test.dart @@ -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); diff --git a/apps/desktop/test/uosc_controls_test.dart b/apps/desktop/test/uosc_controls_test.dart index 0c8f230..8547f15 100644 --- a/apps/desktop/test/uosc_controls_test.dart +++ b/apps/desktop/test/uosc_controls_test.dart @@ -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>(design).value, isFalse); + expect(tester.widget>(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>(design).value, isTrue); + expect(tester.widget>(design).value, isTrue); }); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db6c6af..efc1335 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 649750d..b9359d0 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -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.