{
  "format" : 1,
  "manifest" : {
    "capabilities" : [
      {
        "run" : {
          "commands" : [
            "osascript"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Music",
    "id" : "io.navbytes.stackbar.plugins.music",
    "minimumStackbar" : "0.11.0",
    "name" : "Music",
    "summary" : "What's playing in Music or Spotify, with play, pause, skip and volume.",
    "surfaces" : [
      "menuBar",
      "panel",
      "widget",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

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

        }
      }
    ],
    "version" : "1.0.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "bDymmrtxbrddONYj0h9RvMKmPJu2RHhKvoPPtTJqfQILVDCfjrCX753IhesdS+CzRy5/2T0WIKsX6OVwyWwxBA==",
  "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. macOS asks once\n/// whether Stackbar may control 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.0.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        capabilities: [.run(commands: [\"osascript\"])],\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Music\"\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    }\n\n    var track: Track?\n    var problem: String?\n    var checked = false\n\n    @Stored(\"preferred\") var preferred = \"Whichever is playing\"\n    @Stored(\"showInMenuBar\") var showInMenuBar = \"Title and artist\"\n\n    /// One line per running player: state, name, artist, album, duration\n    /// (seconds), position (seconds), volume (0-100), tab-separated.\n    static let statusScript = \"\"\"\n    set output to \"\"\n    if application \"Music\" is running then\n        tell application \"Music\"\n            if player state is not stopped then\n                set t to current track\n                set output to output & \"Music\" & tab & (player state as text) & tab & (name of t) & tab & (artist of t) & tab & (album of t) & tab & (duration of t) & tab & player position & tab & sound volume & linefeed\n            end if\n        end tell\n    end if\n    if application \"Spotify\" is running then\n        tell application \"Spotify\"\n            if player state is not stopped then\n                set t to current track\n                set output to output & \"Spotify\" & tab & (player state as text) & tab & (name of t) & tab & (artist of t) & tab & (album of t) & tab & ((duration of t) / 1000) & tab & player position & tab & sound volume & linefeed\n            end if\n        end tell\n    end if\n    return output\n    \"\"\"\n\n    func refresh() async throws {\n        await check()\n    }\n\n    func check() async {\n        do {\n            let result = try await Stackbar.run(\"osascript\", [\"-e\", Self.statusScript], timeout: .seconds(10))\n            checked = true\n            guard result.succeeded else {\n                // -1743: the user said no to automation.\n                problem = result.errorText.contains(\"-1743\") || result.errorText.contains(\"Not authorized\")\n                    ? \"Stackbar isn't allowed to control the player. Turn it on in System Settings > Privacy & Security > Automation > Stackbar.\"\n                    : \"The player didn't answer: \\(result.errorText.trimmingCharacters(in: .whitespacesAndNewlines).prefix(160))\"\n                return\n            }\n            track = Self.choose(Self.parse(result.text), preferred: Player(rawValue: preferred))\n            problem = nil\n        } catch {\n            checked = true\n            problem = \"\\(error)\"\n        }\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            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]))\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    func tell(_ 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.\n        let result = try await Stackbar.run(\"osascript\", [\"-e\", \"if application \\\"\\(player.rawValue)\\\" is running then tell application \\\"\\(player.rawValue)\\\" to \\(command)\"], timeout: .seconds(10))\n        guard result.succeeded else { throw HostError(\"\\(player.rawValue) didn't answer: \\(result.errorText.prefix(160))\") }\n        try? await Task.sleep(for: .milliseconds(250))\n        await check()\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    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 > 32 ? String(text.prefix(31)) + \"…\" : text, symbol: track.playing ? \"music.note\" : \"pause.fill\").role(track.playing ? .primary : .secondary)\n        } else {\n            Label(\"\", symbol: track?.playing == true ? \"music.note\" : \"music.note\").role(track == nil ? .secondary : .primary)\n        }\n    }\n\n    var panel: ViewContent {\n        if let problem {\n            ContentUnavailableView(\"Can't reach the player\", symbol: \"music.note\", message: problem)\n        } else if let track {\n            Text(track.name).style(.headline)\n            Text([track.artist, track.album].filter { !$0.isEmpty }.joined(separator: \" — \")).style(.caption)\n            if track.duration > 0 {\n                ProgressBar(\"\\(Self.time(track.position)) / \\(Self.time(track.duration))\", value: min(track.position, track.duration), total: track.duration)\n            }\n            HStack {\n                Button(\"Previous\", symbol: \"backward.fill\") { try await self.tell(\"previous track\") }\n                Button(track.playing ? \"Pause\" : \"Play\", symbol: track.playing ? \"pause.fill\" : \"play.fill\", role: .accent, key: \"playpause\") { try await self.tell(\"playpause\") }\n                Button(\"Next\", symbol: \"forward.fill\") { try await self.tell(\"next track\") }\n            }\n            Slider(\"Volume\", value: track.volume, in: 0...100, step: 5) { value in\n                try await self.tell(\"set sound volume to \\(Int(value))\")\n            }\n            Text(\"Playing in \\(track.player.rawValue)\").style(.caption)\n        } else if checked {\n            ContentUnavailableView(\"Nothing playing\", symbol: \"music.note\", message: \"Start something in Music or Spotify.\")\n        } else {\n            ContentUnavailableView(\"Checking…\", symbol: \"music.note\")\n        }\n    }\n\n    var widget: ViewContent {\n        if let track {\n            Text(track.name).style(.headline)\n            Text(track.artist).style(.caption)\n        } else {\n            Text(\"Nothing playing\").style(.caption)\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) { 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" : "561a387dfa8ce38d04999c44335fd7ae79cebca15469ab54ea88d90c30d32ffa"
}