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

95 lines
3.5 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
//
// SeekBar.swift
// MusicPlugin
//
// Draggable progress bar used at the bottom of the expanded card.
// Visual rules:
// - 4pt tall normally, grows to 6pt on hover.
// - Background track white @ 10%.
// - Filled portion lime #CAFF00.
// - 12x12 white knob shows only on hover or during drag.
// - Dragging updates a local preview; onEnded commits via seek(to:).
//
import SwiftUI
struct SeekBar: View {
/// Current progress in 0...1 driven by NowPlayingState.
let progress: Double
/// Track total duration (seconds), needed to compute the final
/// seek target when the drag ends.
let duration: TimeInterval
/// Called with an absolute time (seconds) when the user finishes
/// dragging. Not called mid-drag.
let onSeek: (TimeInterval) -> Void
// Lime brand accent.
private static let lime = Color(
red: 0xCA / 255.0,
green: 0xFF / 255.0,
blue: 0x00 / 255.0
)
@State private var dragProgress: Double?
@State private var isHovering = false
var body: some View {
GeometryReader { geometry in
let displayed = min(max(dragProgress ?? progress, 0), 1)
let trackHeight: CGFloat = (isHovering || dragProgress != nil) ? 6 : 4
let knobSize: CGFloat = 12
let knobCenterX = CGFloat(displayed) * geometry.size.width
let knobVisible = isHovering || dragProgress != nil
ZStack(alignment: .leading) {
// Background track
RoundedRectangle(cornerRadius: trackHeight / 2)
.fill(Color.white.opacity(0.10))
.frame(height: trackHeight)
// Filled portion
RoundedRectangle(cornerRadius: trackHeight / 2)
.fill(Self.lime)
.frame(
width: max(0, geometry.size.width * CGFloat(displayed)),
height: trackHeight
)
.animation(.easeInOut(duration: 0.2), value: displayed)
// Knob
Circle()
.fill(Color.white)
.frame(width: knobSize, height: knobSize)
.shadow(color: .black.opacity(0.25), radius: 2, y: 1)
.offset(x: max(0, knobCenterX - knobSize / 2))
.opacity(knobVisible ? 1 : 0)
.animation(.easeOut(duration: 0.15), value: knobVisible)
}
.frame(height: 16, alignment: .center)
.contentShape(Rectangle())
.onHover { hovering in
isHovering = hovering
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard geometry.size.width > 0 else { return }
let next = min(max(value.location.x / geometry.size.width, 0), 1)
dragProgress = next
}
.onEnded { value in
guard geometry.size.width > 0, duration > 0 else {
dragProgress = nil
return
}
let next = min(max(value.location.x / geometry.size.width, 0), 1)
dragProgress = nil
onSeek(duration * next)
}
)
.animation(.easeInOut(duration: 0.2), value: trackHeight)
}
.frame(height: 16)
}
}