Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81b9d05de6 | ||
|
|
f9b79aa47c | ||
|
|
e16cf50f2c | ||
|
|
8aa1186d29 | ||
|
|
49fee1ca19 | ||
|
|
c91d57633f | ||
|
|
22ce40a488 | ||
|
|
07276ce020 | ||
|
|
4c8e40769a | ||
|
|
211ba65e5e | ||
|
|
fe4749ec7f | ||
|
|
51af830160 | ||
|
|
d9d7881747 |
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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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,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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user