9a8a8a02ad
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
279 lines
9.7 KiB
Swift
279 lines
9.7 KiB
Swift
import Cocoa
|
|
import FlutterMacOS
|
|
|
|
@main
|
|
class AppDelegate: FlutterAppDelegate {
|
|
private let winampChannelName = "winamp"
|
|
|
|
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
|
return true
|
|
}
|
|
|
|
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
|
|
return false
|
|
}
|
|
|
|
override func applicationWillFinishLaunching(_ notification: Notification) {
|
|
super.applicationWillFinishLaunching(notification)
|
|
guard !activateExistingInstanceOrTerminate() else { return }
|
|
// Register the channel handler eagerly so Dart's startup menu-state sync
|
|
// is not dropped. `applicationDidFinishLaunching` is never delivered in
|
|
// this Flutter version, so this is the earliest reliable point. The
|
|
// FlutterViewController is already attached here, but poll briefly as a
|
|
// fallback for other Flutter versions where it may not be yet.
|
|
registerWinampChannelWhenReady()
|
|
installMouseDiagnostics()
|
|
}
|
|
|
|
/// Temporary diagnostics: logs every mouse event macOS delivers to one of
|
|
/// our Flutter windows (frame, location, and whether the window moved the
|
|
/// mouse over its content). Used to trace why the drag loop gets no events
|
|
/// at launch while it works after the first resize.
|
|
private var mouseDiag: Any?
|
|
private func installMouseDiagnostics() {
|
|
guard mouseDiag == nil else { return }
|
|
mouseDiag = NSEvent.addLocalMonitorForEvents(
|
|
matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .mouseMoved]
|
|
) { event in
|
|
guard let window = event.window,
|
|
let vc = window.contentViewController as? FlutterViewController
|
|
else { return event }
|
|
let view = vc.view
|
|
let local = view.convert(event.locationInWindow, from: nil)
|
|
let inView = view.bounds.contains(local)
|
|
NSLog(
|
|
"mdiag %@ local=(%.1f,%.1f) inView=%@ frame=%@ wf=%@",
|
|
Self.name(for: event.type),
|
|
local.x, local.y,
|
|
inView ? "y" : "n",
|
|
NSStringFromRect(view.bounds),
|
|
NSStringFromRect(window.frame)
|
|
)
|
|
return event
|
|
}
|
|
}
|
|
|
|
private static func name(for type: NSEvent.EventType) -> String {
|
|
switch type {
|
|
case .leftMouseDown: return "down"
|
|
case .leftMouseDragged: return "drag"
|
|
case .leftMouseUp: return "up"
|
|
case .mouseMoved: return "move"
|
|
default: return "other"
|
|
}
|
|
}
|
|
|
|
/// Quits this instance if another copy of the app is already running, and
|
|
/// activates the existing one. Without this, a second launch stacks a second
|
|
/// set of windows (the windows are borderless with custom drag, so a stray
|
|
/// main window cannot even be closed). Returns `true` when this instance
|
|
/// terminated itself (i.e. the launch must stop), `false` to continue.
|
|
private func activateExistingInstanceOrTerminate() -> Bool {
|
|
let bundleId = Bundle.main.bundleIdentifier ?? ""
|
|
if bundleId.isEmpty { return false }
|
|
let selfPid = ProcessInfo.processInfo.processIdentifier
|
|
let others = NSRunningApplication.runningApplications(
|
|
withBundleIdentifier: bundleId
|
|
).filter { $0.processIdentifier != selfPid }
|
|
guard let other = others.first else { return false }
|
|
other.activate(options: [.activateIgnoringOtherApps])
|
|
NSApp.terminate(nil)
|
|
return true
|
|
}
|
|
|
|
private func registerWinampChannelWhenReady() {
|
|
guard winampChannel != nil else {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
self?.registerWinampChannelWhenReady()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
private var flutterController: FlutterViewController? {
|
|
return mainFlutterWindow?.contentViewController as? FlutterViewController
|
|
}
|
|
|
|
/// Lazily-created channel back to Dart. The handler is registered once so
|
|
/// Dart can report state (always-on-top, EQ/PL open, UI scale) to update
|
|
/// the matching menu checkmarks.
|
|
private var winampChannel: FlutterMethodChannel? {
|
|
if _winampChannel == nil, let controller = flutterController {
|
|
let channel = FlutterMethodChannel(
|
|
name: winampChannelName,
|
|
binaryMessenger: controller.engine.binaryMessenger
|
|
)
|
|
channel.setMethodCallHandler(handleMethodCall)
|
|
_winampChannel = channel
|
|
}
|
|
return _winampChannel
|
|
}
|
|
|
|
private var _winampChannel: FlutterMethodChannel?
|
|
|
|
/// Custom cursors registered by the Dart side, keyed by skin cursor name.
|
|
private var cursors: [String: NSCursor] = [:]
|
|
|
|
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
switch call.method {
|
|
case "setAlwaysOnTop":
|
|
if let args = call.arguments as? [String: Any],
|
|
let on = args["isAlwaysOnTop"] as? Bool {
|
|
setMenuItemState(id: "win-always-on-top", title: "Always on Top", on: on)
|
|
}
|
|
result(nil)
|
|
case "setWindowShade":
|
|
if let args = call.arguments as? [String: Any], let on = args["on"] as? Bool {
|
|
setMenuItemState(id: "win-window-shade", title: "Window Shade", on: on)
|
|
}
|
|
result(nil)
|
|
case "setEqOpen":
|
|
if let args = call.arguments as? [String: Any], let open = args["open"] as? Bool {
|
|
setMenuItemState(id: "win-equalizer", title: "Equalizer", on: open)
|
|
}
|
|
result(nil)
|
|
case "setPlOpen":
|
|
if let args = call.arguments as? [String: Any], let open = args["open"] as? Bool {
|
|
setMenuItemState(id: "win-playlist", title: "Playlist", on: open)
|
|
}
|
|
result(nil)
|
|
case "setUiScale":
|
|
if let args = call.arguments as? [String: Any], let scale = args["scale"] as? Double {
|
|
setUiScaleMenuState(scale)
|
|
}
|
|
result(nil)
|
|
case "getScreenBounds":
|
|
// The union of every screen's visible frame (menu bar and dock
|
|
// excluded), converted to window_manager's top-left origin coordinates.
|
|
// The primary screen's full frame is the origin reference, so displays
|
|
// arranged above it map to negative Y and displays below it to Y beyond
|
|
// the primary's height — the drag clamp/snap then span the whole desktop.
|
|
let screens = NSScreen.screens
|
|
if !screens.isEmpty {
|
|
let f = screens[0].frame
|
|
var union = screens[0].visibleFrame
|
|
for screen in screens.dropFirst() {
|
|
union = union.union(screen.visibleFrame)
|
|
}
|
|
result([
|
|
"x": union.origin.x,
|
|
"y": f.height - union.origin.y - union.height,
|
|
"width": union.width,
|
|
"height": union.height,
|
|
])
|
|
} else {
|
|
result(nil)
|
|
}
|
|
case "getMouseLocation":
|
|
// The global cursor position, flipped into window_manager's top-left
|
|
// origin space (same convention as getScreenBounds and setPosition), so
|
|
// the custom drag loop can position windows from absolute screen points
|
|
// instead of window-local pointer coordinates.
|
|
let mouse = NSEvent.mouseLocation
|
|
let screenHeight = NSScreen.screens.first?.frame.height ?? mouse.y
|
|
result(["x": mouse.x, "y": screenHeight - mouse.y])
|
|
case "registerCursor":
|
|
if let args = call.arguments as? [String: Any],
|
|
let name = args["name"] as? String,
|
|
let pngData = (args["png"] as? FlutterStandardTypedData)?.data,
|
|
let image = NSImage(data: pngData) {
|
|
let hotX = args["hotspotX"] as? Int ?? 0
|
|
let hotY = args["hotspotY"] as? Int ?? 0
|
|
cursors[name] = NSCursor(image: image, hotSpot: NSPoint(x: hotX, y: hotY))
|
|
}
|
|
result(nil)
|
|
case "setCursor":
|
|
if let args = call.arguments as? [String: Any], let name = args["name"] as? String {
|
|
if let cursor = cursors[name] {
|
|
cursor.set()
|
|
} else {
|
|
NSCursor.arrow.set()
|
|
}
|
|
}
|
|
result(nil)
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
private func setMenuItemState(id: String, title: String, on: Bool) {
|
|
menuItem(id: id, title: title)?.state = on ? .on : .off
|
|
}
|
|
|
|
private func setUiScaleMenuState(_ scale: Double) {
|
|
let targetId: String
|
|
let targetTitle: String
|
|
if scale == 1.0 {
|
|
targetId = "win-ui-scale-1x"
|
|
targetTitle = "1.0x"
|
|
} else if scale == 2.0 {
|
|
targetId = "win-ui-scale-2x"
|
|
targetTitle = "2.0x"
|
|
} else {
|
|
targetId = "win-ui-scale-3x"
|
|
targetTitle = "3.0x"
|
|
}
|
|
for (id, title) in [
|
|
("win-ui-scale-1x", "1.0x"),
|
|
("win-ui-scale-2x", "2.0x"),
|
|
("win-ui-scale-3x", "3.0x"),
|
|
] {
|
|
setMenuItemState(id: id, title: title, on: id == targetId && title == targetTitle)
|
|
}
|
|
}
|
|
|
|
private func menuItem(id: String, title: String) -> NSMenuItem? {
|
|
guard let menu = NSApplication.shared.mainMenu else { return nil }
|
|
return findMenuItem(id: id, title: title, in: menu)
|
|
}
|
|
|
|
private func findMenuItem(id: String, title: String, in menu: NSMenu) -> NSMenuItem? {
|
|
for item in menu.items {
|
|
if item.identifier?.rawValue == id || item.title == title {
|
|
return item
|
|
}
|
|
if let submenu = item.submenu,
|
|
let found = findMenuItem(id: id, title: title, in: submenu) {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func send(_ method: String, _ arguments: Any? = nil) {
|
|
winampChannel?.invokeMethod(method, arguments: arguments)
|
|
}
|
|
|
|
@IBAction func toggleAlwaysOnTop(_ sender: NSMenuItem) {
|
|
send("toggleAlwaysOnTop")
|
|
}
|
|
|
|
@IBAction func toggleWindowShade(_ sender: NSMenuItem) {
|
|
send("toggleWindowShade")
|
|
}
|
|
|
|
@IBAction func toggleEqualizer(_ sender: NSMenuItem) {
|
|
send("toggleEq")
|
|
}
|
|
|
|
@IBAction func togglePlaylist(_ sender: NSMenuItem) {
|
|
send("togglePlaylist")
|
|
}
|
|
|
|
@IBAction func setUIScale1x(_ sender: NSMenuItem) {
|
|
send("setUIScale", ["scale": 1.0])
|
|
}
|
|
|
|
@IBAction func setUIScale2x(_ sender: NSMenuItem) {
|
|
send("setUIScale", ["scale": 2.0])
|
|
}
|
|
|
|
@IBAction func setUIScale3x(_ sender: NSMenuItem) {
|
|
send("setUIScale", ["scale": 3.0])
|
|
}
|
|
|
|
@IBAction func importSkin(_ sender: NSMenuItem) {
|
|
send("importSkin")
|
|
}
|
|
}
|