Improve window snapping/dragging
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -4,9 +4,8 @@
|
||||
- [ ] There should be a preferences window that matches the original application's look/feel/layout. The options can be no-op stubs, except for the skins.
|
||||
- [ ] Skins should be importable from .wsz files. Imported skins should no longer rely on the original file and should be available in the skins list.
|
||||
- [ ] Cursors should be supported. If a skin includes a custom cursor, we should change the cursor to the custom version from the skin. This includes the original cursors from the default skin.
|
||||
- [X] The "classic" winamp skin code should be a project-level package. This will allow us to, later, add a "modern" winamp skin codepath. The app itself should handle logic, but skin-related items should be abstracted into its own package (including any tooling that was developed to assist in debugging the skin loading/functionality).
|
||||
- [ ] The playlist window should be resizable if you drag from the bottom right corner.
|
||||
- [ ] The presets button on the equalizer doesn't open the presets window as it should.
|
||||
- [x] The presets button on the equalizer doesn't open the presets window as it should.
|
||||
- [ ] The playlist window has 5 action buttons along the bottom that are currently implemented incorrectly. Right now, the sprites are drawn full-size, but they should be clipped. However, when you tap and hold on the button, it should open the full sprite sheet as a pop-up. Releasing the cursor on any of the items should trigger the action. Verify this against the original source.
|
||||
- [ ] Right-clicking the dock icon should give us the same window menu options for the main window, equalizer, and playlist window visibility. The window menu currently lacks an option for the main window, so it should be added. Closing all windows shouldn't exit the program.
|
||||
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: package:arcane_analysis/analysis_options.yaml
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"fill" : "automatic",
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"glass" : false,
|
||||
"image-name" : "WinampIcon.png",
|
||||
"name" : "WinampIcon",
|
||||
"position" : {
|
||||
"scale" : 3.5,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
+192
-26
@@ -5,14 +5,15 @@ import "dart:ui" as ui;
|
||||
import "package:dartamp_skin/dartamp_skin.dart";
|
||||
import "package:desktop_multi_window/desktop_multi_window.dart";
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:window_manager/window_manager.dart";
|
||||
|
||||
import "src/common_widgets.dart";
|
||||
import "src/docking/dock_controller.dart";
|
||||
import "src/docking/dock_logic.dart";
|
||||
import "src/main_window/main_window.dart";
|
||||
import "src/main_window/presets_window.dart";
|
||||
import "src/player/player_state.dart";
|
||||
import "src/skin/skin_cursors.dart";
|
||||
import "src/skin/skin_manager.dart";
|
||||
@@ -42,6 +43,7 @@ Future<void> main(List<String> args) async {
|
||||
initialShade: subWindowArgs.shade,
|
||||
initialAlwaysOnTop: subWindowArgs.alwaysOnTop,
|
||||
initialSkinName: subWindowArgs.skinName,
|
||||
initialMode: subWindowArgs.mode,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -147,12 +149,17 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
String _wasSkinName = baseSkinName;
|
||||
WindowController? _eqController;
|
||||
WindowController? _plController;
|
||||
WindowController? _presetsController;
|
||||
StreamSubscription<void>? _windowsSubscription;
|
||||
|
||||
/// Initial rects queued for a sub-window that has not booted yet; pushed
|
||||
/// when it reports `subReady`.
|
||||
final Map<DockWindow, Rect> _pendingSubBounds = {};
|
||||
|
||||
/// The initial rect queued for the presets dialog (not docked), pushed once
|
||||
/// its control channel registers.
|
||||
Rect? _pendingPresetsBounds;
|
||||
|
||||
/// The latest desired top-left per window, coalesced by [_flushMoves].
|
||||
final Map<DockWindow, Offset> _pendingMoves = {};
|
||||
Timer? _moveFlushTimer;
|
||||
@@ -163,6 +170,9 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
/// Saved top-left positions of the sub-windows, loaded at startup.
|
||||
final Map<SubWindowType, (double, double)> _savedSubPositions = {};
|
||||
|
||||
/// Saved sizes (width, height) of sub-windows, loaded at startup.
|
||||
final Map<SubWindowType, (double, double)> _savedSubSizes = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -225,17 +235,23 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
await windowManager.setAlwaysOnTop(_player.alwaysOnTop);
|
||||
await windowManager.setTitle("Winamp");
|
||||
await _loadScreenBounds();
|
||||
await windowManager.setSize(_scaledSize());
|
||||
// Try to restore a saved size first (user-resized main window), falling
|
||||
// back to the default scaled size when none saved.
|
||||
final savedMainSize = await PlayerState.loadWindowSize("main");
|
||||
final sizeToUse = savedMainSize == null
|
||||
? _scaledSize()
|
||||
: Size(savedMainSize.$1, savedMainSize.$2);
|
||||
await windowManager.setSize(sizeToUse);
|
||||
final savedMain = await PlayerState.loadWindowPos("main");
|
||||
if (savedMain != null) {
|
||||
final saved = _clampToScreen(
|
||||
Rect.fromLTWH(
|
||||
savedMain.$1,
|
||||
savedMain.$2,
|
||||
_scaledSize().width,
|
||||
_scaledSize().height,
|
||||
sizeToUse.width,
|
||||
sizeToUse.height,
|
||||
),
|
||||
_scaledSize(),
|
||||
sizeToUse,
|
||||
);
|
||||
await windowManager.setPosition(saved.topLeft);
|
||||
} else {
|
||||
@@ -280,8 +296,16 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
unawaited(_syncMenuToNative());
|
||||
final savedEq = await PlayerState.loadWindowPos("eq");
|
||||
final savedPl = await PlayerState.loadWindowPos("pl");
|
||||
final savedEqSize = await PlayerState.loadWindowSize("eq");
|
||||
final savedPlSize = await PlayerState.loadWindowSize("pl");
|
||||
if (savedEq != null) _savedSubPositions[SubWindowType.eq] = savedEq;
|
||||
if (savedPl != null) _savedSubPositions[SubWindowType.pl] = savedPl;
|
||||
if (savedEqSize != null) {
|
||||
_savedSubSizes[SubWindowType.eq] = (savedEqSize.$1, savedEqSize.$2);
|
||||
}
|
||||
if (savedPlSize != null) {
|
||||
_savedSubSizes[SubWindowType.pl] = (savedPlSize.$1, savedPlSize.$2);
|
||||
}
|
||||
if (_player.eqOpen) unawaited(_openSubWindow(SubWindowType.eq));
|
||||
if (_player.plOpen) unawaited(_openSubWindow(SubWindowType.pl));
|
||||
}
|
||||
@@ -377,6 +401,7 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
_wasAlwaysOnTop = _player.alwaysOnTop;
|
||||
_invokeSub(DockWindow.eq, "setAlwaysOnTop", _player.alwaysOnTop);
|
||||
_invokeSub(DockWindow.pl, "setAlwaysOnTop", _player.alwaysOnTop);
|
||||
_invokePresets("setAlwaysOnTop", _player.alwaysOnTop);
|
||||
}
|
||||
if (_player.eqOpen != _wasEqOpen) {
|
||||
_wasEqOpen = _player.eqOpen;
|
||||
@@ -485,7 +510,13 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
final mainRect = _dock.rectOf(DockWindow.main);
|
||||
if (mainRect == null) return;
|
||||
final shade = type == SubWindowType.eq ? _player.eqShade : _player.plShade;
|
||||
final size = DockController.scaledSize(window, shade, _player.uiScale);
|
||||
var size = DockController.scaledSize(window, shade, _player.uiScale);
|
||||
// If a saved size exists for this sub-window, prefer it over the
|
||||
// computed default so user resizes persist across launches.
|
||||
final savedSize = _savedSubSizes[type];
|
||||
if (savedSize != null) {
|
||||
size = Size(savedSize.$1, savedSize.$2);
|
||||
}
|
||||
var rect = _defaultSubRect(type, mainRect, size);
|
||||
final saved = _savedSubPositions[type];
|
||||
if (saved != null) {
|
||||
@@ -529,6 +560,8 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
await controller.invokeMethod("setBounds", {
|
||||
"x": rect.left,
|
||||
"y": rect.top,
|
||||
"w": rect.width,
|
||||
"h": rect.height,
|
||||
});
|
||||
_pendingSubBounds.remove(window);
|
||||
} on WindowChannelException catch (e) {
|
||||
@@ -538,6 +571,123 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the EQ presets dialog (spawned by the EQ presets button), switching
|
||||
/// an already-open dialog to [mode].
|
||||
Future<void> _openPresetsWindow(String mode) async {
|
||||
final existing = _presetsController;
|
||||
if (existing != null) {
|
||||
try {
|
||||
await existing.invokeMethod("setMode", mode);
|
||||
await existing.show();
|
||||
return;
|
||||
} on WindowChannelException {
|
||||
_handlePresetsWindowLost();
|
||||
} catch (_) {
|
||||
// The window is mid-boot; fall through and show it once ready.
|
||||
try {
|
||||
await existing.show();
|
||||
return;
|
||||
} catch (_) {
|
||||
_handlePresetsWindowLost();
|
||||
}
|
||||
}
|
||||
}
|
||||
final mainRect = _dock.rectOf(DockWindow.main);
|
||||
final eqRect = _dock.rectOf(DockWindow.eq);
|
||||
try {
|
||||
final controller = await WindowController.create(
|
||||
WindowConfiguration(
|
||||
arguments: SubWindowArgs(
|
||||
type: SubWindowType.presets,
|
||||
shade: false,
|
||||
alwaysOnTop: _player.alwaysOnTop,
|
||||
uiScale: 1.0,
|
||||
skinName: _skins.active.name,
|
||||
mode: mode,
|
||||
).encode(),
|
||||
hiddenAtLaunch: true,
|
||||
),
|
||||
);
|
||||
_presetsController = controller;
|
||||
_setInitialPresetsRect(eqRect ?? mainRect);
|
||||
unawaited(_pushPresetsBounds());
|
||||
} catch (_) {
|
||||
// ignore: failed to open the presets window
|
||||
}
|
||||
}
|
||||
|
||||
/// Positions the presets dialog next to [anchor] (the Equalizer window, or
|
||||
/// the main window when the Equalizer is closed), kept fully on screen.
|
||||
void _setInitialPresetsRect(Rect? anchor) {
|
||||
const size = presetsWindowSize;
|
||||
if (anchor == null) {
|
||||
_pendingPresetsBounds = Rect.fromLTWH(120, 120, size.width, size.height);
|
||||
return;
|
||||
}
|
||||
final screen = _dock.screenRect;
|
||||
if (screen == Rect.zero) {
|
||||
_pendingPresetsBounds = Rect.fromLTWH(
|
||||
anchor.right,
|
||||
anchor.top,
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final x = anchor.right.clamp(screen.left, screen.right - size.width);
|
||||
final y = anchor.top.clamp(screen.top, screen.bottom - size.height);
|
||||
_pendingPresetsBounds = Rect.fromLTWH(x, y, size.width, size.height);
|
||||
}
|
||||
|
||||
/// Pushes the queued initial bounds to the presets dialog, retrying until
|
||||
/// its control channel registers.
|
||||
Future<void> _pushPresetsBounds() async {
|
||||
final rect = _pendingPresetsBounds;
|
||||
final controller = _presetsController;
|
||||
if (rect == null || controller == null) return;
|
||||
try {
|
||||
await controller.invokeMethod("setBounds", {
|
||||
"x": rect.left,
|
||||
"y": rect.top,
|
||||
"w": rect.width,
|
||||
"h": rect.height,
|
||||
});
|
||||
_pendingPresetsBounds = null;
|
||||
} on WindowChannelException {
|
||||
_handlePresetsWindowLost();
|
||||
} catch (_) {
|
||||
if (mounted && _presetsController != null) {
|
||||
Timer(const Duration(milliseconds: 120), () {
|
||||
if (mounted && _presetsController != null) {
|
||||
unawaited(_pushPresetsBounds());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invokes [method] on the presets dialog's control channel, dropping the
|
||||
/// controller when its channel is gone (the window was closed).
|
||||
void _invokePresets(String method, [Object? args]) {
|
||||
final controller = _presetsController;
|
||||
if (controller == null) return;
|
||||
unawaited(
|
||||
controller.invokeMethod(method, args).catchError((Object e) {
|
||||
if (e is WindowChannelException && e.code == "CHANNEL_UNREGISTERED") {
|
||||
_handlePresetsWindowLost();
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Drops the presets dialog whose native window/channel is gone.
|
||||
void _handlePresetsWindowLost() {
|
||||
if (_presetsController == null) return;
|
||||
debugPrint("presets window lost");
|
||||
_presetsController = null;
|
||||
}
|
||||
|
||||
Future<dynamic> _onSubWindowCall(MethodCall call) async {
|
||||
final args = call.arguments is Map ? call.arguments as Map : const {};
|
||||
switch (call.method) {
|
||||
@@ -551,10 +701,33 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
case "plClose":
|
||||
_dock.removeWindow(DockWindow.pl);
|
||||
_player.setPlOpen(false);
|
||||
case "openPresets":
|
||||
final mode = args["mode"] is String ? args["mode"] as String : "load";
|
||||
unawaited(_openPresetsWindow(mode));
|
||||
case "applyPreset":
|
||||
// Forward a preset loaded from the presets dialog to the Equalizer.
|
||||
_invokeSub(DockWindow.eq, "applyPreset", {
|
||||
"preamp": args["preamp"],
|
||||
"bands": args["bands"],
|
||||
});
|
||||
case "getEqSettings":
|
||||
// The presets dialog pulls the Equalizer's current settings to save.
|
||||
final controller = _eqController;
|
||||
if (controller == null) return null;
|
||||
try {
|
||||
return await controller.invokeMethod("getEqSettings");
|
||||
} on WindowChannelException {
|
||||
return null;
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
}
|
||||
case "subReady":
|
||||
final window = _subWindowFromArgs(args);
|
||||
if (window == null) return;
|
||||
unawaited(_pushPendingSubBounds(window));
|
||||
if (window != null) {
|
||||
unawaited(_pushPendingSubBounds(window));
|
||||
} else if (args["type"] == "presets") {
|
||||
unawaited(_pushPresetsBounds());
|
||||
}
|
||||
await _pushDockContext();
|
||||
case "subBounds":
|
||||
final window = _subWindowFromArgs(args);
|
||||
@@ -604,6 +777,7 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
};
|
||||
_invokeSub(DockWindow.eq, "setDockContext", args);
|
||||
_invokeSub(DockWindow.pl, "setDockContext", args);
|
||||
_invokePresets("setDockContext", args);
|
||||
}
|
||||
|
||||
/// Invokes [method] on a sub-window's control channel without ever leaking
|
||||
@@ -690,10 +864,13 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
_savedSubPositions[type] = (entry.value.left, entry.value.top);
|
||||
}
|
||||
unawaited(
|
||||
PlayerState.saveWindowPos(
|
||||
PlayerState.saveWindowPos(name, entry.value.left, entry.value.top),
|
||||
);
|
||||
unawaited(
|
||||
PlayerState.saveWindowSize(
|
||||
name,
|
||||
entry.value.left,
|
||||
entry.value.top,
|
||||
entry.value.width,
|
||||
entry.value.height,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -702,18 +879,21 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
Future<void> _reconcileSubWindows() async {
|
||||
WindowController? eq;
|
||||
WindowController? pl;
|
||||
WindowController? presets;
|
||||
try {
|
||||
for (final window in await WindowController.getAll()) {
|
||||
final args = SubWindowArgs.tryDecode(window.arguments);
|
||||
if (args == null) continue;
|
||||
if (args.type == SubWindowType.eq) eq = window;
|
||||
if (args.type == SubWindowType.pl) pl = window;
|
||||
if (args.type == SubWindowType.presets) presets = window;
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
_eqController = eq;
|
||||
_plController = pl;
|
||||
_presetsController = presets;
|
||||
if (eq == null && _player.eqOpen) {
|
||||
_dock.removeWindow(DockWindow.eq);
|
||||
_player.setEqOpen(false);
|
||||
@@ -810,21 +990,7 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
},
|
||||
);
|
||||
}
|
||||
return Listener(
|
||||
onPointerDown: (e) => debugPrint(
|
||||
"ptr down ${e.position.dx.toStringAsFixed(1)},"
|
||||
"${e.position.dy.toStringAsFixed(1)}",
|
||||
),
|
||||
onPointerMove: (e) => debugPrint(
|
||||
"ptr move ${e.position.dx.toStringAsFixed(1)},"
|
||||
"${e.position.dy.toStringAsFixed(1)}",
|
||||
),
|
||||
onPointerUp: (e) => debugPrint(
|
||||
"ptr up ${e.position.dx.toStringAsFixed(1)},"
|
||||
"${e.position.dy.toStringAsFixed(1)}",
|
||||
),
|
||||
child: body,
|
||||
);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import "dart:ui" as ui;
|
||||
|
||||
import "package:dartamp_skin/dartamp_skin.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// A button that shows a pressed state when tapped, using skin images.
|
||||
class SkinPressButton extends StatefulWidget {
|
||||
|
||||
@@ -205,11 +205,7 @@ class DockController {
|
||||
}
|
||||
|
||||
final doAttachedSnap = snapEnabled && !dragging;
|
||||
if (dragging && snapEnabled) {
|
||||
debugPrint(
|
||||
"dock moveAttached: skipping attached-window snap while dragging",
|
||||
);
|
||||
}
|
||||
|
||||
if (doAttachedSnap) {
|
||||
// Keep the attached windows on screen, propagating any shift to the
|
||||
// whole group (the original `config_keeponscreen` behavior).
|
||||
@@ -259,13 +255,7 @@ class DockController {
|
||||
}
|
||||
|
||||
final updates = <DockWindow, Rect>{driven: mwr, ...moved};
|
||||
debugPrint(
|
||||
"dock moveAttached $driven target=${target.left.toStringAsFixed(1)},"
|
||||
"${target.top.toStringAsFixed(1)} prev=${prevDriven.left.toStringAsFixed(1)},"
|
||||
"${prevDriven.top.toStringAsFixed(1)} mwr=${mwr.left.toStringAsFixed(1)},"
|
||||
"${mwr.top.toStringAsFixed(1)} eq=${updates[DockWindow.eq]?.top.toStringAsFixed(1)} "
|
||||
"pl=${updates[DockWindow.pl]?.top.toStringAsFixed(1)}",
|
||||
);
|
||||
|
||||
for (final entry in updates.entries) {
|
||||
final old = _rects[entry.key];
|
||||
_rects[entry.key] = entry.value;
|
||||
|
||||
@@ -146,6 +146,21 @@ abstract final class DockLogic {
|
||||
static Rect moveBy(Rect r, double dx, double dy) =>
|
||||
Rect.fromLTWH(r.left + dx, r.top + dy, r.width, r.height);
|
||||
|
||||
/// The next position for a window-local drag: the window is moved so its
|
||||
/// grab offset to the pointer is preserved.
|
||||
///
|
||||
/// [localPoint] is the pointer's position relative to the [applied] (native)
|
||||
/// window bounds, so the window base cancels out and the result tracks the
|
||||
/// cursor absolutely (`cursorScreen - grab`). This holds even when [applied]
|
||||
/// is stale, so a lagging native window never accumulates error.
|
||||
static Rect dragCandidate(Rect applied, Offset localPoint, Offset grab) =>
|
||||
Rect.fromLTWH(
|
||||
applied.left + localPoint.dx - grab.dx,
|
||||
applied.top + localPoint.dy - grab.dy,
|
||||
applied.width,
|
||||
applied.height,
|
||||
);
|
||||
|
||||
/// Port of `IsPointInRect` (DOCK.cpp:205).
|
||||
static bool pointInRect(Offset p, Rect r) =>
|
||||
p.dx >= r.left && p.dx < r.right && p.dy >= r.top && p.dy < r.bottom;
|
||||
|
||||
@@ -2,7 +2,7 @@ import "dart:async";
|
||||
import "dart:ui" as ui;
|
||||
|
||||
import "package:dartamp_skin/dartamp_skin.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
import "../common_widgets.dart";
|
||||
import "../player/player_state.dart";
|
||||
@@ -288,8 +288,9 @@ class _EqOnAutoButtons extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets button (`draw_eq_presets`). No presets are wired up yet, so the
|
||||
/// button only shows its pressed state.
|
||||
/// The presets button (`draw_eq_presets`), which opens the EQ presets dialog
|
||||
/// window (the original shows a "Load preset" menu here; this app opens the
|
||||
/// presets window directly in load mode).
|
||||
class _EqPresetsButton extends StatelessWidget {
|
||||
const _EqPresetsButton({required this.skin, required this.player});
|
||||
|
||||
@@ -306,10 +307,14 @@ class _EqPresetsButton extends StatelessWidget {
|
||||
image: eqmain,
|
||||
src: const Rect.fromLTWH(224, 164, 44, 12),
|
||||
srcPressed: const Rect.fromLTWH(224, 176, 44, 12),
|
||||
onTap: () {},
|
||||
onTap: () => unawaited(_openPresets()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _openPresets() async {
|
||||
await subWindowChannel.invokeMethod("openPresets", {"mode": "load"});
|
||||
}
|
||||
}
|
||||
|
||||
/// The response graph (`draw_eq_graphthingy`): the empty graph backdrop, the
|
||||
|
||||
@@ -2,23 +2,15 @@ import "dart:async";
|
||||
import "dart:ui" as ui;
|
||||
|
||||
import "package:dartamp_skin/dartamp_skin.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
import "../common_widgets.dart";
|
||||
import "../player/player_state.dart";
|
||||
import "../skin/skin_cursors.dart";
|
||||
import "../windows/sub_window_channel.dart";
|
||||
|
||||
const Size _plMainSize = Size(275, 290);
|
||||
const Size _plShadeSize = Size(275, 14);
|
||||
|
||||
const double _plWidth = 275;
|
||||
const double _plShadeHeight = 14;
|
||||
const double _listLeft = 12;
|
||||
const double _listRight = 255;
|
||||
|
||||
/// Visible rows for a 290-tall window: `(290 - 58) / pe_fontheight(13)`.
|
||||
const int _numSongs = 17;
|
||||
|
||||
/// Row stride (`pe_fontheight` = 13 for the base-skin Arial 12).
|
||||
const double _rowStride = 13;
|
||||
@@ -199,21 +191,26 @@ class _PlWindowView extends StatelessWidget {
|
||||
return AnimatedBuilder(
|
||||
animation: player,
|
||||
builder: (context, _) {
|
||||
final size = player.plShade ? _plShadeSize : _plMainSize;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
child: SizedBox.fromSize(
|
||||
size: size,
|
||||
child: player.plShade
|
||||
? _PlShadeLayout(skin: skin, player: player)
|
||||
: _PlMainLayout(skin: skin, player: player),
|
||||
),
|
||||
),
|
||||
],
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = constraints.maxHeight;
|
||||
return SizedBox.fromSize(
|
||||
size: Size(width, height),
|
||||
child: player.plShade
|
||||
? _PlShadeLayout(
|
||||
skin: skin,
|
||||
player: player,
|
||||
width: width,
|
||||
)
|
||||
: _PlMainLayout(
|
||||
skin: skin,
|
||||
player: player,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -222,10 +219,17 @@ class _PlWindowView extends StatelessWidget {
|
||||
|
||||
/// The full 280×290 Playlist window (normal mode).
|
||||
class _PlMainLayout extends StatefulWidget {
|
||||
const _PlMainLayout({required this.skin, required this.player});
|
||||
const _PlMainLayout({
|
||||
required this.skin,
|
||||
required this.player,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final ClassicSkin skin;
|
||||
final PlayerState player;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
State<_PlMainLayout> createState() => _PlMainLayoutState();
|
||||
@@ -242,25 +246,30 @@ class _PlMainLayoutState extends State<_PlMainLayout> {
|
||||
}
|
||||
|
||||
void _onTapDown(TapDownDetails details) {
|
||||
if (details.localPosition.dx >= _listRight) return;
|
||||
final visibleRows = ((widget.height - 20 - 38) / _rowStride).floor();
|
||||
final row = ((details.localPosition.dy - _rowsLocalTop) / _rowStride)
|
||||
.floor();
|
||||
if (row < 0 || row >= visibleRows) return;
|
||||
final absIndex = _offset + row;
|
||||
if (row >= 0 && row < _numSongs && absIndex < _playlist.length) {
|
||||
if (absIndex < _playlist.length) {
|
||||
setState(() => _selected = absIndex);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails details) {
|
||||
if (details.localPosition.dx >= _listRight) {
|
||||
final listRight = widget.width - 20;
|
||||
if (details.localPosition.dx >= listRight) {
|
||||
_dragging = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
if (!_dragging) return;
|
||||
final t = _playlist.length - _numSongs;
|
||||
final pos = ((details.localPosition.dy / 214) * t)
|
||||
final visibleRows = ((widget.height - 20 - 38) / _rowStride).floor();
|
||||
final t = _playlist.length - visibleRows;
|
||||
if (t < 1) return;
|
||||
final bodyHeight = widget.height - 20 - 38;
|
||||
final pos = ((details.localPosition.dy / bodyHeight) * t)
|
||||
.round()
|
||||
.clamp(0, t)
|
||||
.toInt();
|
||||
@@ -283,6 +292,7 @@ class _PlMainLayoutState extends State<_PlMainLayout> {
|
||||
final player = widget.player;
|
||||
final pledit = skin.image("pledit.bmp")!;
|
||||
final elapsedSec = player.position * player.durationSec ~/ 256;
|
||||
final bodyHeight = widget.height - 20 - 38;
|
||||
return Stack(
|
||||
children: [
|
||||
const Positioned.fill(
|
||||
@@ -291,22 +301,27 @@ class _PlMainLayoutState extends State<_PlMainLayout> {
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: CustomPaint(
|
||||
size: const Size(_plWidth, 20),
|
||||
size: Size(widget.width, 20),
|
||||
painter: _PlTitlebarPainter(image: pledit, active: true),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 20,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: bodyHeight,
|
||||
child: CustomPaint(
|
||||
size: const Size(_plWidth, 232),
|
||||
size: Size(widget.width, bodyHeight),
|
||||
painter: _PlBodyPainter(pledit),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 20,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: bodyHeight,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: _onTapDown,
|
||||
@@ -315,7 +330,7 @@ class _PlMainLayoutState extends State<_PlMainLayout> {
|
||||
onVerticalDragEnd: _onDragEnd,
|
||||
onVerticalDragCancel: _onDragCancel,
|
||||
child: CustomPaint(
|
||||
size: const Size(_plWidth, 232),
|
||||
size: Size(widget.width, bodyHeight),
|
||||
painter: _PlSongListPainter(
|
||||
skin: skin,
|
||||
keyed: _keyed,
|
||||
@@ -327,25 +342,27 @@ class _PlMainLayoutState extends State<_PlMainLayout> {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 252,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 38,
|
||||
child: CustomPaint(
|
||||
size: const Size(_plWidth, 38),
|
||||
size: Size(widget.width, 38),
|
||||
painter: _PlBottomPainter(pledit),
|
||||
),
|
||||
),
|
||||
_PlButtons(pledit: pledit),
|
||||
_PlButtons(pledit: pledit, width: widget.width),
|
||||
Positioned(
|
||||
top: 262,
|
||||
left: 137,
|
||||
top: widget.height - 28,
|
||||
child: CustomPaint(
|
||||
size: const Size(90, 6),
|
||||
painter: _PlInfoPainter(keyed: _keyed, text: _infoString()),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 275,
|
||||
left: 194,
|
||||
left: widget.width - 86,
|
||||
top: widget.height - 15,
|
||||
child: CustomPaint(
|
||||
size: const Size(32, 6),
|
||||
painter: _PlTimePainter(
|
||||
@@ -355,7 +372,11 @@ class _PlMainLayoutState extends State<_PlMainLayout> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_PlTitleButtons(pledit: pledit, player: player),
|
||||
_PlTitleButtons(
|
||||
pledit: pledit,
|
||||
player: player,
|
||||
width: widget.width,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -657,48 +678,70 @@ class _PlSongListPainter extends CustomPainter {
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
_paintRows(canvas);
|
||||
_paintScrollbar(canvas);
|
||||
_paintRows(canvas, size);
|
||||
_paintScrollbar(canvas, size);
|
||||
}
|
||||
|
||||
void _paintRows(Canvas canvas) {
|
||||
void _paintRows(Canvas canvas, Size paintSize) {
|
||||
final colors = skin.pledit;
|
||||
final listRight = paintSize.width - 20;
|
||||
final listWidth = listRight - _listLeft;
|
||||
final visibleRows = ((paintSize.height - _rowsLocalTop) / _rowStride)
|
||||
.floor();
|
||||
final rowsBottom = _rowsLocalTop + visibleRows * _rowStride;
|
||||
canvas.drawRect(
|
||||
const Rect.fromLTWH(_listLeft, 0, _listRight - _listLeft, 3),
|
||||
Rect.fromLTWH(_listLeft, 0, listWidth, 3),
|
||||
Paint()..color = colors.normalBg,
|
||||
);
|
||||
const rowsBottom = _rowsLocalTop + _numSongs * _rowStride;
|
||||
canvas.drawRect(
|
||||
const Rect.fromLTWH(
|
||||
Rect.fromLTWH(
|
||||
_listLeft,
|
||||
rowsBottom,
|
||||
_listRight - _listLeft,
|
||||
232 - rowsBottom,
|
||||
listWidth,
|
||||
paintSize.height - rowsBottom,
|
||||
),
|
||||
Paint()..color = colors.normalBg,
|
||||
);
|
||||
final visible = (_playlist.length - offset).clamp(0, _numSongs);
|
||||
final visible = (_playlist.length - offset).clamp(0, visibleRows);
|
||||
for (var i = 0; i < visible; i++) {
|
||||
final absIndex = offset + i;
|
||||
final y = _rowsLocalTop + i * _rowStride;
|
||||
final isCurrent = absIndex == 0;
|
||||
final isSelected = absIndex == selected;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(_listLeft, y, _listRight - _listLeft, _rowStride),
|
||||
Rect.fromLTWH(_listLeft, y, listWidth, _rowStride),
|
||||
Paint()..color = isSelected ? colors.selectedBg : colors.normalBg,
|
||||
);
|
||||
final color = isCurrent ? colors.current : colors.normal;
|
||||
_drawRowText(canvas, _playlist[absIndex], y + _rowGlyphOffset, color);
|
||||
_drawRowText(
|
||||
canvas,
|
||||
_playlist[absIndex],
|
||||
y + _rowGlyphOffset,
|
||||
color,
|
||||
paintSize.width,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawRowText(Canvas canvas, _Track track, double y, Color color) {
|
||||
void _drawRowText(
|
||||
Canvas canvas,
|
||||
_Track track,
|
||||
double y,
|
||||
Color color,
|
||||
double paintWidth,
|
||||
) {
|
||||
final time = _fmtTime(track.seconds);
|
||||
final timeLen = time.length;
|
||||
final nameBudget = 49 - (timeLen + 1);
|
||||
final availableWidth =
|
||||
paintWidth - _listLeft - 24 - timeLen * TextFont.charWidth;
|
||||
final nameBudget = availableWidth ~/ TextFont.charWidth;
|
||||
var name = track.name;
|
||||
if (name.length > nameBudget) {
|
||||
name = "${name.substring(0, nameBudget - 3)}...";
|
||||
if (nameBudget > 3) {
|
||||
name = "${name.substring(0, nameBudget - 3)}...";
|
||||
} else {
|
||||
name = "";
|
||||
}
|
||||
}
|
||||
keyed.draw(
|
||||
canvas,
|
||||
@@ -706,7 +749,7 @@ class _PlSongListPainter extends CustomPainter {
|
||||
_listLeft,
|
||||
y,
|
||||
color,
|
||||
clipWidth: nameBudget * TextFont.charWidth,
|
||||
clipWidth: availableWidth.clamp(0.0, double.infinity),
|
||||
);
|
||||
keyed.draw(
|
||||
canvas,
|
||||
@@ -718,25 +761,26 @@ class _PlSongListPainter extends CustomPainter {
|
||||
keyed.draw(
|
||||
canvas,
|
||||
time,
|
||||
_plWidth - 24 - timeLen * TextFont.charWidth,
|
||||
paintWidth - 24 - timeLen * TextFont.charWidth,
|
||||
y,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
void _paintScrollbar(Canvas canvas) {
|
||||
void _paintScrollbar(Canvas canvas, Size paintSize) {
|
||||
final pledit = skin.image("pledit.bmp")!;
|
||||
const trackH = 232;
|
||||
final trackH = paintSize.height;
|
||||
const slidH = 18;
|
||||
final t = _playlist.length - _numSongs;
|
||||
final visibleRows = ((trackH - _rowsLocalTop) / _rowStride).floor();
|
||||
final t = _playlist.length - visibleRows;
|
||||
int yp;
|
||||
if (t < 1) {
|
||||
yp = 0;
|
||||
} else {
|
||||
final p = offset.clamp(0, t);
|
||||
yp = (214 * p) ~/ t;
|
||||
yp = (((trackH - slidH) * p) ~/ t).clamp(0, trackH - slidH).toInt();
|
||||
}
|
||||
const x = _plWidth - 15;
|
||||
final x = paintSize.width - 15;
|
||||
final paint = Paint()..filterQuality = FilterQuality.none;
|
||||
var y = 0;
|
||||
for (; y < yp - 28; y += 29) {
|
||||
@@ -851,16 +895,19 @@ class _PlTimePainter extends CustomPainter {
|
||||
|
||||
/// The 5 button columns (add/rem/sel/misc/load) over the bottom bar.
|
||||
class _PlButtons extends StatelessWidget {
|
||||
const _PlButtons({required this.pledit});
|
||||
const _PlButtons({
|
||||
required this.pledit,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
final ui.Image pledit;
|
||||
final double width;
|
||||
|
||||
static const List<(double, double, Rect)> _bars = [
|
||||
(11, 224, Rect.fromLTWH(48, 111, 3, 54)),
|
||||
(40, 206, Rect.fromLTWH(100, 111, 3, 72)),
|
||||
(69, 224, Rect.fromLTWH(150, 111, 3, 54)),
|
||||
(98, 224, Rect.fromLTWH(200, 111, 3, 54)),
|
||||
(_plWidth - 47, 224, Rect.fromLTWH(250, 111, 3, 54)),
|
||||
];
|
||||
|
||||
static const List<(double, double, Rect)> _buttons = [
|
||||
@@ -877,13 +924,11 @@ class _PlButtons extends StatelessWidget {
|
||||
(101, 224, Rect.fromLTWH(154, 111, 22, 18)),
|
||||
(101, 242, Rect.fromLTWH(154, 130, 22, 18)),
|
||||
(101, 260, Rect.fromLTWH(154, 149, 22, 18)),
|
||||
(_plWidth - 44, 224, Rect.fromLTWH(204, 111, 22, 18)),
|
||||
(_plWidth - 44, 242, Rect.fromLTWH(204, 130, 22, 18)),
|
||||
(_plWidth - 44, 260, Rect.fromLTWH(204, 149, 22, 18)),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rightEdge = width - 47;
|
||||
return Stack(
|
||||
children: [
|
||||
for (final b in _bars)
|
||||
@@ -892,12 +937,29 @@ class _PlButtons extends StatelessWidget {
|
||||
top: b.$2,
|
||||
child: SkinImage(image: pledit, src: b.$3),
|
||||
),
|
||||
Positioned(
|
||||
left: rightEdge,
|
||||
top: 224,
|
||||
child: SkinImage(
|
||||
image: pledit,
|
||||
src: const Rect.fromLTWH(250, 111, 3, 54),
|
||||
),
|
||||
),
|
||||
for (final b in _buttons)
|
||||
Positioned(
|
||||
left: b.$1,
|
||||
top: b.$2,
|
||||
child: SkinImage(image: pledit, src: b.$3),
|
||||
),
|
||||
for (final top in [224.0, 242.0, 260.0])
|
||||
Positioned(
|
||||
left: width - 44,
|
||||
top: top,
|
||||
child: SkinImage(
|
||||
image: pledit,
|
||||
src: const Rect.fromLTWH(204, 111, 22, 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -905,17 +967,22 @@ class _PlButtons extends StatelessWidget {
|
||||
|
||||
/// The titlebar shade/close buttons (`draw_pe_tbutton`).
|
||||
class _PlTitleButtons extends StatelessWidget {
|
||||
const _PlTitleButtons({required this.pledit, required this.player});
|
||||
const _PlTitleButtons({
|
||||
required this.pledit,
|
||||
required this.player,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
final ui.Image pledit;
|
||||
final PlayerState player;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: _plWidth - 20,
|
||||
left: width - 20,
|
||||
top: 3,
|
||||
child: SkinPressButton(
|
||||
image: pledit,
|
||||
@@ -925,7 +992,7 @@ class _PlTitleButtons extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _plWidth - 11,
|
||||
left: width - 11,
|
||||
top: 3,
|
||||
child: SkinPressButton(
|
||||
image: pledit,
|
||||
@@ -945,10 +1012,15 @@ class _PlTitleButtons extends StatelessWidget {
|
||||
|
||||
/// Windowshade (280×14) built from `pledit.bmp`.
|
||||
class _PlShadeLayout extends StatelessWidget {
|
||||
const _PlShadeLayout({required this.skin, required this.player});
|
||||
const _PlShadeLayout({
|
||||
required this.skin,
|
||||
required this.player,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
final ClassicSkin skin;
|
||||
final PlayerState player;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -957,15 +1029,19 @@ class _PlShadeLayout extends StatelessWidget {
|
||||
return Stack(
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: const Size(_plWidth, _plShadeHeight),
|
||||
size: Size(width, _plShadeHeight),
|
||||
painter: _PlShadeTitlebarPainter(image: pledit, active: true),
|
||||
),
|
||||
CustomPaint(
|
||||
size: const Size(_plWidth, _plShadeHeight),
|
||||
painter: _PlShadeSongPainter(keyed: keyed, track: _playlist[0]),
|
||||
size: Size(width, _plShadeHeight),
|
||||
painter: _PlShadeSongPainter(
|
||||
keyed: keyed,
|
||||
track: _playlist[0],
|
||||
width: width,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _plWidth - 20,
|
||||
left: width - 20,
|
||||
top: 3,
|
||||
child: SkinPressButton(
|
||||
image: pledit,
|
||||
@@ -975,7 +1051,7 @@ class _PlShadeLayout extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _plWidth - 11,
|
||||
left: width - 11,
|
||||
top: 3,
|
||||
child: SkinPressButton(
|
||||
image: pledit,
|
||||
@@ -995,19 +1071,29 @@ class _PlShadeLayout extends StatelessWidget {
|
||||
|
||||
/// The windowshade current-song line (`draw_pe_song` with `pos = -1`).
|
||||
class _PlShadeSongPainter extends CustomPainter {
|
||||
const _PlShadeSongPainter({required this.keyed, required this.track});
|
||||
const _PlShadeSongPainter({
|
||||
required this.keyed,
|
||||
required this.track,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
final _KeyedFont keyed;
|
||||
final _Track track;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final time = _fmtTime(track.seconds);
|
||||
final timeLen = time.length;
|
||||
final budget = 49 - (timeLen + 1);
|
||||
final availableWidth = width - 29 - timeLen * TextFont.charWidth - 4;
|
||||
final nameBudget = availableWidth ~/ TextFont.charWidth;
|
||||
var name = track.name;
|
||||
if (name.length > budget) {
|
||||
name = "${name.substring(0, budget - 3)}...";
|
||||
if (name.length > nameBudget) {
|
||||
if (nameBudget > 3) {
|
||||
name = "${name.substring(0, nameBudget - 3)}...";
|
||||
} else {
|
||||
name = "";
|
||||
}
|
||||
}
|
||||
const y = 4.0;
|
||||
keyed.draw(
|
||||
@@ -1016,7 +1102,7 @@ class _PlShadeSongPainter extends CustomPainter {
|
||||
4,
|
||||
y,
|
||||
_displayGreen,
|
||||
clipWidth: budget * TextFont.charWidth,
|
||||
clipWidth: availableWidth.clamp(0.0, double.infinity),
|
||||
);
|
||||
keyed.draw(
|
||||
canvas,
|
||||
@@ -1028,7 +1114,7 @@ class _PlShadeSongPainter extends CustomPainter {
|
||||
keyed.draw(
|
||||
canvas,
|
||||
time,
|
||||
_plWidth - 29 - timeLen * TextFont.charWidth,
|
||||
width - 29 - timeLen * TextFont.charWidth,
|
||||
y,
|
||||
_displayGreen,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import "dart:async";
|
||||
import "dart:collection";
|
||||
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:window_manager/window_manager.dart";
|
||||
|
||||
import "../player/eq_presets.dart";
|
||||
import "../windows/sub_window_channel.dart";
|
||||
|
||||
/// The fixed outer size of the presets window in logical pixels.
|
||||
const Size presetsWindowSize = Size(340, 320);
|
||||
|
||||
/// The EQ preset dialog mode, mirroring the original `IDD_LOADPRESET` /
|
||||
/// `IDD_SAVEPRESET` dialogs (Eq.cpp): load/delete share one listbox dialog and
|
||||
/// save adds a name field.
|
||||
enum PresetsMode { load, save, delete }
|
||||
|
||||
/// The Equalizer presets dialog window.
|
||||
///
|
||||
/// A persistent dialog (spawned by the EQ presets button, tracked by the main
|
||||
/// window) with a mode switcher for Load/Save/Delete, a preset list, a name
|
||||
/// field for Save, and a Default reset. Loads/Defaults push the preset to the
|
||||
/// Equalizer over [subWindowChannel]; Save pulls the Equalizer's current
|
||||
/// settings back over the same channel and persists them via [EqPresets].
|
||||
class PresetsWindow extends StatefulWidget {
|
||||
const PresetsWindow({required this.mode, super.key});
|
||||
|
||||
final PresetsMode mode;
|
||||
|
||||
@override
|
||||
State<PresetsWindow> createState() => _PresetsWindowState();
|
||||
}
|
||||
|
||||
class _PresetsWindowState extends State<PresetsWindow> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
late PresetsMode _mode;
|
||||
Map<String, EqPreset> _presets = const {};
|
||||
String? _selected;
|
||||
bool _busy = false;
|
||||
String? _message;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mode = widget.mode;
|
||||
unawaited(_initPresets());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PresetsWindow oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.mode != _mode) {
|
||||
setState(() => _mode = widget.mode);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initPresets() async {
|
||||
final loaded = await EqPresets.load();
|
||||
var selected = await EqPresets.lastPreset();
|
||||
final sorted = _sorted(loaded);
|
||||
if (selected != null && !sorted.containsKey(selected)) {
|
||||
selected = sorted.isEmpty ? null : sorted.keys.first;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_presets = sorted;
|
||||
_selected = selected;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static SplayTreeMap<String, EqPreset> _sorted(Map<String, EqPreset> presets) {
|
||||
return SplayTreeMap<String, EqPreset>(
|
||||
(a, b) {
|
||||
final byCase = a.toLowerCase().compareTo(b.toLowerCase());
|
||||
return byCase != 0 ? byCase : a.compareTo(b);
|
||||
},
|
||||
)..addAll(presets);
|
||||
}
|
||||
|
||||
Future<void> _reloadPresets({String? select}) async {
|
||||
final loaded = await EqPresets.load();
|
||||
final sorted = _sorted(loaded);
|
||||
final current = _selected;
|
||||
final selection =
|
||||
select ??
|
||||
(current != null && sorted.containsKey(current) ? current : null);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_presets = sorted;
|
||||
_selected = selection;
|
||||
_message = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final name = _selected;
|
||||
if (name == null) return;
|
||||
final preset = await EqPresets.byName(name);
|
||||
if (preset == null) return;
|
||||
await _applyPreset(preset);
|
||||
}
|
||||
|
||||
Future<void> _applyDefault() async {
|
||||
final stored = await EqPresets.byName("Default");
|
||||
await _applyPreset(stored ?? EqPresets.defaultPreset);
|
||||
}
|
||||
|
||||
Future<void> _applyPreset(EqPreset preset) async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_message = null;
|
||||
});
|
||||
try {
|
||||
await subWindowChannel.invokeMethod(
|
||||
"applyPreset",
|
||||
{"preamp": preset.preamp, "bands": preset.bands},
|
||||
);
|
||||
await EqPresets.setLastPreset(preset.name);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _message = "Could not reach the main window.");
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _message = "Enter a preset name.");
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_message = null;
|
||||
});
|
||||
try {
|
||||
final settings = await subWindowChannel.invokeMethod<Map>(
|
||||
"getEqSettings",
|
||||
);
|
||||
if (settings == null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_message = "The Equalizer window is closed.";
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final preamp = (settings["preamp"] as num).toInt();
|
||||
final bands = (settings["bands"] as List)
|
||||
.whereType<num>()
|
||||
.map((v) => v.toInt())
|
||||
.toList();
|
||||
await EqPresets.save(
|
||||
EqPreset(name: name, preamp: preamp, bands: bands),
|
||||
);
|
||||
await EqPresets.setLastPreset(name);
|
||||
_nameController.clear();
|
||||
await _reloadPresets(select: name);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _message = "Could not reach the main window.");
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
final name = _selected;
|
||||
if (name == null) return;
|
||||
await EqPresets.remove(name);
|
||||
if (mounted) setState(() => _selected = null);
|
||||
await _reloadPresets();
|
||||
}
|
||||
|
||||
Future<void> _close() async {
|
||||
try {
|
||||
await windowManager.close();
|
||||
} catch (_) {
|
||||
// ignore: the native window may already be closing
|
||||
}
|
||||
}
|
||||
|
||||
String get _actionLabel => switch (_mode) {
|
||||
PresetsMode.load => "Load",
|
||||
PresetsMode.save => "Save",
|
||||
PresetsMode.delete => "Delete",
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final darkTheme = ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFFF34A2A),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
);
|
||||
final mode = _mode;
|
||||
final hasSelection = _selected != null;
|
||||
final canAct = switch (mode) {
|
||||
PresetsMode.load || PresetsMode.delete => hasSelection,
|
||||
PresetsMode.save => true,
|
||||
};
|
||||
final action = switch (mode) {
|
||||
PresetsMode.load => () => unawaited(_load()),
|
||||
PresetsMode.save => () => unawaited(_save()),
|
||||
PresetsMode.delete => () => unawaited(_delete()),
|
||||
};
|
||||
return Theme(
|
||||
data: darkTheme,
|
||||
child: Material(
|
||||
color: const Color(0xFF1E1F24),
|
||||
child: SizedBox.expand(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
height: 32,
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
"Winamp EQ Presets",
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
tooltip: "Close",
|
||||
onPressed: _busy ? null : () => unawaited(_close()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SegmentedButton<PresetsMode>(
|
||||
segments: const [
|
||||
ButtonSegment(value: PresetsMode.load, label: Text("Load")),
|
||||
ButtonSegment(value: PresetsMode.save, label: Text("Save")),
|
||||
ButtonSegment(
|
||||
value: PresetsMode.delete,
|
||||
label: Text("Delete"),
|
||||
),
|
||||
],
|
||||
selected: {mode},
|
||||
onSelectionChanged: _busy
|
||||
? null
|
||||
: (selection) {
|
||||
setState(() => _mode = selection.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Container(
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFF3A3B40)),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: _presets.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
"No presets",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: _presets.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final name = _presets.keys.elementAt(index);
|
||||
final isSelected = name == _selected;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selected = name;
|
||||
_message = null;
|
||||
});
|
||||
},
|
||||
onDoubleTap: mode == PresetsMode.load
|
||||
? () => unawaited(_load())
|
||||
: null,
|
||||
child: Container(
|
||||
color: isSelected
|
||||
? darkTheme.colorScheme.primaryContainer
|
||||
: null,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected
|
||||
? darkTheme
|
||||
.colorScheme
|
||||
.onPrimaryContainer
|
||||
: null,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mode == PresetsMode.save)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
enabled: !_busy,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Preset name",
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => unawaited(_save()),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 18,
|
||||
child: Center(
|
||||
child: _message == null
|
||||
? null
|
||||
: Text(
|
||||
_message!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: darkTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: _busy || !canAct ? null : action,
|
||||
child: Text(_actionLabel),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => unawaited(_applyDefault()),
|
||||
child: const Text("Default"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => unawaited(_close()),
|
||||
child: const Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import "dart:convert";
|
||||
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
const String _kPresetsKey = "winamp.eq_presets";
|
||||
const String _kLastPresetKey = "winamp.eq_last_preset";
|
||||
|
||||
/// A single saved Equalizer preset: the preamp gain plus the 10 band gains.
|
||||
///
|
||||
/// Band values are 0..64 with 32 at 0 dB, mirroring the original `eq_tab` and
|
||||
/// `config_preamp` state.
|
||||
class EqPreset {
|
||||
const EqPreset({
|
||||
required this.name,
|
||||
required this.preamp,
|
||||
required this.bands,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int preamp;
|
||||
final List<int> bands;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is EqPreset &&
|
||||
other.name == name &&
|
||||
other.preamp == preamp &&
|
||||
_sameBands(other.bands);
|
||||
}
|
||||
|
||||
bool _sameBands(List<int> other) {
|
||||
if (other.length != bands.length) return false;
|
||||
for (var i = 0; i < bands.length; i++) {
|
||||
if (other[i] != bands[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(name, preamp, Object.hashAll(bands));
|
||||
|
||||
/// Decodes a single preset map value (from the JSON store), or `null` when
|
||||
/// the payload is malformed.
|
||||
static EqPreset? fromJson(String name, Object? json) {
|
||||
final map = json is Map ? json : null;
|
||||
if (map == null) return null;
|
||||
final preamp = map["preamp"];
|
||||
final bands = map["bands"];
|
||||
if (preamp is! num || bands is! List) return null;
|
||||
return EqPreset(
|
||||
name: name,
|
||||
preamp: preamp.toInt().clamp(0, 64),
|
||||
bands: bands
|
||||
.whereType<num>()
|
||||
.take(10)
|
||||
.map((v) => v.toInt().clamp(0, 64))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Equalizer preset store, persisted as a JSON map in `SharedPreferences`.
|
||||
///
|
||||
/// Mirrors the original `Winamp EQ library file` (`eqpresets/eqm.preset`,
|
||||
/// Eq.cpp): a flat set of named presets seeded with the built-in set on first
|
||||
/// use (main.cpp `CreateEQPresets`). Load/Delete/Save operate case-insensitively
|
||||
/// on preset names, and the last applied preset is remembered so the presets
|
||||
/// window can preselect it.
|
||||
class EqPresets {
|
||||
/// The 18 built-in presets with their 10 band gains, seeded on first run.
|
||||
static const List<(String, List<int>)> builtins = [
|
||||
("Classical", [31, 31, 31, 31, 31, 31, 44, 44, 44, 48]),
|
||||
("Club", [31, 31, 38, 50, 56, 63, 50, 44, 38, 31]),
|
||||
("Dance", [44, 38, 44, 50, 38, 31, 25, 31, 31, 31]),
|
||||
("Flat", [31, 31, 31, 31, 31, 31, 31, 31, 31, 31]),
|
||||
("Laptop speakers/headphones", [31, 38, 44, 50, 56, 63, 56, 44, 38, 31]),
|
||||
("Large hall", [50, 50, 38, 38, 38, 38, 38, 38, 38, 50]),
|
||||
("Party", [38, 38, 31, 31, 31, 31, 50, 56, 50, 38]),
|
||||
("Pop", [38, 31, 31, 31, 38, 56, 56, 44, 31, 31]),
|
||||
("Reggae", [31, 44, 31, 38, 56, 38, 31, 31, 31, 31]),
|
||||
("Rock", [50, 38, 31, 38, 44, 50, 56, 56, 56, 50]),
|
||||
("Soft", [44, 38, 31, 31, 31, 31, 25, 25, 31, 44]),
|
||||
("Ska", [31, 31, 25, 38, 56, 63, 56, 38, 31, 31]),
|
||||
("Full Bass", [56, 56, 56, 38, 31, 31, 31, 31, 31, 31]),
|
||||
("Soft Rock", [50, 44, 38, 31, 31, 31, 31, 31, 31, 44]),
|
||||
("Full Treble", [31, 31, 31, 31, 31, 38, 56, 56, 56, 56]),
|
||||
("Full Bass & Treble", [56, 56, 56, 31, 31, 31, 56, 56, 56, 56]),
|
||||
("Live", [31, 31, 31, 38, 38, 38, 31, 31, 31, 31]),
|
||||
("Techno", [56, 50, 38, 31, 25, 25, 31, 38, 50, 56]),
|
||||
];
|
||||
|
||||
/// The neutral reset applied by the Default action when no "Default" preset
|
||||
/// has been saved yet (the original `eq_tab` initialization).
|
||||
static const EqPreset defaultPreset = EqPreset(
|
||||
name: "Default",
|
||||
preamp: 31,
|
||||
bands: [31, 31, 31, 31, 31, 31, 31, 31, 31, 31],
|
||||
);
|
||||
|
||||
/// Loads all presets, seeding the built-ins on first run.
|
||||
static Future<Map<String, EqPreset>> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_kPresetsKey);
|
||||
if (raw == null) {
|
||||
final seeded = _builtinPresets();
|
||||
await prefs.setString(_kPresetsKey, jsonEncode(_encode(seeded)));
|
||||
return seeded;
|
||||
}
|
||||
final decoded = _decode(raw);
|
||||
if (decoded.isEmpty) return _builtinPresets();
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/// Saves or overwrites [preset] (matched case-insensitively by name).
|
||||
static Future<void> save(EqPreset preset) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final presets = await _readOrSeed(prefs);
|
||||
final key = _findKey(presets, preset.name) ?? preset.name;
|
||||
presets[key] = preset;
|
||||
await prefs.setString(_kPresetsKey, jsonEncode(_encode(presets)));
|
||||
}
|
||||
|
||||
/// Deletes the preset named [name] (matched case-insensitively), if present.
|
||||
static Future<void> remove(String name) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final presets = await _readOrSeed(prefs);
|
||||
final key = _findKey(presets, name);
|
||||
if (key == null) return;
|
||||
presets.remove(key);
|
||||
await prefs.setString(_kPresetsKey, jsonEncode(_encode(presets)));
|
||||
}
|
||||
|
||||
/// The preset named [name] (matched case-insensitively), or `null`.
|
||||
static Future<EqPreset?> byName(String name) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final presets = await _readOrSeed(prefs);
|
||||
final key = _findKey(presets, name);
|
||||
return key == null ? null : presets[key];
|
||||
}
|
||||
|
||||
/// The name of the last applied preset, or `null`.
|
||||
static Future<String?> lastPreset() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_kLastPresetKey);
|
||||
}
|
||||
|
||||
/// Remembers [name] as the last applied preset.
|
||||
static Future<void> setLastPreset(String name) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kLastPresetKey, name);
|
||||
}
|
||||
|
||||
static Map<String, EqPreset> _builtinPresets() {
|
||||
return {
|
||||
for (final (name, bands) in builtins)
|
||||
name: EqPreset(name: name, preamp: 31, bands: bands),
|
||||
};
|
||||
}
|
||||
|
||||
static Future<Map<String, EqPreset>> _readOrSeed(
|
||||
SharedPreferences prefs,
|
||||
) async {
|
||||
final raw = prefs.getString(_kPresetsKey);
|
||||
if (raw == null) {
|
||||
final seeded = _builtinPresets();
|
||||
await prefs.setString(_kPresetsKey, jsonEncode(_encode(seeded)));
|
||||
return seeded;
|
||||
}
|
||||
final decoded = _decode(raw);
|
||||
return decoded.isEmpty ? _builtinPresets() : decoded;
|
||||
}
|
||||
|
||||
static String? _findKey(Map<String, EqPreset> presets, String name) {
|
||||
for (final key in presets.keys) {
|
||||
if (key.toLowerCase() == name.toLowerCase()) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, Object?> _encode(Map<String, EqPreset> presets) {
|
||||
return {
|
||||
for (final entry in presets.entries)
|
||||
entry.key: {
|
||||
"preamp": entry.value.preamp,
|
||||
"bands": entry.value.bands,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, EqPreset> _decode(String raw) {
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(raw);
|
||||
} on FormatException {
|
||||
return {};
|
||||
}
|
||||
if (decoded is! Map) return {};
|
||||
final result = <String, EqPreset>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final preset = EqPreset.fromJson(entry.key as String, entry.value);
|
||||
if (preset != null) result[entry.key as String] = preset;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ const String _kPlShadeKey = "winamp.pl_shade";
|
||||
const String _kUiScaleKey = "winamp.ui_scale";
|
||||
const String _kActiveSkinKey = "winamp.active_skin";
|
||||
|
||||
/// The `winamp.window_<name>_w/h` preference keys for a window size.
|
||||
String _windowSizeKey(String window, String axis) =>
|
||||
"winamp.window_${window}_$axis";
|
||||
|
||||
/// The `winamp.window_<name>_x/y` preference keys for a window position.
|
||||
String _windowPosKey(String window, String axis) =>
|
||||
"winamp.window_${window}_$axis";
|
||||
@@ -329,6 +333,23 @@ class PlayerState extends ChangeNotifier {
|
||||
await prefs.setDouble(_windowPosKey(window, "y"), y);
|
||||
}
|
||||
|
||||
/// Loads the saved size (width, height) of the window named [window], or
|
||||
/// `null` when never resized.
|
||||
static Future<(double, double)?> loadWindowSize(String window) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final w = prefs.getDouble(_windowSizeKey(window, "w"));
|
||||
final h = prefs.getDouble(_windowSizeKey(window, "h"));
|
||||
if (w == null || h == null) return null;
|
||||
return (w, h);
|
||||
}
|
||||
|
||||
/// Persists the size (width, height) of the window named [window].
|
||||
static Future<void> saveWindowSize(String window, double w, double h) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_windowSizeKey(window, "w"), w);
|
||||
await prefs.setDouble(_windowSizeKey(window, "h"), h);
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
_ticker?.cancel();
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import "dart:async";
|
||||
import "dart:ui" as ui;
|
||||
|
||||
import "package:dartamp_skin/dartamp_skin.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// The platform channel to the macOS app delegate.
|
||||
const winampChannel = MethodChannel("winamp");
|
||||
|
||||
@@ -21,7 +21,7 @@ Future<ClassicSkin> loadAppSkin(String name) async {
|
||||
try {
|
||||
final registry = await SkinRegistry.load(await skinsDirectory());
|
||||
final info = registry.byName(name);
|
||||
if (info == null) return loadBaseSkin();
|
||||
if (info == null) return await loadBaseSkin();
|
||||
return await registry.loadSkin(info);
|
||||
} catch (_) {
|
||||
return loadBaseSkin();
|
||||
|
||||
@@ -3,13 +3,14 @@ import "dart:ui" as ui;
|
||||
|
||||
import "package:dartamp_skin/dartamp_skin.dart";
|
||||
import "package:desktop_multi_window/desktop_multi_window.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:window_manager/window_manager.dart";
|
||||
|
||||
import "../common_widgets.dart";
|
||||
import "../docking/dock_logic.dart";
|
||||
import "../main_window/eq_window.dart";
|
||||
import "../main_window/playlist_window.dart";
|
||||
import "../main_window/presets_window.dart";
|
||||
import "../player/player_state.dart";
|
||||
import "../skin/skin_manager.dart";
|
||||
import "sub_window_channel.dart";
|
||||
@@ -20,7 +21,9 @@ const Size _eqShadeSize = Size(275, 14);
|
||||
const Size _plMainSize = Size(275, 290);
|
||||
const Size _plShadeSize = Size(275, 14);
|
||||
|
||||
/// The root of an Equalizer or Playlist sub-window.
|
||||
enum _ResizeHandle { left, right, top, bottom, bottomRight }
|
||||
|
||||
/// The root of an Equalizer, Playlist, or presets sub-window.
|
||||
///
|
||||
/// Each sub-window is an independent Flutter engine that re-runs the app's
|
||||
/// `main`. This root configures the native window (size, frameless look,
|
||||
@@ -34,6 +37,7 @@ class SubWindowRoot extends StatefulWidget {
|
||||
required this.initialAlwaysOnTop,
|
||||
this.initialUiScale = 1.0,
|
||||
this.initialSkinName = "Base",
|
||||
this.initialMode = "load",
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -43,6 +47,10 @@ class SubWindowRoot extends StatefulWidget {
|
||||
final double initialUiScale;
|
||||
final String initialSkinName;
|
||||
|
||||
/// The EQ preset dialog mode for the presets window ("load", "save", or
|
||||
/// "delete"); ignored for other window types.
|
||||
final String initialMode;
|
||||
|
||||
@override
|
||||
State<SubWindowRoot> createState() => _SubWindowRootState();
|
||||
}
|
||||
@@ -63,15 +71,33 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
/// drag loop can compute grab offsets without async round-trips.
|
||||
Rect? _windowBounds;
|
||||
|
||||
/// The last non-shade size in logical pixels, used to restore normal-mode
|
||||
/// height when toggling back from a windowshade.
|
||||
Size? _lastNormalSize;
|
||||
|
||||
/// When external `setBounds` arrives while the user is interacting (drag or
|
||||
/// resize), it is stored here and applied once the interaction completes to
|
||||
/// avoid feedback loops between native and the in-flight user operations.
|
||||
Rect? _pendingExternalBounds;
|
||||
|
||||
/// The snapshot of the other windows' rects and the screen pushed by the
|
||||
/// main window, used to snap this window during its own drags.
|
||||
_SubDockContext? _dockContext;
|
||||
|
||||
/// The current EQ preset dialog mode, pushed by the main window.
|
||||
late PresetsMode _presetsMode;
|
||||
|
||||
bool get _isEq => widget.type == SubWindowType.eq;
|
||||
bool get _isPl => widget.type == SubWindowType.pl;
|
||||
bool get _isPresets => widget.type == SubWindowType.presets;
|
||||
|
||||
DockWindow get _dockWindow => _isEq ? DockWindow.eq : DockWindow.pl;
|
||||
|
||||
Size get _mainSize => _isEq ? _eqMainSize : _plMainSize;
|
||||
Size get _mainSize => switch (widget.type) {
|
||||
SubWindowType.eq => _eqMainSize,
|
||||
SubWindowType.pl => _plMainSize,
|
||||
SubWindowType.presets => presetsWindowSize,
|
||||
};
|
||||
Size get _shadeSize => _isEq ? _eqShadeSize : _plShadeSize;
|
||||
|
||||
@override
|
||||
@@ -80,13 +106,16 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
_player = PlayerState(
|
||||
alwaysOnTop: widget.initialAlwaysOnTop,
|
||||
eqShade: _isEq ? widget.initialShade : false,
|
||||
plShade: _isEq ? false : widget.initialShade,
|
||||
uiScale: widget.initialUiScale,
|
||||
plShade: _isPl ? widget.initialShade : false,
|
||||
uiScale: _isPresets ? 1.0 : widget.initialUiScale,
|
||||
);
|
||||
_presetsMode = _parsePresetsMode(widget.initialMode);
|
||||
_skinFuture = loadAppSkin(widget.initialSkinName);
|
||||
_drag = _SubWindowDragController(this);
|
||||
_wasShade = widget.initialShade;
|
||||
_wasScale = widget.initialUiScale;
|
||||
// The presets dialog is not UI-scaled; track 1.0 so a shade/always-on-top
|
||||
// push never rescales it from the main window's zoom.
|
||||
_wasScale = _isPresets ? 1.0 : widget.initialUiScale;
|
||||
_player.addListener(_onPlayerChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_readyWindow();
|
||||
@@ -94,6 +123,13 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
_controlWired = _wireWindowControl();
|
||||
}
|
||||
|
||||
static PresetsMode _parsePresetsMode(String mode) {
|
||||
return PresetsMode.values.firstWhere(
|
||||
(m) => m.name == mode,
|
||||
orElse: () => PresetsMode.load,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_player.removeListener(_onPlayerChanged);
|
||||
@@ -125,17 +161,63 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
final args = call.arguments;
|
||||
if (args is Map) {
|
||||
final bounds = _windowBounds;
|
||||
final size = bounds?.size ?? _scaledSize();
|
||||
final currentSize = bounds?.size ?? _scaledSize();
|
||||
final x = (args["x"] as num).toDouble();
|
||||
final y = (args["y"] as num).toDouble();
|
||||
_windowBounds = Rect.fromLTWH(x, y, size.width, size.height);
|
||||
await windowManager.setPosition(Offset(x, y));
|
||||
final width = args["w"] is num
|
||||
? (args["w"] as num).toDouble()
|
||||
: currentSize.width;
|
||||
final height = args["h"] is num
|
||||
? (args["h"] as num).toDouble()
|
||||
: currentSize.height;
|
||||
final newBounds = Rect.fromLTWH(x, y, width, height);
|
||||
// If the user is currently resizing or dragging, stash the bounds
|
||||
// to avoid feedback loops where both sides repeatedly override
|
||||
// each other. Apply after interaction ends.
|
||||
if (_resizing || _drag._active) {
|
||||
_pendingExternalBounds = newBounds;
|
||||
} else {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_windowBounds = newBounds;
|
||||
});
|
||||
} else {
|
||||
_windowBounds = newBounds;
|
||||
}
|
||||
await windowManager.setSize(Size(width, height));
|
||||
await windowManager.setPosition(Offset(x, y));
|
||||
}
|
||||
}
|
||||
case "setDockContext":
|
||||
final args = call.arguments;
|
||||
if (args is Map) {
|
||||
_dockContext = _SubDockContext.decode(args);
|
||||
}
|
||||
case "setMode":
|
||||
if (call.arguments is String) {
|
||||
final mode = _parsePresetsMode(call.arguments as String);
|
||||
if (mode != _presetsMode) {
|
||||
setState(() => _presetsMode = mode);
|
||||
}
|
||||
}
|
||||
case "applyPreset":
|
||||
// The main window forwards a loaded preset to the Equalizer.
|
||||
if (_isEq && call.arguments is Map) {
|
||||
final args = call.arguments as Map;
|
||||
final preamp = args["preamp"];
|
||||
final bands = args["bands"];
|
||||
if (preamp is num && bands is List) {
|
||||
_player.setPreamp(preamp.toInt());
|
||||
final values = bands.whereType<num>().toList();
|
||||
for (var i = 0; i < values.length && i < 10; i++) {
|
||||
_player.setEqBand(i, values[i].toInt());
|
||||
}
|
||||
}
|
||||
}
|
||||
case "getEqSettings":
|
||||
if (_isEq) {
|
||||
return {"preamp": _player.preamp, "bands": _player.eqBands};
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
@@ -147,14 +229,32 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
if (!mounted || _windowReady) return;
|
||||
try {
|
||||
await windowManager.setPreventClose(false);
|
||||
await windowManager.setResizable(false);
|
||||
// Only the playlist window is resizable; equalizer and presets are
|
||||
// fixed-size dialogs.
|
||||
await windowManager.setResizable(_isPl);
|
||||
await windowManager.setAlwaysOnTop(_player.alwaysOnTop);
|
||||
await windowManager.setTitle(
|
||||
_isEq ? "Winamp Equalizer" : "Winamp Playlist",
|
||||
_isEq
|
||||
? "Winamp Equalizer"
|
||||
: _isPresets
|
||||
? "Winamp EQ Presets"
|
||||
: "Winamp Playlist",
|
||||
);
|
||||
await windowManager.setAsFrameless();
|
||||
await windowManager.setBackgroundColor(const Color(0xFF000000));
|
||||
// The skin windows use a transparent background so skins with
|
||||
// transparency render correctly; the presets dialog is fully opaque.
|
||||
await windowManager.setBackgroundColor(
|
||||
_isPresets ? const Color(0xFF1E1F24) : const Color(0x00000000),
|
||||
);
|
||||
await windowManager.setSize(_scaledSize());
|
||||
if (_isPl) {
|
||||
await windowManager.setMinimumSize(
|
||||
Size(
|
||||
_mainSize.width * _player.uiScale,
|
||||
_plMainSize.height * _player.uiScale,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_windowBounds != null) {
|
||||
await windowManager.setPosition(_windowBounds!.topLeft);
|
||||
} else {
|
||||
@@ -170,7 +270,7 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
unawaited(
|
||||
subWindowChannel.invokeMethod(
|
||||
"subReady",
|
||||
{"type": _isEq ? "eq" : "pl"},
|
||||
{"type": widget.type.name},
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
@@ -182,11 +282,149 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
}
|
||||
}
|
||||
|
||||
Size _scaledSize() {
|
||||
final base = _wasShade ? _shadeSize : _mainSize;
|
||||
return Size(base.width * _player.uiScale, base.height * _player.uiScale);
|
||||
// --- Resize handling ---------------------------------------------------
|
||||
|
||||
bool _resizing = false;
|
||||
Rect? _resizeStartBounds;
|
||||
Offset? _resizeStartPointer;
|
||||
_ResizeHandle? _resizeHandle;
|
||||
// Throttle native position/size updates to avoid jitter on rapid pointer
|
||||
// movements. Coalesce multiple updates into a single native setPosition/setSize
|
||||
// call scheduled shortly after the most recent pointer event.
|
||||
Rect? _pendingApplyRect;
|
||||
bool _applyScheduled = false;
|
||||
|
||||
Future<void> _onResizeStart(_ResizeHandle handle, Offset pointer) async {
|
||||
_resizing = true;
|
||||
_resizeHandle = handle;
|
||||
_resizeStartPointer = pointer;
|
||||
_resizeStartBounds = await windowManager.getBounds();
|
||||
}
|
||||
|
||||
void _onResizeUpdate(Offset pointer) {
|
||||
if (!_resizing || _resizeStartBounds == null || _resizeStartPointer == null)
|
||||
return;
|
||||
final start = _resizeStartBounds!;
|
||||
final delta = pointer - _resizeStartPointer!;
|
||||
var left = start.left;
|
||||
var top = start.top;
|
||||
var width = start.width;
|
||||
var height = start.height;
|
||||
// Enforce min width as the default playlist width (not narrower).
|
||||
final minW = (_mainSize.width) * _player.uiScale;
|
||||
// Minimum height is the main window's height when available from the
|
||||
// pushed dock context; fall back to this window's base height otherwise.
|
||||
final mainRect = _dockContext?.rects[DockWindow.main];
|
||||
final minH = mainRect?.height ?? (_mainSize.height * _player.uiScale);
|
||||
switch (_resizeHandle!) {
|
||||
case _ResizeHandle.right:
|
||||
width = (start.width + delta.dx).clamp(minW, double.infinity);
|
||||
case _ResizeHandle.bottom:
|
||||
height = (start.height + delta.dy).clamp(minH, double.infinity);
|
||||
case _ResizeHandle.left:
|
||||
left = start.left + delta.dx;
|
||||
width = (start.width - delta.dx).clamp(minW, double.infinity);
|
||||
case _ResizeHandle.top:
|
||||
top = start.top + delta.dy;
|
||||
height = (start.height - delta.dy).clamp(minH, double.infinity);
|
||||
case _ResizeHandle.bottomRight:
|
||||
width = (start.width + delta.dx).clamp(minW, double.infinity);
|
||||
height = (start.height + delta.dy).clamp(minH, double.infinity);
|
||||
}
|
||||
final candidate = Rect.fromLTWH(left, top, width, height);
|
||||
setState(() {
|
||||
_windowBounds = candidate;
|
||||
if (!_isEq && !(_isEq ? _player.eqShade : _player.plShade)) {
|
||||
_lastNormalSize = Size(
|
||||
width / _player.uiScale,
|
||||
height / _player.uiScale,
|
||||
);
|
||||
}
|
||||
});
|
||||
// Coalesce native updates to reduce jitter: only schedule one outstanding
|
||||
// setPosition/setSize operation and always apply the most recent candidate.
|
||||
_pendingApplyRect = candidate;
|
||||
if (!_applyScheduled) {
|
||||
_applyScheduled = true;
|
||||
Timer(const Duration(milliseconds: 16), () async {
|
||||
final rect = _pendingApplyRect;
|
||||
if (rect != null) {
|
||||
try {
|
||||
await windowManager.setPosition(rect.topLeft);
|
||||
await windowManager.setSize(Size(rect.width, rect.height));
|
||||
_reportSubBounds(rect);
|
||||
} catch (e) {
|
||||
// Ignore transient native failures during rapid moves.
|
||||
}
|
||||
}
|
||||
_applyScheduled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onResizeEnd() async {
|
||||
if (!_resizing) return;
|
||||
_resizing = false;
|
||||
_resizeHandle = null;
|
||||
_resizeStartBounds = null;
|
||||
_resizeStartPointer = null;
|
||||
// Persist the final size now that main will receive the final subBounds
|
||||
final bounds = _windowBounds;
|
||||
if (bounds != null) {
|
||||
unawaited(
|
||||
PlayerState.saveWindowSize(
|
||||
_isEq ? "eq" : "pl",
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
),
|
||||
);
|
||||
}
|
||||
// If an external setBounds arrived while resizing, apply it now so the
|
||||
// window restores to the expected position/size.
|
||||
_applyPendingExternalBounds();
|
||||
}
|
||||
|
||||
/// Applies a `setBounds` push that arrived while the user was interacting
|
||||
/// (drag or resize), restoring the window to the pushed position/size and
|
||||
/// reporting it back to the main window.
|
||||
void _applyPendingExternalBounds() {
|
||||
final nb = _pendingExternalBounds;
|
||||
if (nb == null) return;
|
||||
_pendingExternalBounds = null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_windowBounds = nb;
|
||||
});
|
||||
} else {
|
||||
_windowBounds = nb;
|
||||
}
|
||||
unawaited(windowManager.setSize(nb.size));
|
||||
unawaited(windowManager.setPosition(nb.topLeft));
|
||||
_reportSubBounds(nb);
|
||||
}
|
||||
|
||||
Size _windowSizeForState({bool? shade, double? scale}) {
|
||||
final useShade = shade ?? (_isEq ? _player.eqShade : _player.plShade);
|
||||
final useScale = scale ?? _player.uiScale;
|
||||
final current = _windowBounds?.size;
|
||||
if (current != null) {
|
||||
final width = current.width / _wasScale * useScale;
|
||||
final height = current.height / _wasScale * useScale;
|
||||
final minWidth = _mainSize.width * useScale;
|
||||
final desiredWidth = useShade
|
||||
? width
|
||||
: (width < minWidth ? minWidth : width);
|
||||
final desiredHeight = useShade
|
||||
? _shadeSize.height * useScale
|
||||
: (_lastNormalSize?.height ?? height) * useScale;
|
||||
return Size(desiredWidth, desiredHeight);
|
||||
}
|
||||
final base = useShade ? _shadeSize : _mainSize;
|
||||
return Size(base.width * useScale, base.height * useScale);
|
||||
}
|
||||
|
||||
Size _scaledSize() => _windowSizeForState();
|
||||
|
||||
void _onPlayerChanged() {
|
||||
if (!_windowReady) return;
|
||||
final shade = _isEq ? _player.eqShade : _player.plShade;
|
||||
@@ -201,8 +439,8 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
);
|
||||
}
|
||||
if (shadeChanged || scaleChanged) {
|
||||
final newSize = _windowSizeForState(shade: shade, scale: _player.uiScale);
|
||||
_wasScale = _player.uiScale;
|
||||
final newSize = _scaledSize();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(windowManager.setSize(newSize));
|
||||
});
|
||||
@@ -237,8 +475,17 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isPresets) {
|
||||
return ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: WindowDragSurface(
|
||||
dragController: _drag,
|
||||
child: PresetsWindow(mode: _presetsMode),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ColoredBox(
|
||||
color: const Color(0xFF000000),
|
||||
color: Colors.transparent,
|
||||
child: AnimatedBuilder(
|
||||
animation: _player,
|
||||
builder: (context, _) {
|
||||
@@ -246,26 +493,138 @@ class _SubWindowRootState extends State<SubWindowRoot> {
|
||||
final base = (_isEq ? _player.eqShade : _player.plShade)
|
||||
? _shadeSize
|
||||
: _mainSize;
|
||||
return SizedBox(
|
||||
width: base.width * scale,
|
||||
height: base.height * scale,
|
||||
// Compute the actual outer size in logical pixels. When the window
|
||||
// was resized we prefer the runtime bounds so the UI fills the
|
||||
// window instead of remaining a fixed-size black box.
|
||||
final actualOuterWidth = _windowBounds?.width ?? base.width * scale;
|
||||
final actualOuterHeight =
|
||||
_windowBounds?.height ?? base.height * scale;
|
||||
// The inner child expects coordinates in unscaled (base) pixels,
|
||||
// so divide by the UI scale to produce the child's layout size.
|
||||
final innerWidth = actualOuterWidth / scale;
|
||||
final innerHeight = actualOuterHeight / scale;
|
||||
final content = SizedBox(
|
||||
width: actualOuterWidth,
|
||||
height: actualOuterHeight,
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
alignment: Alignment.topLeft,
|
||||
filterQuality: FilterQuality.none,
|
||||
child: _isEq
|
||||
? EqWindow(
|
||||
player: _player,
|
||||
skinFuture: _skinFuture,
|
||||
dragController: _drag,
|
||||
)
|
||||
: PlWindow(
|
||||
player: _player,
|
||||
skinFuture: _skinFuture,
|
||||
dragController: _drag,
|
||||
),
|
||||
child: SizedBox.fromSize(
|
||||
size: Size(innerWidth, innerHeight),
|
||||
child: _isEq
|
||||
? EqWindow(
|
||||
player: _player,
|
||||
skinFuture: _skinFuture,
|
||||
dragController: _drag,
|
||||
)
|
||||
: PlWindow(
|
||||
player: _player,
|
||||
skinFuture: _skinFuture,
|
||||
dragController: _drag,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// If resizable (playlist), overlay thin resize handles on the
|
||||
// edges and a bottom-right corner grip.
|
||||
if (_isPl) {
|
||||
return Stack(
|
||||
children: [
|
||||
content,
|
||||
// Right edge
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 12,
|
||||
width: 6,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (e) =>
|
||||
_onResizeStart(_ResizeHandle.right, e.globalPosition),
|
||||
onPanUpdate: (e) => _onResizeUpdate(e.globalPosition),
|
||||
onPanEnd: (_) => _onResizeEnd(),
|
||||
child: const MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeLeftRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom edge
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 12,
|
||||
bottom: 0,
|
||||
height: 6,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (e) =>
|
||||
_onResizeStart(_ResizeHandle.bottom, e.globalPosition),
|
||||
onPanUpdate: (e) => _onResizeUpdate(e.globalPosition),
|
||||
onPanEnd: (_) => _onResizeEnd(),
|
||||
child: const MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeUpDown,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Left edge
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 12,
|
||||
width: 6,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (e) =>
|
||||
_onResizeStart(_ResizeHandle.left, e.globalPosition),
|
||||
onPanUpdate: (e) => _onResizeUpdate(e.globalPosition),
|
||||
onPanEnd: (_) => _onResizeEnd(),
|
||||
child: const MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeLeftRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Top edge
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 12,
|
||||
top: 0,
|
||||
height: 6,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (e) =>
|
||||
_onResizeStart(_ResizeHandle.top, e.globalPosition),
|
||||
onPanUpdate: (e) => _onResizeUpdate(e.globalPosition),
|
||||
onPanEnd: (_) => _onResizeEnd(),
|
||||
child: const MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeUpDown,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom-right corner
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (e) => _onResizeStart(
|
||||
_ResizeHandle.bottomRight,
|
||||
e.globalPosition,
|
||||
),
|
||||
onPanUpdate: (e) => _onResizeUpdate(e.globalPosition),
|
||||
onPanEnd: (_) => _onResizeEnd(),
|
||||
child: const MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeUpLeftDownRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -326,10 +685,12 @@ class _SubDockContext {
|
||||
/// `setPosition`, snaps locally against the pushed dock context, and reports
|
||||
/// the final bounds to the main window on release.
|
||||
///
|
||||
/// The drag is relative: Flutter's pointer positions are window-local (they
|
||||
/// shift as the window follows the cursor), so the new position is the current
|
||||
/// bounds plus the pointer's delta since the grab. This needs no native cursor
|
||||
/// call (`screen_retriever.getCursorScreenPoint` is unimplemented on macOS).
|
||||
/// Flutter's pointer positions are window-local (they shift as the window
|
||||
/// follows the cursor), so the new position is the *applied* (native) bounds
|
||||
/// plus the pointer's delta since the grab. Tracking against the last position
|
||||
/// actually set on the native window keeps the math absolute — the native
|
||||
/// bounds cancel out — so a lagging window never accumulates error and the
|
||||
/// drag cannot run away to the screen corners.
|
||||
class _SubWindowDragController implements WindowDragController {
|
||||
_SubWindowDragController(this._state);
|
||||
|
||||
@@ -338,10 +699,20 @@ class _SubWindowDragController implements WindowDragController {
|
||||
bool _active = false;
|
||||
Offset? _grab;
|
||||
|
||||
/// The position last set on the native window. Pointer deltas are measured
|
||||
/// against the real window position, so this (not the ahead-of-native
|
||||
/// `_windowBounds`) is the correct base for the drag math.
|
||||
Rect? _lastApplied;
|
||||
|
||||
// Throttle drag native updates similarly to resize.
|
||||
bool _dragApplyScheduled = false;
|
||||
Offset? _dragPendingTopLeft;
|
||||
|
||||
@override
|
||||
void start(ui.Offset globalPosition) {
|
||||
_active = true;
|
||||
_grab = globalPosition;
|
||||
_lastApplied = _state._windowBounds;
|
||||
_apply(globalPosition);
|
||||
}
|
||||
|
||||
@@ -351,18 +722,21 @@ class _SubWindowDragController implements WindowDragController {
|
||||
}
|
||||
|
||||
void _apply(ui.Offset localPoint) {
|
||||
final bounds = _state._windowBounds;
|
||||
if (bounds == null) return;
|
||||
// If a resize is currently active, don't also try to move the window;
|
||||
// concurrent move + resize causes alternating native updates and jitter.
|
||||
if (_state._resizing) return;
|
||||
final applied = _lastApplied ?? _state._windowBounds;
|
||||
if (applied == null) return;
|
||||
final grab = _grab ?? localPoint;
|
||||
_grab = grab;
|
||||
var candidate = Rect.fromLTWH(
|
||||
bounds.left + localPoint.dx - grab.dx,
|
||||
bounds.top + localPoint.dy - grab.dy,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
);
|
||||
var candidate = DockLogic.dragCandidate(applied, localPoint, grab);
|
||||
final context = _state._dockContext;
|
||||
if (context != null && context.snap) {
|
||||
if (_state._isPresets) {
|
||||
// The presets dialog is not dockable: just keep it on screen.
|
||||
if (context != null && context.screen != Rect.zero) {
|
||||
candidate = DockLogic.clampVisible(candidate, context.screen);
|
||||
}
|
||||
} else if (context != null && context.snap) {
|
||||
candidate = DockLogic.snapToScreen(
|
||||
candidate,
|
||||
context.screen,
|
||||
@@ -379,28 +753,60 @@ class _SubWindowDragController implements WindowDragController {
|
||||
);
|
||||
}
|
||||
}
|
||||
candidate = DockLogic.clampVisible(
|
||||
candidate,
|
||||
context?.screen ?? Rect.zero,
|
||||
);
|
||||
final moved = candidate.topLeft != bounds.topLeft;
|
||||
if (!_state._isPresets) {
|
||||
candidate = DockLogic.clampVisible(
|
||||
candidate,
|
||||
context?.screen ?? Rect.zero,
|
||||
);
|
||||
}
|
||||
final moved = candidate.topLeft != applied.topLeft;
|
||||
_state._windowBounds = candidate;
|
||||
if (moved) {
|
||||
unawaited(windowManager.setPosition(candidate.topLeft));
|
||||
// Report the dragged position so the main window updates its rect map
|
||||
// (this window moves alone; docking/undocking happens here, locally).
|
||||
_state._reportSubBounds(candidate);
|
||||
// Coalesce frequent drag updates to avoid overwhelming native. Capture
|
||||
// the desired top-left now and apply the most recent bounds when the
|
||||
// scheduled task runs. Avoid referencing `candidate` inside the async
|
||||
// closure to prevent stale-capture races.
|
||||
_dragPendingTopLeft = candidate.topLeft;
|
||||
if (!_dragApplyScheduled) {
|
||||
_dragApplyScheduled = true;
|
||||
Timer(const Duration(milliseconds: 16), () async {
|
||||
final topLeft = _dragPendingTopLeft;
|
||||
final applyBounds = _state._windowBounds;
|
||||
if (topLeft != null && applyBounds != null) {
|
||||
try {
|
||||
await windowManager.setPosition(topLeft);
|
||||
// Only the native position is the drag base going forward;
|
||||
// mark it applied so the next pointer event is measured from
|
||||
// where the window really is.
|
||||
_lastApplied = applyBounds;
|
||||
_state._reportSubBounds(applyBounds);
|
||||
} catch (_) {}
|
||||
}
|
||||
_dragApplyScheduled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void end() {
|
||||
Future<void> end() async {
|
||||
_active = false;
|
||||
// The last [apply] already moved and reported the window; report once more
|
||||
// so the main window's rect map settles on the final bounds.
|
||||
// Apply any position the coalescing timer has not sent yet, then drop the
|
||||
// pending move so a late timer cannot fight the pending external bounds.
|
||||
final pending = _dragPendingTopLeft;
|
||||
_dragPendingTopLeft = null;
|
||||
// Report once more so the main window's rect map settles on the final
|
||||
// bounds.
|
||||
final bounds = _state._windowBounds;
|
||||
if (bounds != null) {
|
||||
_state._reportSubBounds(bounds);
|
||||
if (bounds == null) return;
|
||||
if (pending != null) {
|
||||
try {
|
||||
await windowManager.setPosition(pending);
|
||||
_lastApplied = bounds;
|
||||
} catch (_) {}
|
||||
}
|
||||
_state._reportSubBounds(bounds);
|
||||
// If an external setBounds arrived while dragging, apply it now.
|
||||
_state._applyPendingExternalBounds();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import "dart:convert";
|
||||
/// Every OS window spawned by `desktop_multi_window` re-runs the app's
|
||||
/// top-level `main`; the plugin passes each window a launch-arguments string so
|
||||
/// `main` can build the matching app. The main window has empty arguments.
|
||||
enum SubWindowType { eq, pl }
|
||||
enum SubWindowType { eq, pl, presets }
|
||||
|
||||
/// Launch arguments for an Equalizer or Playlist sub-window.
|
||||
/// Launch arguments for an Equalizer, Playlist, or presets sub-window.
|
||||
class SubWindowArgs {
|
||||
const SubWindowArgs({
|
||||
required this.type,
|
||||
@@ -15,6 +15,7 @@ class SubWindowArgs {
|
||||
required this.alwaysOnTop,
|
||||
this.uiScale = 1.0,
|
||||
this.skinName = "Base",
|
||||
this.mode = "load",
|
||||
});
|
||||
|
||||
final SubWindowType type;
|
||||
@@ -28,6 +29,10 @@ class SubWindowArgs {
|
||||
/// The active skin at spawn time, so the sub-window starts skinned.
|
||||
final String skinName;
|
||||
|
||||
/// The EQ preset dialog mode ("load", "save", or "delete"); only used by the
|
||||
/// presets window.
|
||||
final String mode;
|
||||
|
||||
/// Encodes the args into the JSON string the plugin passes to the new
|
||||
/// window's engine.
|
||||
String encode() {
|
||||
@@ -37,6 +42,7 @@ class SubWindowArgs {
|
||||
"alwaysOnTop": alwaysOnTop,
|
||||
"uiScale": uiScale,
|
||||
"skinName": skinName,
|
||||
"mode": mode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +68,7 @@ class SubWindowArgs {
|
||||
alwaysOnTop: map["alwaysOnTop"] == true,
|
||||
uiScale: (map["uiScale"] as num?)?.toDouble().clamp(1.0, 3.0) ?? 1.0,
|
||||
skinName: (map["skinName"] as String?) ?? "Base",
|
||||
mode: (map["mode"] as String?) ?? "load",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
platform :osx, '10.15'
|
||||
platform :osx, '12.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
desktop_multi_window: 566489c048b501134f9d7fb6a2354c60a9126486
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
FlutterMacOS: c232990155153907050900a2e175c7773903ba4e
|
||||
|
||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||
PODFILE CHECKSUM: 1e95c36afbfd1cb6423ceca4de7a8e1b256fb6ac
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -567,7 +567,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
@@ -649,7 +649,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -699,7 +699,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
|
||||
@@ -22,45 +22,6 @@ class AppDelegate: FlutterAppDelegate {
|
||||
// FlutterViewController is already attached here, but poll briefly as a
|
||||
// fallback for other Flutter versions where it may not be yet.
|
||||
registerWinampChannelWhenReady()
|
||||
installMouseDiagnostics()
|
||||
}
|
||||
|
||||
/// Temporary diagnostics: logs every mouse event macOS delivers to one of
|
||||
/// our Flutter windows (frame, location, and whether the window moved the
|
||||
/// mouse over its content). Used to trace why the drag loop gets no events
|
||||
/// at launch while it works after the first resize.
|
||||
private var mouseDiag: Any?
|
||||
private func installMouseDiagnostics() {
|
||||
guard mouseDiag == nil else { return }
|
||||
mouseDiag = NSEvent.addLocalMonitorForEvents(
|
||||
matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .mouseMoved]
|
||||
) { event in
|
||||
guard let window = event.window,
|
||||
let vc = window.contentViewController as? FlutterViewController
|
||||
else { return event }
|
||||
let view = vc.view
|
||||
let local = view.convert(event.locationInWindow, from: nil)
|
||||
let inView = view.bounds.contains(local)
|
||||
NSLog(
|
||||
"mdiag %@ local=(%.1f,%.1f) inView=%@ frame=%@ wf=%@",
|
||||
Self.name(for: event.type),
|
||||
local.x, local.y,
|
||||
inView ? "y" : "n",
|
||||
NSStringFromRect(view.bounds),
|
||||
NSStringFromRect(window.frame)
|
||||
)
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
private static func name(for type: NSEvent.EventType) -> String {
|
||||
switch type {
|
||||
case .leftMouseDown: return "down"
|
||||
case .leftMouseDragged: return "drag"
|
||||
case .leftMouseUp: return "up"
|
||||
case .mouseMoved: return "move"
|
||||
default: return "other"
|
||||
}
|
||||
}
|
||||
|
||||
/// Quits this instance if another copy of the app is already running, and
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"fill" : "automatic",
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"glass" : false,
|
||||
"image-name" : "WinampIcon.png",
|
||||
"name" : "WinampIcon",
|
||||
"position" : {
|
||||
"scale" : 3.5,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,10 @@
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: package:arcane_analysis/analysis_options.yaml
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies:
|
||||
archive: ^4.0.9
|
||||
flutter:
|
||||
sdk: flutter
|
||||
material_ui: ^1.0.0
|
||||
path: ^1.9.0
|
||||
|
||||
dev_dependencies:
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies:
|
||||
file_picker: ^11.0.3
|
||||
flutter:
|
||||
sdk: flutter
|
||||
material_ui: ^1.0.0
|
||||
path_provider: ^2.1.5
|
||||
screen_retriever: ^0.2.2
|
||||
shared_preferences: ^2.5.5
|
||||
|
||||
@@ -167,6 +167,38 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group("dragCandidate", () {
|
||||
test("moves the window by the pointer's window-local delta", () {
|
||||
const grab = Offset(50, 30);
|
||||
const applied = Rect.fromLTWH(100, 100, 275, 116);
|
||||
final out = DockLogic.dragCandidate(
|
||||
applied,
|
||||
const Offset(150, 130),
|
||||
grab,
|
||||
);
|
||||
expect(out.topLeft, const Offset(200, 200));
|
||||
expect(out.width, applied.width);
|
||||
expect(out.height, applied.height);
|
||||
});
|
||||
|
||||
test("tracks the cursor absolutely regardless of the applied base", () {
|
||||
const grab = Offset(50, 30);
|
||||
// Pointer at screen (450, 330): window-local (50, 30) when the window
|
||||
// really is at (400, 300). The base must cancel so a stale (lagging)
|
||||
// applied position never accumulates error.
|
||||
const lagging = Rect.fromLTWH(400, 300, 275, 116);
|
||||
final kept = DockLogic.dragCandidate(lagging, const Offset(50, 30), grab);
|
||||
expect(kept.topLeft, const Offset(400, 300));
|
||||
// Cursor moves to screen (500, 400) -> window-local (100, 100).
|
||||
final moved = DockLogic.dragCandidate(
|
||||
lagging,
|
||||
const Offset(100, 100),
|
||||
grab,
|
||||
);
|
||||
expect(moved.topLeft, const Offset(450, 370));
|
||||
});
|
||||
});
|
||||
|
||||
group("fixOverlaps", () {
|
||||
test("pushes r2 right when the horizontal gap is smaller", () {
|
||||
const r1 = Rect.fromLTWH(0, 0, 50, 200);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import "package:dartamp/src/player/eq_presets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test("first load seeds the 18 built-in presets", () async {
|
||||
final presets = await EqPresets.load();
|
||||
expect(presets.length, 18);
|
||||
expect(presets["Classical"], isNotNull);
|
||||
expect(presets["Flat"]!.bands, everyElement(31));
|
||||
});
|
||||
|
||||
test("save adds a new preset", () async {
|
||||
await EqPresets.save(
|
||||
const EqPreset(
|
||||
name: "My mix",
|
||||
preamp: 40,
|
||||
bands: [35, 36, 37, 38, 39, 40, 41, 42, 43, 44],
|
||||
),
|
||||
);
|
||||
final presets = await EqPresets.load();
|
||||
expect(presets.length, 19);
|
||||
expect(presets["My mix"]!.preamp, 40);
|
||||
expect(presets["My mix"]!.bands, [35, 36, 37, 38, 39, 40, 41, 42, 43, 44]);
|
||||
});
|
||||
|
||||
test("save overwrites an existing preset case-insensitively", () async {
|
||||
await EqPresets.save(
|
||||
const EqPreset(
|
||||
name: "rock",
|
||||
preamp: 50,
|
||||
bands: [50, 50, 50, 50, 50, 50, 50, 50, 50, 50],
|
||||
),
|
||||
);
|
||||
final presets = await EqPresets.load();
|
||||
expect(presets.length, 18);
|
||||
expect(presets["Rock"]!.preamp, 50);
|
||||
});
|
||||
|
||||
test("remove deletes by name case-insensitively", () async {
|
||||
await EqPresets.remove("flat");
|
||||
final presets = await EqPresets.load();
|
||||
expect(presets.containsKey("Flat"), isFalse);
|
||||
expect(presets.length, 17);
|
||||
});
|
||||
|
||||
test(
|
||||
"byName matches case-insensitively and misses missing presets",
|
||||
() async {
|
||||
final found = await EqPresets.byName("POP");
|
||||
expect(found, isNotNull);
|
||||
expect(found!.name, "Pop");
|
||||
expect(await EqPresets.byName("Nope"), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test("last preset round-trips", () async {
|
||||
expect(await EqPresets.lastPreset(), isNull);
|
||||
await EqPresets.setLastPreset("Classical");
|
||||
expect(await EqPresets.lastPreset(), "Classical");
|
||||
});
|
||||
|
||||
test("malformed stored data falls back to the built-ins", () async {
|
||||
SharedPreferences.setMockInitialValues({"winamp.eq_presets": "{oops"});
|
||||
final presets = await EqPresets.load();
|
||||
expect(presets.length, 18);
|
||||
});
|
||||
|
||||
test("defaultPreset is the neutral reset", () {
|
||||
expect(EqPresets.defaultPreset.preamp, 31);
|
||||
expect(EqPresets.defaultPreset.bands, everyElement(31));
|
||||
expect(EqPresets.defaultPreset.name, "Default");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import "package:dartamp/src/main_window/presets_window.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets("shows the preset list in load mode with no name field", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: PresetsWindow(mode: PresetsMode.load)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text("Winamp EQ Presets"), findsOneWidget);
|
||||
expect(find.text("Classical"), findsOneWidget);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("save mode adds the name field", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: PresetsWindow(mode: PresetsMode.save)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text("Preset name"), findsOneWidget);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("selecting a preset enables the Load action", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: PresetsWindow(mode: PresetsMode.load)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
FilledButton button() => tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, "Load"),
|
||||
);
|
||||
expect(button().onPressed, isNull);
|
||||
|
||||
await tester.tap(find.text("Classical"));
|
||||
// The list rows have an onDoubleTap in load mode, so the single-tap
|
||||
// callback only fires after the double-tap timeout elapses.
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
expect(button().onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets("mode switcher toggles between load and delete", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: PresetsWindow(mode: PresetsMode.load)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text("Delete"));
|
||||
await tester.pump();
|
||||
expect(find.widgetWithText(FilledButton, "Delete"), findsOneWidget);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "package:dartamp/main.dart";
|
||||
import "package:dartamp/src/skin/skin_manager.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_driver/driver_extension.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
Future<void> main() async {
|
||||
enableFlutterDriverExtension();
|
||||
|
||||
Reference in New Issue
Block a user