{
  "format" : 1,
  "manifest" : {
    "accent" : "pink",
    "capabilities" : [
      {
        "run" : {
          "commands" : [
            "osascript"
          ]
        }
      },
      {
        "network" : {
          "hosts" : [
            "itunes.apple.com",
            "*.mzstatic.com",
            "i.scdn.co"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Music",
    "id" : "io.navbytes.stackbar.plugins.music",
    "minimumStackbar" : "0.17.0",
    "name" : "Music",
    "summary" : "What's playing in Music or Spotify, with play, pause, skip and volume.",
    "surfaces" : [
      "menuBar",
      "panel",
      "widget",
      "settings"
    ],
    "symbol" : "music.note",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 5
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.3.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "r0Oqigo5OFakpTQyUatstaC1k27mRX9g4uD/qmbXqij0hvo4JdnKJt4wHCn9jEBYsVAFuVmzohYPWiMryA7IAw==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// What's playing in Music or Spotify, in the menu bar. Click to play or\n/// pause; the panel has the track, previous, next and volume.\n///\n/// It asks the player over AppleScript (`osascript`, run by Stackbar every 5\n/// seconds) and never opens a player that isn't running, unless you press\n/// Play with nothing playing. macOS asks once whether Stackbar may control\n/// Music or Spotify.\n@main\nfinal class Music: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.music\",\n        name: \"Music\",\n        version: \"1.3.0\",\n        summary: \"What's playing in Music or Spotify, with play, pause, skip and volume.\",\n        surfaces: [.menuBar, .panel, .widget, .settings],\n        triggers: [.launch, .every(.seconds(5)), .wake],\n        // osascript is the only way to talk to Music and Spotify; the\n        // scripts are fixed text in this file, never built from input.\n        // Album art: Spotify names its own image URL; for Music the cover\n        // is looked up in the iTunes catalog by artist and album.\n        capabilities: [.run(commands: [\"osascript\"]), .network(\"itunes.apple.com\", \"*.mzstatic.com\", \"i.scdn.co\")],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Music\",\n        accent: .pink,\n        symbol: \"music.note\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<Music>] = [\n        PreviewScene(\"hero\", \"What's playing, with controls a click away\") { plugin in\n            plugin.track = Track(player: .music, playing: true, name: \"Harbor Lights\", artist: \"Juniper Vale\", album: \"Slow Tides\",\n                                 duration: 248, position: 97, volume: 60)\n            plugin.covers = [\"Juniper Vale|Slow Tides\": \"\"]\n            plugin.checked = true\n        },\n        PreviewScene(\"widget\", \"Now playing, on your desktop\", surface: .widget) { plugin in\n            plugin.track = Track(player: .spotify, playing: true, name: \"Harbor Lights\", artist: \"Juniper Vale\", album: \"Slow Tides\",\n                                 duration: 248, position: 97, volume: 60)\n            plugin.covers = [\"Juniper Vale|Slow Tides\": \"\"]\n            plugin.checked = true\n        },\n    ]\n\n    enum Player: String, CaseIterable {\n        case music = \"Music\"\n        case spotify = \"Spotify\"\n    }\n\n    struct Track: Equatable {\n        var player: Player\n        var playing: Bool\n        var name: String\n        var artist: String\n        var album: String\n        var duration: Double\n        var position: Double\n        var volume: Double\n        /// Spotify's cover, straight from the player.\n        var artworkURL: String?\n    }\n\n    var track: Track?\n    var problem: String?\n    var checked = false\n    /// Covers found in the iTunes catalog, by \"artist|album\"; \"\" when there is none.\n    var covers: [String: String] = [:]\n\n    @Stored(\"preferred\") var preferred = \"Whichever is playing\"\n    @Stored(\"showInMenuBar\") var showInMenuBar = \"Title and artist\"\n\n    /// One line for the player if it is running: state, name, artist,\n    /// album, duration (seconds), position (seconds), volume (0-100),\n    /// tab-separated. Each player gets its own script, because AppleScript\n    /// can't compile one that names an app that isn't installed.\n    static func statusScript(for player: Player) -> String {\n        let duration = player == .spotify ? \"((duration of t) / 1000)\" : \"(duration of t)\"\n        let artwork = player == .spotify ? \"(artwork url of t)\" : \"\\\"\\\"\"\n        return \"\"\"\n        if application \"\\(player.rawValue)\" is running then\n            tell application \"\\(player.rawValue)\"\n                if player state is not stopped then\n                    set t to current track\n                    return \"\\(player.rawValue)\" & tab & (player state as text) & tab & (name of t) & tab & (artist of t) & tab & (album of t) & tab & \\(duration) & tab & player position & tab & sound volume & tab & \\(artwork)\n                end if\n            end tell\n        end if\n        return \"\"\n        \"\"\"\n    }\n\n    func refresh() async throws {\n        await check()\n    }\n\n    func check() async {\n        var tracks: [Track] = []\n        var denied = false\n        var failed: Player?\n        for player in Player.allCases {\n            guard let result = try? await Stackbar.run(\"osascript\", [\"-e\", Self.statusScript(for: player)], timeout: .seconds(10)) else {\n                failed = player\n                continue\n            }\n            if result.succeeded {\n                tracks += Self.parse(result.text)\n            } else if result.errorText.contains(\"-1743\") || result.errorText.contains(\"Not authorized\") {\n                // -1743: the user said no to automation.\n                denied = true\n            } else if !(result.errorText.contains(\"-2741\") || result.errorText.contains(\"-2740\") || result.errorText.contains(\"-1728\")) {\n                // A compile error means the player isn't installed; anything else is worth saying.\n                failed = player\n            }\n        }\n        checked = true\n        if denied && tracks.isEmpty {\n            problem = \"Stackbar isn't allowed to control the player. Turn it on in System Settings › Privacy & Security › Automation › Stackbar.\"\n        } else if let failed, tracks.isEmpty {\n            problem = \"\\(failed.rawValue) didn't answer. Try again in a moment.\"\n        } else {\n            problem = nil\n        }\n        track = Self.choose(tracks, preferred: Player(rawValue: preferred))\n        if let track, track.artworkURL == nil { await findCover(track) }\n    }\n\n    struct CatalogResults: Decodable {\n        struct Item: Decodable { var artworkUrl100: String? }\n        var results: [Item]\n    }\n\n    /// Looks the album up once in the iTunes catalog, for Music, which has no\n    /// cover URL of its own.\n    func findCover(_ track: Track) async {\n        let key = \"\\(track.artist)|\\(track.album)\"\n        guard covers[key] == nil, !track.artist.isEmpty || !track.album.isEmpty else { return }\n        covers[key] = \"\"\n        var query = URLComponents(string: \"https://itunes.apple.com/search\")\n        query?.queryItems = [URLQueryItem(name: \"term\", value: \"\\(track.artist) \\(track.album)\"), URLQueryItem(name: \"entity\", value: \"album\"), URLQueryItem(name: \"limit\", value: \"1\")]\n        guard let url = query?.url?.absoluteString,\n              let found = try? await Stackbar.getJSON(url, as: CatalogResults.self),\n              let small = found.results.first?.artworkUrl100 else { return }\n        covers[key] = Self.largeCover(small)\n    }\n\n    /// The catalog's 100-pixel cover URL asked for at 600.\n    static func largeCover(_ url: String) -> String {\n        url.replacingOccurrences(of: \"100x100bb\", with: \"600x600bb\")\n    }\n\n    /// The cover for a track: Spotify's own, else the catalog's.\n    func cover(_ track: Track?) -> String? {\n        guard let track else { return nil }\n        if let url = track.artworkURL { return url }\n        let found = covers[\"\\(track.artist)|\\(track.album)\"]\n        return found?.isEmpty == false ? found : nil\n    }\n\n    /// `text` cut to `limit` characters with an ellipsis.\n    static func clipped(_ text: String, _ limit: Int) -> String {\n        text.count > limit ? String(text.prefix(limit - 1)) + \"…\" : text\n    }\n\n    static func parse(_ output: String) -> [Track] {\n        output.split(separator: \"\\n\").compactMap { line in\n            let fields = line.split(separator: \"\\t\", omittingEmptySubsequences: false).map(String.init)\n            guard fields.count >= 8, let player = Player(rawValue: fields[0]) else { return nil }\n            func number(_ text: String) -> Double { Double(text.replacingOccurrences(of: \",\", with: \".\")) ?? 0 }\n            let artwork = fields.count > 8 && fields[8].hasPrefix(\"https://\") ? fields[8] : nil\n            return Track(player: player, playing: fields[1] == \"playing\", name: fields[2], artist: fields[3], album: fields[4], duration: number(fields[5]), position: number(fields[6]), volume: number(fields[7]), artworkURL: artwork)\n        }\n    }\n\n    /// The preferred player if it has a track, else the one playing, else any.\n    static func choose(_ tracks: [Track], preferred: Player?) -> Track? {\n        if let preferred, let track = tracks.first(where: { $0.player == preferred }) { return track }\n        return tracks.first(where: \\.playing) ?? tracks.first\n    }\n\n    /// Sends a command, then checks what the player is doing now.\n    func tell(_ command: String) async throws {\n        try await send(command)\n        try? await Task.sleep(for: .milliseconds(250))\n        await check()\n    }\n\n    func send(_ command: String) async throws {\n        let player = track?.player ?? Player(rawValue: preferred) ?? .music\n        // Fixed commands only: playpause, next track, previous track, set\n        // sound volume to a number. With nothing running, playpause opens\n        // the preferred player and plays its last track.\n        let script = track == nil && command == \"playpause\"\n            ? \"tell application \\\"\\(player.rawValue)\\\" to playpause\"\n            : \"if application \\\"\\(player.rawValue)\\\" is running then tell application \\\"\\(player.rawValue)\\\" to \\(command)\"\n        let result = try await Stackbar.run(\"osascript\", [\"-e\", script], timeout: .seconds(10))\n        guard result.succeeded else { throw HostError(\"\\(player.rawValue) didn't answer: \\(result.errorText.prefix(160))\") }\n    }\n\n    static func time(_ seconds: Double) -> String {\n        let total = Int(seconds.rounded())\n        return String(format: \"%d:%02d\", total / 60, total % 60)\n    }\n\n    // MARK: Surfaces\n\n    var menuBarClick: MenuBarAction? {\n        guard let track else { return nil }\n        return MenuBarAction(track.playing ? \"Click to pause\" : \"Click to play\") { try await self.tell(\"playpause\") }\n    }\n\n    let theme = Theme.pink\n\n    var menuBar: ViewContent {\n        if let track, showInMenuBar != \"Icon only\" {\n            let text = showInMenuBar == \"Title\" ? track.name : \"\\(track.name) – \\(track.artist)\"\n            Label(text.count > 24 ? String(text.prefix(23)) + \"…\" : text, symbol: track.playing ? \"music.note\" : \"pause.fill\").role(track.playing ? .primary : .secondary)\n        } else {\n            Label(\"\", symbol: track?.playing == false ? \"pause.fill\" : \"music.note\").role(track?.playing == true ? .primary : .secondary)\n        }\n    }\n\n    /// The song's span as the host counts it, so the scrubber moves on its\n    /// own between checks.\n    func timing(_ track: Track) -> TimeRange {\n        let start = Date().addingTimeInterval(-track.position)\n        return TimeRange(start: start, end: start.addingTimeInterval(track.duration))\n    }\n\n    /// The cover, or the theme's vivid square with a note until it loads.\n    func artwork(_ track: Track?, size: Double) -> some StackbarView {\n        ZStack {\n            Image(systemName: \"music.note\").font(size: (size * 0.4).rounded(), weight: .semibold).foreground(theme.onSolidPaint)\n                .frame(width: size, height: size)\n                .background(theme.vivid)\n            if let url = cover(track) { Image(url: url) }\n        }\n        .frame(width: size, height: size)\n        .clipShape(.roundedRectangle(cornerRadius: (size * 0.16).rounded()))\n        .shadow(radius: 10, y: 5, color: HexColor(\"#00000059\"))\n        .accessibilityLabel(track.map { \"Cover of \\($0.album.isEmpty ? $0.name : $0.album)\" } ?? \"No cover\")\n    }\n\n    /// Bars that dance while playing, a pause mark when not.\n    func liveMark(_ track: Track) -> some StackbarView {\n        HStack(spacing: Space.xs) {\n            if track.playing {\n                Image(systemName: \"waveform\").font(size: 10, weight: .bold).symbolEffect(.variableColor)\n            } else {\n                Image(systemName: \"pause.fill\").font(size: 9, weight: .bold)\n            }\n            Text((track.playing ? \"Playing in \" : \"Paused in \") + track.player.rawValue).typography(.overline)\n        }\n        .opacity(0.85)\n    }\n\n    func scrubber(_ track: Track) -> some StackbarView {\n        VStack(spacing: Space.xs) {\n            if track.playing {\n                let range = timing(track)\n                ProgressBar(timing: range).foreground(.onMedia)\n                HStack {\n                    TimerText(range, countsDown: false).typography(.captionNumber)\n                    Spacer()\n                    HStack(spacing: 0) {\n                        Text(\"-\").typography(.captionNumber)\n                        TimerText(range).typography(.captionNumber)\n                    }\n                }\n                .opacity(0.8)\n            } else {\n                ProgressBar(value: min(track.position, track.duration), total: track.duration).foreground(.onMedia)\n                HStack {\n                    Text(Self.time(track.position)).typography(.captionNumber)\n                    Spacer()\n                    Text(\"-\" + Self.time(max(0, track.duration - track.position))).typography(.captionNumber)\n                }\n                .opacity(0.8)\n            }\n        }\n    }\n\n    var transport: some StackbarView {\n        HStack {\n            Spacer()\n            GlassBar(spacing: Space.m) {\n                IconButton(\"backward.fill\", label: \"Previous\", prominence: .onMedia, size: 34) { try await self.tell(\"previous track\") }\n                IconButton(track?.playing == true ? \"pause.fill\" : \"play.fill\", label: track?.playing == true ? \"Pause\" : \"Play\",\n                           theme: theme, prominence: .filled, size: 44, key: \"playpause\") { try await self.tell(\"playpause\") }\n                IconButton(\"forward.fill\", label: \"Next\", prominence: .onMedia, size: 34) { try await self.tell(\"next track\") }\n            }\n            Spacer()\n        }\n    }\n\n    var panel: ViewContent {\n        if let problem {\n            EmptyState(\"Can't reach the player\", symbol: problem.contains(\"allowed\") ? \"lock.shield\" : \"exclamationmark.triangle\", message: problem, theme: .warning) {\n                ActionButton(\"Try Again\", symbol: \"arrow.clockwise\", theme: theme, prominent: true) { await self.check() }\n            }\n        } else if let track {\n            Backdrop(imageURL: cover(track), theme: theme, height: track.duration > 0 ? 250 : 206, blur: 36) {\n                HStack(alignment: .end, spacing: Space.m) {\n                    artwork(track, size: 88)\n                    VStack(spacing: Space.xxs) {\n                        liveMark(track)\n                        Text(Self.clipped(track.name, 44)).typography(Typography(size: 17, weight: .bold))\n                        if !track.artist.isEmpty { Text(Self.clipped(track.artist, 40)).typography(.body).opacity(0.9) }\n                        if !track.album.isEmpty { Text(Self.clipped(track.album, 44)).typography(.caption).opacity(0.75) }\n                    }\n                    .frame(fillWidth: true, alignment: .leading)\n                }\n                if track.duration > 0 { scrubber(track) }\n                transport\n            }\n            HStack(spacing: Space.s) {\n                Image(systemName: \"speaker.fill\").font(size: 11).foreground(.role(.secondary))\n                Slider(\"Volume\", value: track.volume, in: 0...100) { value in\n                    // Set it here rather than asking again on every step of a drag.\n                    self.track?.volume = value\n                    try await self.send(\"set sound volume to \\(Int(value))\")\n                }\n                .foreground(theme.solidPaint)\n                Image(systemName: \"speaker.wave.3.fill\").font(size: 11).foreground(.role(.secondary))\n            }\n            .padding(horizontal: Space.xs)\n        } else {\n            Backdrop(theme: theme, height: 196, alignment: .center) {\n                Image(systemName: \"music.note.list\").font(size: 34, weight: .semibold).symbolEffect(.breathe)\n                Text(checked ? \"Nothing playing\" : \"Checking…\").typography(.title)\n                Text(\"Start something in Music or Spotify, or press play.\").typography(.caption).opacity(0.85)\n                transport\n            }\n        }\n    }\n\n    var widget: ViewContent {\n        Backdrop(imageURL: cover(track), theme: theme, height: 131, blur: 30) {\n            HStack(alignment: .end, spacing: Space.m) {\n                artwork(track, size: 56)\n                VStack(spacing: Space.xxs) {\n                    if let track {\n                        liveMark(track)\n                        Text(Self.clipped(track.name, 30)).typography(.headline)\n                        if !track.artist.isEmpty { Text(Self.clipped(track.artist, 32)).typography(.caption).opacity(0.85) }\n                    } else {\n                        Text(\"Nothing playing\").typography(.headline)\n                    }\n                }\n                .frame(fillWidth: true, alignment: .leading)\n            }\n            if let track, track.playing, track.duration > 0 {\n                ProgressBar(timing: timing(track)).foreground(.onMedia)\n            }\n        }\n    }\n\n    var settings: ViewContent {\n        Section(\"Player\") {\n            Picker(\"Follow\", options: [\"Whichever is playing\"] + Player.allCases.map(\\.rawValue), selection: preferred) { choice in\n                self.preferred = choice\n                await self.check()\n            }\n        }\n        Section(\"Menu bar\") {\n            Picker(\"Show\", options: [\"Title and artist\", \"Title\", \"Icon only\"], selection: showInMenuBar, style: .segmented) { self.showInMenuBar = $0 }\n            Text(\"Click the menu-bar item to play or pause; right-click for the panel.\").style(.caption)\n        }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"play-pause\", title: \"Play or Pause Music\", symbol: \"playpause.fill\", keywords: [\"music\", \"spotify\", \"play\", \"pause\"]) { _ in\n            await self.check()\n            try await self.tell(\"playpause\")\n            return .message(self.track.map { \"\\($0.playing ? \"Playing\" : \"Paused\") \\($0.name)\" } ?? \"Done\")\n        }\n        Command(\"next\", title: \"Next Track\", symbol: \"forward.fill\", keywords: [\"music\", \"spotify\", \"skip\"]) { _ in\n            await self.check()\n            try await self.tell(\"next track\")\n            return .message(self.track.map { \"\\($0.name) – \\($0.artist)\" } ?? \"Skipped\")\n        }\n        Command(\"previous\", title: \"Previous Track\", symbol: \"backward.fill\", keywords: [\"music\", \"spotify\", \"back\"]) { _ in\n            await self.check()\n            try await self.tell(\"previous track\")\n            return .done\n        }\n        Command(\"copy-track\", title: \"Copy Current Track\", symbol: \"doc.on.doc\", keywords: [\"music\", \"spotify\", \"song\", \"share\"]) { _ in\n            await self.check()\n            guard let track = self.track else { return .failure(\"Nothing is playing\") }\n            return .copy(\"\\(track.name) – \\(track.artist)\")\n        }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"now_playing\", description: \"The track playing in Music or Spotify\") { _ in\n            await self.check()\n            guard let track = self.track else { return self.problem ?? \"Nothing playing\" }\n            return \"\\(track.playing ? \"playing\" : \"paused\") in \\(track.player.rawValue): \\(track.name) by \\(track.artist) from \\(track.album), \\(Self.time(track.position)) of \\(Self.time(track.duration))\"\n        }\n    }\n}\n",
  "sourceDigest" : "b44a50c044bfb985e51449ffd8371283bf22ed730ab66268872f60c383e1b1aa"
}