9 Commits
Author SHA1 Message Date
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
18 changed files with 404 additions and 90 deletions

No files matched your search

+2
View File
@@ -49,6 +49,8 @@ Future<void> main(List<String> args) async {
await AppSettings.instance.ensureLoaded(); await AppSettings.instance.ensureLoaded();
// The composition root: every system, made once. // The composition root: every system, made once.
final graph = AppGraph.build(); final graph = AppGraph.build();
// Sounds stay off after an audio-device crash (see SoundEngine.crashMark).
graph.sfx.engine.crashMark = '${dirs.support}${Platform.pathSeparator}audio-init.pending';
final ports = graph.ports; final ports = graph.ports;
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others // Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", your server shuts down and no player outlives the engine. // see "left", your server shuts down and no player outlives the engine.
@@ -28,6 +28,9 @@ abstract final class MinimalTimelineSpec {
/// What is left of it while they are hidden. /// What is left of it while they are hidden.
static const lineSize = 3.0; static const lineSize = 3.0;
/// The played part's accent tint.
static const played = 0.4;
/// The position line. /// The position line.
static const lineWidth = 2.0; static const lineWidth = 2.0;
static const fontSize = 15.0; static const fontSize = 15.0;
@@ -467,13 +470,10 @@ class MinimalTimelinePainter extends CustomPainter {
final w = size.width, h = size.height; final w = size.width, h = size.height;
final x = lineX(w); final x = lineX(w);
final band = Offset.zero & size; final band = Offset.zero & size;
// The band, darker as it grows; hidden, a faint track. // The same colours shown and hidden: hidden, it is the band shrunk to
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!); // a line, so growing and shrinking never switches colour.
// Played: an accent tint, solid once the band is a line. canvas.drawRect(band, Paint()..color = MinimalStyle.band);
canvas.drawRect( canvas.drawRect(Rect.fromLTRB(0, 0, x, h), Paint()..color = accent.withValues(alpha: MinimalTimelineSpec.played));
Rect.fromLTRB(0, 0, x, h),
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
);
_stripes(canvas, w, h); _stripes(canvas, w, h);
_chapters(canvas, w, h); _chapters(canvas, w, h);
if (visibility > 0) { if (visibility > 0) {
@@ -500,7 +500,8 @@ class MinimalTimelinePainter extends CustomPainter {
canvas.save(); canvas.save();
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h)); canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
final paint = Paint() 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; ..strokeWidth = 1.5 * scale;
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) { for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint); 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) { 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) { for (final f in chapters) {
if (f <= 0) continue; if (f <= 0) continue;
final cx = w * f; final cx = w * f;
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_soloud/flutter_soloud.dart'; import 'package:flutter_soloud/flutter_soloud.dart';
@@ -50,6 +51,13 @@ class SoundEngine {
Future<bool>? _ready; Future<bool>? _ready;
var _disposed = false; var _disposed = false;
/// A file that exists only while the audio device is opening (set by the
/// app). Found at the next start, the last open took the app down (a
/// native crash in the driver or miniaudio, seen on Windows): sounds stay
/// off rather than crash every start, until [retry] (Settings' preview).
String? crashMark;
var _skipped = false;
/// Per slot: the loaded key and its sound (null: failed to load). /// Per slot: the loaded key and its sound (null: failed to load).
final Map<String, (String, Future<Object?>)> _slots = {}; final Map<String, (String, Future<Object?>)> _slots = {};
/// Load names are unique: SoLoud hands back the old sound for a name it /// Load names are unique: SoLoud hands back the old sound for a name it
@@ -61,15 +69,43 @@ class SoundEngine {
Future<bool> _init() async { Future<bool> _init() async {
if (_disposed) return false; if (_disposed) return false;
final mark = crashMark == null ? null : File(crashMark!);
try {
if (mark != null && mark.existsSync()) {
_skipped = true;
_log.warning('no sounds: opening the audio device crashed the app last time '
'(${mark.path}); a sound preview in Settings tries again');
return false;
}
mark?.writeAsStringSync('opening the audio device');
} catch (_) {}
_log.info('opening the audio device');
try { try {
await _backend.init(); await _backend.init();
_log.info('audio device open');
return true; return true;
} catch (e) { } catch (e) {
_log.warning('no sounds: audio init failed: $e'); _log.warning('no sounds: audio init failed: $e');
return false; return false;
} finally {
try {
if (mark != null && mark.existsSync()) mark.deleteSync();
} catch (_) {}
} }
} }
/// Opens the device again after a crash mark kept sounds off (the user
/// asked for a sound). A real crash leaves the mark again.
void retry() {
if (!_skipped) return;
_skipped = false;
try {
final m = crashMark;
if (m != null) File(m).deleteSync();
} catch (_) {}
_ready = null;
}
/// Plays the sound of [slot] at [volume] (linear gain). [key] names what /// Plays the sound of [slot] at [volume] (linear gain). [key] names what
/// [wav] renders: the same key reuses the loaded sound; another key, or /// [wav] renders: the same key reuses the loaded sound; another key, or
/// [fresh], renders and loads it again. /// [fresh], renders and loads it again.
@@ -517,6 +517,9 @@ class SessionCoordinator {
final epoch = connection.invalidate(); final epoch = connection.invalidate();
await _teardown(silent: true); await _teardown(silent: true);
if (!connection.isCurrent(epoch)) throw const ConnectCancelled(); 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); connection.begin(params);
_wsHost = params.host; _wsHost = params.host;
_wsPort = params.port; _wsPort = params.port;
+5 -2
View File
@@ -37,8 +37,11 @@ class SynthSfx implements SfxService {
/// Plays [kind] even when it is switched off (Settings' preview), always /// Plays [kind] even when it is switched off (Settings' preview), always
/// freshly rendered: an edited spec plays after a hot reload. /// freshly rendered: an edited spec plays after a hot reload.
Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) => Future<void> preview(SfxKind kind, {int readyIndex = 1, SoundPreset? preset}) {
_play(kind, readyIndex, preset ?? presetFor(kind), fresh: true); // Asked for: try the device again even after a crash kept it closed.
engine.retry();
return _play(kind, readyIndex, preset ?? presetFor(kind), fresh: true);
}
/// One slot per sound and ready step: a new preset replaces it. /// One slot per sound and ready step: a new preset replaces it.
Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) { Future<void> _play(SfxKind kind, int readyIndex, SoundPreset preset, {bool fresh = false}) {
+118 -56
View File
@@ -8,6 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../controls/controls_probe.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/model/controls_model.dart';
import '../controls/session_controls.dart'; import '../controls/session_controls.dart';
import '../controls/volume.dart'; import '../controls/volume.dart';
@@ -291,7 +293,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
_videoGen = gen; _videoGen = gen;
_videoKey = GlobalKey<VideoState>(); _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); final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
// The wheel anywhere on the full player steps the volume // The wheel anywhere on the full player steps the volume
// (lists, menus and popovers under the mouse scroll instead). // (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 /// Mini: title, expand and close on hover; the status, play (the full
/// bar at the bottom. A thin progress line stays while not hovered. /// 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() { Widget _miniOverlay() {
final coord = _coord; final coord = _coord;
final playback = coord.playback; final playback = coord.playback;
const scale = 0.8;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _miniHover = true), onEnter: (_) => setState(() => _miniHover = true),
onExit: (_) => setState(() => _miniHover = false), onExit: (_) => setState(() => _miniHover = false),
@@ -589,55 +595,73 @@ class _SessionPlayerState extends State<SessionPlayer> {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, 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( child: Column(
children: [ children: [
Row( Padding(
children: [ padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
const SizedBox(width: 10), child: Row(
const Expanded(child: NowPlayingTitle(cover: false, compact: true)), crossAxisAlignment: CrossAxisAlignment.start,
IconButton( children: [
focusNode: _noFocus, const Expanded(
onPressed: widget.onExpand, child: Padding(
tooltip: 'Expand', padding: EdgeInsets.only(top: 4),
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18), child: NowPlayingTitle(cover: false, compact: true),
), ),
IconButton( ),
focusNode: _noFocus, MinimalButton(
onPressed: widget.onClose, icon: Icons.open_in_full_rounded,
tooltip: 'Sit out (stay in the server)', tooltip: 'Expand',
icon: const Icon(Icons.close, color: Colors.white, size: 18), scale: scale,
), onPressed: widget.onExpand),
], MinimalButton(
icon: Icons.close_rounded,
tooltip: 'Sit out (stay in the server)',
scale: scale,
onPressed: widget.onClose),
],
),
), ),
const Spacer(), const Spacer(),
SignalBuilder(builder: (context) { SignalBuilder(builder: (context) {
final ready = coord.selfMember.value?.ready ?? false; final snap = _model.snapshot.value;
final pos = playback.position.value, dur = playback.duration.value; final pos = playback.position.value, dur = playback.duration.value;
return Row( final status = playerStatusNow(coord)?.short;
children: [ return Padding(
IconButton( padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
focusNode: _noFocus, child: Column(
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)', crossAxisAlignment: CrossAxisAlignment.start,
onPressed: coord.toggleReady, children: [
icon: Icon( if (status != null && status.isNotEmpty)
ready ? Icons.check_circle : Icons.radio_button_unchecked, Padding(
color: ready ? Colors.greenAccent : Colors.white, 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), _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 /// 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 /// 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). /// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
@@ -768,7 +795,9 @@ const _shaderKeys = [
LogicalKeyboardKey.digit6, 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 { class _MiniSeekBar extends StatelessWidget {
final bool thin; final bool thin;
final ValueChanged<Duration>? onSeek; final ValueChanged<Duration>? onSeek;
@@ -777,26 +806,59 @@ class _MiniSeekBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final playback = AppScope.of(context).coordinator.playback; final playback = AppScope.of(context).coordinator.playback;
final accent = Theme.of(context).colorScheme.primary;
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final d = playback.duration.value; final d = playback.duration.value;
final p = playback.position.value; double f(Duration x) => d.inMilliseconds == 0 ? 0.0 : (x.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0); final bar = SizedBox(
final bar = LinearProgressIndicator( height: thin ? 3 : 4,
value: v, width: double.infinity,
minHeight: thin ? 2 : 4, child: CustomPaint(
backgroundColor: thin ? Colors.transparent : Colors.white24, 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; if (thin || onSeek == null || d == Duration.zero) return bar;
void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0)); void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0));
return LayoutBuilder( return LayoutBuilder(
builder: (context, box) => GestureDetector( builder: (context, box) => MouseRegion(
behavior: HitTestBehavior.opaque, cursor: SystemMouseCursors.click,
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth), child: GestureDetector(
onHorizontalDragEnd: null, behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth), onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar), 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" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "1.2.0" version: "1.3.0"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "1.2.0" version: "1.3.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -1,7 +1,7 @@
name: starsync name: starsync
description: Starsync desktop app (repo sync_relay_project). One binary. description: Starsync desktop app (repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 1.3.0 version: 1.3.1
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
@@ -6,6 +6,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.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/core.dart';
import 'package:sync_relay_core/testing.dart'; import 'package:sync_relay_core/testing.dart';
import 'package:sync_relay_server/server.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 { 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')); final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start(); await server.start();
+19
View File
@@ -418,6 +418,25 @@ void main() {
expect(tester.takeException(), isNull); 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 { testWidgets('status pill: who is not ready, with the ready count', (tester) async {
coord.playback.alive.value = true; coord.playback.alive.value = true;
await pumpShell(tester, const Size(1280, 800)); await pumpShell(tester, const Size(1280, 800));
+43
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -147,4 +148,46 @@ void main() {
expect(b.deinits, 1); expect(b.deinits, 1);
}); });
}); });
group('crash mark', () {
late Directory dir;
setUp(() => dir = Directory.systemTemp.createTempSync('sound_mark'));
tearDown(() => dir.deleteSync(recursive: true));
test('exists only while the device opens', () async {
final mark = '${dir.path}/audio-init.pending';
var seen = false;
final b = _MarkBackend(() => seen = File(mark).existsSync());
final e = SoundEngine(backend: b)..crashMark = mark;
expect(await e.warmUp(), isTrue);
expect(seen, isTrue, reason: 'there while opening');
expect(File(mark).existsSync(), isFalse, reason: 'gone once open');
});
test('left by a crash: sounds stay off, a preview tries again', () async {
final mark = '${dir.path}/audio-init.pending';
File(mark).writeAsStringSync('opening the audio device');
final b = _FakeBackend();
final e = SoundEngine(backend: b)..crashMark = mark;
await e.play('chat', 'k', _bytes, volume: 1);
expect(b.inits, 0, reason: 'never opened after a crash');
expect(b.plays, isEmpty);
e.retry();
await e.play('chat', 'k', _bytes, volume: 1);
expect(b.inits, 1);
expect(b.plays, hasLength(1));
expect(File(mark).existsSync(), isFalse);
});
});
}
class _MarkBackend extends _FakeBackend {
final void Function() onInit;
_MarkBackend(this.onInit);
@override
Future<void> init() async {
onInit();
await super.init();
}
} }
+19 -4
View File
@@ -1946,7 +1946,12 @@ panel) and chat messages (Copy message, and a link in it).
switched off in `soundsOff`. The device opens 2 s after startup switched off in `soundsOff`. The device opens 2 s after startup
(`warmUp`), so neither startup nor the first sound waits on it; if it (`warmUp`), so neither startup nor the first sound waits on it; if it
can't open, the engine logs `no sounds: audio init failed` once and drops can't open, the engine logs `no sounds: audio init failed` once and drops
every play. App close disposes the engine (deinit, 500 ms bound) every play. Opening it is logged (`opening the audio device`,
`audio device open`) and leaves `<support>/audio-init.pending` until it
returns: a native crash there (a driver, miniaudio) leaves the file, and
the next start skips sounds (`no sounds: opening the audio device
crashed the app last time`) instead of crashing again; a sound preview
in Settings deletes it and tries once more. App close disposes the engine (deinit, 500 ms bound)
before leaving the server. Settings → Notifications & sounds has the volume, the before leaving the server. Settings → Notifications & sounds has the volume, the
overall sound set (`soundPreset`), and per sound a preview, its own set overall sound set (`soundPreset`), and per sound a preview, its own set
(`soundPresetFor`, else the overall one) and a switch; a preview always (`soundPresetFor`, else the overall one) and a switch; a preview always
@@ -1958,7 +1963,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 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 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 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 out (the player unloads and shows the join-back card, `SitOutCard`, over
the still mounted `Video`); × on that card closes the player, the strip 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 brings it back, and joining back with it closed shows the mini. Fullscreen
@@ -2204,7 +2215,7 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
glass capsules (`MinimalCapsule`, the pills' tint and hairline; glass capsules (`MinimalCapsule`, the pills' tint and hairline;
`MinimalButton` rounded squares, the play button a filled accent `MinimalButton` rounded squares, the play button a filled accent
circle) above `MinimalTimeline`, a 36 px band across the bottom: the 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 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 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 the top edge with a dark cut through the band (a press within reach of
@@ -2214,7 +2225,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 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, (the chapter title above it). A press seeks, every drag move seeks,
after release the line stays at the target until the position catches 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 We know only the end of mpv's cache, so a dropped back buffer is not
shown. shown.
- **Settings** (`AppSettings`, `model/controls_prefs.dart`, all live): - **Settings** (`AppSettings`, `model/controls_prefs.dart`, all live):
+8
View File
@@ -302,6 +302,14 @@ in the same file; a dedicated server prints its folder at start
(`--log-dir`). Users send theirs with Settings → About → "Copy (`--log-dir`). Users send theirs with Settings → About → "Copy
diagnostics". 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` 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 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 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. the `close:` log lines.
- P33 No volume readout on wheel: likely fixed by the controls rework - 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. (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 **Restructure** (in order; reviewed against the code). Truth lives in
three places: the server (members, presence, gate, transport, queue incl. 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, (client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list). integrations (Jellyfin played/resume, AniList list).
7. Release 1.3.0: publish the manifest (protocol 16 makes it a required Released as 1.3.0. Left: check that a 1.2.x app gets a clear "update
update for 1.2.x) and update the VPS dedicated server in the same the app" message and updates (Windows: the first real use, backlog 5).
window; check a 1.2.x app gets a clear "update the app" message.
Composition root (in place, ARCHITECTURE "Composition root"): the Composition root (in place, ARCHITECTURE "Composition root"): the
remaining `.instance` singletons move into `AppGraph` as the steps touch remaining `.instance` singletons move into `AppGraph` as the steps touch
them; `singletons_test` holds the count (today: AppSettings 49, 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" 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 filter on the Schedule from the user's AniList lists. This is the
Schedule's personal tracking; AnimeFlow has no per-user follow. 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 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 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). 8. **Jellyfin** (epic, below).
9. **Smaller items** 9. **Smaller items**
- Video output robustness: if media_kit's hardware path regresses to - The video freeze after GPU idle: next suspect is media_kit's context
software (see ARCHITECTURE, Media: render mode), vendor a switching on the raster thread with NVIDIA.
media_kit_video fix that builds the EGL display from GDK's native - Timelines: draw mpv's real cached ranges (`demuxer-cache-state`
display. The video freeze after GPU idle is not explained by this; `seekable-ranges`, read through media_kit's `observeProperty`), not
next suspect is media_kit's context switching on the raster thread only the end of the cache ahead, like uosc.
with NVIDIA.
- Link conveniences: a link swap in session updates the remembered media; - Link conveniences: a link swap in session updates the remembered media;
show the open link with an open-in-browser button. show the open link with an open-in-browser button.
- Time UI: remaining time, scrubber preview. - Time UI: remaining time, scrubber preview.
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter. description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 1.3.0 version: 1.3.1
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+1 -1
View File
@@ -1,7 +1,7 @@
name: sync_relay_server name: sync_relay_server
description: Pure-Dart sync + media server. Embeddable, no Flutter. description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none publish_to: none
version: 1.3.0 version: 1.3.1
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+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.1",
"protocol": 16, "protocol": 16,
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.0/starsync-1.3.0-linux-x64.tar.gz", "url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.1/starsync-1.3.1-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.", "notes": "Starsync 1.3.1: fixes a crash on start on some Windows PCs (the audio device; the app now starts without sounds instead), joining another server no longer opens an empty player, a cleaner mini player and a softer Minimal progress line.",
"assets": { "assets": {
"linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.0/starsync-1.3.0-linux-x64.tar.gz", "linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.1/starsync-1.3.1-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" "windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v1.3.1/starsync-1.3.1-windows-x64.zip"
} }
} }