13 Commits
Author SHA1 Message Date
foxyz 81b9d05de6 Release v1.3.2
ci / updater-windows (push) Skipped
ci / core (push) Successful in 29s
ci / server (push) Successful in 50s
release / linux (push) Successful in 4m26s
release / windows (push) Successful in 6m30s
2026-10-17 02:14:55 +03:00
foxyzandClaude Opus 5.5 f9b79aa47c Server test: flaky_link's silence limit 1.5 s (600 ms marked a reporting client gone on a busy CI runner)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:05:44 +03:00
foxyzandClaude Opus 5.5 e16cf50f2c Windows: every start ended itself after 3 s (1.3.0, 1.3.1)
ci / core (push) Successful in 28s
ci / server (push) Successful in 51s
ci / updater-windows (push) Successful in 6m27s
Win32Window::Create calls Destroy() before it makes the window, which
ran FlutterWindow::OnDestroy and armed the 3 s exit timer at launch: the
window showed, then the process ended with code 0, no crash, no close
lines. The timer is only armed with an engine to tear down. Found and
fixed by Fun Maker's agent on Windows (a debugger caught the exit on the
timer's thread).

The smoke runs missed it (they closed at ~4 s anyway): the app now stays
up 10 s, and the scripts require 'closing' and 'quitting' from its own
close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:04:55 +03:00
foxyz 8aa1186d29 Revert "Sounds: survive an audio device that crashes the app on open"
This reverts commit c91d57633f.
2026-10-17 02:04:33 +03:00
foxyz 49fee1ca19 Release v1.3.1
ci / updater-windows (push) Skipped
ci / core (push) Successful in 29s
ci / server (push) Failing after 40s
release / linux (push) Successful in 4m17s
release / windows (push) Successful in 6m18s
2026-10-17 00:56:17 +03:00
foxyzandClaude Opus 5.5 c91d57633f Sounds: survive an audio device that crashes the app on open
A Windows friend's 1.3.0 dies about a second in on every start, right
around the 2 s audio warm-up (no Dart error, no close lines). Opening the
device is now logged and leaves audio-init.pending until it returns; a
start that finds it skips sounds instead of crashing again, and a sound
preview in Settings tries once more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:50:26 +03:00
foxyzandClaude Opus 5.5 22ce40a488 tool/windows_report.cmd: double-click crash report for Windows friends (logs, crash events, files, test start, zip on the clipboard)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:25:53 +03:00
foxyzandClaude Opus 5.5 07276ce020 Roadmap: P35 notification bursts, P36 stale warm relay re-dial
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:35:34 +03:00
foxyzandClaude Opus 5.5 4c8e40769a Mini player cleanup: no full-player controls left under it, the full player's play button, a plain progress bar
media_kit keeps a Video's last controls when given null, so after the
full player the mini kept the Minimal bars (dark bands above and below
the picture, a second hatched line). The mini now passes an empty
builder. Its hover bar uses the full player's play button and time,
the status on its own line, Minimal buttons, and a painted bar instead
of Material's progress indicator (its gap and stop dot at the corner).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:14:48 +03:00
foxyzandClaude Opus 5.5 211ba65e5e Roadmap: AniList browser (requests to AnimeFlow, later Sonarr), real cached ranges on the timelines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:04:30 +03:00
foxyzandClaude Opus 5.5 fe4749ec7f Minimal: the hidden line is the band shrunk (same colours, no switch), stronger stripes on it, lighter chapter cuts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:04:09 +03:00
foxyzandClaude Opus 5.5 51af830160 Joining another server no longer opens an empty player
connect() kept the last server's state while dialling the next; the
shell saw its media on arrival and opened the player (full, on a
friend's server) over the new, empty one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:53:37 +03:00
foxyzandClaude Opus 5.5 d9d7881747 Roadmap: 1.3.0 released
ci / core (push) Successful in 30s
ci / server (push) Successful in 51s
ci / updater-windows (push) Successful in 6m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:45:15 +03:00
19 changed files with 346 additions and 100 deletions

No files matched your search

@@ -28,6 +28,9 @@ abstract final class MinimalTimelineSpec {
/// What is left of it while they are hidden.
static const lineSize = 3.0;
/// The played part's accent tint.
static const played = 0.4;
/// The position line.
static const lineWidth = 2.0;
static const fontSize = 15.0;
@@ -467,13 +470,10 @@ class MinimalTimelinePainter extends CustomPainter {
final w = size.width, h = size.height;
final x = lineX(w);
final band = Offset.zero & size;
// The band, darker as it grows; hidden, a faint track.
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
// Played: an accent tint, solid once the band is a line.
canvas.drawRect(
Rect.fromLTRB(0, 0, x, h),
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
);
// The same colours shown and hidden: hidden, it is the band shrunk to
// a line, so growing and shrinking never switches colour.
canvas.drawRect(band, Paint()..color = MinimalStyle.band);
canvas.drawRect(Rect.fromLTRB(0, 0, x, h), Paint()..color = accent.withValues(alpha: MinimalTimelineSpec.played));
_stripes(canvas, w, h);
_chapters(canvas, w, h);
if (visibility > 0) {
@@ -500,7 +500,8 @@ class MinimalTimelinePainter extends CustomPainter {
canvas.save();
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility))
// Stronger on the line, where only a few pixels of each show.
..color = Colors.white.withValues(alpha: 0.12 + 0.22 * (1 - visibility))
..strokeWidth = 1.5 * scale;
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
@@ -509,7 +510,7 @@ class MinimalTimelinePainter extends CustomPainter {
}
void _chapters(Canvas canvas, double w, double h) {
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
final cut = Paint()..color = Colors.black.withValues(alpha: 0.3);
for (final f in chapters) {
if (f <= 0) continue;
final cx = w * f;
+12 -4
View File
@@ -12,9 +12,14 @@ import 'ports/player_host.dart';
// in a session player with a video output through the media pipeline,
// play a second and close the normal way ([shutDownApp]). Progress goes to
// `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`,
// `closing`, or `skip <why>` / `fail <why>`), so the script can tell a
// runner that cannot show a window (skip) from a close that hangs or
// leaves a process behind (fail).
// `closing`, `quitting`, or `skip <why>` / `fail <why>`), so the script can
// tell a runner that cannot show a window (skip) from a close that hangs or
// leaves a process behind, or a process that ends on its own (fail). It
// stays up [smokeUptime] first: a start that dies by itself (the 1.3.0
// Windows exit timer) ends before `closing`.
/// How long the smoke run keeps the app up before closing it.
const smokeUptime = Duration(seconds: 10);
/// The smoke run's media, or null for a normal start.
String? get smokeMedia {
@@ -65,6 +70,9 @@ Future<void> runSmoke(AppGraph graph, String media) async {
} catch (e) {
step('fail $e');
}
final wait = smokeUptime - sw.elapsed;
if (wait > Duration.zero) await Future.delayed(wait);
step('closing');
await shutDownApp();
// Reached only through the app's own close (not a kill on the way).
await shutDownApp(beforeQuit: () async => step('quitting'));
}
@@ -517,6 +517,9 @@ class SessionCoordinator {
final epoch = connection.invalidate();
await _teardown(silent: true);
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
// The last server's state is not this one's: kept, its media opened the
// player (full, on a friend's server) over an empty new server.
serverState.value = null;
connection.begin(params);
_wsHost = params.host;
_wsPort = params.port;
+118 -56
View File
@@ -8,6 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../controls/controls_probe.dart';
import '../controls/minimal/style.dart';
import '../controls/model/controls_layout.dart';
import '../controls/model/controls_model.dart';
import '../controls/session_controls.dart';
import '../controls/volume.dart';
@@ -291,7 +293,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
_videoGen = gen;
_videoKey = GlobalKey<VideoState>();
}
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
// Never null for the mini: media_kit keeps the last controls
// when given null, so the full player's would stay under it.
final VideoControlsBuilder controls = _full ? _sessionControls : _noControls;
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
// The wheel anywhere on the full player steps the volume
// (lists, menus and popovers under the mouse scroll instead).
@@ -568,11 +572,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
),
);
/// Mini: title, expand and close on hover; ready, time and a seekable
/// bar at the bottom. A thin progress line stays while not hovered.
/// Mini: title, expand and close on hover; the status, play (the full
/// player's play button: ready in a server) and the time, and a bar that
/// seeks. A thin progress line stays while not hovered.
Widget _miniOverlay() {
final coord = _coord;
final playback = coord.playback;
const scale = 0.8;
return MouseRegion(
onEnter: (_) => setState(() => _miniHover = true),
onExit: (_) => setState(() => _miniHover = false),
@@ -589,55 +595,73 @@ class _SessionPlayerState extends State<SessionPlayer> {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent, Colors.black87],
stops: [0, 0.35, 0.55, 1],
colors: [Color(0x99000000), Color(0x00000000), Color(0x00000000), Color(0xCC000000)],
),
),
child: Column(
children: [
Row(
children: [
const SizedBox(width: 10),
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
IconButton(
focusNode: _noFocus,
onPressed: widget.onExpand,
tooltip: 'Expand',
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18),
),
IconButton(
focusNode: _noFocus,
onPressed: widget.onClose,
tooltip: 'Sit out (stay in the server)',
icon: const Icon(Icons.close, color: Colors.white, size: 18),
),
],
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Expanded(
child: Padding(
padding: EdgeInsets.only(top: 4),
child: NowPlayingTitle(cover: false, compact: true),
),
),
MinimalButton(
icon: Icons.open_in_full_rounded,
tooltip: 'Expand',
scale: scale,
onPressed: widget.onExpand),
MinimalButton(
icon: Icons.close_rounded,
tooltip: 'Sit out (stay in the server)',
scale: scale,
onPressed: widget.onClose),
],
),
),
const Spacer(),
SignalBuilder(builder: (context) {
final ready = coord.selfMember.value?.ready ?? false;
final snap = _model.snapshot.value;
final pos = playback.position.value, dur = playback.duration.value;
return Row(
children: [
IconButton(
focusNode: _noFocus,
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)',
onPressed: coord.toggleReady,
icon: Icon(
ready ? Icons.check_circle : Icons.radio_button_unchecked,
color: ready ? Colors.greenAccent : Colors.white,
final status = playerStatusNow(coord)?.short;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (status != null && status.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 0, 2),
child: Text(status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFFFC857), fontSize: 12)),
),
Row(
children: [
MinimalButton(
icon: controlIcon(BarControl.playPause, snap),
tooltip: controlTooltip(BarControl.playPause, snap),
accent: true,
scale: scale,
onPressed: _model.togglePlay,
),
const SizedBox(width: 8),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(
color: MinimalStyle.dim,
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()])),
],
),
),
Text('${formatClock(pos)} / ${formatClock(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(playerStatusNow(_coord)?.short ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)),
),
const SizedBox(width: 10),
],
],
),
);
}),
_MiniSeekBar(onSeek: playback.userSeek),
@@ -654,6 +678,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
}
/// The mini player's empty controls slot (see [SessionPlayer]'s video).
Widget _noControls(VideoState state) => const SizedBox.shrink();
/// A floating pill's visibility: only while the controls are up (the
/// mouse moved) or the mouse is on it. With [announce] it is also up for
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
@@ -768,7 +795,9 @@ const _shaderKeys = [
LogicalKeyboardKey.digit6,
];
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks.
/// Mini player progress across the bottom edge: a 3 px line, or (hovered)
/// a 4 px bar with its track and the cached part that seeks (taller to
/// hit). Plain rectangles: the player's rounded clip shapes the ends.
class _MiniSeekBar extends StatelessWidget {
final bool thin;
final ValueChanged<Duration>? onSeek;
@@ -777,26 +806,59 @@ class _MiniSeekBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final playback = AppScope.of(context).coordinator.playback;
final accent = Theme.of(context).colorScheme.primary;
return SignalBuilder(builder: (context) {
final d = playback.duration.value;
final p = playback.position.value;
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
final bar = LinearProgressIndicator(
value: v,
minHeight: thin ? 2 : 4,
backgroundColor: thin ? Colors.transparent : Colors.white24,
double f(Duration x) => d.inMilliseconds == 0 ? 0.0 : (x.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
final bar = SizedBox(
height: thin ? 3 : 4,
width: double.infinity,
child: CustomPaint(
painter: _MiniBarPainter(
played: f(playback.position.value),
cached: thin ? null : f(playback.buffered.value),
accent: accent,
),
),
);
if (thin || onSeek == null || d == Duration.zero) return bar;
void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0));
return LayoutBuilder(
builder: (context, box) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
onHorizontalDragEnd: null,
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar),
builder: (context, box) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
child: Padding(padding: const EdgeInsets.only(top: 8), child: bar),
),
),
);
});
}
}
class _MiniBarPainter extends CustomPainter {
final double played;
/// End of the cache ahead (a track is drawn only with it).
final double? cached;
final Color accent;
const _MiniBarPainter({required this.played, required this.cached, required this.accent});
@override
void paint(Canvas canvas, Size size) {
final w = size.width, h = size.height;
final c = cached;
if (c != null) {
canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white.withValues(alpha: 0.18));
if (c > played) {
canvas.drawRect(Rect.fromLTRB(w * played, 0, w * c, h), Paint()..color = Colors.white.withValues(alpha: 0.3));
}
}
canvas.drawRect(Rect.fromLTRB(0, 0, w * played, h), Paint()..color = accent);
}
@override
bool shouldRepaint(_MiniBarPainter old) => old.played != played || old.cached != cached || old.accent != accent;
}
+2 -2
View File
@@ -865,14 +865,14 @@ packages:
path: "../../packages/core"
relative: true
source: path
version: "1.2.0"
version: "1.3.0"
sync_relay_server:
dependency: "direct main"
description:
path: "../../packages/server"
relative: true
source: path
version: "1.2.0"
version: "1.3.0"
synchronized:
dependency: transitive
description:
+1 -1
View File
@@ -1,7 +1,7 @@
name: starsync
description: Starsync desktop app (repo sync_relay_project). One binary.
publish_to: none
version: 1.3.0
version: 1.3.2
environment:
sdk: '>=3.13.0-0 <4.0.0'
@@ -6,6 +6,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart';
import 'package:signals/signals.dart' show effect;
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_core/testing.dart';
import 'package:sync_relay_server/server.dart';
@@ -443,6 +444,39 @@ void main() {
}
});
test('moving to another server never shows the last one\'s media', timeout: const Timeout(Duration(minutes: 1)),
() async {
final home = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'home'));
final empty = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'empty'));
await home.start();
await empty.start();
final origin = await _origin(await _media());
try {
final alice = await member(home.boundPort, 'alice');
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${origin.port}/ep.mkv', anyway: true)), isNull);
await _poll(() => alice.serverState.peek()?.media != null, 'media up at home');
// The shell opens the player on any state with media as soon as the
// app is in a server again: nothing from the last one may be left
// while the new one is dialled.
final seen = <String>[];
final stop = effect(() {
if (alice.connection.state.value is ConnConnecting) {
seen.add(alice.serverState.peek()?.media?['name'] as String? ?? '');
}
});
await alice.connect(ConnectParams(host: '127.0.0.1', port: empty.boundPort, secret: 'pw', name: 'alice'));
await _poll(() => alice.serverState.peek() != null, 'the new server\'s state');
stop();
expect(seen, isNotEmpty);
expect(seen.where((n) => n.isNotEmpty), isEmpty, reason: 'no media from the last server');
expect(alice.serverState.peek()?.media, isNull);
} finally {
await home.stop();
await empty.stop();
await origin.close(force: true);
}
});
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start();
+19
View File
@@ -418,6 +418,25 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('the mini player clears the full player\'s controls (media_kit keeps the last ones on null)',
(tester) async {
coord.playback.alive.value = true;
final given = <PlayerMode, Object?>{};
PlayerMode mode() => tester.state<AppShellState>(find.byType(AppShell)).mode;
await pumpShell(tester, const Size(1280, 800), video: (context, controls) {
given[mode()] = controls;
return const ColoredBox(color: Color(0xFF7A3B2E));
});
online(party);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('tab-settings')));
await tester.pumpAndSettle();
expect(mode(), PlayerMode.mini);
expect(given[PlayerMode.full], isNotNull);
expect(given[PlayerMode.mini], isNotNull, reason: 'null would keep the full controls');
expect(given[PlayerMode.mini], isNot(same(given[PlayerMode.full])));
});
testWidgets('status pill: who is not ready, with the ready count', (tester) async {
coord.playback.alive.value = true;
await pumpShell(tester, const Size(1280, 800));
@@ -80,7 +80,11 @@ bool FlutterWindow::OnCreate() {
void FlutterWindow::OnDestroy() {
// The engine and the plugins go now; the app released its players first.
ExitAfter(3000);
// Win32Window::Create runs this too, before there is an engine: only a
// real teardown may end the process (else every start dies at 3 s).
if (flutter_controller_) {
ExitAfter(3000);
}
window_channel_ = nullptr;
if (flutter_controller_) {
flutter_controller_ = nullptr;
+16 -4
View File
@@ -1958,7 +1958,13 @@ panel) and chat messages (Copy message, and a link in it).
snaps to the nearest corner with the shared curve). A mode change fades
the layer out, moves it while invisible and fades it in (with the tabs
when the full player comes or goes), so the video is never mounted twice
nor remounted. A tab sends the full player to mini; × on the mini sits
nor remounted. The mini has no control skin: its `Video` gets an empty
controls builder (never null: media_kit keeps the last controls on
null, which left the full player's bars under the mini). Hovered it
shows the title with expand and ×, the status line, the full player's
play button (`controlIcon`/`togglePlay`: ready in a server) with the
time, and a 4 px bar (track, cached, played) that seeks; otherwise a
3 px played line along the bottom edge. A tab sends the full player to mini; × on the mini sits
out (the player unloads and shows the join-back card, `SitOutCard`, over
the still mounted `Video`); × on that card closes the player, the strip
brings it back, and joining back with it closed shows the mini. Fullscreen
@@ -2204,7 +2210,7 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
glass capsules (`MinimalCapsule`, the pills' tint and hairline;
`MinimalButton` rounded squares, the play button a filled accent
circle) above `MinimalTimeline`, a 36 px band across the bottom: the
played part tinted in the theme's primary, the position a white line,
played part tinted in the theme's primary (40 %), the position a white line,
the part not downloaded yet (from `buffered`, or the position when
that is behind, to the end) in thin stripes, chapter starts as dots on
the top edge with a dark cut through the band (a press within reach of
@@ -2214,7 +2220,11 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
not reach the end). Hover: a faint line and a glass tip with the time
(the chapter title above it). A press seeks, every drag move seeks,
after release the line stays at the target until the position catches
up (at most 2 s). Hidden, it shrinks (120 ms) to a 3 px progress line.
up (at most 2 s). Hidden, it shrinks (120 ms) to a 3 px progress line
that is the band itself, smaller: the same dark band, the same played
tint (40 % of the accent), so growing or shrinking never changes colour;
the stripes are stronger on the line (a few pixels of each show) so the
part not downloaded still reads; chapter cuts are a light dark line.
We know only the end of mpv's cache, so a dropped back buffer is not
shown.
- **Settings** (`AppSettings`, `model/controls_prefs.dart`, all live):
@@ -2283,7 +2293,9 @@ app kills itself (`Process.killPid`, no exit handlers; it only gets there
if the close did not go through); the runners end the process 15 s after
the first close request (Linux: a `delete-event` handler connected before
window_manager's; Windows: the first `WM_CLOSE`), and Windows also 3 s
after the window is destroyed, while the engine and plugins go.
after the window is destroyed, while the engine and plugins go (only
with an engine: `Win32Window::Create` runs `OnDestroy` before the window
exists, and a timer armed there ended every start at 3 s).
## Logging
+14 -4
View File
@@ -210,10 +210,12 @@ updater tests, the release build and the close smoke run on that build
`STARSYNC_SMOKE=<clip>` (a 5 s clip from ffmpeg, else a WAV the script
writes) under its own profile (`STARSYNC_PROFILE=smoke`), opens the clip
in a player with a video output through the media pipeline
(`platform/smoke.dart`), plays a second and closes the normal way; the
steps go to `STARSYNC_SMOKE_OUT` (`start`, `opened <ms> render=<mode>`,
`closing`, or `skip` / `fail` with why). It fails when the app does not
exit within 45 s or leaves a `starsync` process, skips with a warning
(`platform/smoke.dart`), plays it, stays up 10 s from its start and
closes the normal way; the steps go to `STARSYNC_SMOKE_OUT` (`start`,
`opened <ms> render=<mode>`, `closing`, `quitting` just before the
window closes, or `skip` / `fail` with why). It fails when the app ends
by itself before `closing` or never reaches `quitting`, does not exit
within 45 s or leaves a `starsync` process, skips with a warning
when the runner cannot bring the app or its video output up, and prints
the `smoke:`, `close:` and `mpv: render` lines of the app log.
`tool/smoke_linux.sh` is the same on Linux (needs a display; after
@@ -302,6 +304,14 @@ in the same file; a dedicated server prints its folder at start
(`--log-dir`). Users send theirs with Settings → About → "Copy
diagnostics".
A Windows friend without a terminal habit: `tool/windows_report.cmd`
(one file: a cmd header that runs the PowerShell below it). Dropped into
the Starsync folder and double-clicked, it collects the app's logs,
Windows' crash events (1000/1001/1026), that install's file list (and
any other found on Desktop, Downloads, Documents, AppData, Program
Files) and a 20 s test start with its exit code, zips them to the
Desktop and copies the zip (Ctrl+V into Discord).
Start with the session log when there is one: `session-YYYY-MM-DD.log`
in the server's folder (the hosting app's logs folder, or the dedicated
server's `--log-dir`), every member's lines and the server's merged in
+17 -9
View File
@@ -52,6 +52,12 @@ Problems are numbered; delete a line when its fix ships.
the `close:` log lines.
- P33 No volume readout on wheel: likely fixed by the controls rework
(1.2.1-dev.1; a test covers both skins); confirm in real use.
- P35 System notifications in a burst hit KDE Plasma's limit
(`ExcessNotificationGeneration`, 3 refused within 3 s after a swap while
the window was in the background): throttle or merge them app side.
- P36 After a reconnect the app re-dials the warm relay of an Up next
item that was already played ("relay channel refused: not the source of
Up next item q8"): harmless, the feeder should end with its item.
**Restructure** (in order; reviewed against the code). Truth lives in
three places: the server (members, presence, gate, transport, queue incl.
@@ -59,9 +65,8 @@ the automatic continuation and countdown, relay state), the device
(client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list).
7. Release 1.3.0: publish the manifest (protocol 16 makes it a required
update for 1.2.x) and update the VPS dedicated server in the same
window; check a 1.2.x app gets a clear "update the app" message.
Released as 1.3.0. Left: check that a 1.2.x app gets a clear "update
the app" message and updates (Windows: the first real use, backlog 5).
Composition root (in place, ARCHITECTURE "Composition root"): the
remaining `.instance` singletons move into `AppGraph` as the steps touch
them; `singletons_test` holds the count (today: AppSettings 49,
@@ -246,6 +251,10 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
3. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule from the user's AniList lists. This is the
Schedule's personal tracking; AnimeFlow has no per-user follow.
**AniList browser** (wanted next once 1.3.0 holds up): view shows and
browse older ones (search, seasons, details), and request one from
there on AnimeFlow; later on Sonarr too, which is what a public
release of the app would use.
4. **Ops**: Check shaders on Windows; run the relay e2e "the source drops
uncleanly and comes back" a few times under the full suite (its
@@ -292,12 +301,11 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
8. **Jellyfin** (epic, below).
9. **Smaller items**
- Video output robustness: if media_kit's hardware path regresses to
software (see ARCHITECTURE, Media: render mode), vendor a
media_kit_video fix that builds the EGL display from GDK's native
display. The video freeze after GPU idle is not explained by this;
next suspect is media_kit's context switching on the raster thread
with NVIDIA.
- The video freeze after GPU idle: next suspect is media_kit's context
switching on the raster thread with NVIDIA.
- Timelines: draw mpv's real cached ranges (`demuxer-cache-state`
`seekable-ranges`, read through media_kit's `observeProperty`), not
only the end of the cache ahead, like uosc.
- Link conveniences: a link swap in session updates the remembered media;
show the open link with an open-in-browser button.
- Time UI: remaining time, scrubber preview.
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 1.3.0
version: 1.3.2
environment:
sdk: ^3.0.0
dependencies:
+1 -1
View File
@@ -1,7 +1,7 @@
name: sync_relay_server
description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none
version: 1.3.0
version: 1.3.2
environment:
sdk: ^3.0.0
dependencies:
+4 -3
View File
@@ -72,9 +72,10 @@ void main() {
final clients = <_Client>[];
setUp(() async {
// Short limits so the demo runs in a couple of seconds.
// Short limits so the demo runs in a few seconds; silence well above
// a busy CI runner's stalls (600 ms marked a reporting client gone).
server = SyncServer(AppConfig(port: 0, secret: 'shh', name: 'test'),
liveness: const Liveness(silence: Duration(milliseconds: 600)));
liveness: const Liveness(silence: Duration(milliseconds: 1500)));
await server.start();
proxy = await FlakyProxy.start(server.boundPort);
});
@@ -106,7 +107,7 @@ void main() {
.toJson());
c.send(const SwapIntent(media: HelloMedia(name: 'mine.mkv', size: 100, duration: 0, relay: MediaRelay())).toJson());
await until(() => engine.queue.length == 1 && server.hub.relay != null && member()!.ready, 'ready, queued, relayed');
await Future<void>.delayed(const Duration(milliseconds: 1200));
await Future<void>.delayed(const Duration(milliseconds: 2000));
expect(member()!.presence, isNot(Presence.gone), reason: 'reports through the proxy keep it present');
proxy.last!.blackhole();
+2
View File
@@ -34,6 +34,8 @@ grep -qx start "$OUT" 2>/dev/null || { [[ $exited == 1 ]] && { echo "skipped: th
grep -q '^skip ' "$OUT" && { echo "skipped: $(grep '^skip ' "$OUT")"; exit 0; }
grep -q '^fail ' "$OUT" && { echo "smoke failed: $(grep '^fail ' "$OUT")" >&2; exit 1; }
grep -q '^opened ' "$OUT" || { echo "the media never opened" >&2; exit 1; }
grep -qx closing "$OUT" || { echo "the app ended on its own before closing" >&2; exit 1; }
grep -qx quitting "$OUT" || { echo "the close did not reach its end (killed on the way?)" >&2; exit 1; }
[[ $exited == 1 ]] || { echo "the app did not close within $LIMIT s (P32)" >&2; exit 1; }
[[ -z "$left" ]] || { echo "a starsync process was left behind (P32)" >&2; exit 1; }
echo "smoke ok: opened and closed in $secs s, no process left"
+2
View File
@@ -64,6 +64,8 @@ if (-not ($lines -contains 'start')) {
if ($lines | Where-Object { $_ -like 'skip *' }) { Write-Warning "skipped: $($lines -like 'skip *')"; exit 0 }
if ($lines | Where-Object { $_ -like 'fail *' }) { Write-Error "smoke failed: $($lines -like 'fail *')"; exit 1 }
if (-not ($lines | Where-Object { $_ -like 'opened *' })) { Write-Error 'the media never opened'; exit 1 }
if (-not ($lines -contains 'closing')) { Write-Error "the app ended on its own before closing (exit $($p.ExitCode))"; exit 1 }
if (-not ($lines -contains 'quitting')) { Write-Error 'the close did not reach its end (killed on the way?)'; exit 1 }
if (-not $exited) { Write-Error "the app did not close within $Limit s (P32)"; exit 1 }
if ($left) { Write-Error "a starsync process was left behind (P32)"; exit 1 }
Write-Host "smoke ok: opened and closed in $secs s, no process left"
+80
View File
@@ -0,0 +1,80 @@
@echo off
rem Starsync crash report: put this file in the Starsync folder (next to
rem starsync.exe or watch_together.exe) and double-click it. It makes
rem starsync-report.zip on the Desktop and copies it: Ctrl+V in Discord.
set "STARSYNC_REPORT_DIR=%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$s = Get-Content -LiteralPath '%~f0' -Raw; iex $s.Substring($s.IndexOf('#STAR' + 'SYNC-PS'))"
echo.
pause
exit /b
#STARSYNC-PS
# Starsync crash report (run windows_report.cmd from the Starsync folder): collects logs, crash events and a test start, zips
# them to the Desktop and puts the zip on the clipboard (Ctrl+V in Discord).
$ErrorActionPreference = 'SilentlyContinue'
$o = Join-Path $env:TEMP 'starsync-report'
Remove-Item -Recurse -Force $o
New-Item -ItemType Directory $o | Out-Null
$info = Join-Path $o 'info.txt'
function Say($s) { Add-Content -Path $info -Value $s }
Say ('Report ' + (Get-Date -Format s))
Say ((Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture | Format-List | Out-String).Trim())
Say ((Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion | Format-List | Out-String).Trim())
# The app's logs (new and old data folders), newest first.
$logs = Get-ChildItem -Path "$env:APPDATA\*\*\logs\*" -Include 'app-*', 'diagnostics-*', 'session-*', 'server-*' |
Sort-Object LastWriteTime -Descending | Select-Object -First 10
foreach ($l in $logs) {
$dest = Join-Path $o ($l.Directory.Parent.Name + '_' + $l.Name)
Copy-Item $l.FullName $dest
}
Say ''
Say 'Data folders:'
Get-ChildItem -Path "$env:APPDATA\*\*" -Directory | Where-Object { $_.Name -match 'starsync|watch' } |
ForEach-Object { Say (' ' + $_.FullName) }
# Windows' own crash records for the app.
Get-WinEvent -FilterHashtable @{ LogName = 'Application'; Id = 1000, 1001, 1026; StartTime = (Get-Date).AddDays(-3) } |
Where-Object { $_.Message -match 'starsync|watch_together' } |
Format-List TimeCreated, Id, ProviderName, Message | Out-File (Join-Path $o 'crash-events.txt') -Width 300
# Every install found: its files, and one test start of starsync.exe.
$roots = @([Environment]::GetFolderPath('Desktop'), "$env:USERPROFILE\Downloads", "$env:USERPROFILE\Desktop", $env:LOCALAPPDATA, $env:ProgramFiles, "$env:USERPROFILE\Documents") | Select-Object -Unique
$exes = foreach ($r in $roots) { Get-ChildItem -Path $r -Recurse -Depth 3 -Include 'starsync.exe', 'watch_together.exe' -File }
# The folder this report was started from comes first.
$here = $env:STARSYNC_REPORT_DIR
if ($here) { $here = $here.TrimEnd('\') }
$dirs = @($here) + ($exes | ForEach-Object { $_.DirectoryName }) |
Where-Object { $_ -and ((Test-Path (Join-Path $_ 'starsync.exe')) -or (Test-Path (Join-Path $_ 'watch_together.exe'))) } |
Select-Object -Unique
foreach ($d in $dirs) {
Say ''
Say ('Install: ' + $d)
Get-ChildItem -Path $d -Recurse -File | ForEach-Object {
Say (' ' + $_.FullName.Substring($d.Length) + ' ' + $_.Length + ' ' + $_.LastWriteTime.ToString('s'))
}
$exe = Join-Path $d 'starsync.exe'
if (-not (Test-Path $exe)) { $exe = Join-Path $d 'watch_together.exe' }
$p = Start-Process -FilePath $exe -WorkingDirectory $d -PassThru
Start-Sleep -Seconds 20
if ($p.HasExited) {
Say ('Test start of ' + $exe + ': exited, code ' + $p.ExitCode + ' (0x' + ('{0:X8}' -f $p.ExitCode) + ')')
} else {
Say ('Test start of ' + $exe + ': still running after 20 s')
Stop-Process -Id $p.Id -Force
}
}
if (-not $dirs) { Say 'No starsync.exe or watch_together.exe found on Desktop, Downloads, Documents, AppData or Program Files.' }
# Logs written by the test start.
Get-ChildItem -Path "$env:APPDATA\*\*\logs\*" -Include 'app-*' | Where-Object { $_.LastWriteTime -gt (Get-Date).AddMinutes(-2) } |
ForEach-Object { Copy-Item $_.FullName (Join-Path $o ('after-test_' + $_.Name)) }
$zip = Join-Path ([Environment]::GetFolderPath('Desktop')) 'starsync-report.zip'
Remove-Item -Force $zip
Compress-Archive -Path "$o\*" -DestinationPath $zip
Set-Clipboard -Path $zip
Start-Process explorer.exe -ArgumentList "/select,`"$zip`""
Write-Host ''
Write-Host 'Done: starsync-report.zip is on your Desktop and copied.' -ForegroundColor Green
Write-Host 'Open Discord and press Ctrl+V to send it.' -ForegroundColor Green
+5 -5
View File
@@ -1,10 +1,10 @@
{
"version": "1.3.0",
"version": "1.3.2",
"protocol": 16,
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.0/starsync-1.3.0-linux-x64.tar.gz",
"notes": "Starsync 1.3.0: the app is now called Starsync (rebuilt inside for stability); a shared countdown to what plays next; watched counts only time you actually watched; faster relay seeks; fixes for exit freezes, Windows shaders and X11 rendering. Everyone must update to keep watching together.",
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.2/starsync-1.3.2-linux-x64.tar.gz",
"notes": "Starsync 1.3.2: fixes Windows closing by itself about 3 seconds after every start (1.3.0 and 1.3.1).",
"assets": {
"linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.0/starsync-1.3.0-linux-x64.tar.gz",
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.0/starsync-1.3.0-windows-x64.zip"
"linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.2/starsync-1.3.2-linux-x64.tar.gz",
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.2/starsync-1.3.2-windows-x64.zip"
}
}