v2.0.1: compact UI + AppleScript timeouts

UI polish (ExpandedView rewrite):
- Horizontal hero row: 128×128 album art on the left, title/artist/
  album + source badge on the right. Half the vertical footprint of
  v2.0.0 at the same info density.
- Dropped the "NOW PLAYING" eyebrow (redundant with the source badge).
- Tightened outer padding 28 → 20, inter-section spacing 22-28 → 16.
- Play button 56 → 48, prev/next 44 → 36; still 44pt tap targets via
  the invisible hover frame.

AppleScript timeout fix (the real bug, unrelated to UI):
- Every fetch() script now wraps the `tell application` block in
  `with timeout of N seconds` (2s for Spotify/Music, 3s for Chrome).
- Music.app hanging was stalling the entire source router for 120s
  (default AppleEvent timeout), freezing the UI on stale Spotify data.
- runAppleScript() suppresses error -1712 (errAETimeout) alongside
  existing -600 / -1728 — expected, not noisy.

Info.plist: CFBundleShortVersionString 2.0.0 → 2.0.1,
CFBundleVersion 2 → 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-04-19 11:09:29 +08:00
parent 64daaa3371
commit c67ddd0024
8 changed files with 147 additions and 122 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ build/
*.swiftmodule *.swiftmodule
*.dSYM *.dSYM
.build/ .build/
icon/

View File

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.0.0</string> <string>2.0.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>2</string> <string>3</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>MusicPlugin.MusicPlugin</string> <string>MusicPlugin.MusicPlugin</string>
</dict> </dict>

View File

@ -35,7 +35,7 @@ final class MusicPlugin: NSObject, MioPlugin {
var id: String { "music-player" } var id: String { "music-player" }
var name: String { "Music Player" } var name: String { "Music Player" }
var icon: String { "music.note" } var icon: String { "music.note" }
var version: String { "2.0.0" } var version: String { "2.0.1" }
func activate() { func activate() {
NSLog("[mio-plugin-music] activate") NSLog("[mio-plugin-music] activate")

View File

@ -432,7 +432,11 @@ func runAppleScript(_ source: String, tag: String) async -> String? {
let result = script.executeAndReturnError(&errorDict) let result = script.executeAndReturnError(&errorDict)
if let errorDict { if let errorDict {
let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0 let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0
if num != -600 && num != -1728 { // Silence known-expected error codes:
// -600 = application not running
// -1712 = errAETimeout (our `with timeout of N seconds` firing)
// -1728 = AEError, generic Apple Event descriptor issue
if num != -600 && num != -1712 && num != -1728 {
let msg = errorDict[NSAppleScript.errorMessage] as? String ?? "<no message>" let msg = errorDict[NSAppleScript.errorMessage] as? String ?? "<no message>"
NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)") NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
} }

View File

@ -18,10 +18,17 @@ enum AppleMusicAppleScript {
// MARK: - Fetch // MARK: - Fetch
static func fetch() async -> AppleScriptTrackInfo? { static func fetch() async -> AppleScriptTrackInfo? {
// Music.app occasionally stalls its AppleEvent handler (observed in
// macOS 15.x when the app is mid-sync). Without an explicit timeout
// each fetch inherits the 120-second default, which freezes the whole
// source router for 2 minutes. `with timeout of 2 seconds` raises
// errAETimeout (-1712) if Music doesn't respond quickly, and our
// Swift layer turns that into nil so the router can move on.
let script = """ let script = """
tell application "System Events" tell application "System Events"
if not (exists process "Music") then return "NOT_RUNNING" if not (exists process "Music") then return "NOT_RUNNING"
end tell end tell
with timeout of 2 seconds
tell application "Music" tell application "Music"
if player state is playing or player state is paused then if player state is playing or player state is paused then
set trackName to name of current track set trackName to name of current track
@ -36,6 +43,7 @@ enum AppleMusicAppleScript {
return "NOT_PLAYING" return "NOT_PLAYING"
end if end if
end tell end tell
end timeout
""" """
guard let raw = await runAppleScript(script, tag: "music") else { return nil } guard let raw = await runAppleScript(script, tag: "music") else { return nil }

View File

@ -34,10 +34,13 @@ enum ChromeWebSource {
// MARK: - Fetch // MARK: - Fetch
static func fetch() async -> ChromeTrackInfo? { static func fetch() async -> ChromeTrackInfo? {
// Iterating N tabs × JS injection is O(N) and can be slow with many
// tabs; cap at 3 seconds so the router doesn't stall the whole cycle.
let script = """ let script = """
tell application "System Events" tell application "System Events"
if not (exists process "Google Chrome") then return "NOT_RUNNING" if not (exists process "Google Chrome") then return "NOT_RUNNING"
end tell end tell
with timeout of 3 seconds
tell application "Google Chrome" tell application "Google Chrome"
set playingTitle to "" set playingTitle to ""
set playingURL to "" set playingURL to ""
@ -70,6 +73,7 @@ enum ChromeWebSource {
if playingURL is not "" then return "PLAYING_TAB||" & playingTitle & "||" & playingURL & "||" & playingInfo if playingURL is not "" then return "PLAYING_TAB||" & playingTitle & "||" & playingURL & "||" & playingInfo
return "NOT_FOUND" return "NOT_FOUND"
end tell end tell
end timeout
""" """
guard let raw = await runAppleScript(script, tag: "chrome") else { return nil } guard let raw = await runAppleScript(script, tag: "chrome") else { return nil }

View File

@ -34,10 +34,15 @@ enum SpotifyAppleScript {
// MARK: - Fetch // MARK: - Fetch
static func fetch() async -> AppleScriptTrackInfo? { static func fetch() async -> AppleScriptTrackInfo? {
// `with timeout of 2 seconds` bounds the tell block; if Spotify hangs,
// AppleScript raises errAETimeout (-1712) and our Swift layer returns
// nil so the source router can move on instead of the serial queue
// stalling for the default 120-second AppleEvent timeout.
let script = """ let script = """
tell application "System Events" tell application "System Events"
if not (exists process "Spotify") then return "NOT_RUNNING" if not (exists process "Spotify") then return "NOT_RUNNING"
end tell end tell
with timeout of 2 seconds
tell application "Spotify" tell application "Spotify"
if player state is playing or player state is paused then if player state is playing or player state is paused then
set trackName to name of current track set trackName to name of current track
@ -56,6 +61,7 @@ enum SpotifyAppleScript {
return "NOT_PLAYING" return "NOT_PLAYING"
end if end if
end tell end tell
end timeout
""" """
guard let raw = await runAppleScript(script, tag: "spotify") else { return nil } guard let raw = await runAppleScript(script, tag: "spotify") else { return nil }
@ -87,6 +93,7 @@ enum SpotifyAppleScript {
tell application "System Events" tell application "System Events"
if not (exists process "Spotify") then return "" if not (exists process "Spotify") then return ""
end tell end tell
with timeout of 2 seconds
tell application "Spotify" tell application "Spotify"
try try
return artwork url of current track return artwork url of current track
@ -94,6 +101,7 @@ enum SpotifyAppleScript {
return "" return ""
end try end try
end tell end tell
end timeout
""" """
guard let urlString = await runAppleScript(script, tag: "spotify-art"), guard let urlString = await runAppleScript(script, tag: "spotify-art"),
!urlString.isEmpty, !urlString.isEmpty,

View File

@ -9,13 +9,18 @@
// 3. Nothing playing (title.isEmpty) // 3. Nothing playing (title.isEmpty)
// 4. Now playing (default) // 4. Now playing (default)
// //
// Background uses an extracted tint from the album art (fades to // v2.0.1 layout: compact horizontal hero inspired by SuperIsland's
// near-black). Control surface, text and spacing follow the // NowPlaying. Medium album art on the left, metadata + source badge
// MioIsland aesthetic: // on the right, progress + times inline below, transport controls
// - #0A0A0A near-black base // at bottom. Half the vertical footprint of v2.0.0 for the same
// - white text with opacity tiers (1.0 / 0.7 / 0.5 / 0.3) // information density.
// - lime #CAFF00 as the single accent color //
// - 16pt corner on the big card, 8pt on small chips // Background uses a tint extracted from the album art (fades into
// a near-black base). Palette:
// #0A0A0A near-black base
// white text tiers 1.0 / 0.75 / 0.45 / 0.3
// lime #CAFF00 as the single accent color
// 16pt corner on the big art, 8pt on small chips
// //
import AppKit import AppKit
@ -45,7 +50,7 @@ struct ExpandedView: View {
.ignoresSafeArea() .ignoresSafeArea()
content content
.padding(28) .padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@ -99,50 +104,45 @@ struct ExpandedView: View {
} }
} }
// MARK: - Playing card // MARK: - Playing card compact horizontal layout
private var playingCard: some View { private var playingCard: some View {
VStack(spacing: 0) { VStack(spacing: 16) {
// Header row: small eyebrow + source badge. // Hero row: album art left, metadata + source badge right
HStack(alignment: .firstTextBaseline) { HStack(alignment: .top, spacing: 14) {
Text(L10n.nowPlayingHeading.uppercased()) albumArt
.font(.system(size: 10, weight: .bold))
.tracking(2) VStack(alignment: .leading, spacing: 4) {
.foregroundColor(Self.ink.opacity(0.5)) // Source badge, flush right with the artwork top
HStack {
Spacer() Spacer()
sourceBadge sourceBadge
} }
.padding(.bottom, 22)
// Album art (big, centered)
albumArt
.padding(.bottom, 24)
// Title + artist + album
VStack(spacing: 8) {
Text(state.title.isEmpty ? L10n.unknownTitle : state.title) Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 18, weight: .semibold))
.foregroundColor(Self.ink) .foregroundColor(Self.ink)
.multilineTextAlignment(.center)
.lineLimit(2) .lineLimit(2)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist) Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
.font(.system(size: 14, weight: .regular)) .font(.system(size: 13, weight: .regular))
.foregroundColor(Self.ink.opacity(0.75)) .foregroundColor(Self.ink.opacity(0.75))
.lineLimit(1) .lineLimit(1)
if !state.album.isEmpty { if !state.album.isEmpty {
Text(state.album) Text(state.album)
.font(.system(size: 12, weight: .regular)) .font(.system(size: 11, weight: .regular))
.foregroundColor(Self.ink.opacity(0.45)) .foregroundColor(Self.ink.opacity(0.45))
.lineLimit(1) .lineLimit(1)
} }
}
.padding(.horizontal, 8)
.padding(.bottom, 28)
// Seek bar + time labels Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// Progress + times inline on one row
VStack(spacing: 6) { VStack(spacing: 6) {
SeekBar( SeekBar(
progress: state.progress, progress: state.progress,
@ -161,12 +161,12 @@ struct ExpandedView: View {
.foregroundColor(Self.ink.opacity(0.5)) .foregroundColor(Self.ink.opacity(0.5))
} }
} }
.padding(.bottom, 24)
// Transport controls // Transport controls
transportControls transportControls
.padding(.top, 2)
} }
.frame(maxWidth: 520) .frame(maxWidth: 460)
} }
private var albumArt: some View { private var albumArt: some View {
@ -175,33 +175,33 @@ struct ExpandedView: View {
Image(nsImage: art) Image(nsImage: art)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: 260, height: 260) .frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} else { } else {
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white.opacity(0.08)) .fill(Color.white.opacity(0.08))
.frame(width: 260, height: 260) .frame(width: 128, height: 128)
.overlay( .overlay(
Image(systemName: "music.note") Image(systemName: "music.note")
.font(.system(size: 64, weight: .light)) .font(.system(size: 34, weight: .light))
.foregroundColor(Self.ink.opacity(0.35)) .foregroundColor(Self.ink.opacity(0.35))
) )
} }
} }
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10) .shadow(color: .black.opacity(0.35), radius: 14, x: 0, y: 6)
} }
private var sourceBadge: some View { private var sourceBadge: some View {
HStack(spacing: 6) { HStack(spacing: 5) {
Circle() Circle()
.fill(state.isPlaying ? Self.lime : Self.ink.opacity(0.4)) .fill(state.isPlaying ? Self.lime : Self.ink.opacity(0.4))
.frame(width: 6, height: 6) .frame(width: 5, height: 5)
Text(displaySourceName) Text(displaySourceName)
.font(.system(size: 10, weight: .medium)) .font(.system(size: 10, weight: .medium))
.foregroundColor(Self.ink.opacity(0.7)) .foregroundColor(Self.ink.opacity(0.7))
} }
.padding(.horizontal, 10) .padding(.horizontal, 8)
.padding(.vertical, 5) .padding(.vertical, 3)
.background( .background(
Capsule().fill(Color.white.opacity(0.08)) Capsule().fill(Color.white.opacity(0.08))
) )
@ -212,25 +212,25 @@ struct ExpandedView: View {
} }
private var transportControls: some View { private var transportControls: some View {
HStack(spacing: 40) { HStack(spacing: 28) {
transportButton( transportButton(
symbol: "backward.fill", symbol: "backward.fill",
size: 20, size: 16,
tooltip: L10n.previousTooltip tooltip: L10n.previousTooltip
) { ) {
state.previousTrack() state.previousTrack()
} }
// Play / pause. Larger, accent button. // Play / pause accent button, slightly smaller than v2.0.0 (48 vs 56)
Button(action: { state.togglePlayPause() }) { Button(action: { state.togglePlayPause() }) {
ZStack { ZStack {
Circle() Circle()
.fill(Self.lime) .fill(Self.lime)
.frame(width: 56, height: 56) .frame(width: 48, height: 48)
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill") Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22, weight: .bold)) .font(.system(size: 18, weight: .bold))
.foregroundColor(.black) .foregroundColor(.black)
.offset(x: state.isPlaying ? 0 : 2) // optical nudge for play .offset(x: state.isPlaying ? 0 : 2) // optical nudge for play glyph
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@ -239,7 +239,7 @@ struct ExpandedView: View {
transportButton( transportButton(
symbol: "forward.fill", symbol: "forward.fill",
size: 20, size: 16,
tooltip: L10n.nextTooltip tooltip: L10n.nextTooltip
) { ) {
state.nextTrack() state.nextTrack()
@ -264,23 +264,23 @@ struct ExpandedView: View {
// MARK: - Empty card (nothing playing) // MARK: - Empty card (nothing playing)
private var emptyCard: some View { private var emptyCard: some View {
VStack(spacing: 14) { VStack(spacing: 12) {
Image(systemName: "music.note") Image(systemName: "music.note")
.font(.system(size: 44, weight: .light)) .font(.system(size: 40, weight: .light))
.foregroundColor(Self.ink.opacity(0.3)) .foregroundColor(Self.ink.opacity(0.3))
Text(L10n.nothingPlaying) Text(L10n.nothingPlaying)
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundColor(Self.ink.opacity(0.7)) .foregroundColor(Self.ink.opacity(0.7))
Text(L10n.nothingPlayingHint) Text(L10n.nothingPlayingHint)
.font(.system(size: 12, weight: .regular)) .font(.system(size: 11, weight: .regular))
.foregroundColor(Self.ink.opacity(0.4)) .foregroundColor(Self.ink.opacity(0.4))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
.padding(32) .padding(24)
.frame(maxWidth: 360) .frame(maxWidth: 320)
} }
// MARK: - Warning cards (host outdated / chinese app detected) // MARK: - Warning cards (host outdated / chinese app detected)
@ -291,31 +291,31 @@ struct ExpandedView: View {
hint: String, hint: String,
tint: Color tint: Color
) -> some View { ) -> some View {
VStack(spacing: 14) { VStack(spacing: 12) {
Image(systemName: symbol) Image(systemName: symbol)
.font(.system(size: 40, weight: .regular)) .font(.system(size: 36, weight: .regular))
.foregroundColor(tint) .foregroundColor(tint)
Text(title) Text(title)
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundColor(Self.ink.opacity(0.9)) .foregroundColor(Self.ink.opacity(0.9))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text(hint) Text(hint)
.font(.system(size: 12, weight: .regular)) .font(.system(size: 11, weight: .regular))
.foregroundColor(Self.ink.opacity(0.55)) .foregroundColor(Self.ink.opacity(0.55))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineSpacing(2) .lineSpacing(2)
} }
.padding(28) .padding(22)
.frame(maxWidth: 380) .frame(maxWidth: 340)
.background( .background(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.white.opacity(0.04)) .fill(Color.white.opacity(0.04))
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 0.5) .stroke(Color.white.opacity(0.08), lineWidth: 0.5)
) )
} }
@ -359,7 +359,7 @@ private struct TransportIconButton: View {
Image(systemName: symbol) Image(systemName: symbol)
.font(.system(size: size, weight: .semibold)) .font(.system(size: size, weight: .semibold))
.foregroundColor(isHovered ? Self.lime : Color.white.opacity(0.75)) .foregroundColor(isHovered ? Self.lime : Color.white.opacity(0.75))
.frame(width: 44, height: 44) .frame(width: 36, height: 36)
.background( .background(
Circle() Circle()
.fill(Color.white.opacity(isHovered ? 0.10 : 0.0)) .fill(Color.white.opacity(isHovered ? 0.10 : 0.0))