Desktop session batch 09-12: frameless chrome, bisect removal, chat/idle fixes

Frameless (window_manager, hidden titlebar): chrome bar on welcome,
drag-to-move appbar + window buttons on connect, drag layer + window
buttons in session controls; min size 160x90; volume-scroll off for
drag; session maximize = fullscreen toggle. Verified on KDE Wayland.
Delete legacy/minimal/nosheet bisect screens; session routes direct
(black-screen fix verified incl. WAN).
Avatar: pipeline now 128px aspect-kept PNG (was cropped JPEG);
profile card centered 64px hover-to-change; member avatar drops
ResizeImage downscale. NOTE open: pipeline 512 KiB check vs server
64 KiB enforcement mismatch.
Fixes: idle warn/unready skipped while PLAYING; sendChat drops
blanks; one chat row per send (drop textless server feed echo);
chat field keeps focus after send. Playback libass true; sdk floor
3.13. Handoff 9-11: 9+10 fixed, 11 jumpy seek still open.
Docs: handoff/roadmap/protocol record all of the above; few-commits
preference going forward.
This commit is contained in:
foxyz committed 2026-09-12 11:42:18 +03:00
1 parent 3303d88a5b
commit ff154c5030
26 files changed
+1147 -881

No files matched your search

@@ -3,12 +3,14 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../widgets/avatar_pipeline.dart';
import '../widgets/media_input.dart';
import '../widgets/user_section.dart';
import '../widgets/window_buttons.dart';
class ConnectScreen extends StatefulWidget {
const ConnectScreen({super.key});
@@ -226,9 +228,15 @@ class _ConnectScreenState extends State<ConnectScreen> {
Widget build(BuildContext context) {
final session = SessionState.instance;
return Scaffold(
appBar: AppBar(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome),
title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: DragToMoveArea(
child: AppBar(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome),
title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')),
actions: const [WindowButtons()],
),
),
),
body: Stack(
alignment: AlignmentGeometry.center,
@@ -1,151 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../state/session_state.dart';
const _legacyControls = MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
keyboardShortcuts: {},
bottomButtonBar: [
MaterialDesktopVolumeButton(),
MaterialDesktopPositionIndicator(),
Spacer(),
MaterialDesktopFullscreenButton(),
],
);
class LegacySessionScreen extends StatefulWidget {
const LegacySessionScreen({super.key});
@override
State<LegacySessionScreen> createState() => _LegacySessionScreenState();
}
class _LegacySessionScreenState extends State<LegacySessionScreen> {
VideoController? _video;
final _focus = FocusNode();
@override
void initState() {
super.initState();
_video = VideoController(
SessionState.instance.coordinator.playback.player);
}
@override
void dispose() {
_focus.dispose();
super.dispose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.space) {
SessionState.instance.coordinator.toggleReady();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
final coord = session.coordinator;
return Focus(
focusNode: _focus,
autofocus: true,
onKeyEvent: _onKey,
child: Scaffold(
appBar: AppBar(
title: SignalBuilder(
builder: (context) {
final h = coord.health.value;
final r = coord.room.value;
return Text('Room · ${r?.transport ?? '…'} · ${h.message}');
},
),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => session.leave(),
),
],
),
body: Column(
children: [
SignalBuilder(builder: (context) {
final h = coord.health.value;
final starve = coord.starving.value;
final text = [
h.message,
if (starve != null) starve,
].join(' · ');
return Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(text),
);
}),
Expanded(
child: _video == null
? const Center(child: Text('player starting…'))
: MaterialDesktopVideoControlsTheme(
normal: _legacyControls,
fullscreen: _legacyControls,
child: Video(controller: _video!),
),
),
SignalBuilder(builder: (context) {
final r = coord.room.value;
if (r == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.all(8),
child: Wrap(
spacing: 8,
children: [
for (final m in r.members)
Chip(
avatar: CircleAvatar(
child: Text(m.name.isEmpty
? '?'
: m.name[0].toUpperCase()),
),
label: Text(m.name),
backgroundColor: m.ready
? Colors.green.withValues(alpha: 0.3)
: null,
),
],
),
);
}),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SignalBuilder(builder: (context) {
final ready =
coord.selfMember.value?.ready ?? false;
return ElevatedButton(
onPressed: () => coord.toggleReady(),
child: Text(ready ? 'Ready ✓' : 'Not ready'),
);
}),
const SizedBox(width: 12),
OutlinedButton(
onPressed: () => coord.forcePlayAll(),
child: const Text('Force play all'),
),
],
),
),
],
),
),
);
}
}
@@ -1,125 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../state/session_state.dart';
const _minimalControls = MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
keyboardShortcuts: {},
bottomButtonBar: [
MaterialDesktopVolumeButton(),
_ReadyButton(),
_MarkAllButton(),
MaterialDesktopPositionIndicator(),
Spacer(),
MaterialDesktopFullscreenButton(),
],
);
class MinimalSessionScreen extends StatefulWidget {
const MinimalSessionScreen({super.key});
@override
State<MinimalSessionScreen> createState() => _MinimalSessionScreenState();
}
class _MinimalSessionScreenState extends State<MinimalSessionScreen> {
VideoController? _video;
@override
void initState() {
super.initState();
_video = VideoController(
SessionState.instance.coordinator.playback.player);
HardwareKeyboard.instance.addHandler(_key);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_key);
super.dispose();
}
bool _key(KeyEvent event) {
if (event is! KeyDownEvent) return false;
if (SessionState.instance.route.peek() != AppRoute.session) {
return false;
}
if (event.logicalKey == LogicalKeyboardKey.space) {
SessionState.instance.coordinator.toggleReady();
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: _video == null
? const Center(child: Text('player starting…'))
: MaterialDesktopVideoControlsTheme(
normal: _minimalControls,
fullscreen: _minimalControls,
child: Video(controller: _video!),
),
),
const Positioned(
top: 8,
right: 8,
child: Text('MINIMAL',
style: TextStyle(color: Colors.red, fontSize: 12)),
),
],
),
);
}
}
class _ReadyButton extends StatelessWidget {
const _ReadyButton();
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) {
final ready = SessionState
.instance.coordinator.selfMember.value?.ready ??
false;
return TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () =>
SessionState.instance.coordinator.toggleReady(),
child: Text(ready ? 'Ready ✓' : 'Not ready',
style: const TextStyle(fontSize: 12)),
);
},
);
}
}
class _MarkAllButton extends StatelessWidget {
const _MarkAllButton();
@override
Widget build(BuildContext context) {
return TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () =>
SessionState.instance.coordinator.forcePlayAll(),
child: const Text('Mark everyone ready',
style: TextStyle(fontSize: 12)),
);
}
}
@@ -1,208 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../session/health.dart';
import '../state/session_state.dart';
import '../widgets/member_avatar.dart';
const _nosheetControls = MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
keyboardShortcuts: {},
visibleOnMount: true,
hideMouseOnControlsRemoval: false,
controlsHoverDuration: Duration(hours: 24),
bottomButtonBar: [
MaterialDesktopVolumeButton(),
_ReadyButton(),
_MarkAllButton(),
MaterialDesktopPositionIndicator(),
Spacer(),
MaterialDesktopFullscreenButton(),
],
);
class NosheetSessionScreen extends StatefulWidget {
const NosheetSessionScreen({super.key});
@override
State<NosheetSessionScreen> createState() => _NosheetSessionScreenState();
}
class _NosheetSessionScreenState extends State<NosheetSessionScreen> {
VideoController? _video;
@override
void initState() {
super.initState();
_video = VideoController(
SessionState.instance.coordinator.playback.player);
HardwareKeyboard.instance.addHandler(_key);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_key);
super.dispose();
}
bool _key(KeyEvent event) {
if (event is! KeyDownEvent) return false;
if (SessionState.instance.route.peek() != AppRoute.session) {
return false;
}
if (event.logicalKey == LogicalKeyboardKey.space) {
SessionState.instance.coordinator.toggleReady();
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: _video == null
? const Center(child: Text('player starting…'))
: MaterialDesktopVideoControlsTheme(
normal: _nosheetControls,
fullscreen: _nosheetControls,
child: Video(controller: _video!),
),
),
const Positioned(
top: 8,
right: 8,
child: Text('NOSHEET',
style: TextStyle(color: Colors.red, fontSize: 12)),
),
Positioned(
top: 12,
left: 12,
child: SignalBuilder(builder: (context) {
final h = coord.health.value;
final starve = coord.starving.value;
final retrying = coord.reconnecting.value;
final unwell = h.severity == HealthSeverity.warn ||
h.severity == HealthSeverity.error ||
retrying ||
starve != null;
if (!unwell) return const SizedBox.shrink();
return Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(999),
),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
[h.message, if (starve != null) starve].join(' · '),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
);
}),
),
SignalBuilder(builder: (context) {
final r = coord.room.value;
if (r == null || r.transport != 'held') {
return const SizedBox.shrink();
}
final waiting = [
for (final m in r.members)
if (!m.ready) m
];
if (waiting.isEmpty) return const SizedBox.shrink();
return Positioned.fill(
child: IgnorePointer(
child: Center(
child: Wrap(
spacing: -20,
children: [
for (final m in waiting)
Opacity(
opacity: m.stale ? 0.5 : 1.0,
child: MemberAvatar(
avatarBase64: m.avatar,
name: m.name,
size: 64,
),
),
],
),
),
),
);
}),
Positioned(
right: 12,
top: 56,
child: SignalBuilder(builder: (context) {
final items = coord.toasts.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final t in items.reversed.take(5))
Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Text(t.text),
),
),
],
);
}),
),
],
),
);
}
}
class _ReadyButton extends StatelessWidget {
const _ReadyButton();
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) {
final ready = SessionState
.instance.coordinator.selfMember.value?.ready ??
false;
return TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () =>
SessionState.instance.coordinator.toggleReady(),
child: Text(ready ? 'Ready ✓' : 'Not ready',
style: const TextStyle(fontSize: 12)),
);
},
);
}
}
class _MarkAllButton extends StatelessWidget {
const _MarkAllButton();
@override
Widget build(BuildContext context) {
return TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () =>
SessionState.instance.coordinator.forcePlayAll(),
child: const Text('Mark everyone ready',
style: TextStyle(fontSize: 12)),
);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,16 +1,32 @@
import 'package:flutter/material.dart';
import '../state/session_state.dart';
import '../widgets/window_chrome_bar.dart';
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(
children: [
WindowChromeBar(title: 'Watch Together'),
Expanded(child: _WelcomeBody()),
],
),
);
}
}
class _WelcomeBody extends StatelessWidget {
const _WelcomeBody();
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
return Scaffold(
body: Center(
child: ConstrainedBox(
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -44,7 +60,6 @@ class WelcomeScreen extends StatelessWidget {
],
),
),
),
);
);
}
}