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>
This commit is contained in:
2026-08-09 21:14:54 +02:00
parent 55c396b2a2
commit 2f5edfc512
74 changed files with 630 additions and 72 deletions
+3
View File
@@ -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:
+77 -39
View File
@@ -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<void> main(List<String> args) async {
type: subWindowArgs.type,
initialShade: subWindowArgs.shade,
initialAlwaysOnTop: subWindowArgs.alwaysOnTop,
initialSkinName: subWindowArgs.skinName,
),
),
);
@@ -46,6 +49,7 @@ Future<void> main(List<String> 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<void> main(List<String> 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<WinampRoot> createState() => _WinampRootState();
@@ -121,12 +131,14 @@ class WinampRoot extends StatefulWidget {
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;
@@ -141,12 +153,15 @@ class _WinampRootState extends State<WinampRoot> {
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<WinampRoot> {
void dispose() {
_windowsSubscription?.cancel();
_player.removeListener(_onPlayerChanged);
_skins.removeListener(_onSkinChanged);
_player.dispose();
super.dispose();
}
@@ -249,6 +265,30 @@ class _WinampRootState extends State<WinampRoot> {
_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;
@@ -264,6 +304,7 @@ class _WinampRootState extends State<WinampRoot> {
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<WinampRoot> {
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<WinampRoot> {
@override
Widget build(BuildContext context) {
return FutureBuilder<ClassicSkin>(
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,
),
),
);
},
),
),
);
},
);
+1 -1
View File
@@ -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";
+4 -5
View File
@@ -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<ClassicSkin>? skinFuture;
@override
Widget build(BuildContext context) {
return FutureBuilder<ClassicSkin>(
future: loadBaseSkin(),
future: skinFuture ?? loadBaseSkin(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return ColoredBox(
+1 -4
View File
@@ -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);
+4 -6
View File
@@ -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<ClassicSkin>? skinFuture;
@override
Widget build(BuildContext context) {
return FutureBuilder<ClassicSkin>(
future: loadBaseSkin(),
future: skinFuture ?? loadBaseSkin(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return ColoredBox(
+17
View File
@@ -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<String?> loadActiveSkin() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_kActiveSkinKey);
}
/// Persists the active skin name; `null` restores the default.
static Future<void> 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), (_) {
+102
View File
@@ -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<Directory> 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<ClassicSkin> 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<SkinInfo> 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<SkinManager> 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<void> _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<void> 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<SkinInfo> importArchive(
Uint8List bytes, {
String? name,
String? fileName,
}) async {
final info = await _registry.importArchive(
bytes,
name: name,
fileName: fileName,
);
await apply(info);
return info;
}
}
+14 -2
View File
@@ -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<SubWindowRoot> createState() => _SubWindowRootState();
@@ -42,6 +46,7 @@ class SubWindowRoot extends StatefulWidget {
class _SubWindowRootState extends State<SubWindowRoot> {
late final PlayerState _player;
late Future<ClassicSkin> _skinFuture;
bool _windowReady = false;
bool _wasShade = false;
double _wasScale = 1.0;
@@ -60,6 +65,7 @@ class _SubWindowRootState extends State<SubWindowRoot> {
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<SubWindowRoot> {
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<SubWindowRoot> {
alignment: Alignment.topLeft,
filterQuality: FilterQuality.none,
child: _isEq
? EqWindow(player: _player)
: PlWindow(player: _player),
? EqWindow(player: _player, skinFuture: _skinFuture)
: PlWindow(player: _player, skinFuture: _skinFuture),
),
);
},
+6
View File
@@ -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",
);
}
}
+1
View File
@@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
+4
View File
@@ -161,4 +161,8 @@ class AppDelegate: FlutterAppDelegate {
@IBAction func setUIScale3x(_ sender: NSMenuItem) {
send("setUIScale", ["scale": 3.0])
}
@IBAction func importSkin(_ sender: NSMenuItem) {
send("importSkin")
}
}
+6
View File
@@ -34,6 +34,12 @@
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem title="Load Skin…" id="mnu-load-skin">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="importSkin:" target="Voe-Tx-rLC" id="act-import-skin"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
<menuItem title="Services" id="NMo-om-nkz">
<modifierMask key="keyEquivalentModifierMask"/>
@@ -0,0 +1 @@
include: package:arcane_analysis/analysis_options.yaml

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 376 B

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

@@ -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";
@@ -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;
}
@@ -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<ui.Image> 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<ClassicSkin> loadBaseSkin() async {
final images = <String, ui.Image>{};
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<ClassicSkin> loadSkinFromArchive(
if (key.contains("\\") || key.contains("/")) continue;
files[key] = file.content as List<int>;
}
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<ClassicSkin> loadSkinFromDirectory(
ClassicSkin base,
Directory directory, {
String? name,
}) async {
final files = <String, List<int>>{};
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<ClassicSkin> _buildSkin(
@@ -144,14 +165,16 @@ Future<ClassicSkin> _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<int>);
final m = RegExp(r'id\s*=\s*"([^"]+)"').firstMatch(content);
if (m != null) return m.group(1)!;
}
return "Skin";
return null;
}
// ---------------------------------------------------------------------------
@@ -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<SkinInfo> skins)
: _skins = List<SkinInfo>.of(skins);
/// Directory the imported skins live in.
final Directory _directory;
final List<SkinInfo> _skins;
static const String manifestName = "manifest.json";
/// Scans [directory] for imported skins. The directory is created if it
/// does not exist yet.
static Future<SkinRegistry> load(Directory directory) async {
if (!await directory.exists()) {
await directory.create(recursive: true);
}
return SkinRegistry._(directory, _scan(directory));
}
static List<SkinInfo> _scan(Directory directory) {
final result = <SkinInfo>[
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<String, dynamic> && 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<SkinInfo> get skins => List<SkinInfo>.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<ClassicSkin> 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<SkinInfo> 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<int>, 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;
}
+25
View File
@@ -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/
@@ -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);
});
}
@@ -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",
'<skin id="My Cool Skin" />'.length,
utf8.encode('<skin id="My Cool Skin" />'),
),
);
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<String, dynamic>;
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(":")));
});
}
@@ -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<TextFont> _loadFont() async {
@@ -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<void> main() async {
final out = Directory("tool/preview");
@@ -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<void> main(List<String> args) async {
for (final name in args) {
@@ -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<String> args) {
final file = File(args[0]);

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before

Width:  |  Height:  |  Size: 505 B

After

Width:  |  Height:  |  Size: 505 B

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+3 -3
View File
@@ -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/
+6 -2
View File
@@ -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<void> 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,
),
);
}
+1
View File
@@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)