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:
foxyzandClaude Opus 5.5 committed 2026-10-11 05:58:18 +03:00
1 parent 2a7b553597
commit 95cd668cfb
17 files changed
+630 -125

No files matched your search

+3 -1
View File
@@ -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;
+101 -39
View File
@@ -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)),
],
),
),
],
),
),
+7 -12
View File
@@ -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'),
],
),
);
+18 -21
View File
@@ -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,