From 2f5edfc512cc9069b002da8baa2748fdeba5a3c0 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Sun, 9 Aug 2026 21:14:54 +0200 Subject: [PATCH] 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 --- devtools_options.yaml | 3 + lib/main.dart | 116 +++++++++----- lib/src/common_widgets.dart | 2 +- lib/src/main_window/eq_window.dart | 9 +- lib/src/main_window/main_window.dart | 5 +- lib/src/main_window/playlist_window.dart | 10 +- lib/src/player/player_state.dart | 17 ++ lib/src/skin/skin_manager.dart | 102 ++++++++++++ lib/src/windows/sub_window_app.dart | 16 +- lib/src/windows/window_args.dart | 6 + linux/flutter/generated_plugins.cmake | 1 + macos/Runner/AppDelegate.swift | 4 + macos/Runner/Base.lproj/MainMenu.xib | 6 + packages/dartamp_skin/analysis_options.yaml | 1 + .../dartamp_skin/assets}/skins/base/MAIN.BMP | Bin .../assets}/skins/base/balance.bmp | Bin .../assets}/skins/base/cbuttons.bmp | Bin .../dartamp_skin/assets}/skins/base/eq_ex.bmp | Bin .../assets}/skins/base/eqmain.bmp | Bin .../dartamp_skin/assets}/skins/base/gen.bmp | Bin .../assets}/skins/base/monoster.bmp | Bin .../assets}/skins/base/numbers.bmp | Bin .../assets}/skins/base/playpaus.bmp | Bin .../assets}/skins/base/pledit.bmp | Bin .../assets}/skins/base/posbar.bmp | Bin .../assets}/skins/base/shufrep.bmp | Bin .../dartamp_skin/assets}/skins/base/spec.bmp | Bin .../dartamp_skin/assets}/skins/base/text.bmp | Bin .../assets}/skins/base/titlebar.bmp | Bin .../assets}/skins/base/volume.bmp | Bin packages/dartamp_skin/lib/dartamp_skin.dart | 16 ++ .../dartamp_skin/lib/src}/bmp_decoder.dart | 0 .../dartamp_skin/lib/src}/classic_skin.dart | 0 .../dartamp_skin/lib/src}/skin_image.dart | 0 packages/dartamp_skin/lib/src/skin_info.dart | 18 +++ .../dartamp_skin/lib/src}/skin_loader.dart | 33 +++- .../dartamp_skin/lib/src/skin_registry.dart | 148 ++++++++++++++++++ .../dartamp_skin/lib/src}/skin_slider.dart | 0 .../dartamp_skin/lib/src}/text_font.dart | 0 packages/dartamp_skin/pubspec.yaml | 25 +++ .../dartamp_skin/test/base_skin_test.dart | 24 +++ .../dartamp_skin/test/skin_registry_test.dart | 116 ++++++++++++++ .../dartamp_skin/test}/text_font_test.dart | 3 +- .../dartamp_skin/tool}/check_bmp.dart | 2 +- .../dartamp_skin/tool}/compare_bmp.py | 0 .../dartamp_skin/tool}/dump_rgba.dart | 2 +- .../dartamp_skin/tool}/peek_bmp.dart | 2 +- .../dartamp_skin/tool}/preview/MAIN.BMP.png | Bin .../dartamp_skin/tool}/preview/MAIN.BMP.ppm | 0 .../dartamp_skin/tool}/preview/MAIN.BMP.rgba | 0 .../tool}/preview/balance.bmp.rgba | Bin .../tool}/preview/cbuttons.bmp.rgba | 0 .../dartamp_skin/tool}/preview/eq_ex.bmp.rgba | 0 .../tool}/preview/eqmain.bmp.rgba | 0 .../dartamp_skin/tool}/preview/gen.bmp.rgba | Bin .../tool}/preview/monoster.bmp.rgba | Bin .../tool}/preview/numbers.bmp.png | Bin .../tool}/preview/numbers.bmp.ppm | Bin .../tool}/preview/numbers.bmp.rgba | Bin .../tool}/preview/playpaus.bmp.rgba | Bin .../tool}/preview/pledit.bmp.rgba | Bin .../tool}/preview/posbar.bmp.rgba | Bin .../tool}/preview/shufrep.bmp.rgba | Bin .../dartamp_skin/tool}/preview/spec.bmp.rgba | Bin .../dartamp_skin/tool}/preview/text.bmp.png | Bin .../dartamp_skin/tool}/preview/text.bmp.ppm | Bin .../dartamp_skin/tool}/preview/text.bmp.rgba | Bin .../tool}/preview/titlebar.bmp.png | Bin .../tool}/preview/titlebar.bmp.ppm | Bin .../tool}/preview/titlebar.bmp.rgba | Bin .../tool}/preview/volume.bmp.rgba | 0 pubspec.yaml | 6 +- test_driver/app.dart | 8 +- windows/flutter/generated_plugins.cmake | 1 + 74 files changed, 630 insertions(+), 72 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/src/skin/skin_manager.dart create mode 100644 packages/dartamp_skin/analysis_options.yaml rename {assets => packages/dartamp_skin/assets}/skins/base/MAIN.BMP (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/balance.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/cbuttons.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/eq_ex.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/eqmain.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/gen.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/monoster.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/numbers.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/playpaus.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/pledit.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/posbar.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/shufrep.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/spec.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/text.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/titlebar.bmp (100%) rename {assets => packages/dartamp_skin/assets}/skins/base/volume.bmp (100%) create mode 100644 packages/dartamp_skin/lib/dartamp_skin.dart rename {lib/src/skin => packages/dartamp_skin/lib/src}/bmp_decoder.dart (100%) rename {lib/src/skin => packages/dartamp_skin/lib/src}/classic_skin.dart (100%) rename {lib/src/skin => packages/dartamp_skin/lib/src}/skin_image.dart (100%) create mode 100644 packages/dartamp_skin/lib/src/skin_info.dart rename {lib/src/skin => packages/dartamp_skin/lib/src}/skin_loader.dart (89%) create mode 100644 packages/dartamp_skin/lib/src/skin_registry.dart rename {lib/src/skin => packages/dartamp_skin/lib/src}/skin_slider.dart (100%) rename {lib/src/skin => packages/dartamp_skin/lib/src}/text_font.dart (100%) create mode 100644 packages/dartamp_skin/pubspec.yaml create mode 100644 packages/dartamp_skin/test/base_skin_test.dart create mode 100644 packages/dartamp_skin/test/skin_registry_test.dart rename {test => packages/dartamp_skin/test}/text_font_test.dart (96%) rename {tool => packages/dartamp_skin/tool}/check_bmp.dart (92%) rename {tool => packages/dartamp_skin/tool}/compare_bmp.py (100%) rename {tool => packages/dartamp_skin/tool}/dump_rgba.dart (88%) rename {tool => packages/dartamp_skin/tool}/peek_bmp.dart (95%) rename {tool => packages/dartamp_skin/tool}/preview/MAIN.BMP.png (100%) rename {tool => packages/dartamp_skin/tool}/preview/MAIN.BMP.ppm (100%) rename {tool => packages/dartamp_skin/tool}/preview/MAIN.BMP.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/balance.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/cbuttons.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/eq_ex.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/eqmain.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/gen.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/monoster.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/numbers.bmp.png (100%) rename {tool => packages/dartamp_skin/tool}/preview/numbers.bmp.ppm (100%) rename {tool => packages/dartamp_skin/tool}/preview/numbers.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/playpaus.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/pledit.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/posbar.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/shufrep.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/spec.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/text.bmp.png (100%) rename {tool => packages/dartamp_skin/tool}/preview/text.bmp.ppm (100%) rename {tool => packages/dartamp_skin/tool}/preview/text.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/titlebar.bmp.png (100%) rename {tool => packages/dartamp_skin/tool}/preview/titlebar.bmp.ppm (100%) rename {tool => packages/dartamp_skin/tool}/preview/titlebar.bmp.rgba (100%) rename {tool => packages/dartamp_skin/tool}/preview/volume.bmp.rgba (100%) diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/main.dart b/lib/main.dart index 63d6b3a..9207210 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,9 @@ 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"; @@ -8,8 +11,7 @@ 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/classic_skin.dart"; -import "src/skin/skin_loader.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"; @@ -35,6 +37,7 @@ Future main(List args) async { type: subWindowArgs.type, initialShade: subWindowArgs.shade, initialAlwaysOnTop: subWindowArgs.alwaysOnTop, + initialSkinName: subWindowArgs.skinName, ), ), ); @@ -46,6 +49,7 @@ Future main(List args) async { 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( @@ -54,6 +58,7 @@ Future main(List args) async { initialEqOpen: eqOpen, initialPlOpen: plOpen, initialUiScale: uiScale, + skinManager: skinManager, ), ); } @@ -75,6 +80,7 @@ class DartampApp extends StatelessWidget { required this.initialEqOpen, required this.initialPlOpen, required this.initialUiScale, + required this.skinManager, super.key, }); @@ -83,6 +89,7 @@ class DartampApp extends StatelessWidget { final bool initialEqOpen; final bool initialPlOpen; final double initialUiScale; + final SkinManager skinManager; @override Widget build(BuildContext context) { @@ -94,6 +101,7 @@ class DartampApp extends StatelessWidget { initialEqOpen: initialEqOpen, initialPlOpen: initialPlOpen, initialUiScale: initialUiScale, + skinManager: skinManager, ), ); } @@ -106,6 +114,7 @@ class WinampRoot extends StatefulWidget { required this.initialEqOpen, required this.initialPlOpen, required this.initialUiScale, + required this.skinManager, super.key, }); @@ -114,6 +123,7 @@ class WinampRoot extends StatefulWidget { final bool initialEqOpen; final bool initialPlOpen; final double initialUiScale; + final SkinManager skinManager; @override State createState() => _WinampRootState(); @@ -121,12 +131,14 @@ class WinampRoot extends StatefulWidget { class _WinampRootState extends State { 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? _windowsSubscription; @@ -141,12 +153,15 @@ class _WinampRootState extends State { 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); @@ -171,6 +186,7 @@ class _WinampRootState extends State { void dispose() { _windowsSubscription?.cancel(); _player.removeListener(_onPlayerChanged); + _skins.removeListener(_onSkinChanged); _player.dispose(); super.dispose(); } @@ -249,6 +265,30 @@ class _WinampRootState extends State { _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 _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 _openSubWindow(SubWindowType type) async { final isEq = type == SubWindowType.eq; var controller = isEq ? _eqController : _plController; @@ -264,6 +304,7 @@ class _WinampRootState extends State { shade: isEq ? _player.eqShade : _player.plShade, alwaysOnTop: _player.alwaysOnTop, uiScale: _player.uiScale, + skinName: _skins.active.name, ).encode(), hiddenAtLaunch: true, ), @@ -329,6 +370,8 @@ class _WinampRootState extends State { case "setUIScale": final arguments = call.arguments as Map; _player.setUiScale((arguments["scale"] as num).toDouble()); + case "importSkin": + await _importSkin(); } } @@ -356,45 +399,40 @@ class _WinampRootState extends State { @override Widget build(BuildContext context) { - return FutureBuilder( - future: loadBaseSkin(), - builder: (context, snapshot) { - if (snapshot.hasError) { - return ColoredBox( - color: const Color(0xFF00FF00), - child: Center(child: Text("Skin load failed: ${snapshot.error}")), - ); - } - if (!snapshot.hasData) { - return const ColoredBox(color: Colors.black); - } - return AnimatedBuilder( - animation: _player, - 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: snapshot.data!, - player: _player, - ), - ), + 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, ), ), - ); - }, + ), + ), ); }, ); diff --git a/lib/src/common_widgets.dart b/lib/src/common_widgets.dart index d832557..7781017 100644 --- a/lib/src/common_widgets.dart +++ b/lib/src/common_widgets.dart @@ -1,6 +1,6 @@ import "dart:ui" as ui; -import "package:dartamp/src/skin/skin_image.dart"; +import "package:dartamp_skin/dartamp_skin.dart"; import "package:flutter/material.dart"; import "package:flutter/widgets.dart"; import "package:window_manager/window_manager.dart"; diff --git a/lib/src/main_window/eq_window.dart b/lib/src/main_window/eq_window.dart index 11b523f..f8dbdb5 100644 --- a/lib/src/main_window/eq_window.dart +++ b/lib/src/main_window/eq_window.dart @@ -1,13 +1,11 @@ import "dart:async"; import "dart:ui" as ui; +import "package:dartamp_skin/dartamp_skin.dart"; import "package:flutter/material.dart"; import "../common_widgets.dart"; import "../player/player_state.dart"; -import "../skin/classic_skin.dart"; -import "../skin/skin_image.dart"; -import "../skin/skin_loader.dart"; import "../windows/sub_window_channel.dart"; const Size _eqMainSize = Size(275, 116); @@ -20,14 +18,15 @@ const Size _eqShadeSize = Size(275, 14); /// presets buttons, the response graph, and 11 draggable sliders (preamp + 10 /// bands) drawn on top. Windowshade mode uses `eq_ex.bmp`. class EqWindow extends StatelessWidget { - const EqWindow({required this.player, super.key}); + const EqWindow({required this.player, this.skinFuture, super.key}); final PlayerState player; + final Future? skinFuture; @override Widget build(BuildContext context) { return FutureBuilder( - future: loadBaseSkin(), + future: skinFuture ?? loadBaseSkin(), builder: (context, snapshot) { if (snapshot.hasError) { return ColoredBox( diff --git a/lib/src/main_window/main_window.dart b/lib/src/main_window/main_window.dart index 6fa1999..7b69248 100644 --- a/lib/src/main_window/main_window.dart +++ b/lib/src/main_window/main_window.dart @@ -2,15 +2,12 @@ import "dart:async"; import "dart:math"; import "dart:ui" as ui; +import "package:dartamp_skin/dartamp_skin.dart"; import "package:flutter/widgets.dart"; import "package:window_manager/window_manager.dart"; import "../common_widgets.dart"; import "../player/player_state.dart"; -import "../skin/classic_skin.dart"; -import "../skin/skin_image.dart"; -import "../skin/skin_slider.dart"; -import "../skin/text_font.dart"; const Size _mainSize = Size(275, 116); const Size _wsSize = Size(275, 14); diff --git a/lib/src/main_window/playlist_window.dart b/lib/src/main_window/playlist_window.dart index 346d982..9c187c7 100644 --- a/lib/src/main_window/playlist_window.dart +++ b/lib/src/main_window/playlist_window.dart @@ -1,14 +1,11 @@ import "dart:async"; import "dart:ui" as ui; +import "package:dartamp_skin/dartamp_skin.dart"; import "package:flutter/material.dart"; import "../common_widgets.dart"; import "../player/player_state.dart"; -import "../skin/classic_skin.dart"; -import "../skin/skin_image.dart"; -import "../skin/skin_loader.dart"; -import "../skin/text_font.dart"; import "../windows/sub_window_channel.dart"; const Size _plMainSize = Size(280, 290); @@ -152,14 +149,15 @@ class _KeyedFont { /// Lives in its own OS window (its own Flutter engine); the parent /// [SubWindowRoot] manages native window sizing and cross-window messaging. class PlWindow extends StatelessWidget { - const PlWindow({required this.player, super.key}); + const PlWindow({required this.player, this.skinFuture, super.key}); final PlayerState player; + final Future? skinFuture; @override Widget build(BuildContext context) { return FutureBuilder( - future: loadBaseSkin(), + future: skinFuture ?? loadBaseSkin(), builder: (context, snapshot) { if (snapshot.hasError) { return ColoredBox( diff --git a/lib/src/player/player_state.dart b/lib/src/player/player_state.dart index fcc571b..dedf0f8 100644 --- a/lib/src/player/player_state.dart +++ b/lib/src/player/player_state.dart @@ -10,6 +10,7 @@ const String _kEqShadeKey = "winamp.eq_shade"; const String _kPlOpenKey = "winamp.pl_open"; const String _kPlShadeKey = "winamp.pl_shade"; const String _kUiScaleKey = "winamp.ui_scale"; +const String _kActiveSkinKey = "winamp.active_skin"; /// Simulated playback/UI state for the player, mirroring the flags tracked by /// the original Winamp main window. No audio yet; the transport simply drives @@ -291,6 +292,22 @@ class PlayerState extends ChangeNotifier { return (prefs.getDouble(_kUiScaleKey) ?? 1.0).clamp(1.0, 3.0); } + /// The name of the active skin, or `null` when the default (Base) applies. + static Future loadActiveSkin() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_kActiveSkinKey); + } + + /// Persists the active skin name; `null` restores the default. + static Future saveActiveSkin(String? name) async { + final prefs = await SharedPreferences.getInstance(); + if (name == null) { + await prefs.remove(_kActiveSkinKey); + } else { + await prefs.setString(_kActiveSkinKey, name); + } + } + void _startTicker() { _ticker?.cancel(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { diff --git a/lib/src/skin/skin_manager.dart b/lib/src/skin/skin_manager.dart new file mode 100644 index 0000000..2f9f312 --- /dev/null +++ b/lib/src/skin/skin_manager.dart @@ -0,0 +1,102 @@ +import "dart:io"; + +import "package:dartamp_skin/dartamp_skin.dart"; +import "package:flutter/foundation.dart"; +import "package:path_provider/path_provider.dart"; + +import "../player/player_state.dart"; + +/// Resolves the directory holding the extracted skins, creating it on first +/// use. Lives under the app-support folder so imported skins never touch the +/// original Winamp files. +Future skinsDirectory() async { + final support = await getApplicationSupportDirectory(); + return Directory("${support.path}/dartamp/skins"); +} + +/// Loads the named skin for an engine that has no [SkinManager] of its own +/// (the Equalizer/Playlist sub-windows). Falls back to the bundled base skin +/// when the name is unknown or the load fails. +Future loadAppSkin(String name) async { + try { + final registry = await SkinRegistry.load(await skinsDirectory()); + final info = registry.byName(name); + if (info == null) return loadBaseSkin(); + return await registry.loadSkin(info); + } catch (_) { + return loadBaseSkin(); + } +} + +/// Owns the installed skins, the active selection, and the loaded +/// [ClassicSkin] for the main window. +/// +/// The active skin is persisted across launches and broadcast to the +/// Equalizer/Playlist sub-windows by whoever owns the manager. +class SkinManager extends ChangeNotifier { + SkinManager._(this._registry, this._active); + + final SkinRegistry _registry; + SkinInfo _active; + ClassicSkin? _skin; + String? _lastError; + + /// The installed skins, base first, in scan order. + List get skins => _registry.skins; + + /// The currently selected skin. + SkinInfo get active => _active; + + /// The loaded skin, or `null` until [load] completes. + ClassicSkin? get skin => _skin; + + /// The last load error, or `null` when the last load succeeded. + String? get lastError => _lastError; + + /// Scans the skins directory and activates the saved skin (defaults to the + /// bundled base skin). + static Future load() async { + final registry = await SkinRegistry.load(await skinsDirectory()); + final saved = await PlayerState.loadActiveSkin(); + final savedInfo = saved == null ? null : registry.byName(saved); + final active = savedInfo ?? registry.skins.first; + final manager = SkinManager._(registry, active); + await manager._reload(); + return manager; + } + + Future _reload() async { + _lastError = null; + try { + _skin = await _registry.loadSkin(_active); + } catch (error) { + _lastError = "$error"; + _active = _registry.skins.first; + _skin = await loadBaseSkin(); + } + notifyListeners(); + } + + /// Selects [info] as the active skin, persists it, and reloads. + Future apply(SkinInfo info) async { + if (info.name == _active.name && _skin != null) return; + _active = info; + await PlayerState.saveActiveSkin(info.name); + await _reload(); + } + + /// Imports a `.wsz` archive and applies it immediately. + Future importArchive( + Uint8List bytes, { + String? name, + String? fileName, + }) async { + final info = await _registry.importArchive( + bytes, + name: name, + fileName: fileName, + ); + await apply(info); + return info; + } +} diff --git a/lib/src/windows/sub_window_app.dart b/lib/src/windows/sub_window_app.dart index 555b9cc..89f9c44 100644 --- a/lib/src/windows/sub_window_app.dart +++ b/lib/src/windows/sub_window_app.dart @@ -1,5 +1,6 @@ import "dart:async"; +import "package:dartamp_skin/dartamp_skin.dart"; import "package:desktop_multi_window/desktop_multi_window.dart"; import "package:flutter/material.dart"; import "package:window_manager/window_manager.dart"; @@ -7,6 +8,7 @@ import "package:window_manager/window_manager.dart"; import "../main_window/eq_window.dart"; import "../main_window/playlist_window.dart"; import "../player/player_state.dart"; +import "../skin/skin_manager.dart"; import "sub_window_channel.dart"; import "window_args.dart"; @@ -28,6 +30,7 @@ class SubWindowRoot extends StatefulWidget { required this.initialShade, required this.initialAlwaysOnTop, this.initialUiScale = 1.0, + this.initialSkinName = "Base", super.key, }); @@ -35,6 +38,7 @@ class SubWindowRoot extends StatefulWidget { final bool initialShade; final bool initialAlwaysOnTop; final double initialUiScale; + final String initialSkinName; @override State createState() => _SubWindowRootState(); @@ -42,6 +46,7 @@ class SubWindowRoot extends StatefulWidget { class _SubWindowRootState extends State { late final PlayerState _player; + late Future _skinFuture; bool _windowReady = false; bool _wasShade = false; double _wasScale = 1.0; @@ -60,6 +65,7 @@ class _SubWindowRootState extends State { plShade: _isEq ? false : widget.initialShade, uiScale: widget.initialUiScale, ); + _skinFuture = loadAppSkin(widget.initialSkinName); _wasShade = widget.initialShade; _wasScale = widget.initialUiScale; _player.addListener(_onPlayerChanged); @@ -90,6 +96,12 @@ class _SubWindowRootState extends State { if (call.arguments is num) { _player.setUiScale((call.arguments as num).toDouble()); } + case "setSkin": + if (call.arguments is String) { + setState(() { + _skinFuture = loadAppSkin(call.arguments as String); + }); + } } }); } catch (_) { @@ -166,8 +178,8 @@ class _SubWindowRootState extends State { alignment: Alignment.topLeft, filterQuality: FilterQuality.none, child: _isEq - ? EqWindow(player: _player) - : PlWindow(player: _player), + ? EqWindow(player: _player, skinFuture: _skinFuture) + : PlWindow(player: _player, skinFuture: _skinFuture), ), ); }, diff --git a/lib/src/windows/window_args.dart b/lib/src/windows/window_args.dart index 47b71ac..87a3b18 100644 --- a/lib/src/windows/window_args.dart +++ b/lib/src/windows/window_args.dart @@ -14,6 +14,7 @@ class SubWindowArgs { required this.shade, required this.alwaysOnTop, this.uiScale = 1.0, + this.skinName = "Base", }); final SubWindowType type; @@ -24,6 +25,9 @@ class SubWindowArgs { /// same size/zoom. Updated live over the control channel afterwards. final double uiScale; + /// The active skin at spawn time, so the sub-window starts skinned. + final String skinName; + /// Encodes the args into the JSON string the plugin passes to the new /// window's engine. String encode() { @@ -32,6 +36,7 @@ class SubWindowArgs { "shade": shade, "alwaysOnTop": alwaysOnTop, "uiScale": uiScale, + "skinName": skinName, }); } @@ -56,6 +61,7 @@ class SubWindowArgs { shade: map["shade"] == true, alwaysOnTop: map["alwaysOnTop"] == true, uiScale: (map["uiScale"] as num?)?.toDouble().clamp(1.0, 3.0) ?? 1.0, + skinName: (map["skinName"] as String?) ?? "Base", ); } } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index bb275f8..21a51f8 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 45d01a9..2f6b91b 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -161,4 +161,8 @@ class AppDelegate: FlutterAppDelegate { @IBAction func setUIScale3x(_ sender: NSMenuItem) { send("setUIScale", ["scale": 3.0]) } + + @IBAction func importSkin(_ sender: NSMenuItem) { + send("importSkin") + } } diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib index 5e95312..f69543c 100644 --- a/macos/Runner/Base.lproj/MainMenu.xib +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -34,6 +34,12 @@ + + + + + + diff --git a/packages/dartamp_skin/analysis_options.yaml b/packages/dartamp_skin/analysis_options.yaml new file mode 100644 index 0000000..e764191 --- /dev/null +++ b/packages/dartamp_skin/analysis_options.yaml @@ -0,0 +1 @@ +include: package:arcane_analysis/analysis_options.yaml diff --git a/assets/skins/base/MAIN.BMP b/packages/dartamp_skin/assets/skins/base/MAIN.BMP similarity index 100% rename from assets/skins/base/MAIN.BMP rename to packages/dartamp_skin/assets/skins/base/MAIN.BMP diff --git a/assets/skins/base/balance.bmp b/packages/dartamp_skin/assets/skins/base/balance.bmp similarity index 100% rename from assets/skins/base/balance.bmp rename to packages/dartamp_skin/assets/skins/base/balance.bmp diff --git a/assets/skins/base/cbuttons.bmp b/packages/dartamp_skin/assets/skins/base/cbuttons.bmp similarity index 100% rename from assets/skins/base/cbuttons.bmp rename to packages/dartamp_skin/assets/skins/base/cbuttons.bmp diff --git a/assets/skins/base/eq_ex.bmp b/packages/dartamp_skin/assets/skins/base/eq_ex.bmp similarity index 100% rename from assets/skins/base/eq_ex.bmp rename to packages/dartamp_skin/assets/skins/base/eq_ex.bmp diff --git a/assets/skins/base/eqmain.bmp b/packages/dartamp_skin/assets/skins/base/eqmain.bmp similarity index 100% rename from assets/skins/base/eqmain.bmp rename to packages/dartamp_skin/assets/skins/base/eqmain.bmp diff --git a/assets/skins/base/gen.bmp b/packages/dartamp_skin/assets/skins/base/gen.bmp similarity index 100% rename from assets/skins/base/gen.bmp rename to packages/dartamp_skin/assets/skins/base/gen.bmp diff --git a/assets/skins/base/monoster.bmp b/packages/dartamp_skin/assets/skins/base/monoster.bmp similarity index 100% rename from assets/skins/base/monoster.bmp rename to packages/dartamp_skin/assets/skins/base/monoster.bmp diff --git a/assets/skins/base/numbers.bmp b/packages/dartamp_skin/assets/skins/base/numbers.bmp similarity index 100% rename from assets/skins/base/numbers.bmp rename to packages/dartamp_skin/assets/skins/base/numbers.bmp diff --git a/assets/skins/base/playpaus.bmp b/packages/dartamp_skin/assets/skins/base/playpaus.bmp similarity index 100% rename from assets/skins/base/playpaus.bmp rename to packages/dartamp_skin/assets/skins/base/playpaus.bmp diff --git a/assets/skins/base/pledit.bmp b/packages/dartamp_skin/assets/skins/base/pledit.bmp similarity index 100% rename from assets/skins/base/pledit.bmp rename to packages/dartamp_skin/assets/skins/base/pledit.bmp diff --git a/assets/skins/base/posbar.bmp b/packages/dartamp_skin/assets/skins/base/posbar.bmp similarity index 100% rename from assets/skins/base/posbar.bmp rename to packages/dartamp_skin/assets/skins/base/posbar.bmp diff --git a/assets/skins/base/shufrep.bmp b/packages/dartamp_skin/assets/skins/base/shufrep.bmp similarity index 100% rename from assets/skins/base/shufrep.bmp rename to packages/dartamp_skin/assets/skins/base/shufrep.bmp diff --git a/assets/skins/base/spec.bmp b/packages/dartamp_skin/assets/skins/base/spec.bmp similarity index 100% rename from assets/skins/base/spec.bmp rename to packages/dartamp_skin/assets/skins/base/spec.bmp diff --git a/assets/skins/base/text.bmp b/packages/dartamp_skin/assets/skins/base/text.bmp similarity index 100% rename from assets/skins/base/text.bmp rename to packages/dartamp_skin/assets/skins/base/text.bmp diff --git a/assets/skins/base/titlebar.bmp b/packages/dartamp_skin/assets/skins/base/titlebar.bmp similarity index 100% rename from assets/skins/base/titlebar.bmp rename to packages/dartamp_skin/assets/skins/base/titlebar.bmp diff --git a/assets/skins/base/volume.bmp b/packages/dartamp_skin/assets/skins/base/volume.bmp similarity index 100% rename from assets/skins/base/volume.bmp rename to packages/dartamp_skin/assets/skins/base/volume.bmp diff --git a/packages/dartamp_skin/lib/dartamp_skin.dart b/packages/dartamp_skin/lib/dartamp_skin.dart new file mode 100644 index 0000000..75a0e4f --- /dev/null +++ b/packages/dartamp_skin/lib/dartamp_skin.dart @@ -0,0 +1,16 @@ +/// Classic Winamp skin support for dartamp. +/// +/// Provides the [ClassicSkin] data model, BMP decoding, `.wsz`/base-skin +/// loading, and the small skin-rendering widgets ([SkinImage], [SkinSlider], +/// [SkinText]) that blit crops of skin bitmaps the way the original GDI code +/// did. +library; + +export "src/bmp_decoder.dart"; +export "src/classic_skin.dart"; +export "src/skin_image.dart"; +export "src/skin_info.dart"; +export "src/skin_loader.dart"; +export "src/skin_registry.dart"; +export "src/skin_slider.dart"; +export "src/text_font.dart"; diff --git a/lib/src/skin/bmp_decoder.dart b/packages/dartamp_skin/lib/src/bmp_decoder.dart similarity index 100% rename from lib/src/skin/bmp_decoder.dart rename to packages/dartamp_skin/lib/src/bmp_decoder.dart diff --git a/lib/src/skin/classic_skin.dart b/packages/dartamp_skin/lib/src/classic_skin.dart similarity index 100% rename from lib/src/skin/classic_skin.dart rename to packages/dartamp_skin/lib/src/classic_skin.dart diff --git a/lib/src/skin/skin_image.dart b/packages/dartamp_skin/lib/src/skin_image.dart similarity index 100% rename from lib/src/skin/skin_image.dart rename to packages/dartamp_skin/lib/src/skin_image.dart diff --git a/packages/dartamp_skin/lib/src/skin_info.dart b/packages/dartamp_skin/lib/src/skin_info.dart new file mode 100644 index 0000000..19fe91e --- /dev/null +++ b/packages/dartamp_skin/lib/src/skin_info.dart @@ -0,0 +1,18 @@ +import "dart:io"; + +/// Describes an installed skin. +/// +/// The bundled base skin has a null [directory]; imported skins point at the +/// directory their extracted `.wsz` files live in. +class SkinInfo { + const SkinInfo({required this.name, this.directory}); + + /// Display name shown in skin lists. + final String name; + + /// Directory containing the skin's files, or null for the bundled base. + final Directory? directory; + + /// Whether this is the built-in base skin rather than a user import. + bool get isBundled => directory == null; +} diff --git a/lib/src/skin/skin_loader.dart b/packages/dartamp_skin/lib/src/skin_loader.dart similarity index 89% rename from lib/src/skin/skin_loader.dart rename to packages/dartamp_skin/lib/src/skin_loader.dart index 5fa54ab..fbda4cb 100644 --- a/lib/src/skin/skin_loader.dart +++ b/packages/dartamp_skin/lib/src/skin_loader.dart @@ -1,5 +1,6 @@ import "dart:async"; import "dart:convert"; +import "dart:io"; import "dart:typed_data"; import "dart:ui" as ui; @@ -65,11 +66,13 @@ Future bmpToUiImage(BmpImage bmp) { return completer.future; } -/// Loads the bundled base skin from Flutter assets. +/// Loads the bundled base skin from the package's Flutter assets. Future loadBaseSkin() async { final images = {}; for (final name in bundledSkinFiles) { - final data = await rootBundle.load("assets/skins/base/$name"); + final data = await rootBundle.load( + "packages/dartamp_skin/assets/skins/base/$name", + ); final bytes = data.buffer.asUint8List( data.offsetInBytes, data.lengthInBytes, @@ -95,7 +98,25 @@ Future loadSkinFromArchive( if (key.contains("\\") || key.contains("/")) continue; files[key] = file.content as List; } - return _buildSkin(base, files, name: name ?? _skinDisplayName(archive)); + return _buildSkin(base, files, name: name ?? skinDisplayName(archive)); +} + +/// Loads a previously-extracted skin directory (see [SkinRegistry.import]) +/// on top of [base]. Only top-level files are read; anything in a +/// subdirectory is ignored, mirroring the flat archive layout. +Future loadSkinFromDirectory( + ClassicSkin base, + Directory directory, { + String? name, +}) async { + final files = >{}; + for (final entity in directory.listSync()) { + if (entity is! File) continue; + final key = entity.uri.pathSegments.last.toLowerCase(); + if (key.contains("\\") || key.contains("/")) continue; + files[key] = await entity.readAsBytes(); + } + return _buildSkin(base, files, name: name); } Future _buildSkin( @@ -144,14 +165,16 @@ Future _buildSkin( ); } -String _skinDisplayName(Archive archive) { +/// Best-effort display name for an archive: the `id` attribute from its +/// `skin.xml`, or null when the archive has none. +String? skinDisplayName(Archive archive) { final xml = archive.files.where((f) => f.name.toLowerCase() == "skin.xml"); if (xml.isNotEmpty) { final content = utf8.decode(xml.first.content as List); final m = RegExp(r'id\s*=\s*"([^"]+)"').firstMatch(content); if (m != null) return m.group(1)!; } - return "Skin"; + return null; } // --------------------------------------------------------------------------- diff --git a/packages/dartamp_skin/lib/src/skin_registry.dart b/packages/dartamp_skin/lib/src/skin_registry.dart new file mode 100644 index 0000000..77fb260 --- /dev/null +++ b/packages/dartamp_skin/lib/src/skin_registry.dart @@ -0,0 +1,148 @@ +import "dart:convert"; +import "dart:io"; +import "dart:typed_data"; + +import "package:archive/archive.dart"; + +import "classic_skin.dart"; +import "skin_info.dart"; +import "skin_loader.dart"; + +/// The name of the built-in base skin in [skins]. +const String baseSkinName = "Base"; + +/// Indexes the installed skins in a directory and loads them on top of the +/// bundled base skin. +/// +/// The registry scans [Directory] subfolders for extracted `.wsz` skins; each +/// folder may carry a `manifest.json` written by [SkinRegistry.importArchive] +/// with the skin's display name. The bundled base skin is always listed. +class SkinRegistry { + SkinRegistry._(this._directory, List skins) + : _skins = List.of(skins); + + /// Directory the imported skins live in. + final Directory _directory; + + final List _skins; + + static const String manifestName = "manifest.json"; + + /// Scans [directory] for imported skins. The directory is created if it + /// does not exist yet. + static Future load(Directory directory) async { + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return SkinRegistry._(directory, _scan(directory)); + } + + static List _scan(Directory directory) { + final result = [ + const SkinInfo(name: baseSkinName), + ]; + for (final entity in directory.listSync()) { + if (entity is! Directory) continue; + final name = _displayNameFor(entity); + result.add(SkinInfo(name: name, directory: entity)); + } + return result; + } + + static String _displayNameFor(Directory directory) { + final manifest = File( + directory.uri.resolve(manifestName).toFilePath(), + ); + if (manifest.existsSync()) { + try { + final map = jsonDecode(manifest.readAsStringSync()); + if (map is Map && map["name"] is String) { + return map["name"] as String; + } + } on FormatException { + // fall through to the directory name + } + } + return directory.uri.pathSegments.last; + } + + /// The installed skins, base first, in scan order. + List get skins => List.unmodifiable(_skins); + + SkinInfo? byName(String name) { + for (final info in _skins) { + if (info.name == name) return info; + } + return null; + } + + /// Loads [info] on top of the bundled base skin. + Future loadSkin(SkinInfo info) async { + final base = await loadBaseSkin(); + final directory = info.directory; + if (directory == null) return base; + return loadSkinFromDirectory(base, directory, name: info.name); + } + + /// Extracts a `.wsz` archive into the skins directory and returns its + /// [SkinInfo]. [name] overrides the display name; otherwise the `skin.xml` + /// id is used, falling back to [bytes]' suggested filename or a generic + /// label. + /// + /// Re-importing the same skin replaces its previous folder. + Future importArchive( + Uint8List bytes, { + String? name, + String? fileName, + }) async { + final archive = ZipDecoder().decodeBytes(bytes, verify: false); + final resolvedName = + name ?? skinDisplayName(archive) ?? _labelFromFileName(fileName); + final folderName = _safeFolderName(resolvedName); + + final target = Directory( + _directory.uri.resolve("$folderName/").toFilePath(), + ); + if (await target.exists()) { + await target.delete(recursive: true); + } + await target.create(recursive: true); + + for (final file in archive.files) { + if (!file.isFile) continue; + final key = file.name; + if (key.contains("\\") || key.contains("/")) continue; + await File( + target.uri.resolve(key).toFilePath(), + ).writeAsBytes(file.content as List, flush: true); + } + + final manifest = { + "name": resolvedName, + "importedAt": DateTime.now().toIso8601String(), + }; + await File( + target.uri.resolve(manifestName).toFilePath(), + ).writeAsString(jsonEncode(manifest), flush: true); + + final info = SkinInfo(name: resolvedName, directory: target); + _skins.removeWhere((s) => s.name == info.name); + _skins.add(info); + return info; + } +} + +String _labelFromFileName(String? fileName) { + if (fileName == null) return "Imported Skin"; + return fileName + .split(RegExp(r"[\\/]")) + .last + .replaceAll(RegExp(r"\.(wsz|zip)$", caseSensitive: false), ""); +} + +/// Keeps folder names filesystem-safe while staying human readable. +String _safeFolderName(String name) { + final cleaned = name.trim().replaceAll(RegExp(r"[^A-Za-z0-9_. -]"), "_"); + if (cleaned.isEmpty) return "skin"; + return cleaned; +} diff --git a/lib/src/skin/skin_slider.dart b/packages/dartamp_skin/lib/src/skin_slider.dart similarity index 100% rename from lib/src/skin/skin_slider.dart rename to packages/dartamp_skin/lib/src/skin_slider.dart diff --git a/lib/src/skin/text_font.dart b/packages/dartamp_skin/lib/src/text_font.dart similarity index 100% rename from lib/src/skin/text_font.dart rename to packages/dartamp_skin/lib/src/text_font.dart diff --git a/packages/dartamp_skin/pubspec.yaml b/packages/dartamp_skin/pubspec.yaml new file mode 100644 index 0000000..a64c3e2 --- /dev/null +++ b/packages/dartamp_skin/pubspec.yaml @@ -0,0 +1,25 @@ +name: dartamp_skin +description: >- + Classic Winamp skin loading, decoding, and rendering helpers used by the + dartamp desktop app. Kept as a separate package so a future "modern" skin + codepath can live alongside the classic one. +version: 0.1.0 +publish_to: "none" + +environment: + sdk: ^3.12.2 + +dependencies: + archive: ^4.0.9 + flutter: + sdk: flutter + path: ^1.9.0 + +dev_dependencies: + arcane_analysis: ^1.0.5 + flutter_test: + sdk: flutter + +flutter: + assets: + - assets/skins/base/ diff --git a/packages/dartamp_skin/test/base_skin_test.dart b/packages/dartamp_skin/test/base_skin_test.dart new file mode 100644 index 0000000..f1bb3cc --- /dev/null +++ b/packages/dartamp_skin/test/base_skin_test.dart @@ -0,0 +1,24 @@ +import "package:dartamp_skin/dartamp_skin.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test("loadBaseSkin decodes every bundled bitmap", () async { + final skin = await loadBaseSkin(); + expect(skin.name, "Base"); + for (final name in bundledSkinFiles) { + final image = skin.image(name); + expect(image, isNotNull, reason: "$name should be loaded"); + expect(image!.width, greaterThan(0)); + } + }); + + test("loadBaseSkin decodes MAIN.BMP via case-insensitive lookup", () async { + final skin = await loadBaseSkin(); + final main = skin.image("MAIN.BMP"); + expect(main, isNotNull); + expect(main!.width, 275); + expect(main.height, 116); + }); +} diff --git a/packages/dartamp_skin/test/skin_registry_test.dart b/packages/dartamp_skin/test/skin_registry_test.dart new file mode 100644 index 0000000..443cec7 --- /dev/null +++ b/packages/dartamp_skin/test/skin_registry_test.dart @@ -0,0 +1,116 @@ +import "dart:convert"; +import "dart:io"; +import "dart:typed_data"; + +import "package:archive/archive.dart"; +import "package:dartamp_skin/dartamp_skin.dart"; +import "package:flutter_test/flutter_test.dart"; + +Uint8List _encode(Archive archive) => + Uint8List.fromList(ZipEncoder().encode(archive)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp("skin_registry_test"); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test("empty registry lists only the bundled base skin", () async { + final registry = await SkinRegistry.load(tempDir); + expect(registry.skins.map((s) => s.name), ["Base"]); + expect(registry.skins.first.isBundled, isTrue); + }); + + test( + "importArchive extracts files, writes a manifest, and reappears", + () async { + final archive = Archive(); + final numbers = File( + "assets/skins/base/numbers.bmp", + ).readAsBytesSync(); + archive.addFile( + ArchiveFile("numbers.bmp", numbers.length, numbers), + ); + archive.addFile( + ArchiveFile( + "pledit.txt", + "pledit.txt".length, + utf8.encode("pledit.txt"), + ), + ); + archive.addFile( + ArchiveFile( + "skin.xml", + ''.length, + utf8.encode(''), + ), + ); + final bytes = _encode(archive); + + final registry = await SkinRegistry.load(tempDir); + final info = await registry.importArchive( + bytes, + fileName: "myskin.wsz", + ); + + expect(info.name, "My Cool Skin"); + expect(info.isBundled, isFalse); + + final folder = info.directory!; + expect(File("${folder.path}/numbers.bmp").existsSync(), isTrue); + expect(File("${folder.path}/manifest.json").existsSync(), isTrue); + final manifest = + jsonDecode( + File("${folder.path}/manifest.json").readAsStringSync(), + ) + as Map; + expect(manifest["name"], "My Cool Skin"); + + final reloaded = await SkinRegistry.load(tempDir); + expect(reloaded.skins.map((s) => s.name), contains("My Cool Skin")); + }, + ); + + test("loadSkin overrides the base with imported bitmaps", () async { + final archive = Archive(); + final numbers = File( + "assets/skins/base/numbers.bmp", + ).readAsBytesSync(); + archive.addFile( + ArchiveFile("numbers.bmp", numbers.length, numbers), + ); + final bytes = _encode(archive); + + final registry = await SkinRegistry.load(tempDir); + final info = await registry.importArchive( + bytes, + name: "Test", + fileName: "test.wsz", + ); + + final skin = await registry.loadSkin(info); + expect(skin.name, "Test"); + expect(skin.image("numbers.bmp")!.width, greaterThan(0)); + // Files the archive omits fall back to the base skin. + expect(skin.image("main.bmp"), isNotNull); + }); + + test("folder names are sanitized", () async { + final archive = Archive(); + final bytes = _encode(archive); + final registry = await SkinRegistry.load(tempDir); + final info = await registry.importArchive( + bytes, + name: "Weird/Name: *?", + fileName: "weird.wsz", + ); + expect(info.directory!.uri.pathSegments.last, isNot(contains(":"))); + }); +} diff --git a/test/text_font_test.dart b/packages/dartamp_skin/test/text_font_test.dart similarity index 96% rename from test/text_font_test.dart rename to packages/dartamp_skin/test/text_font_test.dart index 399463f..5765686 100644 --- a/test/text_font_test.dart +++ b/packages/dartamp_skin/test/text_font_test.dart @@ -1,8 +1,7 @@ import "dart:io"; import "dart:ui" as ui; -import "package:dartamp/src/skin/bmp_decoder.dart"; -import "package:dartamp/src/skin/text_font.dart"; +import "package:dartamp_skin/dartamp_skin.dart"; import "package:flutter_test/flutter_test.dart"; Future _loadFont() async { diff --git a/tool/check_bmp.dart b/packages/dartamp_skin/tool/check_bmp.dart similarity index 92% rename from tool/check_bmp.dart rename to packages/dartamp_skin/tool/check_bmp.dart index 3f51e03..0fe795f 100644 --- a/tool/check_bmp.dart +++ b/packages/dartamp_skin/tool/check_bmp.dart @@ -3,7 +3,7 @@ import "dart:io"; import "dart:typed_data"; -import "package:dartamp/src/skin/bmp_decoder.dart"; +import "package:dartamp_skin/dartamp_skin.dart"; Future main() async { final out = Directory("tool/preview"); diff --git a/tool/compare_bmp.py b/packages/dartamp_skin/tool/compare_bmp.py similarity index 100% rename from tool/compare_bmp.py rename to packages/dartamp_skin/tool/compare_bmp.py diff --git a/tool/dump_rgba.dart b/packages/dartamp_skin/tool/dump_rgba.dart similarity index 88% rename from tool/dump_rgba.dart rename to packages/dartamp_skin/tool/dump_rgba.dart index 5f6be8d..579440e 100644 --- a/tool/dump_rgba.dart +++ b/packages/dartamp_skin/tool/dump_rgba.dart @@ -3,7 +3,7 @@ import "dart:io"; import "dart:typed_data"; -import "package:dartamp/src/skin/bmp_decoder.dart"; +import "package:dartamp_skin/dartamp_skin.dart"; Future main(List args) async { for (final name in args) { diff --git a/tool/peek_bmp.dart b/packages/dartamp_skin/tool/peek_bmp.dart similarity index 95% rename from tool/peek_bmp.dart rename to packages/dartamp_skin/tool/peek_bmp.dart index 7c062d1..a77d674 100644 --- a/tool/peek_bmp.dart +++ b/packages/dartamp_skin/tool/peek_bmp.dart @@ -1,6 +1,6 @@ import "dart:io"; -import "package:dartamp/src/skin/bmp_decoder.dart"; +import "package:dartamp_skin/dartamp_skin.dart"; void main(List args) { final file = File(args[0]); diff --git a/tool/preview/MAIN.BMP.png b/packages/dartamp_skin/tool/preview/MAIN.BMP.png similarity index 100% rename from tool/preview/MAIN.BMP.png rename to packages/dartamp_skin/tool/preview/MAIN.BMP.png diff --git a/tool/preview/MAIN.BMP.ppm b/packages/dartamp_skin/tool/preview/MAIN.BMP.ppm similarity index 100% rename from tool/preview/MAIN.BMP.ppm rename to packages/dartamp_skin/tool/preview/MAIN.BMP.ppm diff --git a/tool/preview/MAIN.BMP.rgba b/packages/dartamp_skin/tool/preview/MAIN.BMP.rgba similarity index 100% rename from tool/preview/MAIN.BMP.rgba rename to packages/dartamp_skin/tool/preview/MAIN.BMP.rgba diff --git a/tool/preview/balance.bmp.rgba b/packages/dartamp_skin/tool/preview/balance.bmp.rgba similarity index 100% rename from tool/preview/balance.bmp.rgba rename to packages/dartamp_skin/tool/preview/balance.bmp.rgba diff --git a/tool/preview/cbuttons.bmp.rgba b/packages/dartamp_skin/tool/preview/cbuttons.bmp.rgba similarity index 100% rename from tool/preview/cbuttons.bmp.rgba rename to packages/dartamp_skin/tool/preview/cbuttons.bmp.rgba diff --git a/tool/preview/eq_ex.bmp.rgba b/packages/dartamp_skin/tool/preview/eq_ex.bmp.rgba similarity index 100% rename from tool/preview/eq_ex.bmp.rgba rename to packages/dartamp_skin/tool/preview/eq_ex.bmp.rgba diff --git a/tool/preview/eqmain.bmp.rgba b/packages/dartamp_skin/tool/preview/eqmain.bmp.rgba similarity index 100% rename from tool/preview/eqmain.bmp.rgba rename to packages/dartamp_skin/tool/preview/eqmain.bmp.rgba diff --git a/tool/preview/gen.bmp.rgba b/packages/dartamp_skin/tool/preview/gen.bmp.rgba similarity index 100% rename from tool/preview/gen.bmp.rgba rename to packages/dartamp_skin/tool/preview/gen.bmp.rgba diff --git a/tool/preview/monoster.bmp.rgba b/packages/dartamp_skin/tool/preview/monoster.bmp.rgba similarity index 100% rename from tool/preview/monoster.bmp.rgba rename to packages/dartamp_skin/tool/preview/monoster.bmp.rgba diff --git a/tool/preview/numbers.bmp.png b/packages/dartamp_skin/tool/preview/numbers.bmp.png similarity index 100% rename from tool/preview/numbers.bmp.png rename to packages/dartamp_skin/tool/preview/numbers.bmp.png diff --git a/tool/preview/numbers.bmp.ppm b/packages/dartamp_skin/tool/preview/numbers.bmp.ppm similarity index 100% rename from tool/preview/numbers.bmp.ppm rename to packages/dartamp_skin/tool/preview/numbers.bmp.ppm diff --git a/tool/preview/numbers.bmp.rgba b/packages/dartamp_skin/tool/preview/numbers.bmp.rgba similarity index 100% rename from tool/preview/numbers.bmp.rgba rename to packages/dartamp_skin/tool/preview/numbers.bmp.rgba diff --git a/tool/preview/playpaus.bmp.rgba b/packages/dartamp_skin/tool/preview/playpaus.bmp.rgba similarity index 100% rename from tool/preview/playpaus.bmp.rgba rename to packages/dartamp_skin/tool/preview/playpaus.bmp.rgba diff --git a/tool/preview/pledit.bmp.rgba b/packages/dartamp_skin/tool/preview/pledit.bmp.rgba similarity index 100% rename from tool/preview/pledit.bmp.rgba rename to packages/dartamp_skin/tool/preview/pledit.bmp.rgba diff --git a/tool/preview/posbar.bmp.rgba b/packages/dartamp_skin/tool/preview/posbar.bmp.rgba similarity index 100% rename from tool/preview/posbar.bmp.rgba rename to packages/dartamp_skin/tool/preview/posbar.bmp.rgba diff --git a/tool/preview/shufrep.bmp.rgba b/packages/dartamp_skin/tool/preview/shufrep.bmp.rgba similarity index 100% rename from tool/preview/shufrep.bmp.rgba rename to packages/dartamp_skin/tool/preview/shufrep.bmp.rgba diff --git a/tool/preview/spec.bmp.rgba b/packages/dartamp_skin/tool/preview/spec.bmp.rgba similarity index 100% rename from tool/preview/spec.bmp.rgba rename to packages/dartamp_skin/tool/preview/spec.bmp.rgba diff --git a/tool/preview/text.bmp.png b/packages/dartamp_skin/tool/preview/text.bmp.png similarity index 100% rename from tool/preview/text.bmp.png rename to packages/dartamp_skin/tool/preview/text.bmp.png diff --git a/tool/preview/text.bmp.ppm b/packages/dartamp_skin/tool/preview/text.bmp.ppm similarity index 100% rename from tool/preview/text.bmp.ppm rename to packages/dartamp_skin/tool/preview/text.bmp.ppm diff --git a/tool/preview/text.bmp.rgba b/packages/dartamp_skin/tool/preview/text.bmp.rgba similarity index 100% rename from tool/preview/text.bmp.rgba rename to packages/dartamp_skin/tool/preview/text.bmp.rgba diff --git a/tool/preview/titlebar.bmp.png b/packages/dartamp_skin/tool/preview/titlebar.bmp.png similarity index 100% rename from tool/preview/titlebar.bmp.png rename to packages/dartamp_skin/tool/preview/titlebar.bmp.png diff --git a/tool/preview/titlebar.bmp.ppm b/packages/dartamp_skin/tool/preview/titlebar.bmp.ppm similarity index 100% rename from tool/preview/titlebar.bmp.ppm rename to packages/dartamp_skin/tool/preview/titlebar.bmp.ppm diff --git a/tool/preview/titlebar.bmp.rgba b/packages/dartamp_skin/tool/preview/titlebar.bmp.rgba similarity index 100% rename from tool/preview/titlebar.bmp.rgba rename to packages/dartamp_skin/tool/preview/titlebar.bmp.rgba diff --git a/tool/preview/volume.bmp.rgba b/packages/dartamp_skin/tool/preview/volume.bmp.rgba similarity index 100% rename from tool/preview/volume.bmp.rgba rename to packages/dartamp_skin/tool/preview/volume.bmp.rgba diff --git a/pubspec.yaml b/pubspec.yaml index 586b5fc..9808f3b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,11 +7,13 @@ environment: sdk: ^3.12.2 dependencies: - archive: ^4.0.9 + dartamp_skin: + path: packages/dartamp_skin desktop_multi_window: ^0.3.0 file_picker: ^11.0.3 flutter: sdk: flutter + path_provider: ^2.1.5 shared_preferences: ^2.5.5 window_manager: ^0.5.2 @@ -24,5 +26,3 @@ dev_dependencies: flutter: uses-material-design: true - assets: - - assets/skins/base/ diff --git a/test_driver/app.dart b/test_driver/app.dart index f8dd09f..7cc5c5a 100644 --- a/test_driver/app.dart +++ b/test_driver/app.dart @@ -1,16 +1,20 @@ 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"; -void main() { +Future main() async { enableFlutterDriverExtension(); + WidgetsFlutterBinding.ensureInitialized(); + final skinManager = await SkinManager.load(); runApp( - const DartampApp( + DartampApp( initialWindowShade: false, initialAlwaysOnTop: false, initialEqOpen: false, initialPlOpen: false, initialUiScale: 1.0, + skinManager: skinManager, ), ); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3ef659e..d9f98ef 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)