2f5edfc512
- Added `dartamp_skin` as a local dependency in `pubspec.yaml`. - Updated `path_provider` to version ^2.1.5. - Modified `test_driver/app.dart` to initialize `SkinManager` and pass it to `DartampApp`. - Updated `generated_plugins.cmake` to include `jni` in the FFI plugin list. Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
441 lines
13 KiB
Dart
441 lines
13 KiB
Dart
import "dart:async";
|
|
import "dart:io";
|
|
|
|
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:window_manager/window_manager.dart";
|
|
|
|
import "src/common_widgets.dart";
|
|
import "src/main_window/main_window.dart";
|
|
import "src/player/player_state.dart";
|
|
import "src/skin/skin_manager.dart";
|
|
import "src/windows/sub_window_app.dart";
|
|
import "src/windows/sub_window_channel.dart";
|
|
import "src/windows/window_args.dart";
|
|
|
|
const Size _mainSize = Size(275, 116);
|
|
const Size _shadeSize = Size(275, 14);
|
|
|
|
const _winampChannel = MethodChannel("winamp");
|
|
|
|
Future<void> main(List<String> args) async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
// Sub-windows (Equalizer/Playlist) are separate Flutter engines that re-run
|
|
// this entrypoint; the plugin passes each one its launch arguments so we can
|
|
// build the matching app.
|
|
final subWindowArgs = await _resolveSubWindowArgs();
|
|
if (subWindowArgs != null) {
|
|
await windowManager.ensureInitialized();
|
|
runApp(
|
|
MaterialApp(
|
|
debugShowCheckedModeBanner: false,
|
|
home: SubWindowRoot(
|
|
type: subWindowArgs.type,
|
|
initialShade: subWindowArgs.shade,
|
|
initialAlwaysOnTop: subWindowArgs.alwaysOnTop,
|
|
initialSkinName: subWindowArgs.skinName,
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final windowShade = await PlayerState.loadWindowShade();
|
|
final alwaysOnTop = await PlayerState.loadAlwaysOnTop();
|
|
final eqOpen = await PlayerState.loadEqOpen();
|
|
final plOpen = await PlayerState.loadPlOpen();
|
|
final uiScale = await PlayerState.loadUiScale();
|
|
final skinManager = await SkinManager.load();
|
|
await windowManager.ensureInitialized();
|
|
runApp(
|
|
DartampApp(
|
|
initialWindowShade: windowShade,
|
|
initialAlwaysOnTop: alwaysOnTop,
|
|
initialEqOpen: eqOpen,
|
|
initialPlOpen: plOpen,
|
|
initialUiScale: uiScale,
|
|
skinManager: skinManager,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<SubWindowArgs?> _resolveSubWindowArgs() async {
|
|
try {
|
|
final controller = await WindowController.fromCurrentEngine();
|
|
return SubWindowArgs.tryDecode(controller.arguments);
|
|
} catch (_) {
|
|
// ignore: the plugin may not be ready; treat as the main window
|
|
}
|
|
return null;
|
|
}
|
|
|
|
class DartampApp extends StatelessWidget {
|
|
const DartampApp({
|
|
required this.initialWindowShade,
|
|
required this.initialAlwaysOnTop,
|
|
required this.initialEqOpen,
|
|
required this.initialPlOpen,
|
|
required this.initialUiScale,
|
|
required this.skinManager,
|
|
super.key,
|
|
});
|
|
|
|
final bool initialWindowShade;
|
|
final bool initialAlwaysOnTop;
|
|
final bool initialEqOpen;
|
|
final bool initialPlOpen;
|
|
final double initialUiScale;
|
|
final SkinManager skinManager;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
debugShowCheckedModeBanner: false,
|
|
home: WinampRoot(
|
|
initialWindowShade: initialWindowShade,
|
|
initialAlwaysOnTop: initialAlwaysOnTop,
|
|
initialEqOpen: initialEqOpen,
|
|
initialPlOpen: initialPlOpen,
|
|
initialUiScale: initialUiScale,
|
|
skinManager: skinManager,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class WinampRoot extends StatefulWidget {
|
|
const WinampRoot({
|
|
required this.initialWindowShade,
|
|
required this.initialAlwaysOnTop,
|
|
required this.initialEqOpen,
|
|
required this.initialPlOpen,
|
|
required this.initialUiScale,
|
|
required this.skinManager,
|
|
super.key,
|
|
});
|
|
|
|
final bool initialWindowShade;
|
|
final bool initialAlwaysOnTop;
|
|
final bool initialEqOpen;
|
|
final bool initialPlOpen;
|
|
final double initialUiScale;
|
|
final SkinManager skinManager;
|
|
|
|
@override
|
|
State<WinampRoot> createState() => _WinampRootState();
|
|
}
|
|
|
|
class _WinampRootState extends State<WinampRoot> {
|
|
late final PlayerState _player;
|
|
late final SkinManager _skins;
|
|
bool _windowReady = false;
|
|
bool _wasShade = false;
|
|
double _wasScale = 1.0;
|
|
bool _wasAlwaysOnTop = false;
|
|
bool _wasEqOpen = false;
|
|
bool _wasPlOpen = false;
|
|
String _wasSkinName = baseSkinName;
|
|
WindowController? _eqController;
|
|
WindowController? _plController;
|
|
StreamSubscription<void>? _windowsSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_player = PlayerState(
|
|
windowShade: widget.initialWindowShade,
|
|
alwaysOnTop: widget.initialAlwaysOnTop,
|
|
eqOpen: widget.initialEqOpen,
|
|
plOpen: widget.initialPlOpen,
|
|
uiScale: widget.initialUiScale,
|
|
);
|
|
_skins = widget.skinManager;
|
|
_wasShade = widget.initialWindowShade;
|
|
_wasScale = widget.initialUiScale;
|
|
_wasAlwaysOnTop = widget.initialAlwaysOnTop;
|
|
_wasEqOpen = widget.initialEqOpen;
|
|
_wasPlOpen = widget.initialPlOpen;
|
|
_wasSkinName = _skins.active.name;
|
|
_player.addListener(_onPlayerChanged);
|
|
_skins.addListener(_onSkinChanged);
|
|
|
|
// Set up method channel for macOS menu actions
|
|
_winampChannel.setMethodCallHandler(_handleMethodCall);
|
|
|
|
// Sub-windows report shade toggles and close requests back here.
|
|
unawaited(
|
|
subWindowChannel.setMethodCallHandler(_onSubWindowCall),
|
|
);
|
|
|
|
// Track native sub-window lifetime so externally-closed windows don't leave
|
|
// the Equalizer/Playlist menu checkmarks stuck on.
|
|
_windowsSubscription = onWindowsChanged.listen((_) {
|
|
unawaited(_reconcileSubWindows());
|
|
});
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_readyWindow();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_windowsSubscription?.cancel();
|
|
_player.removeListener(_onPlayerChanged);
|
|
_skins.removeListener(_onSkinChanged);
|
|
_player.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _readyWindow() async {
|
|
if (!mounted || _windowReady) return;
|
|
try {
|
|
await windowManager.setPreventClose(false);
|
|
await windowManager.setResizable(false);
|
|
await windowManager.setAlwaysOnTop(_player.alwaysOnTop);
|
|
await windowManager.setTitle("Winamp");
|
|
await windowManager.setSize(_scaledSize());
|
|
_windowReady = true;
|
|
} catch (_) {
|
|
// The native window may not be ready yet on macOS; defer to the next
|
|
// frame so later gestures (drag, shade toggle) still work once it is.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _readyWindow();
|
|
});
|
|
return;
|
|
}
|
|
unawaited(_syncMenuToNative());
|
|
if (_player.eqOpen) unawaited(_openSubWindow(SubWindowType.eq));
|
|
if (_player.plOpen) unawaited(_openSubWindow(SubWindowType.pl));
|
|
}
|
|
|
|
Size _scaledSize() {
|
|
final base = _player.windowShade ? _shadeSize : _mainSize;
|
|
final scale = _player.uiScale;
|
|
return Size(base.width * scale, base.height * scale);
|
|
}
|
|
|
|
void _onPlayerChanged() {
|
|
if (!_windowReady) return;
|
|
final shadeChanged = _player.windowShade != _wasShade;
|
|
final scaleChanged = _player.uiScale != _wasScale;
|
|
if (shadeChanged || scaleChanged) {
|
|
_wasShade = _player.windowShade;
|
|
_wasScale = _player.uiScale;
|
|
windowManager.setSize(_scaledSize());
|
|
}
|
|
if (scaleChanged) {
|
|
_eqController?.invokeMethod("setUiScale", _player.uiScale);
|
|
_plController?.invokeMethod("setUiScale", _player.uiScale);
|
|
}
|
|
windowManager.setAlwaysOnTop(_player.alwaysOnTop);
|
|
if (_player.alwaysOnTop != _wasAlwaysOnTop) {
|
|
_wasAlwaysOnTop = _player.alwaysOnTop;
|
|
_eqController?.invokeMethod("setAlwaysOnTop", _player.alwaysOnTop);
|
|
_plController?.invokeMethod("setAlwaysOnTop", _player.alwaysOnTop);
|
|
}
|
|
if (_player.eqOpen != _wasEqOpen) {
|
|
_wasEqOpen = _player.eqOpen;
|
|
if (_player.eqOpen) {
|
|
unawaited(_openSubWindow(SubWindowType.eq));
|
|
} else {
|
|
_eqController?.hide();
|
|
}
|
|
}
|
|
if (_player.plOpen != _wasPlOpen) {
|
|
_wasPlOpen = _player.plOpen;
|
|
if (_player.plOpen) {
|
|
unawaited(_openSubWindow(SubWindowType.pl));
|
|
} else {
|
|
_plController?.hide();
|
|
}
|
|
}
|
|
unawaited(_syncMenuToNative());
|
|
}
|
|
|
|
void _toggleEqWindow() {
|
|
_player.setEqOpen(!_player.eqOpen);
|
|
}
|
|
|
|
void _togglePlaylistWindow() {
|
|
_player.setPlOpen(!_player.plOpen);
|
|
}
|
|
|
|
void _onSkinChanged() {
|
|
final name = _skins.active.name;
|
|
if (name == _wasSkinName) return;
|
|
_wasSkinName = name;
|
|
_eqController?.invokeMethod("setSkin", name);
|
|
_plController?.invokeMethod("setSkin", name);
|
|
}
|
|
|
|
Future<void> _importSkin() async {
|
|
final result = await FilePicker.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: const ["wsz", "zip"],
|
|
dialogTitle: "Load Winamp Skin",
|
|
);
|
|
final path = result?.files.single.path;
|
|
if (path == null) return;
|
|
try {
|
|
final bytes = await File(path).readAsBytes();
|
|
await _skins.importArchive(bytes, fileName: path.split("/").last);
|
|
} catch (_) {
|
|
// ignore: a failed import leaves the active skin unchanged
|
|
}
|
|
}
|
|
|
|
Future<void> _openSubWindow(SubWindowType type) async {
|
|
final isEq = type == SubWindowType.eq;
|
|
var controller = isEq ? _eqController : _plController;
|
|
if (controller != null) {
|
|
await controller.show();
|
|
return;
|
|
}
|
|
try {
|
|
controller = await WindowController.create(
|
|
WindowConfiguration(
|
|
arguments: SubWindowArgs(
|
|
type: type,
|
|
shade: isEq ? _player.eqShade : _player.plShade,
|
|
alwaysOnTop: _player.alwaysOnTop,
|
|
uiScale: _player.uiScale,
|
|
skinName: _skins.active.name,
|
|
).encode(),
|
|
hiddenAtLaunch: true,
|
|
),
|
|
);
|
|
} catch (_) {
|
|
if (isEq) {
|
|
_player.setEqOpen(false);
|
|
} else {
|
|
_player.setPlOpen(false);
|
|
}
|
|
return;
|
|
}
|
|
// The sub-window shows itself once configured.
|
|
if (isEq) {
|
|
_eqController = controller;
|
|
} else {
|
|
_plController = controller;
|
|
}
|
|
}
|
|
|
|
Future<dynamic> _onSubWindowCall(MethodCall call) async {
|
|
switch (call.method) {
|
|
case "toggleEqShade":
|
|
_player.toggleEqShade();
|
|
case "togglePlShade":
|
|
_player.togglePlShade();
|
|
case "eqClose":
|
|
_player.setEqOpen(false);
|
|
case "plClose":
|
|
_player.setPlOpen(false);
|
|
}
|
|
}
|
|
|
|
Future<void> _reconcileSubWindows() async {
|
|
WindowController? eq;
|
|
WindowController? pl;
|
|
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;
|
|
}
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
_eqController = eq;
|
|
_plController = pl;
|
|
if (eq == null && _player.eqOpen) _player.setEqOpen(false);
|
|
if (pl == null && _player.plOpen) _player.setPlOpen(false);
|
|
}
|
|
|
|
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
|
switch (call.method) {
|
|
case "toggleEq":
|
|
_toggleEqWindow();
|
|
case "togglePlaylist":
|
|
_togglePlaylistWindow();
|
|
case "toggleAlwaysOnTop":
|
|
_player.setAlwaysOnTop(!_player.alwaysOnTop);
|
|
case "toggleWindowShade":
|
|
_player.toggleWindowShade();
|
|
case "setUIScale":
|
|
final arguments = call.arguments as Map;
|
|
_player.setUiScale((arguments["scale"] as num).toDouble());
|
|
case "importSkin":
|
|
await _importSkin();
|
|
}
|
|
}
|
|
|
|
/// Reports state back to the macOS menu bar so checkmarks stay in sync with
|
|
/// what the in-window buttons do. Best-effort; native may not be up yet.
|
|
Future<void> _syncMenuToNative() async {
|
|
try {
|
|
await _winampChannel.invokeMethod("setAlwaysOnTop", {
|
|
"isAlwaysOnTop": _player.alwaysOnTop,
|
|
});
|
|
await _winampChannel.invokeMethod("setWindowShade", {
|
|
"on": _player.windowShade,
|
|
});
|
|
await _winampChannel.invokeMethod("setEqOpen", {"open": _player.eqOpen});
|
|
await _winampChannel.invokeMethod("setPlOpen", {"open": _player.plOpen});
|
|
await _winampChannel.invokeMethod("setUiScale", {
|
|
"scale": _player.uiScale,
|
|
});
|
|
} on PlatformException {
|
|
// ignore: menu sync is best-effort
|
|
} on MissingPluginException {
|
|
// ignore: menu sync is best-effort
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final skin = _skins.skin;
|
|
if (skin == null) {
|
|
return const ColoredBox(color: Colors.black);
|
|
}
|
|
if (_skins.lastError != null) {
|
|
return ColoredBox(
|
|
color: const Color(0xFF00FF00),
|
|
child: Center(child: Text("Skin load failed: ${_skins.lastError}")),
|
|
);
|
|
}
|
|
return AnimatedBuilder(
|
|
animation: Listenable.merge([_player, _skins]),
|
|
builder: (context, _) {
|
|
final scale = _player.uiScale;
|
|
final height = (_player.windowShade ? _shadeSize : _mainSize).height;
|
|
return ColoredBox(
|
|
color: const Color(0xFF000000),
|
|
child: DragSurface(
|
|
draggable: _windowReady,
|
|
onWindowShadeToggle: _player.toggleWindowShade,
|
|
child: SizedBox(
|
|
width: _mainSize.width * scale,
|
|
height: height * scale,
|
|
child: Transform.scale(
|
|
scale: scale,
|
|
alignment: Alignment.topLeft,
|
|
filterQuality: FilterQuality.none,
|
|
child: MainWindowView(
|
|
skin: skin,
|
|
player: _player,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|