chopstick
All widgets

Now playing: YouTube Music

Comes with Chopstick

YouTube Music playing in Chrome or Safari: cover, track, timeline you can scrub, play/pause, skip and like.

Already installed? It's in your widget library.

Type it in the notch's search

  • Play YouTube Music
  • Pause YouTube Music
  • Next song on YouTube Music
  • Previous song on YouTube Music
  • Like this song
  • Show YouTube Music

What it runs

A widget is a folder of plain files. Here is all of its code.

widget.json
{
  "id": "youtube-music",
  "name": "Now playing: YouTube Music",
  "icon": "icon.png",
  "description": "YouTube Music playing in Chrome or Safari: cover, track, timeline you can scrub, play/pause, skip and like.",
  "watch": [
    {
      "script": "state.sh",
      "every": 3,
      "as": "ytm"
    }
  ],
  "show_when": {
    "ytm.show": true
  },
  "triggers": [
    {
      "when": {
        "ytm.state": "blocked"
      },
      "open": "setup"
    }
  ],
  "initial": "playing",
  "commands": {
    "play": {
      "phrase": "Play YouTube Music",
      "description": "Resume playback in the YouTube Music tab.",
      "do": [
        {
          "set": {
            "command": "play"
          }
        },
        {
          "run": "control.sh"
        },
        {
          "refresh": "ytm"
        }
      ]
    },
    "pause": {
      "phrase": "Pause YouTube Music",
      "description": "Pause the YouTube Music tab.",
      "do": [
        {
          "set": {
            "command": "pause"
          }
        },
        {
          "run": "control.sh"
        },
        {
          "refresh": "ytm"
        }
      ]
    },
    "next": {
      "phrase": "Next song on YouTube Music",
      "description": "Skip to the next song in YouTube Music.",
      "do": [
        {
          "set": {
            "command": "next"
          }
        },
        {
          "run": "control.sh"
        },
        {
          "refresh": "ytm"
        }
      ]
    },
    "previous": {
      "phrase": "Previous song on YouTube Music",
      "description": "Go back to the previous song in YouTube Music.",
      "do": [
        {
          "set": {
            "command": "previous"
          }
        },
        {
          "run": "control.sh"
        },
        {
          "refresh": "ytm"
        }
      ]
    },
    "like": {
      "phrase": "Like this song",
      "description": "Like (or un-like) the current YouTube Music song.",
      "do": [
        {
          "set": {
            "command": "like"
          }
        },
        {
          "run": "control.sh"
        },
        {
          "refresh": "ytm"
        }
      ]
    },
    "show": {
      "phrase": "Show YouTube Music",
      "description": "Bring the YouTube Music tab to the front.",
      "do": [
        {
          "set": {
            "command": "focus"
          }
        },
        {
          "run": "control.sh"
        }
      ]
    }
  },
  "states": {
    "playing": {
      "compact": {
        "leading": {
          "image": "{{ytm.artwork}}"
        },
        "trailing": {
          "equalizer": {
            "active_when": {
              "ytm.state": "playing"
            },
            "tint_from_image": "{{ytm.artwork}}"
          }
        }
      },
      "expanded": {
        "size": "compact",
        "blocks": [
          {
            "row": [
              {
                "image": "{{ytm.artwork}}",
                "size": "medium"
              },
              {
                "stack": [
                  {
                    "text": "{{ytm.name | Nothing playing}}",
                    "style": "title",
                    "lines": 1
                  },
                  {
                    "text": "{{ytm.artist}}",
                    "tint": "gray",
                    "lines": 1
                  }
                ],
                "spacing": "tight"
              },
              "spacer",
              {
                "equalizer": {
                  "active_when": {
                    "ytm.state": "playing"
                  },
                  "tint_from_image": "{{ytm.artwork}}"
                }
              }
            ],
            "align": "center"
          },
          {
            "slider": {
              "value": "{{ytm.position | 0}}",
              "max": "{{ytm.duration_s | 0}}",
              "format": "time",
              "ticking_when": {
                "ytm.state": "playing"
              },
              "tint_from_image": "{{ytm.artwork}}",
              "on_change": [
                {
                  "set": {
                    "command": "seek {{item.value}}"
                  }
                },
                {
                  "run": "control.sh"
                },
                {
                  "refresh": "ytm"
                }
              ]
            }
          },
          {
            "row": [
              "spacer",
              {
                "button": {
                  "style": "plain",
                  "symbol": "{{ytm.like_symbol | hand.thumbsup}}",
                  "tint": "{{ytm.like_tint}}",
                  "do": [
                    {
                      "set": {
                        "command": "like"
                      }
                    },
                    {
                      "run": "control.sh"
                    },
                    {
                      "refresh": "ytm"
                    }
                  ]
                }
              },
              "spacer",
              {
                "button": {
                  "style": "plain",
                  "symbol": "backward.fill",
                  "do": [
                    {
                      "set": {
                        "command": "previous"
                      }
                    },
                    {
                      "run": "control.sh"
                    },
                    {
                      "refresh": "ytm"
                    }
                  ]
                }
              },
              "spacer",
              {
                "button": {
                  "style": "plain",
                  "symbol": "{{ytm.toggle | pause.fill}}",
                  "size": "large",
                  "do": [
                    {
                      "set": {
                        "command": "playpause"
                      }
                    },
                    {
                      "run": "control.sh"
                    },
                    {
                      "refresh": "ytm"
                    }
                  ]
                }
              },
              "spacer",
              {
                "button": {
                  "style": "plain",
                  "symbol": "forward.fill",
                  "do": [
                    {
                      "set": {
                        "command": "next"
                      }
                    },
                    {
                      "run": "control.sh"
                    },
                    {
                      "refresh": "ytm"
                    }
                  ]
                }
              },
              "spacer",
              {
                "button": {
                  "style": "plain",
                  "symbol": "play.rectangle.fill",
                  "tint": "red",
                  "do": [
                    {
                      "set": {
                        "command": "focus"
                      }
                    },
                    {
                      "run": "control.sh"
                    }
                  ]
                }
              },
              "spacer"
            ],
            "align": "center"
          }
        ]
      }
    },
    "setup": {
      "task": true,
      "compact": {
        "leading": {
          "symbol": "play.rectangle.fill",
          "tint": "red"
        },
        "trailing": {
          "symbol": "exclamationmark.triangle.fill",
          "tint": "orange"
        }
      },
      "expanded": {
        "size": "regular",
        "header": {
          "symbol": "play.rectangle.fill",
          "tint": "red",
          "title": "YouTube Music needs one setting"
        },
        "blocks": [
          {
            "markdown": "To read and control the YouTube Music tab, turn on **View › Developer › Allow JavaScript from Apple Events** in {{ytm.browser | Chrome}}. (Safari: **Develop › Allow JavaScript from Apple Events**.)"
          },
          {
            "row": [
              "spacer",
              {
                "button": {
                  "label": "Not now",
                  "style": "plain",
                  "do": [
                    "dismiss"
                  ]
                }
              },
              {
                "button": {
                  "label": "I turned it on",
                  "style": "filled",
                  "do": [
                    {
                      "refresh": "ytm"
                    },
                    {
                      "go": "playing"
                    }
                  ]
                }
              }
            ]
          }
        ]
      }
    }
  },
  "preview": {
    "states": [
      "playing",
      "setup"
    ],
    "data": {
      "ytm": {
        "state": "playing",
        "show": true,
        "browser": "Chrome",
        "name": "Nights",
        "artist": "Frank Ocean",
        "album": "Blonde",
        "artwork": "https://image-cdn-fa.spotifycdn.com/image/ab67616d00001e02c5649add07ed3720be9d5526",
        "position": 83,
        "duration_s": 307,
        "liked": true,
        "like_symbol": "hand.thumbsup.fill",
        "like_tint": "red",
        "toggle": "pause.fill"
      }
    }
  }
}
control.sh
#!/bin/sh
# Sends $CHOPSTICK_COMMAND to the YouTube Music tab: playpause, play, pause, next,
# previous, like, focus, or "seek <seconds>".
# shellcheck disable=SC2086
osascript -l JavaScript ytm.js $CHOPSTICK_COMMAND >/dev/null
state.sh
#!/bin/sh
# What YouTube Music is playing in Chrome or Safari, as JSON. Prints {} without
# asking a browser anything when neither is open.
pgrep -xq "Google Chrome" || pgrep -xq Safari || { echo '{}'; exit 0; }
osascript -l JavaScript ytm.js state 2>/dev/null || echo '{}'
ytm.js
// Talks to a YouTube Music tab in Chrome or Safari: `osascript -l JavaScript ytm.js <command>`.
// Commands: state (prints JSON), playpause, play, pause, next, previous, seek <s>, like, focus.
// Prints {} if no tab is open, {"state":"blocked"} if the browser won't run page JavaScript.

const READ = `(() => {
  const v = document.querySelector('video'), m = navigator.mediaSession.metadata
  if (!v || !m || !m.title) return JSON.stringify({ state: 'idle' })
  const art = (m.artwork || []).slice(-1)[0]
  const like = document.querySelector('ytmusic-player-bar ytmusic-like-button-renderer')
  const liked = !!like && like.getAttribute('like-status') === 'LIKE'
  const playing = !v.paused
  return JSON.stringify({
    state: playing ? 'playing' : 'paused', name: m.title, artist: m.artist, album: m.album,
    artwork: art ? art.src : '', position: Math.round(v.currentTime), duration_s: Math.round(v.duration || 0),
    liked, like_symbol: liked ? 'hand.thumbsup.fill' : 'hand.thumbsup', like_tint: liked ? 'red' : '',
    toggle: playing ? 'pause.fill' : 'play.fill' })
})()`

const click = sel => `(() => { const b = document.querySelector(${JSON.stringify(sel)}); if (b) b.click(); return !!b })()`
const ACTIONS = {
  playpause: click('ytmusic-player-bar #play-pause-button'),
  play: `(() => { const v = document.querySelector('video'); if (v && v.paused) document.querySelector('ytmusic-player-bar #play-pause-button').click(); return true })()`,
  pause: `(() => { const v = document.querySelector('video'); if (v && !v.paused) document.querySelector('ytmusic-player-bar #play-pause-button').click(); return true })()`,
  next: click('ytmusic-player-bar .next-button'),
  previous: click('ytmusic-player-bar .previous-button'),
  like: `(() => { const r = document.querySelector('ytmusic-player-bar ytmusic-like-button-renderer');
    const b = r && (r.querySelector('#button-shape-like button') || r.querySelector('.like')); if (b) b.click(); return !!b })()`,
}

function running(name) {
  try { return Application(name).running() } catch (e) { return false }
}

// Every YouTube Music tab: { browser, app, win, tab, exec(js) }
function tabs() {
  const found = []
  if (running('Google Chrome')) {
    const app = Application('Google Chrome')
    app.windows().forEach(win => win.tabs().forEach((tab, i) => {
      if (/^https:\/\/music\.youtube\.com/.test(tab.url()))
        found.push({ browser: 'Chrome', app, win, tab, index: i + 1, exec: js => tab.execute({ javascript: js }) })
    }))
  }
  if (running('Safari')) {
    const app = Application('Safari')
    app.windows().forEach(win => { try { win.tabs().forEach(tab => {
      if (/^https:\/\/music\.youtube\.com/.test(tab.url() || ''))
        found.push({ browser: 'Safari', app, win, tab, exec: js => app.doJavaScript(js, { in: tab }) })
    }) } catch (e) {} })
  }
  return found
}

function run(argv) {
  const [cmd = 'state', arg] = argv
  const all = tabs()
  if (!all.length) return '{}'

  // Read every tab; the one playing wins, else the first that has a track.
  let best = null, blocked = null
  for (const t of all) {
    let s
    try { s = JSON.parse(t.exec(READ)) } catch (e) { blocked = blocked || t; continue }
    s.browser = t.browser
    if (!best || (s.state === 'playing' && best.s.state !== 'playing') || (best.s.state === 'idle' && s.state !== 'idle')) best = { t, s }
  }
  if (!best) return JSON.stringify({ state: 'blocked', browser: blocked.browser, show: true })

  const { t, s } = best
  if (cmd === 'state') { s.show = s.state === 'playing'; return JSON.stringify(s) }
  if (cmd === 'seek') return String(t.exec(`(() => { document.querySelector('video').currentTime = ${Number(arg) || 0}; return true })()`))
  if (cmd === 'focus') {
    if (t.browser === 'Chrome') t.win.activeTabIndex = t.index
    else t.win.currentTab = t.tab
    t.win.index = 1
    t.app.activate()
    return 'true'
  }
  if (!ACTIONS[cmd]) throw new Error('Unknown command ' + cmd)
  return String(t.exec(ACTIONS[cmd]))
}