904b9b3d-c0eb-42f3-acef-958.../Sources/ui/HeaderSlotView.swift

122 lines
4.1 KiB
Swift
Raw Permalink Normal View History

v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
//
// HeaderSlotView.swift
// MusicPlugin
//
// Tiny 20x20 icon that lives in the notch header slot. Shows:
// - A music.note SF symbol tinted by play state.
// - 3 thin "pseudo-spectrum" bars breathing next to it when playing.
// - 1.15x hover scale + pointing-hand cursor.
// Tap posts .openPlugin (userInfo = ["pluginId": "music-player"]) so
// the host app knows to slide the music plugin view into focus.
//
import AppKit
import SwiftUI
/// Notification the host listens for to switch the notch panel to a
/// specific plugin. Matches the existing openPlugin contract used by
/// other plugins.
extension Notification.Name {
static let openPlugin = Notification.Name("com.codeisland.openPlugin")
}
struct HeaderSlotView: View {
@ObservedObject private var state = NowPlayingState.shared
@State private var isHovered = false
private static let lime = Color(
red: 0xCA / 255.0,
green: 0xFF / 255.0,
blue: 0x00 / 255.0
)
var body: some View {
Button(action: openPluginPanel) {
HStack(spacing: 2) {
Image(systemName: "music.note")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(iconColor)
// Pseudo spectrum: 3 tiny bars to the right of the note
if shouldShowBars {
PseudoSpectrumBars(isPlaying: state.isPlaying, tint: iconColor)
.frame(width: 7, height: 10)
}
}
.frame(width: 20, height: 20)
.contentShape(Rectangle())
.scaleEffect(isHovered ? 1.15 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHovered)
.animation(.easeInOut(duration: 0.2), value: state.isPlaying)
}
.buttonStyle(.plain)
.onHover { hovering in
isHovered = hovering
if hovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
.frame(width: 20, height: 20)
.fixedSize()
}
private var shouldShowBars: Bool {
// Hide bars entirely in "idle" (no track at all), feels cleaner.
// Show them (static) in paused, (breathing) in playing.
!state.title.isEmpty
}
private var iconColor: Color {
if state.isPlaying { return Self.lime }
if !state.title.isEmpty { return Color.white.opacity(0.5) }
return Color.white.opacity(0.25)
}
private func openPluginPanel() {
NotificationCenter.default.post(
name: .openPlugin,
object: nil,
userInfo: ["pluginId": "music-player"]
)
}
}
// MARK: - Pseudo spectrum (3 bars)
/// 3 tiny vertical bars that "breathe" when music is playing. Heights
/// are driven by sin() against a TimelineView clock so we animate
/// smoothly without an NSTimer.
private struct PseudoSpectrumBars: View {
let isPlaying: Bool
let tint: Color
var body: some View {
TimelineView(.animation(minimumInterval: 0.15, paused: !isPlaying)) { context in
let time = context.date.timeIntervalSinceReferenceDate
HStack(alignment: .center, spacing: 1.5) {
ForEach(0..<3, id: \.self) { i in
RoundedRectangle(cornerRadius: 0.75)
.fill(tint)
.frame(width: 1.5, height: barHeight(index: i, time: time))
}
}
}
}
/// Height in 2...8pt. Each bar gets a distinct phase so they
/// don't rise in sync. When paused we hold a quiet mid-height.
private func barHeight(index: Int, time: TimeInterval) -> CGFloat {
guard isPlaying else { return 4 }
// Each bar has its own frequency + phase offset so they feel
// independent. Frequencies chosen by ear to look "alive" but
// not frantic at 0.15s updates.
let freq = [2.1, 3.3, 2.7][index]
let phase = [0.0, 1.1, 2.4][index]
let raw = sin(time * freq + phase) // -1...1
let normalized = (raw + 1) * 0.5 // 0...1
return 2 + CGFloat(normalized) * 6 // 2...8
}
}