mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
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:
parent
64daaa3371
commit
c67ddd0024
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@ build/
|
||||
*.swiftmodule
|
||||
*.dSYM
|
||||
.build/
|
||||
icon/
|
||||
|
||||
@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.0</string>
|
||||
<string>2.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<string>3</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>MusicPlugin.MusicPlugin</string>
|
||||
</dict>
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 ?? "<no message>"
|
||||
NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user