Compare commits
8
Commits
d9d7881747
..
v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49fee1ca19 | ||
|
|
c91d57633f | ||
|
|
22ce40a488 | ||
|
|
07276ce020 | ||
|
|
4c8e40769a | ||
|
|
211ba65e5e | ||
|
|
fe4749ec7f | ||
|
|
51af830160 |
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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}) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+15
-6
@@ -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.
|
||||||
@@ -245,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
|
||||||
@@ -291,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,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,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:
|
||||||
|
|||||||
@@ -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.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user