Skip to content

Instantly share code, notes, and snippets.

@GeneralD
Created March 14, 2026 16:25
Show Gist options
  • Select an option

  • Save GeneralD/531bd74387abf1d602b1cef4c891a720 to your computer and use it in GitHub Desktop.

Select an option

Save GeneralD/531bd74387abf1d602b1cef4c891a720 to your computer and use it in GitHub Desktop.
backdrop — original single-file Swift script (before rearchitecture)
#!/usr/bin/env swift
// Desktop backdrop - lyrics overlay, video wallpaper, and more
// Uses the same lyrics cache as now-playing
// NOTE: Must run with swift interpreter (not swiftc) for MediaRemote access
import AppKit
import AVFoundation
import Observation
import SQLite3
import SwiftUI
// MARK: - Shared Types (cache-compatible with now-playing)
struct LyricsResult: Codable {
let id: Int?
let trackName: String?
let artistName: String?
let albumName: String?
let duration: Double?
let instrumental: Bool?
let plainLyrics: String?
let syncedLyrics: String?
static let empty = LyricsResult(id: nil, trackName: nil, artistName: nil, albumName: nil, duration: nil, instrumental: nil, plainLyrics: nil, syncedLyrics: nil)
}
struct TimedLine {
let time: TimeInterval
let text: String
}
enum LyricsContent {
case timed([TimedLine])
case plain([String])
init?(from result: LyricsResult?) {
if let synced = result?.syncedLyrics.flatMap({ Self.parseSyncedLyrics($0) }), !synced.isEmpty {
self = .timed(synced)
} else if let plain = result?.plainLyrics {
self = .plain(plain.components(separatedBy: "\n"))
} else {
return nil
}
}
private static func parseSyncedLyrics(_ raw: String) -> [TimedLine] {
let re = #/\[(\d+):(\d+(?:\.\d+)?)\]\s*(.*)/#
return raw.split(separator: "\n").compactMap { line in
guard let match = try? re.firstMatch(in: line),
let min = Double(String(match.1)),
let sec = Double(String(match.2)) else { return nil }
return TimedLine(time: min * 60 + sec,
text: String(match.3).trimmingCharacters(in: .whitespaces))
}
}
}
typealias SearchCandidate = (title: String, artist: String)
// MARK: - MediaRemote
struct NowPlayingInfo {
let title: String?
let artist: String?
let artworkData: Data?
let duration: TimeInterval?
let rawElapsed: TimeInterval?
let playbackRate: Double
let timestamp: Date?
var elapsed: TimeInterval? {
rawElapsed.map { base in
guard let ts = timestamp else { return base }
return base + playbackRate * Date().timeIntervalSince(ts)
}
}
}
class MediaRemoteClient {
private typealias Fn = @convention(c) (DispatchQueue, @escaping (CFDictionary?) -> Void) -> Void
private let function: Fn
init?() {
let path = "/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote"
guard let handle = dlopen(path, RTLD_NOW),
let sym = dlsym(handle, "MRMediaRemoteGetNowPlayingInfo") else { return nil }
function = unsafeBitCast(sym, to: Fn.self)
}
func poll(completion: @escaping (NowPlayingInfo?) -> Void) {
function(DispatchQueue.main) { dict in
completion((dict as? [String: Any]).map(Self.parse))
}
}
private static func parse(_ dict: [String: Any]) -> NowPlayingInfo {
NowPlayingInfo(
title: dict["kMRMediaRemoteNowPlayingInfoTitle"] as? String,
artist: dict["kMRMediaRemoteNowPlayingInfoArtist"] as? String,
artworkData: dict["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data,
duration: dict["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval,
rawElapsed: dict["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval,
playbackRate: dict["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1.0,
timestamp: dict["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date
)
}
}
// MARK: - LRCLIB API
struct LRCLib {
private func httpGet(_ url: URL) -> Data? {
var request = URLRequest(url: url)
request.setValue("now-playing/1.0", forHTTPHeaderField: "User-Agent")
let semaphore = DispatchSemaphore(value: 0)
var responseData: Data?
URLSession.shared.dataTask(with: request) { data, _, _ in
responseData = data
semaphore.signal()
}.resume()
semaphore.wait()
return responseData
}
private func buildURL(_ path: String, queryItems: [URLQueryItem]) -> URL? {
var components = URLComponents(string: "https://lrclib.net/api/\(path)")!
components.queryItems = queryItems
return components.url
}
func get(title: String, artist: String, duration: TimeInterval?) -> LyricsResult? {
let items = [
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "artist_name", value: artist),
duration.map { URLQueryItem(name: "duration", value: String(Int($0))) },
].compactMap { $0 }
guard let url = buildURL("get", queryItems: items), let data = httpGet(url) else { return nil }
return try? JSONDecoder().decode(LyricsResult.self, from: data)
}
func search(query: String) -> LyricsResult? {
guard let url = buildURL("search", queryItems: [URLQueryItem(name: "q", value: query)]),
let data = httpGet(url),
let results = try? JSONDecoder().decode([LyricsResult].self, from: data) else { return nil }
return results.first { $0.syncedLyrics != nil }
?? results.first { $0.plainLyrics != nil }
}
}
// MARK: - Title Parsing
struct TitleParser {
private let noiseWords: Set<String> = [
"mv", "pv", "official video", "official music video", "music video",
"lyric video", "lyrics video", "the first take", "audio", "official audio",
"full ver.", "full version", "short ver.", "short version", "topic", "vevo",
]
private let bracketPatterns = [
"【[^】]*】", "「[^」]*」", "『[^』]*』",
"\\([^)]*\\)", "([^)]*)", "\\[[^\\]]*\\]",
]
func stripBrackets(_ s: String) -> String {
bracketPatterns.reduce(s) { result, pattern in
result.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}.trimmingCharacters(in: .whitespaces)
}
func isNoise(_ s: String) -> Bool {
let trimmed = s.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return true }
return noiseWords.contains(trimmed.lowercased())
}
func splitTitle(_ title: String) -> [String] {
[" - ", " / ", " | ", "|"]
.reduce([title]) { parts, sep in parts.flatMap { $0.components(separatedBy: sep) } }
.map { stripBrackets($0).trimmingCharacters(in: .whitespaces) }
.filter { !isNoise($0) }
}
func generateCandidates(title: String, artist: String) -> [SearchCandidate] {
let parts = splitTitle(title)
let cleaned = stripBrackets(title)
let artistUsable = !isNoise(artist)
let candidates: [SearchCandidate] = [
artistUsable ? [(cleaned, artist)] : [],
parts.count >= 2 ? [(parts[1], parts[0]), (parts[0], parts[1])] : [],
artistUsable ? parts.filter { $0 != cleaned }.map { ($0, artist) } : [],
parts.count == 1 && !artistUsable ? [(parts[0], "")] : [],
].flatMap { $0 }
var seen = Set<String>()
return candidates.filter { c in
seen.insert("\(c.title.lowercased())|\(c.artist.lowercased())").inserted
}
}
}
// MARK: - Local Data Store (SQLite Cache)
struct LocalDataStore {
private let db: OpaquePointer?
private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
private func bindOptionalText(_ stmt: OpaquePointer?, _ index: Int32, _ value: String?) {
_ = value.map { sqlite3_bind_text(stmt, index, $0, -1, transient) } ?? sqlite3_bind_null(stmt, index)
}
private func bindOptionalDouble(_ stmt: OpaquePointer?, _ index: Int32, _ value: Double?) {
_ = value.map { sqlite3_bind_double(stmt, index, $0) } ?? sqlite3_bind_null(stmt, index)
}
private func bindOptionalInt(_ stmt: OpaquePointer?, _ index: Int32, _ value: Int32?) {
_ = value.map { sqlite3_bind_int(stmt, index, $0) } ?? sqlite3_bind_null(stmt, index)
}
init() {
let cacheDir = URL(fileURLWithPath:
ProcessInfo.processInfo.environment["XDG_CACHE_HOME"] ?? "\(NSHomeDirectory())/.cache")
var handle: OpaquePointer?
guard sqlite3_open(cacheDir.appendingPathComponent("lyrics.db").path, &handle) == SQLITE_OK else {
db = nil
return
}
db = handle
// Migrate: drop legacy single-table schema
sqlite3_exec(db, "DROP TABLE IF EXISTS lyrics", nil, nil, nil)
sqlite3_exec(db, """
CREATE TABLE IF NOT EXISTS lrclib_tracks (
id INTEGER PRIMARY KEY,
track_name TEXT,
artist_name TEXT,
album_name TEXT,
duration REAL,
instrumental INTEGER,
plain_lyrics TEXT,
synced_lyrics TEXT
)
""", nil, nil, nil)
sqlite3_exec(db, """
CREATE TABLE IF NOT EXISTS lyrics_lookup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
artist TEXT NOT NULL,
lrclib_id INTEGER NOT NULL REFERENCES lrclib_tracks(id),
UNIQUE (title, artist)
)
""", nil, nil, nil)
// Migrate: remove legacy now-playing cache directory
try? FileManager.default.removeItem(at: cacheDir.appendingPathComponent("now-playing"))
}
func read(title: String, artist: String) -> LyricsResult? {
guard let db else { return nil }
var stmt: OpaquePointer?
defer { sqlite3_finalize(stmt) }
let sql = """
SELECT t.id, t.track_name, t.artist_name, t.album_name, t.duration,
t.instrumental, t.plain_lyrics, t.synced_lyrics
FROM lyrics_lookup l JOIN lrclib_tracks t ON l.lrclib_id = t.id
WHERE l.title = ? AND l.artist = ?
"""
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
sqlite3_bind_text(stmt, 1, title, -1, transient)
sqlite3_bind_text(stmt, 2, artist, -1, transient)
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
return LyricsResult(
id: sqlite3_column_type(stmt, 0) != SQLITE_NULL ? Int(sqlite3_column_int64(stmt, 0)) : nil,
trackName: sqlite3_column_text(stmt, 1).map(String.init(cString:)),
artistName: sqlite3_column_text(stmt, 2).map(String.init(cString:)),
albumName: sqlite3_column_text(stmt, 3).map(String.init(cString:)),
duration: sqlite3_column_type(stmt, 4) != SQLITE_NULL ? sqlite3_column_double(stmt, 4) : nil,
instrumental: sqlite3_column_type(stmt, 5) != SQLITE_NULL ? sqlite3_column_int(stmt, 5) != 0 : nil,
plainLyrics: sqlite3_column_text(stmt, 6).map(String.init(cString:)),
syncedLyrics: sqlite3_column_text(stmt, 7).map(String.init(cString:))
)
}
func write(title: String, artist: String, lyrics: LyricsResult) {
guard let db, let lrclibId = lyrics.id else { return }
var trackStmt: OpaquePointer?
defer { sqlite3_finalize(trackStmt) }
let trackSQL = """
INSERT OR REPLACE INTO lrclib_tracks
(id, track_name, artist_name, album_name, duration, instrumental, plain_lyrics, synced_lyrics)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"""
guard sqlite3_prepare_v2(db, trackSQL, -1, &trackStmt, nil) == SQLITE_OK else { return }
sqlite3_bind_int64(trackStmt, 1, Int64(lrclibId))
bindOptionalText(trackStmt, 2, lyrics.trackName)
bindOptionalText(trackStmt, 3, lyrics.artistName)
bindOptionalText(trackStmt, 4, lyrics.albumName)
bindOptionalDouble(trackStmt, 5, lyrics.duration)
bindOptionalInt(trackStmt, 6, lyrics.instrumental.map { $0 ? Int32(1) : Int32(0) })
bindOptionalText(trackStmt, 7, lyrics.plainLyrics)
bindOptionalText(trackStmt, 8, lyrics.syncedLyrics)
guard sqlite3_step(trackStmt) == SQLITE_DONE else { return }
var lookupStmt: OpaquePointer?
defer { sqlite3_finalize(lookupStmt) }
guard sqlite3_prepare_v2(db, "INSERT OR REPLACE INTO lyrics_lookup (title, artist, lrclib_id) VALUES (?, ?, ?)", -1, &lookupStmt, nil) == SQLITE_OK else { return }
sqlite3_bind_text(lookupStmt, 1, title, -1, transient)
sqlite3_bind_text(lookupStmt, 2, artist, -1, transient)
sqlite3_bind_int64(lookupStmt, 3, Int64(lrclibId))
sqlite3_step(lookupStmt)
}
}
// MARK: - Remote Data Store (LRCLIB API)
struct RemoteDataStore {
private let api = LRCLib()
private let parser = TitleParser()
func fetch(title: String, artist: String, duration: TimeInterval?) -> LyricsResult? {
let candidates = parser.generateCandidates(title: title, artist: artist)
let getResults = candidates
.filter { !$0.artist.isEmpty }
.compactMap { api.get(title: $0.title, artist: $0.artist, duration: duration) }
.filter { $0.plainLyrics != nil }
// Prefer results with synced lyrics over plain-only
return getResults.first { $0.syncedLyrics != nil }
?? getResults.first
?? {
let searchResults = candidates
.map { $0.artist.isEmpty ? $0.title : "\($0.title) \($0.artist)" }
.compactMap { api.search(query: $0) }
.filter { $0.plainLyrics != nil }
return searchResults.first { $0.syncedLyrics != nil }
?? searchResults.first
}()
}
}
// MARK: - Lyric Repository
struct LyricRepository {
private let local = LocalDataStore()
private let remote = RemoteDataStore()
func fetch(title: String, artist: String, duration: TimeInterval?) -> LyricsResult {
guard !artist.isEmpty else {
return remote.fetch(title: title, artist: artist, duration: duration) ?? .empty
}
if let cached = local.read(title: title, artist: artist) { return cached }
guard let result = remote.fetch(title: title, artist: artist, duration: duration) else {
return .empty
}
local.write(title: title, artist: artist, lyrics: result)
return result
}
}
// MARK: - Process Management
func findOverlayPIDs() -> [Int32] {
let myPID = ProcessInfo.processInfo.processIdentifier
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
task.arguments = ["-f", "backdrop"]
let pipe = Pipe()
task.standardOutput = pipe
try? task.run()
task.waitUntilExit()
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.split(separator: "\n")
.compactMap { Int32($0) }
.filter { $0 != myPID } ?? []
}
func stopExisting() -> Bool {
let pids = findOverlayPIDs()
guard !pids.isEmpty else { return false }
pids.forEach { kill($0, SIGTERM) }
for _ in 0 ..< 20 {
guard pids.contains(where: { kill($0, 0) == 0 }) else { break }
usleep(100_000)
}
pids.filter { kill($0, 0) == 0 }.forEach { kill($0, SIGKILL) }
usleep(100_000)
return true
}
// MARK: - Overlay State
@MainActor @Observable
final class OverlayState {
var title: String?
var artist: String?
var artworkData: Data?
var lyrics: LyricsContent?
var activeLineIndex: Int?
var fetchGeneration: Int = 0
var screenOrigin: CGPoint = .zero
func reset() {
title = nil; artist = nil; artworkData = nil
lyrics = nil; activeLineIndex = nil
fetchGeneration += 1
}
}
// MARK: - Configuration
struct TextStyleConfig: Codable {
let font: String?
let size: Double?
let weight: String?
let color: String?
let shadow: String?
let spacing: Double?
init(font: String? = nil, size: Double? = nil, weight: String? = nil, color: String? = nil, shadow: String? = nil, spacing: Double? = nil) {
self.font = font; self.size = size; self.weight = weight
self.color = color; self.shadow = shadow; self.spacing = spacing
}
func merging(over base: TextStyleConfig) -> TextStyleConfig {
.init(
font: font ?? base.font,
size: size ?? base.size,
weight: weight ?? base.weight,
color: color ?? base.color,
shadow: shadow ?? base.shadow,
spacing: spacing ?? base.spacing
)
}
}
struct ResolvedTextStyle {
let spacing: Double
let swiftFont: Font
let swiftColor: Color
let swiftShadow: Color
let lineHeight: Double
init(from config: TextStyleConfig) {
let font = config.font ?? "Zen Maru Gothic"
let size = config.size ?? 12
let weight = config.weight ?? "regular"
spacing = config.spacing ?? 6
let fontWeight: Font.Weight = switch weight.lowercased() {
case "ultralight": .ultraLight
case "thin": .thin
case "light": .light
case "medium": .medium
case "semibold": .semibold
case "bold": .bold
case "heavy": .heavy
case "black": .black
default: .regular
}
let available = NSFontManager.shared.availableFontFamilies.contains(font)
swiftFont = available
? Font.custom(font, size: size).weight(fontWeight)
: Font.system(size: size, weight: fontWeight)
swiftColor = parseHexColor(config.color ?? "#FFFFFFD9")
swiftShadow = parseHexColor(config.shadow ?? "#000000E6")
let nsFont = available ? NSFont(name: font, size: size) : nil
let fallback = NSFont.systemFont(ofSize: size)
lineHeight = ceil((nsFont?.ascender ?? fallback.ascender) - (nsFont?.descender ?? fallback.descender)
+ (nsFont?.leading ?? fallback.leading)) + spacing * 2
}
}
struct TextConfig: Codable {
let `default`: TextStyleConfig
let title: TextStyleConfig?
let artist: TextStyleConfig?
let lyric: TextStyleConfig?
let highlight: [String]
private static let titleDefaults: TextStyleConfig = .init(size: 18, weight: "bold")
private static let artistDefaults: TextStyleConfig = .init(weight: "medium")
var resolvedTitle: ResolvedTextStyle { .init(from: (title ?? .init()).merging(over: Self.titleDefaults.merging(over: `default`))) }
var resolvedArtist: ResolvedTextStyle { .init(from: (artist ?? .init()).merging(over: Self.artistDefaults.merging(over: `default`))) }
var resolvedLyric: ResolvedTextStyle { .init(from: (lyric ?? .init()).merging(over: `default`)) }
var highlightStyle: AnyShapeStyle {
let colors = highlight.map(parseHexColor)
guard colors.count > 1 else {
return .init(colors.first ?? .white)
}
let stops = colors.enumerated().map { i, color in
Gradient.Stop(color: color, location: CGFloat(i) / CGFloat(colors.count - 1))
}
return .init(LinearGradient(stops: stops, startPoint: .leading, endPoint: .trailing))
}
init(`default`: TextStyleConfig = .init(), title: TextStyleConfig? = nil, artist: TextStyleConfig? = nil, lyric: TextStyleConfig? = nil, highlight: [String] = ["#B8942DFF", "#EDCF73FF", "#FFEB99FF", "#CCA64DFF", "#A68038FF"]) {
self.default = `default`
self.title = title
self.artist = artist
self.lyric = lyric
self.highlight = highlight
}
enum CodingKeys: String, CodingKey {
case `default`, title, artist, lyric, highlight
}
}
struct ArtworkConfig: Codable {
let size: CGFloat
init(size: CGFloat = 96) { self.size = size }
}
struct RippleConfig: Codable {
let color: String
let radius: Double
let duration: Double
let idle: Double
var resolvedColor: NSColor { NSColor(parseHexColor(color)).usingColorSpace(.deviceRGB) ?? .white }
init(color: String = "#AAAAFFFF", radius: Double = 60, duration: Double = 0.6, idle: Double = 1) {
self.color = color
self.radius = radius
self.duration = duration
self.idle = idle
}
}
func parseHexColor(_ hex: String) -> Color {
let h = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex
guard h.count == 6 || h.count == 8,
let value = UInt64(h, radix: 16) else { return .white }
let r, g, b, a: Double
switch h.count {
case 8:
r = Double((value >> 24) & 0xFF) / 255
g = Double((value >> 16) & 0xFF) / 255
b = Double((value >> 8) & 0xFF) / 255
a = Double(value & 0xFF) / 255
default:
r = Double((value >> 16) & 0xFF) / 255
g = Double((value >> 8) & 0xFF) / 255
b = Double(value & 0xFF) / 255
a = 1
}
return Color(red: r, green: g, blue: b, opacity: a)
}
enum ScreenSelector: Codable, Equatable {
case main
case primary
case index(Int)
case smallest
case largest
case match
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let n = try? container.decode(Int.self) {
self = .index(n)
return
}
let s = try container.decode(String.self)
switch s.lowercased() {
case "main": self = .main
case "primary": self = .primary
case "smallest": self = .smallest
case "largest": self = .largest
case "match": self = .match
default: self = .main
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .main: try container.encode("main")
case .primary: try container.encode("primary")
case .index(let n): try container.encode(n)
case .smallest: try container.encode("smallest")
case .largest: try container.encode("largest")
case .match: try container.encode("match")
}
}
}
func resolveScreen(selector: ScreenSelector, wallpaperURL: URL?) -> NSScreen {
let screens = NSScreen.screens
guard !screens.isEmpty else { return NSScreen.main ?? NSScreen() }
switch selector {
case .main:
return .main ?? screens[0]
case .primary:
return screens[0]
case .index(let n):
return n < screens.count ? screens[n] : screens[0]
case .smallest:
return screens.min { $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height } ?? screens[0]
case .largest:
return screens.max { $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height } ?? screens[0]
case .match:
guard let url = wallpaperURL else { return .main ?? screens[0] }
let sem = DispatchSemaphore(value: 0)
var videoAspect: CGFloat?
Task {
defer { sem.signal() }
guard let track = try? await AVURLAsset(url: url).loadTracks(withMediaType: .video).first,
let size = try? await track.load(.naturalSize),
let transform = try? await track.load(.preferredTransform) else { return }
let s = size.applying(transform)
videoAspect = abs(s.width) / abs(s.height)
}
sem.wait()
guard let videoAspect else { return .main ?? screens[0] }
return screens.min { a, b in
let aa = a.frame.width / a.frame.height
let ba = b.frame.width / b.frame.height
return abs(aa - videoAspect) < abs(ba - videoAspect)
} ?? screens[0]
}
}
struct BackdropConfig: Codable {
let text: TextConfig
let artwork: ArtworkConfig
let ripple: RippleConfig
let screen: ScreenSelector
let wallpaper: String?
let configDir: String?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
text = try container.decodeIfPresent(TextConfig.self, forKey: .text) ?? .init()
artwork = try container.decodeIfPresent(ArtworkConfig.self, forKey: .artwork) ?? .init()
ripple = try container.decodeIfPresent(RippleConfig.self, forKey: .ripple) ?? .init()
screen = try container.decodeIfPresent(ScreenSelector.self, forKey: .screen) ?? .main
wallpaper = try container.decodeIfPresent(String.self, forKey: .wallpaper)
configDir = nil
}
var wallpaperURL: URL? {
guard let wallpaper else { return nil }
guard !wallpaper.hasPrefix("/") else { return URL(fileURLWithPath: wallpaper) }
return configDir.map { URL(fileURLWithPath: $0).appendingPathComponent(wallpaper) }
}
init(text: TextConfig = .init(), artwork: ArtworkConfig = .init(), ripple: RippleConfig = .init(), screen: ScreenSelector = .main, wallpaper: String? = nil, configDir: String? = nil) {
self.text = text
self.artwork = artwork
self.ripple = ripple
self.screen = screen
self.wallpaper = wallpaper
self.configDir = configDir
}
enum CodingKeys: String, CodingKey {
case text, artwork, ripple, screen, wallpaper
}
static func load() -> BackdropConfig {
let home = NSHomeDirectory()
let xdgConfig = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] ?? "\(home)/.config"
let candidates = [
"\(xdgConfig)/backdrop/config.json",
"\(home)/.backdrop/config.json",
]
guard let path = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }),
let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
let decoded = try? JSONDecoder().decode(BackdropConfig.self, from: data)
else { return .init() }
return BackdropConfig(
text: decoded.text, artwork: decoded.artwork, ripple: decoded.ripple,
screen: decoded.screen, wallpaper: decoded.wallpaper,
configDir: (path as NSString).deletingLastPathComponent
)
}
}
var config = BackdropConfig.load()
// MARK: - Column Layout
struct ColumnLayout {
let columnWidth: Double
let columnGap: Double
let maxColumns: Int
let linesPerColumn: Int
init(width: Double, lyricsHeight: Double) {
columnGap = round(width * 0.03)
columnWidth = round(width * 0.28)
maxColumns = max(1, Int((width + columnGap) / (columnWidth + columnGap)))
linesPerColumn = max(1, Int(lyricsHeight / config.text.resolvedLyric.lineHeight))
}
func columnsNeeded(for lineCount: Int) -> Int {
min(maxColumns, max(1, (lineCount + linesPerColumn - 1) / linesPerColumn))
}
}
// MARK: - Ripple Effect
@MainActor @Observable
final class RippleState {
struct Ripple: Identifiable {
let id = UUID()
let position: CGPoint
let startTime: Date
let idle: Bool
let hueShift: Double = .random(in: -0.15...0.15)
}
var ripples: [Ripple] = []
private var currentPosition: CGPoint = .zero
private var lastRipplePosition: CGPoint = .zero
private var lastIdleRipple: Date = .now
func update(screenPoint: CGPoint) {
currentPosition = screenPoint
let distance = hypot(screenPoint.x - lastRipplePosition.x, screenPoint.y - lastRipplePosition.y)
guard distance > 40 else { return }
lastRipplePosition = screenPoint
lastIdleRipple = .now
ripples.append(.init(position: screenPoint, startTime: .now, idle: false))
cleanup()
}
func idle() {
guard config.ripple.idle > 0,
Date.now.timeIntervalSince(lastIdleRipple) > config.ripple.idle else { return }
lastIdleRipple = .now
ripples.append(.init(position: currentPosition, startTime: .now, idle: true))
cleanup()
}
private func cleanup() {
ripples.removeAll { Date.now.timeIntervalSince($0.startTime) > config.ripple.duration * 3 }
}
}
@MainActor
struct RippleView: View {
let rippleState: RippleState
let screenOrigin: CGPoint
private let baseColor = config.ripple.resolvedColor
var body: some View {
TimelineView(.animation) { timeline in
Canvas { context, size in
let now = timeline.date
for ripple in rippleState.ripples {
let elapsed = now.timeIntervalSince(ripple.startTime)
let rc = config.ripple
let dur = ripple.idle ? rc.duration * 3 : rc.duration
guard elapsed < dur else { continue }
let t = elapsed / dur
let easeOut = 1 - (1 - t) * (1 - t)
let radius = easeOut * rc.radius
let shifted = Color(
hue: (baseColor.hueComponent + ripple.hueShift).truncatingRemainder(dividingBy: 1),
saturation: baseColor.saturationComponent,
brightness: baseColor.brightnessComponent,
opacity: baseColor.alphaComponent * pow(1 - t, 0.6)
)
let x = ripple.position.x - screenOrigin.x
let y = size.height - (ripple.position.y - screenOrigin.y)
let rect = CGRect(x: x - radius, y: y - radius, width: radius * 2, height: radius * 2)
context.stroke(
Path(ellipseIn: rect),
with: .color(shifted),
lineWidth: 2.5
)
}
}
}
}
}
// MARK: - SwiftUI Views
@MainActor
struct LyricLineView: View {
let text: String
let isActive: Bool
private let style = config.text.resolvedLyric
var body: some View {
Text(text.isEmpty ? " " : text)
.font(style.swiftFont)
.foregroundStyle(isActive ? config.text.highlightStyle : .init(style.swiftColor))
.opacity(isActive ? 1.0 : 0.7)
.scaleEffect(isActive ? 1.03 : 1.0, anchor: .leading)
.shadow(color: style.swiftShadow, radius: 5, x: 0, y: 1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, style.spacing)
.animation(.easeInOut(duration: 0.3), value: isActive)
}
}
@MainActor
struct HeaderView: View {
let title: String?
let artist: String?
let artworkData: Data?
private let titleStyle = config.text.resolvedTitle
private let artistStyle = config.text.resolvedArtist
private var trackID: String { "\(title ?? "")\n\(artist ?? "")" }
var body: some View {
HStack(spacing: 24) {
if let artworkData, let image = NSImage(data: artworkData) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: config.artwork.size)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
VStack(alignment: .leading, spacing: titleStyle.spacing) {
if let title {
Text(title)
.font(titleStyle.swiftFont)
.foregroundStyle(titleStyle.swiftColor)
.shadow(color: titleStyle.swiftShadow, radius: 5, x: 0, y: 1)
.lineLimit(1)
}
if let artist {
Text(artist)
.font(artistStyle.swiftFont)
.foregroundStyle(artistStyle.swiftColor)
.shadow(color: artistStyle.swiftShadow, radius: 5, x: 0, y: 1)
.lineLimit(1)
}
}
Spacer()
}
.id(trackID)
.transition(.opacity)
.animation(.easeInOut(duration: 0.5), value: trackID)
}
}
@MainActor
struct LyricsOverlayView: View {
let state: OverlayState
let rippleState: RippleState
private struct Column: Identifiable {
let id: Int
let entries: [(index: Int, text: String)]
let highlightIndex: Int?
}
private func columns(layout: ColumnLayout) -> [Column] {
let texts: [String]
let highlightIndex: Int?
switch state.lyrics {
case let .timed(lines):
texts = lines.map(\.text)
highlightIndex = state.activeLineIndex
case let .plain(lines):
texts = lines
highlightIndex = nil
case nil:
return []
}
let lpc = layout.linesPerColumn
let count = layout.columnsNeeded(for: texts.count)
return (0 ..< count).map { col in
let start = col * lpc
let end = min(start + lpc, texts.count)
let entries = (start ..< end).map { i in (index: i, text: texts[i]) }
return Column(id: col, entries: entries, highlightIndex: highlightIndex)
}
}
var body: some View {
ZStack {
RippleView(rippleState: rippleState, screenOrigin: state.screenOrigin)
VStack(alignment: .leading, spacing: 32) {
HeaderView(title: state.title, artist: state.artist, artworkData: state.artworkData)
GeometryReader { geo in
let layout = ColumnLayout(width: geo.size.width, lyricsHeight: geo.size.height)
HStack(alignment: .top, spacing: layout.columnGap) {
ForEach(columns(layout: layout)) { column in
VStack(alignment: .leading, spacing: 0) {
ForEach(column.entries, id: \.index) { entry in
LyricLineView(text: entry.text, isActive: entry.index == column.highlightIndex)
}
Spacer()
}
.frame(width: layout.columnWidth)
}
}
}
}
.padding(48)
.padding(.bottom, 32)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
// MARK: - Overlay Window
@MainActor
class OverlayWindow {
private let window: NSWindow
private let mediaRemote: MediaRemoteClient
private let repository = LyricRepository()
private let state = OverlayState()
private let rippleState = RippleState()
private var loopObserver: NSObjectProtocol?
private var mouseMonitor: Any?
private var screenObserver: NSObjectProtocol?
private var sleepObserver: NSObjectProtocol?
private var wakeObserver: NSObjectProtocol?
private let hostingView: NSHostingView<LyricsOverlayView>
private let hasWallpaper: Bool
private var lastTrackKey: (String?, String?) = (nil, nil)
private var latestInfo: NowPlayingInfo?
private var displayLink: CADisplayLink?
private var queuePlayer: AVPlayer?
private static func resolvedFrames(hasWallpaper: Bool) -> (window: NSRect, hosting: NSRect, origin: CGPoint) {
let screen = resolveScreen(selector: config.screen, wallpaperURL: config.wallpaperURL)
let visibleFrame = screen.visibleFrame
let fullFrame = screen.frame
let windowRect = hasWallpaper ? fullFrame : visibleFrame
let hostingFrame = NSRect(
x: visibleFrame.minX - windowRect.minX,
y: visibleFrame.minY - windowRect.minY,
width: visibleFrame.width,
height: visibleFrame.height
)
return (windowRect, hostingFrame, CGPoint(x: visibleFrame.minX, y: visibleFrame.minY))
}
init(mediaRemote: MediaRemoteClient) {
self.mediaRemote = mediaRemote
hasWallpaper = config.wallpaperURL != nil
let frames = Self.resolvedFrames(hasWallpaper: hasWallpaper)
let window = NSWindow(
contentRect: frames.window,
styleMask: .borderless,
backing: .buffered,
defer: false
)
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1)
window.backgroundColor = hasWallpaper ? .black : .clear
window.isOpaque = false
window.ignoresMouseEvents = true
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
state.screenOrigin = frames.origin
let hostingView = NSHostingView(rootView: LyricsOverlayView(
state: state,
rippleState: rippleState
))
hostingView.frame = frames.hosting
self.hostingView = hostingView
if let wallpaperURL = config.wallpaperURL {
let containerView = NSView(frame: NSRect(origin: .zero, size: frames.window.size))
let player = AVPlayer(url: wallpaperURL)
player.isMuted = true
player.preventsDisplaySleepDuringVideoPlayback = false
player.actionAtItemEnd = .none
queuePlayer = player
loopObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem, queue: .main
) { [weak player] _ in
player?.seek(to: .zero)
player?.play()
}
let playerLayer = AVPlayerLayer(player: player)
playerLayer.frame = containerView.bounds
playerLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
playerLayer.videoGravity = .resizeAspectFill
containerView.wantsLayer = true
containerView.layer?.addSublayer(playerLayer)
containerView.addSubview(hostingView)
window.contentView = containerView
player.play()
} else {
window.contentView = hostingView
}
self.window = window
window.orderFront(nil)
mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved) { [rippleState] event in
rippleState.update(screenPoint: NSEvent.mouseLocation)
}
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.recalculateLayout() }
}
let ws = NSWorkspace.shared.notificationCenter
sleepObserver = ws.addObserver(
forName: NSWorkspace.screensDidSleepNotification,
object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.queuePlayer?.pause() }
}
wakeObserver = ws.addObserver(
forName: NSWorkspace.screensDidWakeNotification,
object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.queuePlayer?.play() }
}
}
private func recalculateLayout() {
let frames = Self.resolvedFrames(hasWallpaper: hasWallpaper)
window.setFrame(frames.window, display: false)
state.screenOrigin = frames.origin
hostingView.frame = frames.hosting
if let containerView = window.contentView, containerView !== hostingView {
containerView.frame = NSRect(origin: .zero, size: frames.window.size)
}
}
func pollMediaRemote() {
mediaRemote.poll { [self] info in
guard let info else { return clearIfNeeded() }
latestInfo = info
updateTrack(from: info)
updateActiveLineIndex(from: info)
}
}
func updateUI() {
rippleState.idle()
guard let info = latestInfo else { return }
updateActiveLineIndex(from: info)
}
func startDisplayLink() {
let dl = window.displayLink(target: self, selector: #selector(displayLinkFired))
dl.add(to: .main, forMode: .common)
displayLink = dl
}
func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func displayLinkFired(_ link: CADisplayLink) {
updateUI()
}
private func clearIfNeeded() {
guard lastTrackKey != (nil, nil) else { return }
lastTrackKey = (nil, nil)
state.reset()
}
private func updateTrack(from info: NowPlayingInfo) {
if info.artworkData != state.artworkData { state.artworkData = info.artworkData }
let trackKey = (info.title, info.artist)
guard trackKey != lastTrackKey else { return }
lastTrackKey = trackKey
state.title = info.title
state.artist = info.artist
state.activeLineIndex = nil
state.fetchGeneration += 1
let generation = state.fetchGeneration
let repo = repository
DispatchQueue.global().async {
let result: LyricsResult? = {
guard let title = info.title, let artist = info.artist else { return nil }
return repo.fetch(title: title, artist: artist, duration: info.duration)
}()
let content = LyricsContent(from: result)
DispatchQueue.main.async { [self] in
guard generation == state.fetchGeneration else { return }
state.title = result?.trackName ?? info.title
state.artist = result?.artistName ?? info.artist
state.lyrics = content
state.activeLineIndex = nil
}
}
}
private func updateActiveLineIndex(from info: NowPlayingInfo) {
guard case let .timed(lines) = state.lyrics else { return }
let index = info.elapsed.flatMap { elapsed in lines.lastIndex { $0.time <= elapsed } }
guard index != state.activeLineIndex else { return }
state.activeLineIndex = index
}
func close() {
stopDisplayLink()
queuePlayer?.pause()
loopObserver.map(NotificationCenter.default.removeObserver)
mouseMonitor.map(NSEvent.removeMonitor)
screenObserver.map(NotificationCenter.default.removeObserver)
let ws = NSWorkspace.shared.notificationCenter
sleepObserver.map(ws.removeObserver)
wakeObserver.map(ws.removeObserver)
window.orderOut(nil)
window.close()
}
}
// MARK: - CLI
let command = CommandLine.arguments.dropFirst().first ?? "--help"
switch command {
case "--help", "-h":
print("""
Usage: backdrop <command>
Commands:
start Start the overlay as a background process
stop Stop the running overlay
restart Stop and start the overlay
service install Register as login item (LaunchAgent)
service uninstall Remove login item
completion <shell> Output shell completion script (zsh, bash)
""")
exit(0)
case "completion":
let shell = CommandLine.arguments.dropFirst(2).first
switch shell {
case "zsh":
print("""
#compdef backdrop
_backdrop() {
local -a commands
commands=(
'start:Start the overlay as a background process'
'stop:Stop the running overlay'
'restart:Stop and start the overlay'
'service:Manage login item service'
'completion:Output shell completion script'
)
local -a service_commands
service_commands=(
'install:Register as login item (LaunchAgent)'
'uninstall:Remove login item'
)
local -a completion_commands
completion_commands=(
'zsh:Output zsh completion script'
'bash:Output bash completion script'
)
_arguments -C '1:command:->cmd' '*::arg:->args'
case $state in
cmd)
_describe 'command' commands
;;
args)
case $words[1] in
service)
_describe 'subcommand' service_commands
;;
completion)
_describe 'shell' completion_commands
;;
esac
;;
esac
}
_backdrop "$@"
""")
case "bash":
print("""
_backdrop() {
local cur prev commands service_cmds completion_cmds
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
commands="start stop restart service completion"
service_cmds="install uninstall"
completion_cmds="zsh bash"
case "$prev" in
backdrop)
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
;;
service)
COMPREPLY=( $(compgen -W "$service_cmds" -- "$cur") )
;;
completion)
COMPREPLY=( $(compgen -W "$completion_cmds" -- "$cur") )
;;
esac
}
complete -F _backdrop backdrop
""")
default:
fputs("Usage: backdrop completion <zsh|bash>\n", stderr)
exit(1)
}
exit(0)
case "service":
let subcommand = CommandLine.arguments.dropFirst(2).first
let label = "com.user.backdrop"
let plistPath = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
switch subcommand {
case "install":
let scriptPath = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL.path
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" \
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>\(label)</string>
<key>ProgramArguments</key>
<array>
<string>\(scriptPath)</string>
<string>__daemon</string>
</array>
<key>RunAtLoad</key><true/>
</dict>
</plist>
"""
_ = stopExisting()
// bootout first in case already registered
let uid = getuid()
let target = "gui/\(uid)"
let bootout = Process()
bootout.executableURL = URL(fileURLWithPath: "/bin/launchctl")
bootout.arguments = ["bootout", "\(target)/\(label)"]
bootout.standardError = FileHandle.nullDevice
try? bootout.run()
bootout.waitUntilExit()
try! plist.write(to: plistPath, atomically: true, encoding: .utf8)
let bootstrap = Process()
bootstrap.executableURL = URL(fileURLWithPath: "/bin/launchctl")
bootstrap.arguments = ["bootstrap", target, plistPath.path]
try! bootstrap.run()
bootstrap.waitUntilExit()
guard bootstrap.terminationStatus == 0 else {
fputs("Failed to start service (launchctl bootstrap exit \(bootstrap.terminationStatus))\n", stderr)
exit(1)
}
print("Installed and started: \(plistPath.path)")
case "uninstall":
guard FileManager.default.fileExists(atPath: plistPath.path) else {
print("Not installed"); exit(0)
}
let uid = getuid()
let bootout = Process()
bootout.executableURL = URL(fileURLWithPath: "/bin/launchctl")
bootout.arguments = ["bootout", "gui/\(uid)/\(label)"]
bootout.standardError = FileHandle.nullDevice
try? bootout.run()
bootout.waitUntilExit()
_ = stopExisting()
try! FileManager.default.removeItem(at: plistPath)
print("Uninstalled")
default:
fputs("Usage: backdrop service <install|uninstall>\n", stderr)
exit(1)
}
exit(0)
case "stop":
print(stopExisting() ? "Stopped" : "Not running")
exit(0)
case "restart":
_ = stopExisting()
fallthrough
case "start":
if command == "start", !findOverlayPIDs().isEmpty { print("Already running"); exit(0) }
// Launch self as background daemon
let script = CommandLine.arguments[0]
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/env")
task.arguments = ["swift", script, "__daemon"]
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
guard let _ = try? task.run() else {
fputs("Failed to start overlay\n", stderr)
exit(1)
}
print("Overlay started (PID \(task.processIdentifier))")
exit(0)
// Internal: swift scripts can't fork(), so "start" re-launches itself with this arg to run as a background daemon
case "__daemon":
break
default:
fputs("Unknown command: \(command)\n", stderr)
exit(1)
}
// Child process continues here
guard let mediaRemote = MediaRemoteClient() else {
fputs("Failed to load MediaRemote\n", stderr)
exit(1)
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
MainActor.assumeIsolated {
let overlay = OverlayWindow(mediaRemote: mediaRemote)
/// Clean shutdown: close window then exit
let signalSources = [SIGTERM, SIGINT].map { signalType -> DispatchSourceSignal in
signal(signalType, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: signalType, queue: .main)
source.setEventHandler {
overlay.close()
exit(0)
}
source.resume()
return source
}
_ = signalSources
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
MainActor.assumeIsolated { overlay.pollMediaRemote() }
}
overlay.pollMediaRemote()
overlay.startDisplayLink()
}
app.run()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment