mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
244 lines
7.4 KiB
Swift
244 lines
7.4 KiB
Swift
|
|
//
|
|||
|
|
// DesktopLyricsWindow.swift
|
|||
|
|
// MioIsland Music Plugin
|
|||
|
|
//
|
|||
|
|
// Floating desktop "lyrics" overlay window — always-on-top, movable by
|
|||
|
|
// dragging anywhere on its background, dismissable with Escape. Three
|
|||
|
|
// style variants the user can cycle through:
|
|||
|
|
//
|
|||
|
|
// • Bar (Model 1) — narrow single-line pill, 520×64
|
|||
|
|
// • Karaoke (Model 2) — two-line card, current + next, 560×170
|
|||
|
|
// • Cinema (Model 3) — 3-line large typography, 640×260
|
|||
|
|
//
|
|||
|
|
// All variants derive their title/artist/progress/isPlaying from
|
|||
|
|
// NowPlayingState.shared. Lyrics data is NOT yet piped in (MediaRemote
|
|||
|
|
// adapter doesn't expose lyric timings and there's no public API on
|
|||
|
|
// Apple Music / Spotify), so the "lyric line" slot shows a placeholder
|
|||
|
|
// string. When a lyric source lands, only the `currentLine` /
|
|||
|
|
// `nextLine` / `prevLine` computed properties need to change.
|
|||
|
|
//
|
|||
|
|
// Window is one per app — `DesktopLyricsWindow.shared` serves toggles
|
|||
|
|
// from the ExpandedView's pin button.
|
|||
|
|
//
|
|||
|
|
|
|||
|
|
import AppKit
|
|||
|
|
import SwiftUI
|
|||
|
|
import Combine
|
|||
|
|
|
|||
|
|
// MARK: - Style enum
|
|||
|
|
|
|||
|
|
enum LyricsStyle: String, CaseIterable, Identifiable {
|
|||
|
|
case bar
|
|||
|
|
case karaoke
|
|||
|
|
case cinema
|
|||
|
|
|
|||
|
|
var id: String { rawValue }
|
|||
|
|
|
|||
|
|
var windowSize: CGSize {
|
|||
|
|
switch self {
|
|||
|
|
case .bar: return CGSize(width: 520, height: 64)
|
|||
|
|
case .karaoke: return CGSize(width: 560, height: 170)
|
|||
|
|
case .cinema: return CGSize(width: 640, height: 260)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var displayName: String {
|
|||
|
|
switch self {
|
|||
|
|
case .bar: return L10n.isChinese ? "单行胶囊" : "Bar"
|
|||
|
|
case .karaoke: return L10n.isChinese ? "双行卡拉" : "Karaoke"
|
|||
|
|
case .cinema: return L10n.isChinese ? "影院大字" : "Cinema"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Window
|
|||
|
|
|
|||
|
|
@MainActor
|
|||
|
|
final class DesktopLyricsWindow {
|
|||
|
|
static let shared = DesktopLyricsWindow()
|
|||
|
|
|
|||
|
|
private var window: NSWindow?
|
|||
|
|
private let stylePrefsKey = "mio.music.lyricsStyle.v1"
|
|||
|
|
|
|||
|
|
private init() {}
|
|||
|
|
|
|||
|
|
var isVisible: Bool {
|
|||
|
|
window?.isVisible ?? false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func toggle() {
|
|||
|
|
if isVisible {
|
|||
|
|
hide()
|
|||
|
|
} else {
|
|||
|
|
show()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func show() {
|
|||
|
|
if let existing = window {
|
|||
|
|
existing.orderFront(nil)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let style = loadStyle()
|
|||
|
|
let root = DesktopLyricsRootView(initialStyle: style) { [weak self] newStyle in
|
|||
|
|
self?.saveStyle(newStyle)
|
|||
|
|
self?.resizeTo(newStyle.windowSize)
|
|||
|
|
}
|
|||
|
|
let host = NSHostingView(rootView: root)
|
|||
|
|
|
|||
|
|
let win = DraggableBorderlessWindow(
|
|||
|
|
contentRect: NSRect(origin: .zero, size: style.windowSize),
|
|||
|
|
styleMask: [.borderless],
|
|||
|
|
backing: .buffered,
|
|||
|
|
defer: false
|
|||
|
|
)
|
|||
|
|
win.contentView = host
|
|||
|
|
win.backgroundColor = .clear
|
|||
|
|
win.isOpaque = false
|
|||
|
|
win.hasShadow = true
|
|||
|
|
win.isMovableByWindowBackground = true
|
|||
|
|
win.level = .floating // always-on-top
|
|||
|
|
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|||
|
|
win.ignoresMouseEvents = false // we need clicks for controls
|
|||
|
|
win.isReleasedWhenClosed = false
|
|||
|
|
|
|||
|
|
// Default placement — bottom center of the primary screen, 80pt
|
|||
|
|
// above the Dock. User can drag from there.
|
|||
|
|
if let screen = NSScreen.main {
|
|||
|
|
let f = screen.visibleFrame
|
|||
|
|
let x = f.midX - style.windowSize.width / 2
|
|||
|
|
let y = f.minY + 80
|
|||
|
|
win.setFrameOrigin(NSPoint(x: x, y: y))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
win.orderFront(nil)
|
|||
|
|
window = win
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func hide() {
|
|||
|
|
window?.orderOut(nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func close() {
|
|||
|
|
window?.close()
|
|||
|
|
window = nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func resizeTo(_ size: CGSize) {
|
|||
|
|
guard let win = window else { return }
|
|||
|
|
var frame = win.frame
|
|||
|
|
frame.origin.y += (frame.size.height - size.height) // anchor to bottom edge
|
|||
|
|
frame.size = size
|
|||
|
|
win.setFrame(frame, display: true, animate: true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func loadStyle() -> LyricsStyle {
|
|||
|
|
if let raw = UserDefaults.standard.string(forKey: stylePrefsKey),
|
|||
|
|
let s = LyricsStyle(rawValue: raw) {
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
return .bar
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func saveStyle(_ style: LyricsStyle) {
|
|||
|
|
UserDefaults.standard.set(style.rawValue, forKey: stylePrefsKey)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Borderless NSWindows can become key (so Escape works) and swallow the
|
|||
|
|
// mouse events on our control buttons while still letting drag-background
|
|||
|
|
// move the window.
|
|||
|
|
private final class DraggableBorderlessWindow: NSWindow {
|
|||
|
|
override var canBecomeKey: Bool { true }
|
|||
|
|
override var canBecomeMain: Bool { false }
|
|||
|
|
|
|||
|
|
override func keyDown(with event: NSEvent) {
|
|||
|
|
// Escape → hide (consistent with other floating overlays).
|
|||
|
|
if event.keyCode == 53 {
|
|||
|
|
DesktopLyricsWindow.shared.hide()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
super.keyDown(with: event)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Root view
|
|||
|
|
|
|||
|
|
/// Hosts the style picker + the currently selected variant. State-changing
|
|||
|
|
/// props go up to the window via the `onStyleChange` callback so the
|
|||
|
|
/// window can resize.
|
|||
|
|
private struct DesktopLyricsRootView: View {
|
|||
|
|
@ObservedObject private var state = NowPlayingState.shared
|
|||
|
|
@State private var style: LyricsStyle
|
|||
|
|
let onStyleChange: (LyricsStyle) -> Void
|
|||
|
|
|
|||
|
|
init(initialStyle: LyricsStyle, onStyleChange: @escaping (LyricsStyle) -> Void) {
|
|||
|
|
_style = State(initialValue: initialStyle)
|
|||
|
|
self.onStyleChange = onStyleChange
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
ZStack(alignment: .topTrailing) {
|
|||
|
|
Group {
|
|||
|
|
switch style {
|
|||
|
|
case .bar: LyricsBarView()
|
|||
|
|
case .karaoke: LyricsKaraokeView()
|
|||
|
|
case .cinema: LyricsCinemaView()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.transition(.opacity)
|
|||
|
|
|
|||
|
|
// Tiny style-cycle chip in the very corner — minimal, only
|
|||
|
|
// visible on hover to stay out of the way.
|
|||
|
|
StyleCyclerChip(current: style) { next in
|
|||
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|||
|
|
style = next
|
|||
|
|
}
|
|||
|
|
onStyleChange(next)
|
|||
|
|
}
|
|||
|
|
.padding(8)
|
|||
|
|
}
|
|||
|
|
.frame(
|
|||
|
|
width: style.windowSize.width,
|
|||
|
|
height: style.windowSize.height
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Cycle chip
|
|||
|
|
|
|||
|
|
private struct StyleCyclerChip: View {
|
|||
|
|
let current: LyricsStyle
|
|||
|
|
let onChange: (LyricsStyle) -> Void
|
|||
|
|
|
|||
|
|
@State private var isHovered = false
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
Button {
|
|||
|
|
let all = LyricsStyle.allCases
|
|||
|
|
let idx = all.firstIndex(of: current) ?? 0
|
|||
|
|
onChange(all[(idx + 1) % all.count])
|
|||
|
|
} label: {
|
|||
|
|
HStack(spacing: 5) {
|
|||
|
|
Image(systemName: "rectangle.3.offgrid")
|
|||
|
|
.font(.system(size: 9, weight: .semibold))
|
|||
|
|
Text(current.displayName)
|
|||
|
|
.font(.system(size: 9, weight: .medium, design: .monospaced))
|
|||
|
|
}
|
|||
|
|
.foregroundColor(.white.opacity(isHovered ? 0.9 : 0.5))
|
|||
|
|
.padding(.horizontal, 8)
|
|||
|
|
.padding(.vertical, 4)
|
|||
|
|
.background(
|
|||
|
|
Capsule().fill(Color.black.opacity(isHovered ? 0.45 : 0.25))
|
|||
|
|
)
|
|||
|
|
.overlay(
|
|||
|
|
Capsule().strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
.buttonStyle(.plain)
|
|||
|
|
.onHover { isHovered = $0 }
|
|||
|
|
.help(L10n.lyricsStyleLabel)
|
|||
|
|
}
|
|||
|
|
}
|