Files
dartamp/lib/main.dart
T
hans 2f5edfc512 feat: update dependencies and integrate skin manager
- 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>
2026-08-09 21:14:54 +02:00

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,
),
),
),
),
);
},
);
}
}