@naxodev/opencode-music-player 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +5 -4
  2. package/dist/index.js +35 -50
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -8,7 +8,7 @@ It supports browsers, Spotify, Apple Music, Kaset, and other apps exposed throug
8
8
 
9
9
  One reconnecting music-session client supplies replayed and live state, provider status, transport, and daemon-owned native artwork bytes. The shared same-user daemon owns provider discovery, provider events and polling, the playback clock, and global transport ordering.
10
10
 
11
- OpenCode keeps plugin/controller lifecycle, the Solid compact and sidebar UI, optimistic transport presentation, seek coalescing, notifications, waveform projection, iTunes catalog fallback and downloads, conversion, bounded presentation cache/jobs, and Kitty or half-block rendering. Plugin disposal removes local listeners and presentation work, then disposes only its session client. Other clients keep the shared daemon alive.
11
+ OpenCode keeps plugin/controller lifecycle, the Solid compact and sidebar UI, transport loading feedback, seek coalescing, notifications, waveform projection, iTunes catalog fallback and downloads, conversion, bounded presentation cache/jobs, and Kitty or half-block rendering. Plugin disposal removes local listeners and presentation work, then disposes only its session client. Other clients keep the shared daemon alive.
12
12
 
13
13
  Read the [music session architecture field guide](../../docs/music-session-architecture.html) for the daemon protocol, replay, reconnect, and cleanup model.
14
14
 
@@ -38,13 +38,12 @@ set -g allow-passthrough on
38
38
  ```
39
39
 
40
40
  > [!IMPORTANT]
41
- > This source targets stable `opencode v2.0.3`. The previous beta host is unsupported. See the [compatibility contract](../../docs/opencode-compatibility.md) for the exact dependency set and release requirements.
41
+ > This source targets the exact stable host in the [validated compatibility metadata](../../docs/package-compatibility.md). The previous beta host is unsupported. See the [compatibility contract](../../docs/opencode-compatibility.md) for runtime ownership and release requirements.
42
42
 
43
43
  ## Requirements
44
44
 
45
45
  - macOS
46
- - OpenCode `v2.0.3`
47
- - The Bun-based OpenCode runtime; Bun 1.3.7 for workspace development
46
+ - The exact tested OpenCode host and workspace Bun pin listed in the compatibility metadata
48
47
  - [`media-control`](https://github.com/ungive/media-control), recommended:
49
48
 
50
49
  ```sh
@@ -97,6 +96,8 @@ Start `opencode` and run `/plugins` in the TUI. The list should include `music-p
97
96
 
98
97
  ## Controls
99
98
 
99
+ Play/pause uses the shared [playback toggle contract](../music-core/README.md#playback-toggle-contract). Each activation toggles the daemon's accepted state in queue order, even when this view's icon is stale. Rapid clicks remain separate commands.
100
+
100
101
  The compact bar appears below the active route whenever a current track exists, including while playback is paused. It remains visible when the session sidebar is collapsed. Wide terminals show the playback marker, title, and artist. Medium terminals omit the artist. Narrow terminals truncate the title, then keep only the playback marker when metadata cannot fit safely. The bar always stays on one row.
101
102
 
102
103
  | Input | Action |
package/dist/index.js CHANGED
@@ -561,6 +561,9 @@ function createSessionSystemMedia(overrides = {}) {
561
561
  async play() {
562
562
  await (await activeClient()).play();
563
563
  },
564
+ async toggle() {
565
+ await (await activeClient()).toggle();
566
+ },
564
567
  async pause() {
565
568
  await (await activeClient()).pause();
566
569
  },
@@ -1037,6 +1040,10 @@ function AlbumArtwork(props) {
1037
1040
  paintPending = false;
1038
1041
  if (!disposed && ownership.isCurrent())
1039
1042
  paintNativeImage();
1043
+ }).catch((error) => {
1044
+ paintPending = false;
1045
+ if (!disposed && ownership.isCurrent())
1046
+ console.error("Failed to paint music artwork", error);
1040
1047
  });
1041
1048
  };
1042
1049
  onMount(() => props.context.renderer.on("frame", scheduleNativeImage));
@@ -1186,7 +1193,7 @@ var BLUE = [
1186
1193
  ];
1187
1194
  function blueFor(level, playing, theme) {
1188
1195
  if (!playing && level < 0.05)
1189
- return theme.text.subdued;
1196
+ return theme.text.muted;
1190
1197
  if (level <= 0.02)
1191
1198
  return BLUE[1];
1192
1199
  const idx = Math.min(BLUE.length - 1, Math.floor(level * (BLUE.length - 1)));
@@ -1422,7 +1429,7 @@ function CompactPlayer(props) {
1422
1429
  _$setProp2(_el$2, "wrapMode", "none");
1423
1430
  _$setProp2(_el$2, "truncate", true);
1424
1431
  _$insert3(_el$2, renderLine);
1425
- _$effect2((_$p) => _$setProp2(_el$2, "fg", theme().text.action.primary.default, _$p));
1432
+ _$effect2((_$p) => _$setProp2(_el$2, "fg", theme().text.action.primary.base, _$p));
1426
1433
  return _el$;
1427
1434
  })()
1428
1435
  });
@@ -1507,11 +1514,11 @@ function ProgressBar(props) {
1507
1514
  _$insert3(_el$7, () => rendered().right);
1508
1515
  _$effect2((_p$) => {
1509
1516
  var _v$ = width(), _v$2 = {
1510
- fg: props.accent ?? props.theme.text.action.primary.default
1517
+ fg: props.accent ?? props.theme.text.action.primary.base
1511
1518
  }, _v$3 = {
1512
- fg: props.accent ?? props.theme.text.action.primary.default
1519
+ fg: props.accent ?? props.theme.text.action.primary.base
1513
1520
  }, _v$4 = {
1514
- fg: props.theme.text.subdued
1521
+ fg: props.theme.text.muted
1515
1522
  };
1516
1523
  _v$ !== _p$.e && (_p$.e = _$setProp2(_el$3, "width", _v$, _p$.e));
1517
1524
  _v$2 !== _p$.t && (_p$.t = _$setProp2(_el$5, "style", _v$2, _p$.t));
@@ -1548,7 +1555,7 @@ function LiveElapsed(props) {
1548
1555
  var _el$8 = _$createElement3("text");
1549
1556
  _$use3((element) => text = element, _el$8);
1550
1557
  _$insert3(_el$8, () => formatMs(liveProgress(props.source.current())));
1551
- _$effect2((_$p) => _$setProp2(_el$8, "fg", props.theme.text.subdued, _$p));
1558
+ _$effect2((_$p) => _$setProp2(_el$8, "fg", props.theme.text.muted, _$p));
1552
1559
  return _el$8;
1553
1560
  })();
1554
1561
  }
@@ -1557,17 +1564,17 @@ function IconBtn(props) {
1557
1564
  const [press, setPress] = createSignal(false);
1558
1565
  const bg = () => {
1559
1566
  if (press() || props.active) {
1560
- return props.theme.background.action.primary.default;
1567
+ return props.theme.background.action.primary.base;
1561
1568
  }
1562
- return props.theme.background.surface.offset;
1569
+ return props.theme.background.raised.base;
1563
1570
  };
1564
1571
  const fg2 = () => {
1565
1572
  if (press() || props.active)
1566
- return props.accent ?? props.theme.text.action.primary.default;
1573
+ return props.accent ?? props.theme.text.action.primary.base;
1567
1574
  if (props.primary) {
1568
- return hover() ? props.theme.text.action.primary.default : props.theme.text.default;
1575
+ return hover() ? props.theme.text.action.primary.base : props.theme.text.base;
1569
1576
  }
1570
- return hover() ? props.theme.text.action.primary.default : props.theme.text.default;
1577
+ return hover() ? props.theme.text.action.primary.base : props.theme.text.base;
1571
1578
  };
1572
1579
  const w = () => props.width ?? 3;
1573
1580
  const h = () => props.height ?? 1;
@@ -1656,7 +1663,7 @@ function SidebarPlayer(props) {
1656
1663
  children: (err) => (() => {
1657
1664
  var _el$17 = _$createElement3("text");
1658
1665
  _$insert3(_el$17, () => sanitizeTerminalText(err()));
1659
- _$effect2((_$p) => _$setProp2(_el$17, "fg", theme().text.feedback.error.default, _$p));
1666
+ _$effect2((_$p) => _$setProp2(_el$17, "fg", theme().text.feedback.error.base, _$p));
1660
1667
  return _el$17;
1661
1668
  })()
1662
1669
  }), _el$10);
@@ -1682,7 +1689,7 @@ function SidebarPlayer(props) {
1682
1689
  var _c$ = _$memo(() => !!track());
1683
1690
  return () => _c$() ? track()?.artwork_loading ? "Loading artwork\u2026" : "Artwork unavailable" : "";
1684
1691
  })());
1685
- _$effect2((_$p) => _$setProp2(_el$19, "fg", theme().text.subdued, _$p));
1692
+ _$effect2((_$p) => _$setProp2(_el$19, "fg", theme().text.muted, _$p));
1686
1693
  return _el$18;
1687
1694
  })();
1688
1695
  },
@@ -1703,7 +1710,7 @@ function SidebarPlayer(props) {
1703
1710
  return (() => {
1704
1711
  var _el$20 = _$createElement3("text");
1705
1712
  _$insert3(_el$20, () => props.state.loading ? "Syncing\u2026" : "Nothing playing");
1706
- _$effect2((_$p) => _$setProp2(_el$20, "fg", theme().text.subdued, _$p));
1713
+ _$effect2((_$p) => _$setProp2(_el$20, "fg", theme().text.muted, _$p));
1707
1714
  return _el$20;
1708
1715
  })();
1709
1716
  },
@@ -1721,7 +1728,7 @@ function SidebarPlayer(props) {
1721
1728
  get children() {
1722
1729
  var _el$24 = _$createElement3("text");
1723
1730
  _$insert3(_el$24, () => sanitizeTerminalText(t2().artists));
1724
- _$effect2((_$p) => _$setProp2(_el$24, "fg", theme().text.subdued, _$p));
1731
+ _$effect2((_$p) => _$setProp2(_el$24, "fg", theme().text.muted, _$p));
1725
1732
  return _el$24;
1726
1733
  }
1727
1734
  }), null);
@@ -1732,11 +1739,11 @@ function SidebarPlayer(props) {
1732
1739
  get children() {
1733
1740
  var _el$25 = _$createElement3("text");
1734
1741
  _$insert3(_el$25, () => sanitizeTerminalText(t2().album));
1735
- _$effect2((_$p) => _$setProp2(_el$25, "fg", theme().text.subdued, _$p));
1742
+ _$effect2((_$p) => _$setProp2(_el$25, "fg", theme().text.muted, _$p));
1736
1743
  return _el$25;
1737
1744
  }
1738
1745
  }), null);
1739
- _$effect2((_$p) => _$setProp2(_el$22, "fg", theme().text.default, _$p));
1746
+ _$effect2((_$p) => _$setProp2(_el$22, "fg", theme().text.base, _$p));
1740
1747
  return _el$21;
1741
1748
  })()
1742
1749
  }), _el$12);
@@ -1784,7 +1791,7 @@ function SidebarPlayer(props) {
1784
1791
  source
1785
1792
  }), _el$15);
1786
1793
  _$insert3(_el$15, () => formatMs(duration()));
1787
- _$effect2((_$p) => _$setProp2(_el$15, "fg", theme().text.subdued, _$p));
1794
+ _$effect2((_$p) => _$setProp2(_el$15, "fg", theme().text.muted, _$p));
1788
1795
  return _el$13;
1789
1796
  }
1790
1797
  }), _el$16);
@@ -1843,7 +1850,7 @@ function SidebarPlayer(props) {
1843
1850
  onClick: () => props.onNext()
1844
1851
  }), null);
1845
1852
  _$effect2((_p$) => {
1846
- var _v$9 = playing() ? track()?.artwork?.accent ?? theme().text.action.primary.default : theme().border.default, _v$0 = TRANSPORT_LAYOUT.gap;
1853
+ var _v$9 = playing() ? track()?.artwork?.accent ?? theme().text.action.primary.base : theme().border.base, _v$0 = TRANSPORT_LAYOUT.gap;
1847
1854
  _v$9 !== _p$.e && (_p$.e = _$setProp2(_el$1, "borderColor", _v$9, _p$.e));
1848
1855
  _v$0 !== _p$.t && (_p$.t = _$setProp2(_el$16, "gap", _v$0, _p$.t));
1849
1856
  return _p$;
@@ -1899,8 +1906,6 @@ function createController(context, dependencies = controllerDependencies) {
1899
1906
  };
1900
1907
  let transportError = null;
1901
1908
  let receivedSnapshot = false;
1902
- let snapshotEpoch = 0;
1903
- let playbackIntent;
1904
1909
  let activeSeek = null;
1905
1910
  let latestSeek = null;
1906
1911
  const pending = new Set;
@@ -1958,9 +1963,7 @@ function createController(context, dependencies = controllerDependencies) {
1958
1963
  } catch (error) {
1959
1964
  result = Promise.reject(error);
1960
1965
  }
1961
- let succeeded = false;
1962
1966
  Promise.resolve(result).then(() => {
1963
- succeeded = true;
1964
1967
  if (!isActive() || generation !== lifecycleGeneration)
1965
1968
  return;
1966
1969
  if (transportError !== null)
@@ -1977,10 +1980,12 @@ function createController(context, dependencies = controllerDependencies) {
1977
1980
  });
1978
1981
  }).finally(() => {
1979
1982
  try {
1980
- onSettled?.(succeeded);
1983
+ onSettled?.();
1981
1984
  } finally {
1982
1985
  settle(resolve);
1983
1986
  }
1987
+ }).catch((error) => {
1988
+ console.error("Failed to settle music command", error);
1984
1989
  });
1985
1990
  });
1986
1991
  };
@@ -1999,6 +2004,8 @@ function createController(context, dependencies = controllerDependencies) {
1999
2004
  runSeek(next);
2000
2005
  else
2001
2006
  next?.resolves.splice(0).forEach((resolve) => resolve());
2007
+ }).catch((error) => {
2008
+ console.error("Failed to start music seek", error);
2002
2009
  });
2003
2010
  };
2004
2011
  const refreshAll = async () => {
@@ -2017,26 +2024,7 @@ function createController(context, dependencies = controllerDependencies) {
2017
2024
  setTransportError(errMsg(error));
2018
2025
  }
2019
2026
  };
2020
- const playPause = () => {
2021
- const nextPlaying = !(playbackIntent?.target ?? !!session.player?.is_playing);
2022
- const intent = {
2023
- target: nextPlaying,
2024
- startedAtEpoch: snapshotEpoch,
2025
- settled: false
2026
- };
2027
- playbackIntent = intent;
2028
- return runCommand(() => nextPlaying ? media.play() : media.pause(), (succeeded) => {
2029
- if (playbackIntent !== intent)
2030
- return;
2031
- if (!succeeded) {
2032
- playbackIntent = undefined;
2033
- return;
2034
- }
2035
- intent.settled = true;
2036
- if (snapshotEpoch > intent.startedAtEpoch && session.player?.is_playing === intent.target)
2037
- playbackIntent = undefined;
2038
- });
2039
- };
2027
+ const playPause = () => runCommand(() => media.toggle());
2040
2028
  const seek = (positionMs) => {
2041
2029
  const target = seekTarget(positionMs, session.player?.track?.duration_ms);
2042
2030
  if (!session.player?.track || target === null || !isActive())
@@ -2065,18 +2053,13 @@ function createController(context, dependencies = controllerDependencies) {
2065
2053
  return;
2066
2054
  if (event?.type === "snapshot") {
2067
2055
  receivedSnapshot = true;
2068
- snapshotEpoch++;
2069
2056
  setSession((draft) => {
2070
2057
  draft.player = mergePlayerSnapshot(draft.player, event.state);
2071
2058
  });
2072
- if (playbackIntent?.settled && snapshotEpoch > playbackIntent.startedAtEpoch && event.state.is_playing === playbackIntent.target)
2073
- playbackIntent = undefined;
2074
2059
  return;
2075
2060
  }
2076
2061
  if (event?.type === "lifecycle") {
2077
2062
  setLifecycleError(event.message, event.source);
2078
- if (event.source === "connection" && event.message !== null)
2079
- playbackIntent = undefined;
2080
2063
  if (event.message !== null && latestSeek) {
2081
2064
  latestSeek.resolves.splice(0).forEach((resolve) => resolve());
2082
2065
  latestSeek = null;
@@ -2090,7 +2073,9 @@ function createController(context, dependencies = controllerDependencies) {
2090
2073
  draft.player = mergeArtworkCompletion(draft.player, event);
2091
2074
  });
2092
2075
  });
2093
- refreshAll();
2076
+ refreshAll().catch((error) => {
2077
+ console.error("Failed to publish initial music state", error);
2078
+ });
2094
2079
  return {
2095
2080
  session,
2096
2081
  subscribe(listener) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/opencode-music-player",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "macOS system Now Playing controls for the OpenCode 2 TUI",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -64,16 +64,16 @@
64
64
  "pngjs": "^7.0.0"
65
65
  },
66
66
  "peerDependencies": {
67
- "@opencode/plugin": "2.0.3",
67
+ "@opencode/plugin": "2.0.16",
68
68
  "@opentui/core": "0.5.10",
69
69
  "@opentui/solid": "0.5.10"
70
70
  },
71
71
  "devDependencies": {
72
- "@opencode/plugin": "2.0.3",
72
+ "@opencode/plugin": "2.0.16",
73
73
  "@opentui/core": "0.5.10",
74
74
  "@opentui/solid": "0.5.10",
75
75
  "solid-js": "1.9.15",
76
- "@opencode/theme": "2.0.3",
76
+ "@opencode/theme": "2.0.16",
77
77
  "@types/bun": "^1.3.14",
78
78
  "@types/pngjs": "^6.0.5",
79
79
  "prettier": "3.9.6",