From c67ddd00241c9e4c16b8938be237e0b575f07be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E7=BF=94=E5=AE=87?= Date: Sun, 19 Apr 2026 11:09:29 +0800 Subject: [PATCH] v2.0.1: compact UI + AppleScript timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 1 + Info.plist | 4 +- Sources/MusicPlugin.swift | 2 +- Sources/NowPlayingState.swift | 6 +- Sources/sources/AppleMusicAppleScript.swift | 36 +++-- Sources/sources/ChromeWebSource.swift | 4 + Sources/sources/SpotifyAppleScript.swift | 58 +++---- Sources/ui/ExpandedView.swift | 158 ++++++++++---------- 8 files changed, 147 insertions(+), 122 deletions(-) diff --git a/.gitignore b/.gitignore index 33f779a..bf5d0b8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ *.swiftmodule *.dSYM .build/ +icon/ diff --git a/Info.plist b/Info.plist index c89da35..e5b3478 100644 --- a/Info.plist +++ b/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2.0.0 + 2.0.1 CFBundleVersion - 2 + 3 NSPrincipalClass MusicPlugin.MusicPlugin diff --git a/Sources/MusicPlugin.swift b/Sources/MusicPlugin.swift index 14e120a..5c40b58 100644 --- a/Sources/MusicPlugin.swift +++ b/Sources/MusicPlugin.swift @@ -35,7 +35,7 @@ final class MusicPlugin: NSObject, MioPlugin { var id: String { "music-player" } var name: String { "Music Player" } var icon: String { "music.note" } - var version: String { "2.0.0" } + var version: String { "2.0.1" } func activate() { NSLog("[mio-plugin-music] activate") diff --git a/Sources/NowPlayingState.swift b/Sources/NowPlayingState.swift index 86961ca..c6f15a6 100644 --- a/Sources/NowPlayingState.swift +++ b/Sources/NowPlayingState.swift @@ -432,7 +432,11 @@ func runAppleScript(_ source: String, tag: String) async -> String? { let result = script.executeAndReturnError(&errorDict) if let errorDict { 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 ?? "" NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)") } diff --git a/Sources/sources/AppleMusicAppleScript.swift b/Sources/sources/AppleMusicAppleScript.swift index b78747b..a968a5d 100644 --- a/Sources/sources/AppleMusicAppleScript.swift +++ b/Sources/sources/AppleMusicAppleScript.swift @@ -18,24 +18,32 @@ enum AppleMusicAppleScript { // MARK: - Fetch 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 = """ tell application "System Events" if not (exists process "Music") then return "NOT_RUNNING" end tell - tell application "Music" - if player state is playing or player state is paused then - set trackName to name of current track - set trackArtist to artist of current track - set trackAlbum to album of current track - set trackDuration to duration of current track - set trackPosition to player position - set stateString to "PAUSED" - if player state is playing then set stateString to "PLAYING" - return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & trackDuration & "||" & trackPosition - else - return "NOT_PLAYING" - end if - end tell + with timeout of 2 seconds + tell application "Music" + if player state is playing or player state is paused then + set trackName to name of current track + set trackArtist to artist of current track + set trackAlbum to album of current track + set trackDuration to duration of current track + set trackPosition to player position + set stateString to "PAUSED" + if player state is playing then set stateString to "PLAYING" + return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & trackDuration & "||" & trackPosition + else + return "NOT_PLAYING" + end if + end tell + end timeout """ guard let raw = await runAppleScript(script, tag: "music") else { return nil } diff --git a/Sources/sources/ChromeWebSource.swift b/Sources/sources/ChromeWebSource.swift index 23cfa85..3566280 100644 --- a/Sources/sources/ChromeWebSource.swift +++ b/Sources/sources/ChromeWebSource.swift @@ -34,10 +34,13 @@ enum ChromeWebSource { // MARK: - Fetch 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 = """ tell application "System Events" if not (exists process "Google Chrome") then return "NOT_RUNNING" end tell + with timeout of 3 seconds tell application "Google Chrome" set playingTitle to "" set playingURL to "" @@ -70,6 +73,7 @@ enum ChromeWebSource { if playingURL is not "" then return "PLAYING_TAB||" & playingTitle & "||" & playingURL & "||" & playingInfo return "NOT_FOUND" end tell + end timeout """ guard let raw = await runAppleScript(script, tag: "chrome") else { return nil } diff --git a/Sources/sources/SpotifyAppleScript.swift b/Sources/sources/SpotifyAppleScript.swift index 3f00872..ffee773 100644 --- a/Sources/sources/SpotifyAppleScript.swift +++ b/Sources/sources/SpotifyAppleScript.swift @@ -34,28 +34,34 @@ enum SpotifyAppleScript { // MARK: - Fetch 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 = """ tell application "System Events" if not (exists process "Spotify") then return "NOT_RUNNING" end tell - tell application "Spotify" - if player state is playing or player state is paused then - set trackName to name of current track - set trackArtist to artist of current track - set trackAlbum to album of current track - set trackDuration to duration of current track - set trackPosition to player position - set stateString to "PAUSED" - if player state is playing then set stateString to "PLAYING" - set artURL to "" - try - set artURL to artwork url of current track - end try - return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & (trackDuration / 1000) & "||" & trackPosition & "||" & artURL - else - return "NOT_PLAYING" - end if - end tell + with timeout of 2 seconds + tell application "Spotify" + if player state is playing or player state is paused then + set trackName to name of current track + set trackArtist to artist of current track + set trackAlbum to album of current track + set trackDuration to duration of current track + set trackPosition to player position + set stateString to "PAUSED" + if player state is playing then set stateString to "PLAYING" + set artURL to "" + try + set artURL to artwork url of current track + end try + return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & (trackDuration / 1000) & "||" & trackPosition & "||" & artURL + else + return "NOT_PLAYING" + end if + end tell + end timeout """ guard let raw = await runAppleScript(script, tag: "spotify") else { return nil } @@ -87,13 +93,15 @@ enum SpotifyAppleScript { tell application "System Events" if not (exists process "Spotify") then return "" end tell - tell application "Spotify" - try - return artwork url of current track - on error - return "" - end try - end tell + with timeout of 2 seconds + tell application "Spotify" + try + return artwork url of current track + on error + return "" + end try + end tell + end timeout """ guard let urlString = await runAppleScript(script, tag: "spotify-art"), !urlString.isEmpty, diff --git a/Sources/ui/ExpandedView.swift b/Sources/ui/ExpandedView.swift index b8ad392..f415ab3 100644 --- a/Sources/ui/ExpandedView.swift +++ b/Sources/ui/ExpandedView.swift @@ -9,13 +9,18 @@ // 3. Nothing playing (title.isEmpty) // 4. Now playing (default) // -// Background uses an extracted tint from the album art (fades to -// near-black). Control surface, text and spacing follow the -// MioIsland aesthetic: -// - #0A0A0A near-black base -// - white text with opacity tiers (1.0 / 0.7 / 0.5 / 0.3) -// - lime #CAFF00 as the single accent color -// - 16pt corner on the big card, 8pt on small chips +// v2.0.1 layout: compact horizontal hero inspired by SuperIsland's +// NowPlaying. Medium album art on the left, metadata + source badge +// on the right, progress + times inline below, transport controls +// at bottom. Half the vertical footprint of v2.0.0 for the same +// information density. +// +// 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 @@ -45,7 +50,7 @@ struct ExpandedView: View { .ignoresSafeArea() content - .padding(28) + .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } .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 { - VStack(spacing: 0) { - // Header row: small eyebrow + source badge. - HStack(alignment: .firstTextBaseline) { - Text(L10n.nowPlayingHeading.uppercased()) - .font(.system(size: 10, weight: .bold)) - .tracking(2) - .foregroundColor(Self.ink.opacity(0.5)) - Spacer() - sourceBadge - } - .padding(.bottom, 22) + VStack(spacing: 16) { + // Hero row: album art left, metadata + source badge right + HStack(alignment: .top, spacing: 14) { + albumArt - // Album art (big, centered) - albumArt - .padding(.bottom, 24) + VStack(alignment: .leading, spacing: 4) { + // Source badge, flush right with the artwork top + HStack { + Spacer() + sourceBadge + } - // Title + artist + album - VStack(spacing: 8) { - Text(state.title.isEmpty ? L10n.unknownTitle : state.title) - .font(.system(size: 22, weight: .semibold)) - .foregroundColor(Self.ink) - .multilineTextAlignment(.center) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + Text(state.title.isEmpty ? L10n.unknownTitle : state.title) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(Self.ink) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) - Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist) - .font(.system(size: 14, weight: .regular)) - .foregroundColor(Self.ink.opacity(0.75)) - .lineLimit(1) - - if !state.album.isEmpty { - Text(state.album) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(Self.ink.opacity(0.45)) + Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(Self.ink.opacity(0.75)) .lineLimit(1) - } - } - .padding(.horizontal, 8) - .padding(.bottom, 28) - // Seek bar + time labels + if !state.album.isEmpty { + Text(state.album) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(Self.ink.opacity(0.45)) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Progress + times inline on one row VStack(spacing: 6) { SeekBar( progress: state.progress, @@ -161,12 +161,12 @@ struct ExpandedView: View { .foregroundColor(Self.ink.opacity(0.5)) } } - .padding(.bottom, 24) // Transport controls transportControls + .padding(.top, 2) } - .frame(maxWidth: 520) + .frame(maxWidth: 460) } private var albumArt: some View { @@ -175,33 +175,33 @@ struct ExpandedView: View { Image(nsImage: art) .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 260, height: 260) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .frame(width: 128, height: 128) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } else { - RoundedRectangle(cornerRadius: 16, style: .continuous) + RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.white.opacity(0.08)) - .frame(width: 260, height: 260) + .frame(width: 128, height: 128) .overlay( Image(systemName: "music.note") - .font(.system(size: 64, weight: .light)) + .font(.system(size: 34, weight: .light)) .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 { - HStack(spacing: 6) { + HStack(spacing: 5) { Circle() .fill(state.isPlaying ? Self.lime : Self.ink.opacity(0.4)) - .frame(width: 6, height: 6) + .frame(width: 5, height: 5) Text(displaySourceName) .font(.system(size: 10, weight: .medium)) .foregroundColor(Self.ink.opacity(0.7)) } - .padding(.horizontal, 10) - .padding(.vertical, 5) + .padding(.horizontal, 8) + .padding(.vertical, 3) .background( Capsule().fill(Color.white.opacity(0.08)) ) @@ -212,25 +212,25 @@ struct ExpandedView: View { } private var transportControls: some View { - HStack(spacing: 40) { + HStack(spacing: 28) { transportButton( symbol: "backward.fill", - size: 20, + size: 16, tooltip: L10n.previousTooltip ) { state.previousTrack() } - // Play / pause. Larger, accent button. + // Play / pause — accent button, slightly smaller than v2.0.0 (48 vs 56) Button(action: { state.togglePlayPause() }) { ZStack { Circle() .fill(Self.lime) - .frame(width: 56, height: 56) + .frame(width: 48, height: 48) Image(systemName: state.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 22, weight: .bold)) + .font(.system(size: 18, weight: .bold)) .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) @@ -239,7 +239,7 @@ struct ExpandedView: View { transportButton( symbol: "forward.fill", - size: 20, + size: 16, tooltip: L10n.nextTooltip ) { state.nextTrack() @@ -264,23 +264,23 @@ struct ExpandedView: View { // MARK: - Empty card (nothing playing) private var emptyCard: some View { - VStack(spacing: 14) { + VStack(spacing: 12) { Image(systemName: "music.note") - .font(.system(size: 44, weight: .light)) + .font(.system(size: 40, weight: .light)) .foregroundColor(Self.ink.opacity(0.3)) Text(L10n.nothingPlaying) - .font(.system(size: 18, weight: .semibold)) + .font(.system(size: 16, weight: .semibold)) .foregroundColor(Self.ink.opacity(0.7)) Text(L10n.nothingPlayingHint) - .font(.system(size: 12, weight: .regular)) + .font(.system(size: 11, weight: .regular)) .foregroundColor(Self.ink.opacity(0.4)) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } - .padding(32) - .frame(maxWidth: 360) + .padding(24) + .frame(maxWidth: 320) } // MARK: - Warning cards (host outdated / chinese app detected) @@ -291,31 +291,31 @@ struct ExpandedView: View { hint: String, tint: Color ) -> some View { - VStack(spacing: 14) { + VStack(spacing: 12) { Image(systemName: symbol) - .font(.system(size: 40, weight: .regular)) + .font(.system(size: 36, weight: .regular)) .foregroundColor(tint) Text(title) - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: 15, weight: .semibold)) .foregroundColor(Self.ink.opacity(0.9)) .multilineTextAlignment(.center) Text(hint) - .font(.system(size: 12, weight: .regular)) + .font(.system(size: 11, weight: .regular)) .foregroundColor(Self.ink.opacity(0.55)) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .lineSpacing(2) } - .padding(28) - .frame(maxWidth: 380) + .padding(22) + .frame(maxWidth: 340) .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) + RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color.white.opacity(0.04)) ) .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) + RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(Color.white.opacity(0.08), lineWidth: 0.5) ) } @@ -359,7 +359,7 @@ private struct TransportIconButton: View { Image(systemName: symbol) .font(.system(size: size, weight: .semibold)) .foregroundColor(isHovered ? Self.lime : Color.white.opacity(0.75)) - .frame(width: 44, height: 44) + .frame(width: 36, height: 36) .background( Circle() .fill(Color.white.opacity(isHovered ? 0.10 : 0.0))