1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash

The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
  Host cards (hosting no longer takes media), status-ring avatar stack
  with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
  draggable corner-snapping mini player, or fullscreen (window_manager,
  party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
  PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
  mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
  = the new SchedulePage; Settings = left nav over the existing panels.

Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
  "avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
  /a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
  cached by hash in the app (AvatarCache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:23:46 +03:00
1 parent f790470935
commit d29c12e4f0
62 files changed
+5777 -3572

No files matched your search

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
/// The Settings tab's pages, in nav order.
enum SettingsSection {
profile(Icons.person_outline, 'Profile'),
player(Icons.play_circle_outline, 'Player'),
integrations(Icons.extension_outlined, 'Jellyfin & integrations'),
updates(Icons.system_update_alt, 'Updates'),
shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'),
about(Icons.info_outline, 'About');
final IconData icon;
final String label;
const SettingsSection(this.icon, this.label);
}
/// App-wide settings under a left nav (chips on top when narrow). The
/// pages hold the existing settings panels; the shell owns [section] so
/// the profile card can open a page.
class SettingsTab extends StatelessWidget {
final SettingsSection section;
final ValueChanged<SettingsSection> onSection;
final VoidCallback? onEditProfile;
final UpdateController? updates;
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const SettingsTab({
super.key,
required this.section,
required this.onSection,
this.onEditProfile,
this.updates,
this.jellyfin,
});
List<Widget> _page(BuildContext context) {
switch (section) {
case SettingsSection.profile:
return [
SettingsPanel(
title: 'You',
child: SignalBuilder(
builder: (context) {
final me = selfAvatarInfo();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: StatusAvatar(info: me, size: 48, badges: false, ring: false),
title: Text(me.name == '?' ? 'No name yet' : me.name),
subtitle: const Text('Name, picture and colour: click your avatar in the top left'),
trailing: onEditProfile == null
? null
: TextButton(onPressed: onEditProfile, child: const Text('Edit')),
);
},
),
),
];
case SettingsSection.player:
return const [
SettingsPanel(
title: 'Player',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [HwdecSetting(), SizedBox(height: 12), MpvControlsSetting()],
),
),
SettingsPanel(title: 'Video quality & shaders', child: ShaderSettings()),
];
case SettingsSection.integrations:
return [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
),
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()),
];
case SettingsSection.updates:
return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))];
case SettingsSection.shortcuts:
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
case SettingsSection.about:
return [AboutPanel(updates: updates)];
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) {
final narrow = box.maxWidth < 760;
final nav = narrow
? SizedBox(
height: 52,
child: HScroll(
builder: (c) => ListView(
controller: c,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
children: [
for (final s in SettingsSection.values)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
key: ValueKey('settings-${s.name}'),
label: Text(s.label),
selected: section == s,
onSelected: (_) => onSection(s),
),
),
],
),
),
)
: SizedBox(
width: 250,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
children: [
for (final s in SettingsSection.values)
ListTile(
key: ValueKey('settings-${s.name}'),
selected: section == s,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: Icon(s.icon, size: 20),
title: Text(s.label),
onTap: () => onSection(s),
),
],
),
);
final page = fadeThrough(
KeyedSubtree(
key: ValueKey(section),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
children: [
Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 14),
..._page(context),
],
),
),
),
],
),
),
);
return narrow
? Column(children: [nav, Expanded(child: page)])
: Row(children: [nav, Expanded(child: page)]);
},
);
}
}