From e26851325de3d8054b19a37c49878f4d2295d4c6 Mon Sep 17 00:00:00 2001 From: young <847398959@qq.com> Date: Mon, 2 Nov 2026 15:41:34 +0800 Subject: [PATCH 1/2] feat: unify skin settings and interaction across desktop platforms --- CONTRIBUTING.md | 9 +- README.md | 71 ++-- apps/macos/App.swift | 357 ++++++----------- apps/macos/BuiltInVisualizations.swift | 14 +- apps/macos/Info.plist | 2 +- apps/macos/MiucsSettings.swift | 379 ++++++++++++++++++ apps/macos/SkinCatalog.swift | 26 ++ apps/macos/VisualizationHostView.swift | 4 +- apps/macos/VisualizerPanel.swift | 73 +++- apps/windows/Core/Preferences.cs | 19 +- apps/windows/Core/SkinCatalog.cs | 27 ++ apps/windows/DesktopHost.cs | 156 +++---- apps/windows/Program.cs | 10 +- apps/windows/SettingsWindow.xaml | 172 ++++++++ apps/windows/SettingsWindow.xaml.cs | 124 ++++++ apps/windows/SkinPreview.cs | 100 +++++ build.sh | 18 +- docs/appearance.md | 2 + docs/architecture.md | 4 +- docs/plugin-development.md | 31 +- .../platforms/macos/PluginNative.swift | 7 +- .../platforms/macos/PluginSession.swift | 22 +- .../plugins/platforms/windows/PluginHost.cs | 16 +- plugins/pixel-keyboard/preview.png | Bin 0 -> 79803 bytes project.yml | 20 +- scripts/build-windows.ps1 | 33 +- scripts/swift-sources.sh | 8 +- scripts/test-settings.sh | 19 + .../platforms/macos/PencilPaletteTests.swift | 2 +- .../macos/PixelKeyboardPlatformTests.swift | 4 +- tests/platforms/macos/SettingsTests.swift | 156 +++++++ .../macos/StaffNotePlatformTests.swift | 4 +- .../macos/VisualizationAppearanceTests.swift | 2 +- .../platforms/windows/CoreTests/ShellTests.cs | 14 +- tools/macos/FlightPreview.swift | 12 +- tools/macos/NativeVisualizationFixtures.swift | 16 + tools/macos/PerformanceProbe.swift | 4 +- 37 files changed, 1459 insertions(+), 478 deletions(-) create mode 100644 apps/macos/MiucsSettings.swift create mode 100644 apps/macos/SkinCatalog.swift create mode 100644 apps/windows/Core/SkinCatalog.cs create mode 100644 apps/windows/SettingsWindow.xaml create mode 100644 apps/windows/SettingsWindow.xaml.cs create mode 100644 apps/windows/SkinPreview.cs create mode 100644 plugins/pixel-keyboard/preview.png create mode 100644 scripts/test-settings.sh create mode 100644 tests/platforms/macos/SettingsTests.swift create mode 100644 tools/macos/NativeVisualizationFixtures.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7853fd..cc2d3c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,7 @@ # 参与 Miucs 开发 +macOS 当前通过插件皮肤运行,默认构建包含 Lua / Skia;设置窗口使用「皮肤、外观与位置、声音与键盘、关于」。五线谱和飞鸟仅保留为历史代码与回归基线,不能注册为正式皮肤。修改设置与皮肤入口时运行 `zsh scripts/test-settings.sh`;它使用独立偏好和临时插件目录,不采集真实音频与按键。 + 当前可运行版本是 macOS 原生应用。请先阅读 [架构说明](docs/architecture.md)、[音频契约](contracts/audio-features/README.md) 和 [可视化契约](contracts/visualization/README.md)。Windows 已新增独立 .NET 8/WPF 实现和构建入口;跨平台核心测试/交叉构建与 Windows 实机验收分开记录,后者尚待执行。 ## 平台支持 @@ -54,9 +56,8 @@ zsh scripts/validate-audio.sh # 基础音频与独立 NumPy 参考 构建后运行窗口验证与预览: ```sh -dist/Miucs.app/Contents/MacOS/Miucs --verify-flight-window --output docs/validation/flight-window-tests.json -dist/Miucs.app/Contents/MacOS/Miucs --render-flight-preview --output build/flight-preview -dist/Miucs.app/Contents/MacOS/Miucs --render-staff-preview --output build/staff-note-preview +zsh scripts/test-settings.sh +open dist/Miucs.app --args --settings dist/Miucs.app/Contents/MacOS/Miucs --diagnose-inputs --duration 8 --output build/input-diagnostics.json ``` @@ -90,7 +91,7 @@ zsh scripts/render-demo.sh --staff ## 新增可视化 -新增可视化优先从 [Lua 插件开发指南](docs/plugin-development.md) 开始:复制模板、编写场景和动画,再安装到用户目录。插件模式需要使用带运行库的构建并显式开启;无需改动下面的原生模块注册代码。 +新增可视化优先从 [Lua 插件开发指南](docs/plugin-development.md) 开始:复制模板、编写场景和动画,再安装到用户目录。macOS 构建默认包含运行库;新增皮肤无需改动原生注册代码。下面的原生模块说明仅用于历史回归与其他平台。 ### 原生模块(编译进应用) diff --git a/README.md b/README.md index 294acc2..a4b3cc4 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,59 @@ -# Miucs · 桌面音乐可视化 +# Miucs · 桌面音乐皮肤 **让音乐自己飞。** -把音乐和打字变成桌面上的动画:随旋律飞行的小鸟、随节奏摆动的音符,以及跟着按键敲击的像素猫。画面透明、默认鼠标穿透,不抢键盘焦点。 +用声音与按键驱动桌面皮肤。画面透明、默认鼠标穿透,不抢键盘焦点。 -`macOS 14+ · Apple Silicon · Swift 6` · `Windows 10/11 · 实机待验` +`macOS 14+ · Apple Silicon · Swift 6` · `Windows 10/11 · 原有实现,实机待验` -![像素猫键盘:双爪跟随按键,猫头随打字摇动,尾巴随音乐摆动](docs/assets/miucs-pixel-keyboard.gif) +![像素音乐键盘:猫爪跟随按键,猫头随打字摇动,尾巴随音乐摆动](docs/assets/miucs-pixel-keyboard.gif) -> 合成输入驱动原生绘制器生成的演示,未录制真实输入或桌面;GIF 无声。 +> 合成输入驱动的演示,未录制真实输入或桌面;GIF 无声。 -## 三种可视化 +## 皮肤与设置(macOS) -| 模式 | 效果 | 详细说明 | -| --- | --- | --- | -| 像素音乐键盘 | 猫爪敲击真实物理键位,猫头随打字速度摇动,尾巴响应音乐节拍 | [像素键盘](docs/pixel-keyboard.md) | -| 五线谱音符 | 主旋律驱动音符与轨迹,节拍展开轻弧,按键散出星星 | [五线谱](docs/staff-note.md) | -| 铅笔飞鸟 | 音高决定飞行高度,音量与起音带动振翅,管道自动避让 | [飞鸟](docs/sound-flight.md) | +一个插件就是一个皮肤,统一在「设置 → 皮肤」中选择。卡片直接展示包内图片或 GIF;选择后才运行该皮肤。当前自带「像素音乐键盘」。五线谱与铅笔飞鸟已退出 macOS 产品选择和打包范围,旧选择会回退到可用皮肤。 -音频和键盘可以同时驱动画面,也能独立使用。每种模式分别记住位置和大小,切换时不重启输入采集。启用插件模式后,还可以添加自己编写的 Lua 可视化。 +设置窗口分为: + +- **皮肤**:预览与选择、打开皮肤文件夹、重新扫描、重新加载当前皮肤。 +- **外观与位置**:大小、不透明度、桌面拖动与缩放。所有皮肤共用当前位置与大小,切换只更换画面。 +- **声音与键盘**:连接状态、输入监控授权、音频与键盘重连。 +- **关于 Miucs**:应用信息。 + +Miucs 同时显示在程序坞和菜单栏中。启动或点击程序坞图标会打开设置;关闭设置窗口后,皮肤和菜单栏继续运行,再次点击图标即可打开设置。菜单栏保留「打开设置」「显示 / 隐藏皮肤」「调整位置与大小」「退出」。也可按 `⌘ ,` 打开设置,或将鼠标停留在皮肤上 3 秒,通过小锁解锁调整。 ## 构建与启动 ### macOS -需要 Apple Silicon、macOS 14+、支持 Swift 6 的 Xcode 或 Command Line Tools;首次创建本地开发签名需要 OpenSSL 3。在仓库根目录执行: +需要 Apple Silicon、macOS 14+、Swift 6、C++ 工具链、Python 3.10+ 和 CMake 3.24+;首次开发签名需要 OpenSSL 3。构建默认包含 Lua / Skia 皮肤运行库,首次会下载并校验依赖。 ```sh zsh build.sh -open dist/Miucs.app +open dist/Miucs.app --args --settings ``` -首次运行按提示授权系统音频捕获;需要打字效果时,授予「输入监控」权限。菜单中的「键盘监听」可查看状态。休眠唤醒或音频中断后会自动重连,也可手动选择「重新连接音频」。 +普通启动也会加载皮肤,无需 `--plugins`。首次按提示授权系统音频;打字效果需要「输入监控」权限。休眠唤醒或音频中断后自动重连,也可在设置中手动重连。 ### Windows -已有 .NET 8/WPF、WASAPI 实现,实机采集、绘制及多屏验收尚未完成。构建步骤见 [Windows README](apps/windows/README.md),支持边界见 [验收表](docs/windows-acceptance.md)。 - -## 常用操作 - -从菜单栏或托盘打开菜单: - -| 操作 | 用途 | -| --- | --- | -| 可视化 | 切换当前画面 | -| 显示 / 隐藏拾音器 | 控制悬浮画面的显示 | -| 画面大小 | 调整缩放比例 | -| 线条颜色 | 选择五种笔色;像素键盘保留固定配色 | -| 画面不透明度 | 10%~100%,即时生效并自动保存 | -| 调整位置与大小… | 拖动画面移动、拖动右下角缩放,完成后恢复鼠标穿透 | - -也可以将鼠标停留在画面上 3 秒,点击出现的小锁来解锁或锁定。菜单栏猫图标属于应用本身,不随可视化插件切换。 +现有 .NET 8 / WPF / WASAPI 实现保留原有界面;本次独立设置与皮肤入口更新针对 macOS。Windows 构建步骤见 [Windows README](apps/windows/README.md),实机支持边界见[验收表](docs/windows-acceptance.md)。 -## 自己制作插件 +## 添加自己的皮肤 -插件使用 **Lua 5.4 + Skia**,一个文件夹对应一个可视化。复制模板后,编写场景与动画,放入用户插件目录即可加载,无需重新编译应用。 +皮肤使用 **Lua 5.4 + Skia**,一个文件夹对应一个皮肤。将包放入用户插件目录,在设置中点击「重新扫描」即可,无需重新编译应用。包内可提供 `preview.gif` 或 `preview.png` 作为卡片预览;无预览时显示占位图。 -插件模式目前需要显式开启,普通构建仍使用原生绘制。macOS 首次构建插件版还需要 Python 3.10+、CMake 3.24+ 和 C++ 工具链,并会下载校验后的依赖: - -```sh -MIUCS_BUILD_PLUGINS=1 zsh build.sh -open dist/Miucs.app --args --plugins -``` - -请先退出已运行的 Miucs,再按上述方式启动。 - -**[插件开发指南 →](docs/plugin-development.md)**:包含完整示例、双端安装步骤、输入数据、热重载、验证预览和常见问题。 +**[皮肤插件开发指南 →](docs/plugin-development.md)**:模板、安装、输入数据、重载、验证与常见问题。 ## 本地处理 系统音频在本机分析,不依赖网络服务或外部模型;应用不保存真实音频、屏幕画面或输入文字。键盘效果使用物理键位,不受输入法影响。 -主旋律是对混音中突出音高的近似估计,复杂伴奏可能影响结果;音符用于可视化,不生成完整乐谱。算法与限制见[混音主旋律说明](docs/predominant-mixture.md)。 - ## 开发文档 - [贡献指南与测试命令](CONTRIBUTING.md) -- [插件开发指南](docs/plugin-development.md) · [插件 API 契约](contracts/plugins/README.md) +- [插件 API 契约](contracts/plugins/README.md) - [架构与依赖边界](docs/architecture.md) - [音频数据](contracts/audio-features/README.md) · [键盘数据](contracts/keyboard-input/README.md) -- [外观设置](docs/appearance.md) · [验证记录](docs/validation/README.md) +- [验证记录](docs/validation/README.md) diff --git a/apps/macos/App.swift b/apps/macos/App.swift index 2bc1cfd..e32663a 100644 --- a/apps/macos/App.swift +++ b/apps/macos/App.swift @@ -5,102 +5,34 @@ import Combine @main struct MiucsApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - var body: some Scene { - Settings { - EmptyView() - } - .commands { - CommandMenu("可视化") { - ForEach(appDelegate.visualizationDescriptors, id: \.id) { descriptor in - Button(descriptor.name) { appDelegate.selectVisualization(id: descriptor.id) } + Settings { EmptyView() } + .commands { + CommandGroup(replacing: .appSettings) { + Button("设置…") { appDelegate.showSettings() }.keyboardShortcut(",") } } - CommandGroup(replacing: .appSettings) { - VisualizationColorCommand(store: appDelegate.appearanceStore) - VisualizationTransparencyCommand(store: appDelegate.appearanceStore) - } - } - } -} - -private struct VisualizationTransparencyCommand: View { - @ObservedObject var store: VisualizationAppearanceStore - var body: some View { - Picker("画面不透明度", selection: Binding(get: { store.opacity }, set: { store.setOpacity($0) })) { - ForEach(VisualizationAppearanceStore.opacityPresets, id: \.self) { percent in - Text(percent == 100 ? "100%(原始效果)" : "\(percent)%").tag(percent) - } - } - .pickerStyle(.menu) - } -} - -private struct VisualizationColorCommand: View { - @ObservedObject var store: VisualizationAppearanceStore - var body: some View { - Picker("线条颜色", selection: Binding(get: { store.selection }, set: { store.select($0) })) { - ForEach(VisualizationInk.allCases) { ink in Text(ink.name).tag(ink) } - } - .pickerStyle(.menu) } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, ObservableObject { let appearanceStore = VisualizationAppearanceStore() - private var pluginStatusItem: NSMenuItem? + let settings = MiucsSettingsModel() + private var settingsWindow: MiucsSettingsWindow? private var statusItem: NSStatusItem? private var trayIcon: AppTrayIcon? private var panel: VisualizerPanel? private let engine = AudioEngine() private let keyboardEngine = KeyboardEngine() - private var keyboardItem: NSMenuItem? - private var keyboardSubscription: AnyCancellable? private var diagnostics: AudioDiagnostics? + private var visibilityItem: NSMenuItem? private var moveItem: NSMenuItem? - private var sizeItem: NSMenuItem? private let registry = BuiltInVisualizations.registry() - var visualizationDescriptors: [VisualizationDescriptor] { registry.descriptors } - private var visualizationMenu: NSMenu? - private var colorMenu: VisualizationColorMenu? - private var transparencyMenu: VisualizationTransparencyMenu? - private var appearanceSubscription: AnyCancellable? - private var transparencySubscription: AnyCancellable? + private var subscriptions = Set() func applicationDidFinishLaunching(_ notification: Notification) { - NSApp.setActivationPolicy(.accessory) - - if CommandLine.arguments.contains("--verify-flight-window") { - let args = CommandLine.arguments - if let i = args.firstIndex(of: "--output"), i + 1 < args.count { - do { try FlightPreview.verifyWindow(to: URL(fileURLWithPath: args[i + 1])) } - catch { fputs("Window verification failed: \(error)\n", stderr) } - } - NSApp.terminate(nil) - return - } - - if CommandLine.arguments.contains("--render-flight-preview") { - let args = CommandLine.arguments - if let i = args.firstIndex(of: "--output"), i + 1 < args.count { - do { try FlightPreview.render(to: URL(fileURLWithPath: args[i + 1])) } - catch { fputs("Flight preview failed: \(error)\n", stderr) } - } - NSApp.terminate(nil) - return - } - - if CommandLine.arguments.contains("--render-staff-preview") { - let args = CommandLine.arguments - if let i = args.firstIndex(of: "--output"), i + 1 < args.count { - do { try StaffNotePreview.render(to: URL(fileURLWithPath: args[i + 1])) } - catch { fputs("Staff preview failed: \(error)\n", stderr) } - } - NSApp.terminate(nil) - return - } - + NSApp.setActivationPolicy(.regular) if CommandLine.arguments.contains("--diagnose-audio") || CommandLine.arguments.contains("--verify-audio-recovery") { let diagnostics = AudioDiagnostics(engine: engine) self.diagnostics = diagnostics @@ -111,73 +43,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ } return } - if CommandLine.arguments.contains("--diagnose-inputs") { Task { await InputDiagnostics.run(audio: engine, keyboard: keyboardEngine, arguments: CommandLine.arguments) } return } - appearanceStore.saveSelection() + configureSettings() let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - trayIcon = AppTrayIcon(item:item,source:keyboardEngine.channel) - let menu = NSMenu();menu.delegate=self - menu.addItem( - NSMenuItem(title: "显示拾音器", action: #selector(showPanel), keyEquivalent: "s") - ) - menu.addItem( - NSMenuItem(title: "隐藏拾音器", action: #selector(hidePanel), keyEquivalent: "h") - ) - menu.addItem(.separator()) - let move = NSMenuItem(title: "调整位置与大小…", action: #selector(togglePositioning), keyEquivalent: "m") - menu.addItem(move) - moveItem = move - let size = NSMenuItem(title: "画面大小", action: nil, keyEquivalent: "") - let sizes = NSMenu(title: "画面大小") - for (percent, label) in [(10, "极小"), (80, "小"), (100, "标准"), (125, "舒展"), (150, "大"), (200, "特大")] { - let preset = NSMenuItem(title: "\(label) · \(percent)%", action: #selector(changeSize(_:)), keyEquivalent: "") - preset.target = self; preset.tag = percent - sizes.addItem(preset) - } - size.submenu = sizes - menu.addItem(size) - sizeItem = size - let visualizations = NSMenu(title: "可视化") - for descriptor in registry.descriptors { - let option = NSMenuItem(title: descriptor.name, action: #selector(selectVisualization(_:)), keyEquivalent: "") - option.target = self; option.representedObject = descriptor.id - option.state = descriptor.id == BuiltInVisualizations.defaultID ? .on : .off - visualizations.addItem(option) - } - let visualizationItem = NSMenuItem(title: "可视化", action: nil, keyEquivalent: "") - visualizationItem.submenu = visualizations; menu.addItem(visualizationItem) - visualizationMenu = visualizations - if PluginHost.enabled { - let status=NSMenuItem(title:"插件运行中",action:nil,keyEquivalent:"");status.isEnabled=false;pluginStatusItem=status;menu.addItem(status) - let rescan=NSMenuItem(title:"重新扫描插件",action:#selector(rescanPlugins),keyEquivalent:"");rescan.target=self;menu.addItem(rescan) - let reload=NSMenuItem(title:"重新加载当前插件",action:#selector(reloadPlugins),keyEquivalent:"");reload.target=self;menu.addItem(reload) - } - let colors = VisualizationColorMenu(store: appearanceStore) - colorMenu = colors; menu.addItem(colors.item) - let transparency = VisualizationTransparencyMenu(store: appearanceStore) - transparencyMenu = transparency; menu.addItem(transparency.item) + trayIcon = AppTrayIcon(item: item, source: keyboardEngine.channel) + let menu = NSMenu() + menu.delegate = self + func add(_ title: String, _ selector: Selector, _ key: String) -> NSMenuItem { + let entry = NSMenuItem(title: title, action: selector, keyEquivalent: key) + entry.target = self + menu.addItem(entry) + return entry + } + _ = add("打开设置…", #selector(showSettings), ",") + visibilityItem = add("隐藏皮肤", #selector(toggleVisibility), "h") + moveItem = add("调整位置与大小…", #selector(togglePositioning), "m") menu.addItem(.separator()) - menu.addItem( - NSMenuItem(title: "重新连接音频", action: #selector(reconnect), keyEquivalent: "r") - ) - let keyboard = NSMenuItem(title: "键盘监听:正在连接…", action: #selector(connectKeyboard), keyEquivalent: "") - keyboard.target = self; keyboardItem = keyboard; menu.addItem(keyboard) - keyboardSubscription = keyboardEngine.$state.sink { [weak self] state in - self?.updateKeyboardMenu(state) - } - menu.addItem( - NSMenuItem(title: "退出 Miucs", action: #selector(quit), keyEquivalent: "q") - ) + _ = add("退出 Miucs", #selector(quit), "q") item.menu = menu statusItem = item PluginHost.shared.start(audio: engine.channel, keyboard: keyboardEngine.channel) - createPanelIfNeeded() - + refreshSkins() + showSettings() Task { await engine.start() } Task { let key = "MiucsKeyboardPermissionRequested.v1" @@ -186,110 +78,113 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ await keyboardEngine.start(requestAccess: request) } } - + private func configureSettings() { + settings.selectSkin = { [weak self] id in self?.selectSkin(id: id) } + settings.setVisible = { [weak self] value in self?.setVisible(value) } + settings.setScale = { [weak self] value in + guard let self, let panel = self.panel else { return } + panel.setDrawingWidth(panel.descriptor.layout.width * value / 100) + self.syncPanelState() + } + settings.setOpacity = { [weak self] value in self?.appearanceStore.setOpacity(Int(value.rounded())) } + settings.togglePosition = { [weak self] in self?.togglePositioning() } + settings.reconnectAudio = { [weak self] in self?.reconnect() } + settings.connectKeyboard = { [weak self] in self?.connectKeyboard() } + settings.rescan = { [weak self] in self?.rescanSkins() } + settings.reload = { PluginHost.shared.reloadActive() } + settings.openDirectory = { [weak self] in self?.openSkinDirectory() } + engine.$status.sink { [weak self] in self?.settings.audioStatus = $0 }.store(in: &subscriptions) + keyboardEngine.$state.sink { [weak self] in self?.settings.keyboardState = $0 }.store(in: &subscriptions) + PluginHost.shared.$errors.sink { [weak self] in self?.settings.errors = $0 }.store(in: &subscriptions) + appearanceStore.$transparency.sink { [weak self] percent in + self?.panel?.visualizationHost.applyTransparency(percent) + self?.settings.opacity = Double(100 - percent) + }.store(in: &subscriptions) + } private func createPanelIfNeeded() { guard panel == nil, !registry.descriptors.isEmpty else { return } - let panel = VisualizerPanel(source: engine.channel, registry: registry, keyboardSource: keyboardEngine.channel) + let panel = VisualizerPanel(source: engine.channel, registry: registry, keyboardSource: keyboardEngine.channel, + geometryPersistence: .sharedSkin) self.panel = panel - appearanceSubscription = appearanceStore.$selection.sink { [weak self] ink in - self?.panel?.visualizationHost.applyAppearance(ink.appearance) - } - transparencySubscription = appearanceStore.$transparency.sink { [weak self] percent in - self?.panel?.visualizationHost.applyTransparency(percent) - } + panel.visualizationHost.applyAppearance(appearanceStore.appearance) + panel.visualizationHost.applyTransparency(appearanceStore.transparency) + panel.onAdjustmentChange = { [weak self] in self?.syncPanelState() } + panel.onSizeChange = { [weak self] in self?.syncPanelState() } panel.orderFrontRegardless() - panel.onAdjustmentChange = { [weak self, weak panel] in - self?.moveItem?.title = panel?.isAdjustingPosition == true - ? "完成调整(恢复鼠标穿透)" : "调整位置与大小…" - } - panel.onSizeChange = { [weak self] in self?.updateSizeMenu() } - updateSizeMenu() - updateVisualizationMenu() - - } - - func menuWillOpen(_ menu: NSMenu) { - let error=PluginHost.shared.errors.last - pluginStatusItem?.title=error.map { "插件:" + String($0.prefix(120)) } ?? "插件运行中" - pluginStatusItem?.toolTip=error - } - @objc private func rescanPlugins() { - objectWillChange.send() - PluginHost.shared.register(in: registry) - visualizationMenu?.removeAllItems() - for descriptor in registry.descriptors { - let item=NSMenuItem(title:descriptor.name,action:#selector(selectVisualization(_:)),keyEquivalent:"") - item.target=self;item.representedObject=descriptor.id;visualizationMenu?.addItem(item) + syncPanelState() + } + private func syncPanelState() { + settings.selectedID = panel?.descriptor.id ?? "" + settings.isVisible = panel?.isVisible == true + settings.isAdjusting = panel?.isAdjustingPosition == true + if let panel { + settings.scale = panel.drawingScale * 100 + let layout = panel.descriptor.layout + settings.scaleRange = (layout.minimumWidth / layout.width * 100)...(layout.maximumWidth / layout.width * 100) + } + visibilityItem?.title = settings.isVisible ? "隐藏皮肤" : "显示皮肤" + visibilityItem?.isEnabled = panel != nil + moveItem?.title = settings.isAdjusting ? "完成调整" : "调整位置与大小…" + moveItem?.isEnabled = panel != nil + } + private func refreshSkins() { + settings.skins = PluginHost.shared.metadata.compactMap { info in + guard let id = info["id"] as? String, let name = info["name"] as? String, + let directory = info["directory"] as? String else { return nil } + return SkinItem(id: id, name: name, directory: URL(fileURLWithPath: directory)) + } + syncPanelState() + } + private func selectSkin(id: String) { + guard registry.descriptor(id: id) != nil else { return } + createPanelIfNeeded() + _ = panel?.selectVisualization(id: id) + syncPanelState() + } + private func rescanSkins() { + PluginHost.shared.register(in: registry, excluding: SkinItem.retiredIDs) + if let panel, registry.descriptor(id: panel.descriptor.id) == nil { + if let next = registry.descriptors.first { + _ = panel.selectVisualization(id: next.id) + } else { + panel.orderOut(nil) + panel.visualizationHost.dispose() + self.panel = nil + } } createPanelIfNeeded() - updateVisualizationMenu() - } - @objc private func reloadPlugins() { PluginHost.shared.reloadActive() } - @objc private func showPanel() { - panel?.orderFrontRegardless() - } - - @objc private func hidePanel() { - panel?.orderOut(nil) - moveItem?.title = "调整位置与大小…" + refreshSkins() + } + private func openSkinDirectory() { + let url = URL(fileURLWithPath: PluginNative.shared.user) + do { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + if !NSWorkspace.shared.open(url) { settings.errors = ["无法打开皮肤文件夹。"] } + } catch { settings.errors = ["无法创建皮肤文件夹:\(error.localizedDescription)"] } + } + @objc func showSettings() { + if settingsWindow == nil { settingsWindow = MiucsSettingsWindow(model: settings) } + syncPanelState() + settingsWindow?.present() + } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + showSettings() + return false + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + func menuWillOpen(_ menu: NSMenu) { syncPanelState() } + @objc private func toggleVisibility() { setVisible(panel?.isVisible != true) } + private func setVisible(_ value: Bool) { + if value { panel?.orderFrontRegardless() } else { panel?.orderOut(nil) } + syncPanelState() } - @objc private func togglePositioning() { guard let panel else { return } panel.setAdjustingPosition(!panel.isAdjustingPosition) - moveItem?.title = panel.isAdjustingPosition ? "完成调整(恢复鼠标穿透)" : "调整位置与大小…" - } - - @objc private func changeSize(_ sender: NSMenuItem) { - guard let panel else { return } - panel.setDrawingWidth(panel.descriptor.layout.width * Double(sender.tag) / 100) + syncPanelState() } - - @objc private func selectVisualization(_ sender: NSMenuItem) { - guard let id = sender.representedObject as? String else { return } - selectVisualization(id: id) - } - - func selectVisualization(id: String) { - guard panel?.selectVisualization(id: id) == true else { return } - updateVisualizationMenu() - updateSizeMenu() - } - - private func updateVisualizationMenu() { - for option in visualizationMenu?.items ?? [] { - option.state = option.representedObject as? String == panel?.descriptor.id ? .on : .off - } - } - - private func updateSizeMenu() { - guard let panel else { return } - let percent = panel.drawingScale * 100 - sizeItem?.title = "画面大小(\(Int(percent.rounded()))%)" - for preset in sizeItem?.submenu?.items ?? [] { - preset.state = abs(percent - Double(preset.tag)) < 0.5 ? .on : .off - } - } - - @objc private func reconnect() { - Task { await engine.reconnect() } - } - - private func updateKeyboardMenu(_ state: KeyboardSourceState) { - let label: String - switch state { - case .running: label = "已连接" - case .permissionRequired: label = "点击授权…" - case .secureInput: label = "安全输入期间暂停" - case .suspended: label = "等待系统恢复" - case .recovering: label = "正在重连…" - case .connecting: label = "正在连接…" - case .stopped: label = "点击连接…" - } - keyboardItem?.title = "键盘监听:" + label - } - - @objc private func connectKeyboard() { + private func reconnect() { Task { await engine.reconnect() } } + private func connectKeyboard() { Task { await keyboardEngine.start(requestAccess: true) if keyboardEngine.state == .permissionRequired, @@ -298,19 +193,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ } } } - - @objc private func quit() { - keyboardEngine.stop() - engine.stop() - NSApp.terminate(nil) - } - + @objc private func quit() { NSApp.terminate(nil) } func applicationWillTerminate(_ notification: Notification) { trayIcon?.stop() - PluginHost.shared.stop() keyboardEngine.stop() engine.stop() panel?.saveDrawingFrame() panel?.visualizationHost.dispose() + PluginHost.shared.stop() } } diff --git a/apps/macos/BuiltInVisualizations.swift b/apps/macos/BuiltInVisualizations.swift index 071ad4c..36e0666 100644 --- a/apps/macos/BuiltInVisualizations.swift +++ b/apps/macos/BuiltInVisualizations.swift @@ -3,18 +3,10 @@ import Foundation /// Composition root: adding an adapter here never changes audio analysis or the host. @MainActor enum BuiltInVisualizations { - static let defaultID = PencilFlightSimulation.module.id - static func registry(preferences: UserDefaults = .standard, - paletteStore: PencilPaletteStore? = nil) -> VisualizationRegistry { - let store = paletteStore ?? PencilPaletteStore(preferences: preferences) + static let defaultID = "pixel-keyboard" + static func registry(userDirectory: String? = nil) -> VisualizationRegistry { let registry = VisualizationRegistry() - if PluginHost.enabled { - PluginHost.shared.register(in: registry) - return registry - } - registry.register(PencilFlightSimulation.module) { MacPencilFlightSession(paletteStore: store) } - registry.register(StaffNoteSimulation.module) { MacStaffNoteSession() } - registry.register(MacPixelKeyboardSession.module) { MacPixelKeyboardSession() } + PluginHost.shared.register(in: registry, excluding: SkinItem.retiredIDs, userDirectory: userDirectory) return registry } } diff --git a/apps/macos/Info.plist b/apps/macos/Info.plist index 87f581c..ae64282 100644 --- a/apps/macos/Info.plist +++ b/apps/macos/Info.plist @@ -25,7 +25,7 @@ LSMinimumSystemVersion 14.0 LSUIElement - + NSHighResolutionCapable NSAudioCaptureUsageDescription diff --git a/apps/macos/MiucsSettings.swift b/apps/macos/MiucsSettings.swift new file mode 100644 index 0000000..9d7fb69 --- /dev/null +++ b/apps/macos/MiucsSettings.swift @@ -0,0 +1,379 @@ +import AppKit +import SwiftUI + +enum SettingsPage: String, CaseIterable, Identifiable { + case skins = "皮肤", appearance = "外观与位置", inputs = "声音与键盘", about = "关于 Miucs" + var id: String { rawValue } + var symbol: String { + switch self { + case .skins: "square.grid.2x2" + case .appearance: "slider.horizontal.3" + case .inputs: "waveform" + case .about: "info.circle" + } + } + var subtitle: String { + switch self { + case .skins: "点击卡片,使用你喜欢的皮肤。" + case .appearance: "调整皮肤的大小、透明度和摆放位置。" + case .inputs: "连接声音与按键,让皮肤动起来。" + case .about: "让音乐自己飞。" + } + } +} + +@MainActor +final class MiucsSettingsModel: ObservableObject { + @Published var page = SettingsPage.skins + @Published var skins = [SkinItem]() + @Published var selectedID = "" + @Published var isVisible = false + @Published var scale = 100.0 + @Published var scaleRange = 10.0...200.0 + @Published var opacity = 100.0 + @Published var isAdjusting = false + @Published var audioStatus = "正在连接系统音频…" + @Published var keyboardState = KeyboardSourceState.connecting + @Published var errors = [String]() + var selectSkin: (String) -> Void = { _ in } + var setVisible: (Bool) -> Void = { _ in } + var setScale: (Double) -> Void = { _ in } + var setOpacity: (Double) -> Void = { _ in } + var togglePosition: () -> Void = {} + var reconnectAudio: () -> Void = {} + var connectKeyboard: () -> Void = {} + var rescan: () -> Void = {} + var reload: () -> Void = {} + var openDirectory: () -> Void = {} + var currentSkin: SkinItem? { skins.first { $0.id == selectedID } } + var keyboardStatus: String { + switch keyboardState { + case .running: "已连接" + case .permissionRequired: "需要输入监控权限" + case .secureInput: "安全输入期间暂停" + case .suspended: "等待系统恢复" + case .recovering: "正在重新连接…" + case .connecting: "正在连接…" + case .stopped: "未连接" + } + } +} + +@MainActor +final class MiucsSettingsWindow: NSWindowController { + init(model: MiucsSettingsModel) { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 920, height: 570), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "Miucs 设置" + window.minSize = NSSize(width: 780, height: 520) + window.isReleasedWhenClosed = false + window.contentView = SettingsHostingView(rootView: MiucsSettingsView(model: model)) + window.setFrameAutosaveName("MiucsSettings") + super.init(window: window) + window.center() + } + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + func present() { + NSApp.activate(ignoringOtherApps: true) + if window?.isMiniaturized == true { window?.deminiaturize(nil) } + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + } +} + +private final class SettingsHostingView: NSHostingView { + // A click on a visible control also works when another application is active. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} + +private struct MiucsSettingsView: View { + @ObservedObject var model: MiucsSettingsModel + private let accent = Color(red: 0.35, green: 0.44, blue: 0.64) + + var body: some View { + HStack(spacing: 0) { + sidebar + Divider() + VStack(alignment: .leading, spacing: 22) { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text(model.page.rawValue).font(.system(size: 24, weight: .semibold)) + Text(model.page.subtitle).font(.system(size: 12)).foregroundStyle(.secondary) + } + Spacer() + if model.page == .skins || model.page == .appearance { + Toggle("显示皮肤", isOn: Binding(get: { model.isVisible }, set: { model.setVisible($0) })) + .toggleStyle(.switch).controlSize(.small).fixedSize() + .disabled(model.currentSkin == nil) + } + } + ScrollView { + VStack(alignment: .leading, spacing: 18) { + switch model.page { + case .skins: skins + case .appearance: appearance + case .inputs: inputs + case .about: about + } + }.frame(maxWidth: .infinity, alignment: .leading) + } + HStack(spacing: 5) { + Image(systemName: "checkmark").font(.system(size: 10)) + Text(model.page == .skins ? "选择皮肤即时生效" : "更改即时生效,自动保存") + }.font(.system(size: 11)).foregroundStyle(.secondary) + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) + } + .tint(accent) + .frame(minWidth: 760, minHeight: 480) + } + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 24) { + HStack(spacing: 10) { + Image(nsImage: NSImage(named: NSImage.applicationIconName) ?? NSImage()) + .resizable().frame(width: 38, height: 38) + VStack(alignment: .leading, spacing: 2) { + Text("Miucs").font(.system(size: 19, weight: .semibold)) + Text("让音乐自己飞。").font(.system(size: 10)).foregroundStyle(.secondary) + } + }.padding(.horizontal, 8) + VStack(spacing: 5) { + ForEach(SettingsPage.allCases.filter { $0 != .about }) { page in navigation(page) } + } + Spacer() + navigation(.about) + Label("在后台运行", systemImage: "circle.fill") + .font(.system(size: 10)).foregroundStyle(.secondary).padding(.horizontal, 12) + } + .padding(.horizontal, 12).padding(.vertical, 28).frame(width: 180) + .background(Color(nsColor: .controlBackgroundColor)) + } + + private func navigation(_ page: SettingsPage) -> some View { + Button { model.page = page } label: { + Label(page.rawValue, systemImage: page.symbol) + .font(.system(size: 13, weight: model.page == page ? .semibold : .regular)) + .frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.vertical, 10) + .foregroundStyle(model.page == page ? accent : .secondary) + .contentShape(RoundedRectangle(cornerRadius: 7)) + }.buttonStyle(SettingsSelectionButtonStyle(kind: .navigation, selected: model.page == page, accent: accent)) + } + + private var skins: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 10) { + Button("打开皮肤文件夹…", action: model.openDirectory) + Button("重新扫描", action: model.rescan) + Spacer() + Menu { + Button("重新加载当前皮肤", action: model.reload).disabled(model.currentSkin == nil) + } label: { Image(systemName: "ellipsis") } + .menuStyle(.borderlessButton).fixedSize().accessibilityLabel("皮肤操作") + }.controlSize(.small) + if model.skins.isEmpty { + VStack(spacing: 12) { + Image(systemName: "square.dashed").font(.system(size: 34)).foregroundStyle(.secondary) + Text("还没有可用的皮肤").font(.headline) + Text("将皮肤文件夹放入皮肤目录,再点击「重新扫描」。") + .font(.system(size: 12)).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity).padding(.vertical, 60) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 190, maximum: 260), spacing: 16)], alignment: .leading, spacing: 16) { + ForEach(model.skins) { skin in + Button { model.selectSkin(skin.id) } label: { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topTrailing) { + SkinPreview(url: skin.previewURL) + .frame(height: 155).frame(maxWidth: .infinity) + .allowsHitTesting(false) + if model.selectedID == skin.id { + Image(systemName: "checkmark.circle.fill").font(.system(size: 18)) + .foregroundStyle(accent).background(.background, in: Circle()).padding(8) + .allowsHitTesting(false) + } + } + Text(skin.name).font(.system(size: 13, weight: .medium)) + .lineLimit(2).padding(.horizontal, 6).padding(.vertical, 12) + } + .padding(7) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(RoundedRectangle(cornerRadius: 10)) + }.buttonStyle(SettingsSelectionButtonStyle(kind: .card, selected: model.selectedID == skin.id, accent: accent)) + .accessibilityLabel(skin.name) + .accessibilityValue(model.selectedID == skin.id ? "当前皮肤" : "未选择") + } + } + } + if !model.errors.isEmpty { + DisclosureGroup("皮肤加载遇到问题") { + Text(model.errors.joined(separator: "\n")).font(.system(size: 12)) + .textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding(.top, 8) + }.foregroundStyle(.orange) + } + } + } + + private var appearance: some View { + VStack(alignment: .leading, spacing: 20) { + if let skin = model.currentSkin { + HStack(spacing: 12) { + SkinPreview(url: skin.previewURL).frame(width: 76, height: 50) + VStack(alignment: .leading, spacing: 3) { + Text(skin.name).font(.headline) + Text("当前皮肤").font(.system(size: 11)).foregroundStyle(.secondary) + } + } + settingsGroup { + VStack(alignment: .leading, spacing: 12) { + HStack { Text("画面大小"); Spacer(); Text("\(Int(model.scale.rounded()))%").monospacedDigit().foregroundStyle(.secondary) } + Slider(value: Binding(get: { model.scale }, set: { model.setScale($0) }), in: model.scaleRange) + Text("切换皮肤时保持当前位置与大小").font(.system(size: 11)).foregroundStyle(.secondary) + } + Divider() + VStack(alignment: .leading, spacing: 12) { + HStack { Text("不透明度"); Spacer(); Text("\(Int(model.opacity))%").monospacedDigit().foregroundStyle(.secondary) } + Slider(value: Binding(get: { model.opacity }, set: { model.setOpacity($0) }), in: 10...100, step: 10) + } + Divider() + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("位置与大小") + Text(model.isAdjusting ? "完成后恢复鼠标穿透" : "拖动移动,拖动右下角缩放") + .font(.system(size: 11)).foregroundStyle(.secondary) + } + Spacer() + Button(model.isAdjusting ? "完成调整" : "在桌面上调整…", action: model.togglePosition) + } + } + } else { + Text("先在「皮肤」中选择一个皮肤。").foregroundStyle(.secondary) + } + } + } + + private var inputs: some View { + VStack(alignment: .leading, spacing: 20) { + settingsGroup { + HStack { + Label("系统音频", systemImage: "speaker.wave.2") + Spacer() + Button("重新连接", action: model.reconnectAudio) + } + Text(model.audioStatus).font(.system(size: 12)).foregroundStyle(.secondary) + } + settingsGroup { + HStack { + Label("键盘响应", systemImage: "keyboard") + Spacer() + Button(model.keyboardState == .permissionRequired ? "前往系统设置…" : "检查连接", action: model.connectKeyboard) + } + Text(model.keyboardStatus).font(.system(size: 12)) + .foregroundStyle(model.keyboardState == .permissionRequired ? Color.orange : .secondary) + if model.keyboardState == .permissionRequired { + Text("允许 Miucs 使用「输入监控」后,按键才能驱动皮肤。").font(.system(size: 12)).foregroundStyle(.secondary) + } + } + Label("音频和按键只在本机处理,不保存真实音频、屏幕画面或输入文字。", systemImage: "lock.shield") + .font(.system(size: 12)).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + } + + private var about: some View { + VStack(spacing: 16) { + Image(nsImage: NSImage(named: NSImage.applicationIconName) ?? NSImage()).resizable().frame(width: 84, height: 84) + Text("Miucs").font(.system(size: 28, weight: .semibold)) + Text("让音乐自己飞。").foregroundStyle(.secondary) + Text("用声音与按键,让桌面上的皮肤动起来。").font(.system(size: 13)) + Text("版本 \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0")") + .font(.system(size: 11)).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity).padding(.vertical, 35) + } + + private func settingsGroup(@ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 18, content: content) + .padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.primary.opacity(0.08))) + } +} + +private struct SettingsSelectionButtonStyle: ButtonStyle { + enum Kind { case navigation, card } + let kind: Kind + let selected: Bool + let accent: Color + + func makeBody(configuration: Configuration) -> some View { + SettingsSelectionSurface(label: configuration.label, kind: kind, selected: selected, + pressed: configuration.isPressed, accent: accent) + } +} + +private struct SettingsSelectionSurface: View { + let label: Label + let kind: SettingsSelectionButtonStyle.Kind + let selected: Bool + let pressed: Bool + let accent: Color + @State private var hovered = false + @Environment(\.isEnabled) private var isEnabled + + private var shape: RoundedRectangle { RoundedRectangle(cornerRadius: kind == .card ? 10 : 7) } + private var background: Color { + if kind == .card { return Color(nsColor: .controlBackgroundColor) } + return selected ? accent.opacity(0.14) : .clear + } + private var border: Color { + if kind == .card && selected { return accent } + if hovered || pressed { return accent.opacity(0.55) } + return kind == .card ? Color.primary.opacity(0.12) : .clear + } + + var body: some View { + label + .frame(maxWidth: .infinity, alignment: .leading) + .background(background, in: shape) + .overlay { + shape.fill(accent.opacity(pressed ? 0.16 : hovered ? 0.07 : 0)) + .allowsHitTesting(false) + } + .overlay { + shape.strokeBorder(border, lineWidth: kind == .card && selected ? 2 : 1) + .allowsHitTesting(false) + } + .contentShape(shape) + .onHover { hovered = $0 } + .animation(.easeOut(duration: 0.1), value: hovered) + .animation(.easeOut(duration: 0.08), value: pressed) + .opacity(isEnabled ? 1 : 0.5) + } +} + +/// Displays package artwork without activating a second plugin or input subscription. +private struct SkinPreview: NSViewRepresentable { + let url: URL? + func makeNSView(context: Context) -> NSImageView { + let view = SkinPreviewImageView() + view.imageScaling = .scaleProportionallyUpOrDown + view.animates = true + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return view + } + func updateNSView(_ view: NSImageView, context: Context) { + guard context.coordinator.url != url || view.image == nil else { return } + context.coordinator.url = url + view.image = url.flatMap { NSImage(contentsOf: $0) } ?? NSImage(systemSymbolName: "photo", accessibilityDescription: "暂无预览图片") + } + func makeCoordinator() -> Coordinator { Coordinator() } + final class Coordinator { var url: URL? } +} + +private final class SkinPreviewImageView: NSImageView { + // Let the enclosing SwiftUI button own the complete card, including GIF/image pixels. + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} diff --git a/apps/macos/SkinCatalog.swift b/apps/macos/SkinCatalog.swift new file mode 100644 index 0000000..28439e9 --- /dev/null +++ b/apps/macos/SkinCatalog.swift @@ -0,0 +1,26 @@ +import AppKit + +/// A skin is one plugin package. Retired demos are not application choices. +struct SkinItem: Identifiable { + let id: String + let name: String + let directory: URL + let previewURL: URL? + + static let retiredIDs: Set = ["staff-note", "pencil-flight"] + + init(id: String, name: String, directory: URL) { + self.id = id + self.name = name + self.directory = directory + let root = directory.resolvingSymlinksInPath().standardizedFileURL.path + "/" + previewURL = ["gif", "png", "webp", "jpg", "jpeg"].compactMap { ext -> URL? in + let url = directory.appendingPathComponent("preview.\(ext)").resolvingSymlinksInPath() + guard url.path.hasPrefix(root), + let info = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + info.isRegularFile == true, let size = info.fileSize, size <= 20_000_000, + NSImage(contentsOf: url) != nil else { return nil } + return url + }.first + } +} diff --git a/apps/macos/VisualizationHostView.swift b/apps/macos/VisualizationHostView.swift index 8f72e42..f7f21e2 100644 --- a/apps/macos/VisualizationHostView.swift +++ b/apps/macos/VisualizationHostView.swift @@ -156,8 +156,8 @@ private final class HostOverlayView: NSView { case .recovering: text = "声音中断 · 正在自动恢复…" case .suspended: text = "等待系统唤醒…" case .permissionRequired: text = "等待系统音频授权…" - case .userStopped: text = "捕获已停止 · 菜单栏可重新连接" - default: text = "音频未连接 · 从菜单栏重新连接" + case .userStopped: text = "捕获已停止 · 在设置中重新连接" + default: text = "音频未连接 · 在设置中重新连接" } } let font = NSFont(name: "Bradley Hand", size: 12) ?? NSFont.systemFont(ofSize: 12) diff --git a/apps/macos/VisualizerPanel.swift b/apps/macos/VisualizerPanel.swift index c364363..9c38c76 100644 --- a/apps/macos/VisualizerPanel.swift +++ b/apps/macos/VisualizerPanel.swift @@ -1,9 +1,11 @@ import AppKit -/// A transparent, nonactivating desktop canvas with independent module geometry. +/// A transparent canvas. Skin mode keeps one desktop frame across package switches. @MainActor final class VisualizerPanel: NSPanel, NSWindowDelegate { static let selectionPreferencesKey = "MiucsSelectedVisualization.v1" + static let sharedFramePreferencesKey = "MiucsSkinFrame.v1" + enum GeometryPersistence { case perVisualization, sharedSkin } static func framePreferencesKey(for id: String) -> String { id == "pencil-flight" ? "MiucsPencilFlightFrame" : "MiucsVisualizationFrame.v1.\(id)" } @@ -14,6 +16,13 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { private let preferences: UserDefaults private let persistsFrame: Bool private let monitorsHover: Bool + private let geometryPersistence: GeometryPersistence + private var geometryPreferencesKey: String { + geometryPersistence == .sharedSkin ? Self.sharedFramePreferencesKey : Self.framePreferencesKey(for: descriptor.id) + } + private var geometryAutosaveName: String { + geometryPersistence == .sharedSkin ? "MiucsSkin" : Self.autosaveName(for: descriptor.id) + } private(set) var descriptor: VisualizationDescriptor private var isRestoringFrame = true private var isUpdatingDrawingSize = false @@ -31,7 +40,8 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { init(source: any AudioFeatureSource, registry: VisualizationRegistry, selectedID: String? = nil, preferences: UserDefaults = .standard, persistsFrame: Bool = true, - keyboardSource: (any KeyboardFeatureSource)? = nil, monitorsHover: Bool = true) { + keyboardSource: (any KeyboardFeatureSource)? = nil, monitorsHover: Bool = true, + geometryPersistence: GeometryPersistence = .perVisualization) { let requestedID = selectedID ?? preferences.string(forKey: Self.selectionPreferencesKey) let initial = requestedID.flatMap { registry.descriptor(id: $0) } ?? registry.descriptors.first! descriptor = initial @@ -39,6 +49,7 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { keyboardSource: keyboardSource) self.preferences = preferences; self.persistsFrame = persistsFrame self.monitorsHover = monitorsHover + self.geometryPersistence = geometryPersistence super.init(contentRect: NSRect(x: 80, y: 80, width: initial.layout.width, height: initial.layout.height), styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView], backing: .buffered, defer: false) @@ -80,12 +91,18 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { if persistsFrame { setFrameAutosaveName("") } guard visualizationHost.select(id: id) else { isRestoringFrame = false - if persistsFrame { setFrameAutosaveName(Self.autosaveName(for: descriptor.id)) } + if persistsFrame { setFrameAutosaveName(geometryAutosaveName) } if wasRendering { visualizationHost.setRendering(true) } return false } descriptor = next; updateTitle() - restoreGeometry(fallbackTopLeft: anchor) + if geometryPersistence == .sharedSkin { + // Replacing the artwork must not resize, reposition or round the window. + visualizationHost.updateViewport() + if persistsFrame { setFrameAutosaveName(geometryAutosaveName) } + } else { + restoreGeometry(fallbackTopLeft: anchor) + } isRestoringFrame = false saveDrawingFrame() if wasRendering { visualizationHost.setRendering(true) } @@ -102,7 +119,10 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { var restored = false if let saved { setFrame(saved, display: false); restored = true } else if persistsFrame { - restored = setFrameUsingName(Self.autosaveName(for: descriptor.id), force: true) + restored = setFrameUsingName(geometryAutosaveName, force: true) + if !restored, geometryPersistence == .sharedSkin { + restored = setFrameUsingName(Self.autosaveName(for: descriptor.id), force: true) + } } let layout = descriptor.layout if !restored { @@ -112,11 +132,26 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { setFrame(NSRect(x: topLeft.x, y: topLeft.y - layout.height, width: layout.width, height: layout.height), display: false) } - let legacyWidth = descriptor.id == "pencil-flight" ? preferences.object(forKey: "MiucsPencilFlightWidth") as? Double : nil - let width: Double = saved.map { Double($0.width) } ?? (restored ? Double(frame.width) : legacyWidth ?? layout.width) - setDrawingWidth(width, persist: false) + if geometryPersistence == .sharedSkin, restored { + fitSharedFrameToDisplay() + } else { + let legacyWidth = descriptor.id == "pencil-flight" ? preferences.object(forKey: "MiucsPencilFlightWidth") as? Double : nil + let width: Double = saved.map { Double($0.width) } ?? (restored ? Double(frame.width) : legacyWidth ?? layout.width) + setDrawingWidth(width, persist: false) + } + visualizationHost.updateViewport() + if persistsFrame { setFrameAutosaveName(geometryAutosaveName) } + } + + private func fitSharedFrameToDisplay() { + let visible = visibleFrame(), current = frame + guard !visible.contains(current) else { return } + let scale = min(1, min(visible.width / current.width, visible.height / current.height)) + let size = NSSize(width: current.width * scale, height: current.height * scale) + let origin = NSPoint(x: min(max(current.minX, visible.minX), visible.maxX - size.width), + y: min(max(current.minY, visible.minY), visible.maxY - size.height)) + setFrame(NSRect(origin: origin, size: size), display: false) visualizationHost.updateViewport() - if persistsFrame { setFrameAutosaveName(Self.autosaveName(for: descriptor.id)) } } private func visibleFrame() -> NSRect { @@ -150,14 +185,21 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { frame.origin.x.isFinite, frame.origin.y.isFinite, frame.width.isFinite, frame.height.isFinite, frame.width > 0, frame.height > 0 else { return } preferences.set(["x": frame.minX, "y": frame.minY, - "width": frame.width, "height": frame.height], forKey: Self.framePreferencesKey(for: descriptor.id)) - if descriptor.id == "pencil-flight" { preferences.set(frame.width, forKey: "MiucsPencilFlightWidth") } + "width": frame.width, "height": frame.height], forKey: geometryPreferencesKey) + if geometryPersistence == .perVisualization, descriptor.id == "pencil-flight" { preferences.set(frame.width, forKey: "MiucsPencilFlightWidth") } preferences.set(descriptor.id, forKey: Self.selectionPreferencesKey) - if persistsFrame { saveFrame(usingName: Self.autosaveName(for: descriptor.id)) } + if persistsFrame { saveFrame(usingName: geometryAutosaveName) } } private func readSavedFrame() -> NSRect? { - guard let saved = preferences.dictionary(forKey: Self.framePreferencesKey(for: descriptor.id)), + if let saved = readFrame(forKey: geometryPreferencesKey) { return saved } + // One-time migration starts from the selected skin's existing placement. + if geometryPersistence == .sharedSkin { return readFrame(forKey: Self.framePreferencesKey(for: descriptor.id)) } + return nil + } + + private func readFrame(forKey key: String) -> NSRect? { + guard let saved = preferences.dictionary(forKey: key), let x = saved["x"] as? Double, let y = saved["y"] as? Double, let width = saved["width"] as? Double, let height = saved["height"] as? Double, [x, y, width, height].allSatisfy(\.isFinite), width > 0, height > 0 else { return nil } @@ -199,7 +241,10 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { if let control = hoverLockPanel { removeChildWindow(control); control.orderOut(nil) } } @objc private func displaysChanged() { - hideHoverLock(); setAdjustingPosition(false); setDrawingWidth(frame.width) + hideHoverLock(); setAdjustingPosition(false) + if geometryPersistence == .sharedSkin { + fitSharedFrameToDisplay(); positionHoverLock(); saveDrawingFrame(); onSizeChange?() + } else { setDrawingWidth(frame.width) } } @objc private func workspaceStateChanged(_ notification: Notification) { let reason: String diff --git a/apps/windows/Core/Preferences.cs b/apps/windows/Core/Preferences.cs index bdc6c31..968d691 100644 --- a/apps/windows/Core/Preferences.cs +++ b/apps/windows/Core/Preferences.cs @@ -12,6 +12,14 @@ public sealed record WindowFrame(double X, double Y, double Width, double Height public static class WindowPlacement { + public static WindowFrame FitPreservingSize(WindowFrame requested, WindowFrame workArea) + { + if (!requested.IsValid || !workArea.IsValid) throw new ArgumentException("Invalid display geometry"); + double scale = Math.Min(1, Math.Min(workArea.Width / requested.Width, workArea.Height / requested.Height)); + double width = requested.Width * scale, height = requested.Height * scale; + return new(Math.Clamp(requested.X, workArea.X, workArea.X + workArea.Width - width), + Math.Clamp(requested.Y, workArea.Y, workArea.Y + workArea.Height - height), width, height); + } // All coordinates must use the same monitor-local logical coordinate space. public static WindowFrame Fit(WindowFrame requested, WindowFrame workArea, double aspect, double minWidth, double maxWidth) { @@ -27,25 +35,30 @@ public static WindowFrame Fit(WindowFrame requested, WindowFrame workArea, doubl public sealed class Preferences { - public string Visualization { get; set; } = "pencil-flight"; + public string Visualization { get; set; } = "pixel-keyboard"; public string Color { get; set; } = "graphite"; private int transparency; public int Transparency { get => transparency; set => transparency = Math.Clamp(value, 0, 90); } public bool KeyboardEnabled { get; set; } public bool TrayAnimationEnabled { get; set; } = true; public Dictionary Frames { get; set; } = new(); + public WindowFrame? SkinFrame { get; set; } + public WindowFrame RestoreSkinFrame(string selectedId, WindowFrame fallback) => + SkinFrame is { IsValid: true } ? SkinFrame : + Frames.TryGetValue(selectedId, out var old) && old.IsValid ? old : fallback; public static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Miucs", "preferences.v1.json"); - public static Preferences Load(string path, bool allowPluginIds = false) + public static Preferences Load(string path, bool allowPluginIds = true) { try { var result = JsonSerializer.Deserialize(File.ReadAllText(path)); if (result == null) return new(); result.Frames ??= new(); - if (string.IsNullOrWhiteSpace(result.Visualization) || result.Visualization.Length > 128 || (!allowPluginIds && result.Visualization is not ("staff-note" or "pixel-keyboard" or "pencil-flight"))) result.Visualization = "pencil-flight"; + if (string.IsNullOrWhiteSpace(result.Visualization) || result.Visualization.Length > 128 || SkinCatalog.IsRetired(result.Visualization) || (!allowPluginIds && result.Visualization != "pixel-keyboard")) result.Visualization = "pixel-keyboard"; result.Color = Palette.ContainsKey(result.Color ?? "") ? result.Color! : "graphite"; foreach (var key in new List(result.Frames.Keys)) if (result.Frames[key] == null || !result.Frames[key].IsValid) result.Frames.Remove(key); + if (result.SkinFrame?.IsValid != true) result.SkinFrame = null; return result; } catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) { return new(); } diff --git a/apps/windows/Core/SkinCatalog.cs b/apps/windows/Core/SkinCatalog.cs new file mode 100644 index 0000000..c27cd43 --- /dev/null +++ b/apps/windows/Core/SkinCatalog.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Miucs.Windows; + +public static class SkinCatalog +{ + public const long MaximumPreviewBytes = 20_000_000; + public static bool IsRetired(string id) => id is "staff-note" or "pencil-flight"; + public static IEnumerable PreviewCandidates(string directory) + { + foreach (string extension in new[] { "gif", "png", "webp", "jpg", "jpeg" }) + { + string candidate = Path.Combine(directory, "preview." + extension); + bool valid; + try + { + var file = new FileInfo(candidate); + valid = file.Exists && file.Length is > 0 and <= MaximumPreviewBytes && + (file.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { valid = false; } + if (valid) yield return candidate; + } + } +} diff --git a/apps/windows/DesktopHost.cs b/apps/windows/DesktopHost.cs index 629315c..d57c265 100644 --- a/apps/windows/DesktopHost.cs +++ b/apps/windows/DesktopHost.cs @@ -19,12 +19,14 @@ namespace Miucs.Windows; internal sealed class DesktopHost : IDisposable { private readonly Application app; - private readonly Preferences preferences = Preferences.Load(Preferences.DefaultPath, PluginHost.Enabled); + private readonly Preferences preferences = Preferences.Load(Preferences.DefaultPath); private readonly AudioChannel audio = new(); private readonly KeyboardChannel keyboard = new(); private readonly LoopbackCapture capture; private readonly KeyboardCapture keyboardCapture; private readonly Window window; + private readonly SettingsWindow settingsWindow; + private string shellError = ""; private readonly HoverLockControl hoverLock; private readonly Grid root = new(); private readonly Border adjustment; @@ -56,7 +58,14 @@ public DesktopHost(Application app) capture = new LoopbackCapture(audio); keyboardCapture = new KeyboardCapture(keyboard); headSubscription = keyboard.Subscribe(); lastHeadTime = Now; - if (PluginHost.Enabled && File.Exists(Path.Combine(AppContext.BaseDirectory,"miucs_plugins.dll"))) pluginHost = new PluginHost(audio, keyboard); + try + { + if (File.Exists(Path.Combine(AppContext.BaseDirectory, "miucs_plugins.dll"))) + pluginHost = new PluginHost(audio, keyboard, include: id => !SkinCatalog.IsRetired(id)); + else shellError = "皮肤运行库未找到,请重新解压完整应用。"; + } + catch (Exception error) when (error is DllNotFoundException or BadImageFormatException or EntryPointNotFoundException or System.Text.Json.JsonException) + { shellError = "皮肤运行库加载失败:" + error.Message; } window = new Window { Title = "Miucs", WindowStyle = WindowStyle.None, AllowsTransparency = true, @@ -80,12 +89,12 @@ public DesktopHost(Application app) window.SizeChanged += (_, _) => { if (view != null) { view.Width = window.Width; view.Height = window.Height; } }; new WindowInteropHelper(window).EnsureHandle(); NativeWindow.SetInteractive(window, false); - SwitchVisualization(preferences.Visualization is "staff-note" or "pixel-keyboard" or "pencil-flight" || pluginHost?.Views.ContainsKey(preferences.Visualization)==true ? preferences.Visualization : "pencil-flight"); + SwitchVisualization(preferences.Visualization); tray = new Forms.NotifyIcon { Text = "Miucs", ContextMenuStrip = menu }; trayAnimation = new AnimatedTrayIcon(tray, () => AdvanceCatHead(Now)); tray.Visible = true; UpdateTrayAnimation(); - tray.DoubleClick += (_, _) => Dispatch(() => SetVisible(!isVisible)); + tray.DoubleClick += (_, _) => Dispatch(ShowSettings); menu.Opening += (_, _) => BuildMenu(); SystemEvents.PowerModeChanged += OnPowerModeChanged; SystemEvents.SessionSwitch += OnSessionSwitch; @@ -99,6 +108,44 @@ public DesktopHost(Application app) window.Show(); hoverLock = new HoverLockControl(window, () => isVisible && !suspended && !disposed, () => SetAdjusting(!adjusting)); StartRendering(); + settingsWindow = new SettingsWindow(new SettingsActions + { + SelectSkin = SwitchVisualization, SetVisible = SetVisible, TogglePosition = () => SetAdjusting(!adjusting), + SetScale = value => { SetFrame(CurrentFrame() with { Width = view.BaseWidth * value / 100 }, preserveSize: false); SaveFrame(); SavePreferences(); RefreshSettings(); }, + SetOpacity = value => { preferences.Transparency = (int)Math.Round(100 - value); view.Opacity = 1 - preferences.Transparency / 100d; ApplyColor(); SavePreferences(); RefreshSettings(); }, + SetKeyboardEnabled = value => { preferences.KeyboardEnabled = value; if (value && !suspended) keyboardCapture.Start(); else keyboardCapture.Stop(); ResetCatHead(); SavePreferences(); UpdateStatus(); }, + SetTrayAnimation = value => { preferences.TrayAnimationEnabled = value; UpdateTrayAnimation(); SavePreferences(); RefreshSettings(); }, + ReconnectAudio = () => { if (!suspended) capture.Reconnect(); }, + Rescan = RescanSkins, Reload = () => pluginHost?.ReloadActive(), OpenDirectory = OpenSkinDirectory + }); + app.MainWindow = settingsWindow; + ShowSettings(); + } + + internal void ShowSettings() { RefreshSettings(); settingsWindow.Present(); } + private void RefreshSettings() + { + if (settingsWindow == null) return; + using var subscription = keyboard.Subscribe(); + string keyboardStatus = subscription.Read(Now).State.ToString(); + settingsWindow.UpdateState(new SettingsState( + pluginHost?.Views.Values.ToArray() ?? Array.Empty(), view.Id, isVisible, adjusting, + window.Width / view.BaseWidth * 100, view.LayoutMinWidth / view.BaseWidth * 100, + view.LayoutMaxWidth / view.BaseWidth * 100, 100 - preferences.Transparency, + preferences.KeyboardEnabled, preferences.TrayAnimationEnabled, lastStatus, keyboardStatus, + string.Join("\n", new[] { shellError, capture.LastError, pluginHost?.Error }.Where(e => !string.IsNullOrEmpty(e))))); + } + private void OpenSkinDirectory() + { + try { Directory.CreateDirectory(PluginHost.UserDirectory); Process.Start(new ProcessStartInfo(PluginHost.UserDirectory) { UseShellExecute = true }); shellError = ""; } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) { shellError = "无法打开皮肤文件夹:" + e.Message; } + RefreshSettings(); + } + private void RescanSkins() + { + pluginHost?.Scan(); + if (pluginHost?.Views.ContainsKey(view.Id) != true) SwitchVisualization(preferences.Visualization); + RefreshSettings(); } private void Dispatch(Action action) @@ -107,38 +154,35 @@ private void Dispatch(Action action) } private void SwitchVisualization(string id) { - if (pluginHost != null && !pluginHost.Views.ContainsKey(id)) - id=pluginHost.Views.Keys.FirstOrDefault() ?? ""; + if (pluginHost?.Views.ContainsKey(id) != true) + id = pluginHost?.Views.ContainsKey("pixel-keyboard") == true ? "pixel-keyboard" : pluginHost?.Views.Keys.FirstOrDefault() ?? ""; if (view != null && view.Id == id) return; - if (id != "pencil-flight" && id != "staff-note" && id != "pixel-keyboard" && pluginHost == null) return; WindowFrame? previous = view == null ? null : CurrentFrame(); hoverLock?.Reset(); StopRendering(); - if (view != null) - { - SaveFrame(); root.Children.Remove(view); view.Dispose(); - } - view = pluginHost?.Views.ContainsKey(id) == true ? new PluginView(pluginHost,id) : pluginHost != null ? new EmptyPluginView() : id switch { "staff-note" => new StaffNoteView(), "pixel-keyboard" => new PixelKeyboardView(catHead), _ => new PencilFlightView() }; - if (id.Length>0) preferences.Visualization = id; - ApplyColor(); - view.Opacity = 1 - preferences.Transparency / 100d; + if (view != null) { SaveFrame(); root.Children.Remove(view); view.Dispose(); } + view = id.Length > 0 && pluginHost != null ? new PluginView(pluginHost, id) : new EmptyPluginView(); + if (id.Length > 0) preferences.Visualization = id; + ApplyColor(); view.Opacity = 1 - preferences.Transparency / 100d; root.Children.Insert(0, view); - var requested = preferences.Frames.TryGetValue(id, out var saved) ? saved : - new WindowFrame(previous?.X ?? 80, previous?.Y ?? 80, view.BaseWidth, view.BaseHeight); - SetFrame(requested); - SavePreferences(); + if (previous == null) + SetFrame(preferences.RestoreSkinFrame(id, new WindowFrame(80, 80, view.BaseWidth, view.BaseHeight))); + else { view.Width = window.Width; view.Height = window.Height; } + SaveFrame(); SavePreferences(); RefreshSettings(); if (isVisible) StartRendering(); } + private WindowFrame CurrentFrame() => new(window.Left, window.Top, window.Width, window.Height); - private void SetFrame(WindowFrame requested) + private void SetFrame(WindowFrame requested, bool preserveSize = true) { // Move first so MonitorFromWindow identifies the saved monitor; disconnected displays fall back to nearest. if (requested.IsValid) { window.Left = requested.X; window.Top = requested.Y; } - var frame = WindowPlacement.Fit(requested, NativeWindow.WorkArea(window), view.BaseWidth / view.BaseHeight, view.LayoutMinWidth, view.LayoutMaxWidth); + var area = NativeWindow.WorkArea(window); + var frame = preserveSize ? WindowPlacement.FitPreservingSize(requested, area) : WindowPlacement.Fit(requested, area, view.BaseWidth / view.BaseHeight, view.LayoutMinWidth, view.LayoutMaxWidth); window.Left = frame.X; window.Top = frame.Y; window.Width = frame.Width; window.Height = frame.Height; view.Width = frame.Width; view.Height = frame.Height; } - private void SaveFrame() { if (view != null && view.Id.Length>0 && CurrentFrame().IsValid) preferences.Frames[view.Id] = CurrentFrame(); } + private void SaveFrame() { if (view != null && view.Id.Length>0 && CurrentFrame().IsValid) preferences.SkinFrame = CurrentFrame(); } private void SavePreferences() { if (!preferences.Save(Preferences.DefaultPath) && tray != null) @@ -209,6 +253,7 @@ private void SetVisible(bool value) hoverLock?.Reset(); if (value) { SetFrame(CurrentFrame()); window.Show(); StartRendering(); } else { StopRendering(); SaveFrame(); window.Hide(); SavePreferences(); } + RefreshSettings(); } private void SetAdjusting(bool value) { @@ -218,6 +263,7 @@ private void SetAdjusting(bool value) NativeWindow.SetInteractive(window, value); if (value) SetVisible(true); else { root.ReleaseMouseCapture(); SaveFrame(); SavePreferences(); } + RefreshSettings(); } private void BeginDrag(object sender, MouseButtonEventArgs e) { @@ -232,71 +278,24 @@ private void Drag(object sender, MouseEventArgs e) var p = root.PointToScreen(e.GetPosition(root)); var dpi = VisualTreeHelper.GetDpi(window); double dx = (p.X - dragOrigin.Value.X) / dpi.DpiScaleX, dy = (p.Y - dragOrigin.Value.Y) / dpi.DpiScaleY; - if (resizing) SetFrame(dragFrame with { Width = Math.Max(1, dragFrame.Width + dx) }); + if (resizing) SetFrame(dragFrame with { Width = Math.Max(1, dragFrame.Width + dx) }, preserveSize: false); else { window.Left = dragFrame.X + dx; window.Top = dragFrame.Y + dy; } } private void EndDrag(object sender, MouseButtonEventArgs e) { if (dragOrigin == null) return; - root.ReleaseMouseCapture(); SetFrame(CurrentFrame()); SaveFrame(); SavePreferences(); + root.ReleaseMouseCapture(); SetFrame(CurrentFrame()); SaveFrame(); SavePreferences(); RefreshSettings(); } private void BuildMenu() { while (menu.Items.Count > 0) { var old = menu.Items[0]; menu.Items.RemoveAt(0); old.Dispose(); } - menu.Items.Add(new Forms.ToolStripMenuItem("Miucs · " + lastStatus) { Enabled = false }); - if (capture.LastError is string error) menu.Items.Add(new Forms.ToolStripMenuItem(error) { Enabled = false }); - using (var keys = keyboard.Subscribe()) - menu.Items.Add(new Forms.ToolStripMenuItem("键盘状态:" + keys.Read(Now).State) { Enabled = false }); - Add(menu.Items, isVisible ? "隐藏画面" : "显示画面", () => SetVisible(!isVisible)); - Add(menu.Items, "托盘猫摇头", () => - { - preferences.TrayAnimationEnabled = !preferences.TrayAnimationEnabled; - UpdateTrayAnimation(); SavePreferences(); - }, preferences.TrayAnimationEnabled); - var modes = new Forms.ToolStripMenuItem("可视化"); menu.Items.Add(modes); - if (pluginHost != null) - { - foreach (var plugin in pluginHost.Views.Values) { var id=plugin.Id;Add(modes.DropDownItems,plugin.Name,()=>SwitchVisualization(id),view.Id==id); } - Add(menu.Items,"重新扫描插件",()=>{pluginHost.Scan();if(view.Id.Length==0)SwitchVisualization(preferences.Visualization);}); - Add(menu.Items,"重新加载当前插件",()=>pluginHost.ReloadActive()); - } - else - { - Add(modes.DropDownItems, "铅笔飞鸟", () => SwitchVisualization("pencil-flight"), view.Id == "pencil-flight"); - Add(modes.DropDownItems, "五线谱音符", () => SwitchVisualization("staff-note"), view.Id == "staff-note"); - Add(modes.DropDownItems, "像素音乐键盘", () => SwitchVisualization("pixel-keyboard"), view.Id == "pixel-keyboard"); - } - var sizes = new Forms.ToolStripMenuItem("画面大小"); menu.Items.Add(sizes); - foreach (int percent in new[] { 10, 80, 100, 125, 150, 200 }) - Add(sizes.DropDownItems, $"{percent}%", () => { SetFrame(CurrentFrame() with { Width = view.BaseWidth * percent / 100d }); SaveFrame(); SavePreferences(); }); - var colors = new Forms.ToolStripMenuItem("线条颜色"); menu.Items.Add(colors); - foreach (var entry in Preferences.Palette) - Add(colors.DropDownItems, entry.Value.Name, () => { preferences.Color = entry.Key; ApplyColor(); SavePreferences(); }, preferences.Color == entry.Key); - var transparency = new Forms.ToolStripMenuItem($"画面不透明度({100 - preferences.Transparency}%)"); menu.Items.Add(transparency); - for (int value = 10; value <= 100; value += 10) - { - int percent = value; - Add(transparency.DropDownItems, percent == 100 ? "100%(原始效果)" : $"{percent}%", () => - { - preferences.Transparency = 100 - percent; - view.Opacity = 1 - preferences.Transparency / 100d; - ApplyColor(); - SavePreferences(); - }, preferences.Transparency == 100 - percent); - } - Add(menu.Items, adjusting ? "完成调整(恢复鼠标穿透)" : "调整位置与大小…", () => SetAdjusting(!adjusting)); + Add(menu.Items, "打开设置…", ShowSettings); + Add(menu.Items, isVisible ? "隐藏皮肤" : "显示皮肤", () => SetVisible(!isVisible)); + Add(menu.Items, adjusting ? "完成调整" : "调整位置与大小…", () => SetAdjusting(!adjusting)); menu.Items.Add(new Forms.ToolStripSeparator()); - Add(menu.Items, "重新连接音频", () => { if (!suspended) capture.Reconnect(); }); - Add(menu.Items, "键盘输入 · 星爆 / 猫爪", () => - { - preferences.KeyboardEnabled = !preferences.KeyboardEnabled; - if (preferences.KeyboardEnabled && !suspended) keyboardCapture.Start(); else keyboardCapture.Stop(); - ResetCatHead(); - SavePreferences(); - }, preferences.KeyboardEnabled); - menu.Items.Add(new Forms.ToolStripSeparator()); - Add(menu.Items, "退出", () => { Dispose(); app.Shutdown(); }); + Add(menu.Items, "退出 Miucs", () => { Dispose(); app.Shutdown(); }); } + private void Add(Forms.ToolStripItemCollection items, string text, Action action, bool check = false) { var item = new Forms.ToolStripMenuItem(text) { Checked = check }; @@ -314,7 +313,9 @@ private void UpdateStatus() }; if (pluginHost?.Error.Length > 0) lastStatus = "插件:" + pluginHost.Error; status.Text = pluginHost?.Error.Length > 0 ? lastStatus : input.State == AudioState.Running && input.Frame != null ? "" : lastStatus; - tray.Text = ("Miucs · " + lastStatus)[..Math.Min(63,8+lastStatus.Length)]; + string tooltip = "Miucs · " + lastStatus; + tray.Text = tooltip[..Math.Min(63, tooltip.Length)]; + RefreshSettings(); } private void Suspend() { @@ -356,6 +357,7 @@ public void Dispose() SystemParameters.StaticPropertyChanged -= OnSystemParametersChanged; headSubscription.Dispose(); capture.Dispose(); keyboardCapture.Dispose(); view.Dispose(); + settingsWindow?.CloseForExit(); tray.Visible = false; pluginHost?.Dispose(); trayAnimation.Dispose(); tray.Dispose(); menu.Dispose(); window.Close(); } } diff --git a/apps/windows/Program.cs b/apps/windows/Program.cs index ef2b7e1..0878644 100644 --- a/apps/windows/Program.cs +++ b/apps/windows/Program.cs @@ -14,11 +14,17 @@ public static int Main(string[] args) try { WindowsSmokeTests.Run(args.Length > 1 ? args[1] : "build/windows-smoke"); return 0; } catch (Exception error) { Console.Error.WriteLine(error); return 1; } } + using var reopen = new EventWaitHandle(false, EventResetMode.AutoReset, @"Local\Miucs.Windows.ShowSettings"); using var instance = new Mutex(true, @"Local\Miucs.Windows", out bool first); - if (!first) return 0; + if (!first) { reopen.Set(); return 0; } var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; using var host = new DesktopHost(app); - app.Run(); + var registration = ThreadPool.RegisterWaitForSingleObject(reopen, (_, _) => + { + if (!app.Dispatcher.HasShutdownStarted) app.Dispatcher.BeginInvoke(new Action(host.ShowSettings)); + }, null, Timeout.Infinite, false); + try { app.Run(); } + finally { registration.Unregister(null); } return 0; } } diff --git a/apps/windows/SettingsWindow.xaml b/apps/windows/SettingsWindow.xaml new file mode 100644 index 0000000..42733a4 --- /dev/null +++ b/apps/windows/SettingsWindow.xaml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +