@ohmykreee/dsh-remote-control 0.0.0-stage → 1.0.3

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 (373) hide show
  1. package/AGENTS.md +556 -0
  2. package/INSTALLATION.md +310 -0
  3. package/LICENSE +21 -0
  4. package/README.md +195 -2
  5. package/client.js +3 -0
  6. package/components.json +20 -0
  7. package/cordis.patch.yml +3 -0
  8. package/docs/README.en.md +196 -0
  9. package/docs/screenshots/en/overview.png +0 -0
  10. package/docs/screenshots/en/question.png +0 -0
  11. package/docs/screenshots/zh/overview.png +0 -0
  12. package/docs/screenshots/zh/question.png +0 -0
  13. package/icon.svg +24 -0
  14. package/index.js +2 -0
  15. package/lib/account.js +1 -0
  16. package/lib/app-mapping.js +1 -0
  17. package/lib/i18n.js +1 -0
  18. package/lib/media-enrich.js +1 -0
  19. package/lib/notifications.js +1 -0
  20. package/lib/performance.js +1 -0
  21. package/lib/platform/index.js +1 -0
  22. package/lib/probe-adapter.js +1 -0
  23. package/lib/probe-daemon.js +1 -0
  24. package/lib/probe.js +1 -0
  25. package/lib/qr.js +1 -0
  26. package/lib/runtime.js +1 -0
  27. package/lib/service.js +1 -0
  28. package/lib/sessions.js +1 -0
  29. package/lib/settings.js +1 -0
  30. package/lib/types.js +1 -0
  31. package/lib/util.js +1 -0
  32. package/lib/weather.js +1 -0
  33. package/lib/ws.js +1 -0
  34. package/locale/en.json +6 -0
  35. package/locale/zh.json +6 -0
  36. package/package.json +118 -4
  37. package/scripts/browser-shot.mjs +217 -0
  38. package/scripts/build-dsh.mjs +228 -0
  39. package/scripts/build-rust.mjs +319 -0
  40. package/scripts/build-web.mjs +17 -0
  41. package/scripts/check-npm-package.mjs +322 -0
  42. package/scripts/mock-preview.py +574 -0
  43. package/scripts/screenshot-compose.py +89 -0
  44. package/scripts/screenshot-device.sh +75 -0
  45. package/src/dsh/client/api.ts +68 -0
  46. package/src/dsh/client/i18n.ts +573 -0
  47. package/src/dsh/client/icons.ts +56 -0
  48. package/src/dsh/client/index.ts +95 -0
  49. package/src/dsh/client/logic.ts +310 -0
  50. package/src/dsh/client/panel.ts +483 -0
  51. package/src/dsh/client/sections/apps.ts +40 -0
  52. package/src/dsh/client/sections/balance.ts +38 -0
  53. package/src/dsh/client/sections/connection.ts +97 -0
  54. package/src/dsh/client/sections/notifications.ts +43 -0
  55. package/src/dsh/client/sections/pomodoro.ts +184 -0
  56. package/src/dsh/client/sections/preferences.ts +138 -0
  57. package/src/dsh/client/sections/system.ts +120 -0
  58. package/src/dsh/client/sections/weather.ts +276 -0
  59. package/src/dsh/client/styles.ts +1119 -0
  60. package/src/dsh/client/types.ts +366 -0
  61. package/src/dsh/client/ui.ts +142 -0
  62. package/src/dsh/index.ts +108 -0
  63. package/src/dsh/lib/account.ts +520 -0
  64. package/src/dsh/lib/app-mapping.ts +366 -0
  65. package/src/dsh/lib/i18n.ts +82 -0
  66. package/src/dsh/lib/media-enrich.ts +202 -0
  67. package/src/dsh/lib/notifications.ts +322 -0
  68. package/src/dsh/lib/performance.ts +279 -0
  69. package/src/dsh/lib/platform/index.ts +43 -0
  70. package/src/dsh/lib/probe-adapter.ts +275 -0
  71. package/src/dsh/lib/probe-daemon.ts +359 -0
  72. package/src/dsh/lib/probe.ts +395 -0
  73. package/src/dsh/lib/qr.ts +42 -0
  74. package/src/dsh/lib/runtime.ts +126 -0
  75. package/src/dsh/lib/service.ts +1645 -0
  76. package/src/dsh/lib/sessions.ts +907 -0
  77. package/src/dsh/lib/settings.ts +468 -0
  78. package/src/dsh/lib/types.ts +231 -0
  79. package/src/dsh/lib/util.ts +115 -0
  80. package/src/dsh/lib/weather.ts +532 -0
  81. package/src/dsh/lib/ws.ts +328 -0
  82. package/src/dsh/types/qrcode-generator.d.ts +16 -0
  83. package/src/rust/Cargo.lock +1431 -0
  84. package/src/rust/Cargo.toml +68 -0
  85. package/src/rust/src/cli.rs +233 -0
  86. package/src/rust/src/daemon.rs +140 -0
  87. package/src/rust/src/location/linux.rs +118 -0
  88. package/src/rust/src/location/macos.rs +26 -0
  89. package/src/rust/src/location/mod.rs +170 -0
  90. package/src/rust/src/location/windows.rs +205 -0
  91. package/src/rust/src/main.rs +42 -0
  92. package/src/rust/src/media/catalog.rs +454 -0
  93. package/src/rust/src/media/linux.rs +350 -0
  94. package/src/rust/src/media/macos/accessibility.rs +59 -0
  95. package/src/rust/src/media/macos.rs +366 -0
  96. package/src/rust/src/media/mod.rs +298 -0
  97. package/src/rust/src/media/vendor/apple_music.rs +90 -0
  98. package/src/rust/src/media/vendor/common.rs +186 -0
  99. package/src/rust/src/media/vendor/kugou.rs +52 -0
  100. package/src/rust/src/media/vendor/kuwo.rs +49 -0
  101. package/src/rust/src/media/vendor/mod.rs +75 -0
  102. package/src/rust/src/media/vendor/netease.rs +1715 -0
  103. package/src/rust/src/media/vendor/qishui.rs +36 -0
  104. package/src/rust/src/media/vendor/qqmusic.rs +33 -0
  105. package/src/rust/src/media/vendor/spotify.rs +46 -0
  106. package/src/rust/src/media/vendor/wesing.rs +39 -0
  107. package/src/rust/src/media/vendor/youtube_music.rs +49 -0
  108. package/src/rust/src/media/windows/accessibility.rs +85 -0
  109. package/src/rust/src/media/windows/audio.rs +116 -0
  110. package/src/rust/src/media/windows/private.rs +291 -0
  111. package/src/rust/src/media/windows.rs +775 -0
  112. package/src/rust/src/notifications/linux.rs +276 -0
  113. package/src/rust/src/notifications/macos.rs +382 -0
  114. package/src/rust/src/notifications/mod.rs +244 -0
  115. package/src/rust/src/notifications/windows.rs +565 -0
  116. package/src/rust/src/perf/common.rs +109 -0
  117. package/src/rust/src/perf/linux.rs +282 -0
  118. package/src/rust/src/perf/macos.rs +28 -0
  119. package/src/rust/src/perf/mod.rs +157 -0
  120. package/src/rust/src/perf/windows.rs +981 -0
  121. package/src/rust/src/util.rs +360 -0
  122. package/src/rust/src/volume/linux.rs +195 -0
  123. package/src/rust/src/volume/macos.rs +54 -0
  124. package/src/rust/src/volume/mod.rs +116 -0
  125. package/src/rust/src/volume/windows.rs +75 -0
  126. package/src/rust/tests/cli.rs +79 -0
  127. package/src/rust/tests/daemon.rs +107 -0
  128. package/src/svelte/App.svelte +385 -0
  129. package/src/svelte/index.html +18 -0
  130. package/src/svelte/lib/billing.svelte.ts +228 -0
  131. package/src/svelte/lib/components/BalanceCard.svelte +173 -0
  132. package/src/svelte/lib/components/ClockCard.svelte +183 -0
  133. package/src/svelte/lib/components/CountdownCard.svelte +261 -0
  134. package/src/svelte/lib/components/CpuMemoryCard.svelte +238 -0
  135. package/src/svelte/lib/components/EmptyState.svelte +34 -0
  136. package/src/svelte/lib/components/GpuCard.svelte +245 -0
  137. package/src/svelte/lib/components/HomePage.svelte +34 -0
  138. package/src/svelte/lib/components/MusicControls.svelte +256 -0
  139. package/src/svelte/lib/components/MusicCover.svelte +88 -0
  140. package/src/svelte/lib/components/MusicPage.svelte +116 -0
  141. package/src/svelte/lib/components/NotificationsCard.svelte +109 -0
  142. package/src/svelte/lib/components/Panel.svelte +22 -0
  143. package/src/svelte/lib/components/PerformancePage.svelte +55 -0
  144. package/src/svelte/lib/components/PomodoroCard.svelte +250 -0
  145. package/src/svelte/lib/components/QuestionSheet.svelte +354 -0
  146. package/src/svelte/lib/components/RadialGauge.svelte +99 -0
  147. package/src/svelte/lib/components/Rail.svelte +217 -0
  148. package/src/svelte/lib/components/SectionHeader.svelte +40 -0
  149. package/src/svelte/lib/components/SeekBar.svelte +421 -0
  150. package/src/svelte/lib/components/SessionList.svelte +364 -0
  151. package/src/svelte/lib/components/SessionStats.svelte +98 -0
  152. package/src/svelte/lib/components/SessionsPage.svelte +61 -0
  153. package/src/svelte/lib/components/SpendCard.svelte +105 -0
  154. package/src/svelte/lib/components/ThermalCard.svelte +249 -0
  155. package/src/svelte/lib/components/Toast.svelte +32 -0
  156. package/src/svelte/lib/components/WeatherIcon.svelte +40 -0
  157. package/src/svelte/lib/components/WeatherPanel.svelte +420 -0
  158. package/src/svelte/lib/components/ui/alert/alert-action.svelte +20 -0
  159. package/src/svelte/lib/components/ui/alert/alert-description.svelte +23 -0
  160. package/src/svelte/lib/components/ui/alert/alert-title.svelte +23 -0
  161. package/src/svelte/lib/components/ui/alert/alert.svelte +43 -0
  162. package/src/svelte/lib/components/ui/alert/index.ts +17 -0
  163. package/src/svelte/lib/components/ui/avatar/avatar-badge.svelte +26 -0
  164. package/src/svelte/lib/components/ui/avatar/avatar-fallback.svelte +20 -0
  165. package/src/svelte/lib/components/ui/avatar/avatar-group-count.svelte +23 -0
  166. package/src/svelte/lib/components/ui/avatar/avatar-group.svelte +23 -0
  167. package/src/svelte/lib/components/ui/avatar/avatar-image.svelte +17 -0
  168. package/src/svelte/lib/components/ui/avatar/avatar.svelte +26 -0
  169. package/src/svelte/lib/components/ui/avatar/index.ts +22 -0
  170. package/src/svelte/lib/components/ui/badge/badge.svelte +49 -0
  171. package/src/svelte/lib/components/ui/badge/index.ts +2 -0
  172. package/src/svelte/lib/components/ui/button/button.svelte +82 -0
  173. package/src/svelte/lib/components/ui/button/index.ts +17 -0
  174. package/src/svelte/lib/components/ui/card/card-action.svelte +23 -0
  175. package/src/svelte/lib/components/ui/card/card-content.svelte +20 -0
  176. package/src/svelte/lib/components/ui/card/card-description.svelte +20 -0
  177. package/src/svelte/lib/components/ui/card/card-footer.svelte +20 -0
  178. package/src/svelte/lib/components/ui/card/card-header.svelte +23 -0
  179. package/src/svelte/lib/components/ui/card/card-title.svelte +20 -0
  180. package/src/svelte/lib/components/ui/card/card.svelte +22 -0
  181. package/src/svelte/lib/components/ui/card/index.ts +25 -0
  182. package/src/svelte/lib/components/ui/dialog/dialog-close.svelte +11 -0
  183. package/src/svelte/lib/components/ui/dialog/dialog-content.svelte +48 -0
  184. package/src/svelte/lib/components/ui/dialog/dialog-description.svelte +17 -0
  185. package/src/svelte/lib/components/ui/dialog/dialog-footer.svelte +32 -0
  186. package/src/svelte/lib/components/ui/dialog/dialog-header.svelte +20 -0
  187. package/src/svelte/lib/components/ui/dialog/dialog-overlay.svelte +17 -0
  188. package/src/svelte/lib/components/ui/dialog/dialog-portal.svelte +7 -0
  189. package/src/svelte/lib/components/ui/dialog/dialog-title.svelte +17 -0
  190. package/src/svelte/lib/components/ui/dialog/dialog-trigger.svelte +11 -0
  191. package/src/svelte/lib/components/ui/dialog/dialog.svelte +7 -0
  192. package/src/svelte/lib/components/ui/dialog/index.ts +34 -0
  193. package/src/svelte/lib/components/ui/empty/empty-content.svelte +23 -0
  194. package/src/svelte/lib/components/ui/empty/empty-description.svelte +23 -0
  195. package/src/svelte/lib/components/ui/empty/empty-header.svelte +20 -0
  196. package/src/svelte/lib/components/ui/empty/empty-media.svelte +41 -0
  197. package/src/svelte/lib/components/ui/empty/empty-title.svelte +20 -0
  198. package/src/svelte/lib/components/ui/empty/empty.svelte +23 -0
  199. package/src/svelte/lib/components/ui/empty/index.ts +22 -0
  200. package/src/svelte/lib/components/ui/input/index.ts +7 -0
  201. package/src/svelte/lib/components/ui/input/input.svelte +48 -0
  202. package/src/svelte/lib/components/ui/progress/index.ts +7 -0
  203. package/src/svelte/lib/components/ui/progress/progress.svelte +27 -0
  204. package/src/svelte/lib/components/ui/scroll-area/index.ts +10 -0
  205. package/src/svelte/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte +30 -0
  206. package/src/svelte/lib/components/ui/scroll-area/scroll-area.svelte +43 -0
  207. package/src/svelte/lib/components/ui/separator/index.ts +7 -0
  208. package/src/svelte/lib/components/ui/separator/separator.svelte +21 -0
  209. package/src/svelte/lib/components/ui/sheet/index.ts +34 -0
  210. package/src/svelte/lib/components/ui/sheet/sheet-close.svelte +7 -0
  211. package/src/svelte/lib/components/ui/sheet/sheet-content.svelte +55 -0
  212. package/src/svelte/lib/components/ui/sheet/sheet-description.svelte +17 -0
  213. package/src/svelte/lib/components/ui/sheet/sheet-footer.svelte +20 -0
  214. package/src/svelte/lib/components/ui/sheet/sheet-header.svelte +20 -0
  215. package/src/svelte/lib/components/ui/sheet/sheet-overlay.svelte +20 -0
  216. package/src/svelte/lib/components/ui/sheet/sheet-portal.svelte +7 -0
  217. package/src/svelte/lib/components/ui/sheet/sheet-title.svelte +17 -0
  218. package/src/svelte/lib/components/ui/sheet/sheet-trigger.svelte +7 -0
  219. package/src/svelte/lib/components/ui/sheet/sheet.svelte +7 -0
  220. package/src/svelte/lib/components/ui/skeleton/index.ts +7 -0
  221. package/src/svelte/lib/components/ui/skeleton/skeleton.svelte +17 -0
  222. package/src/svelte/lib/components/ui/slider/index.ts +7 -0
  223. package/src/svelte/lib/components/ui/slider/slider.svelte +52 -0
  224. package/src/svelte/lib/components/ui/tooltip/index.ts +19 -0
  225. package/src/svelte/lib/components/ui/tooltip/tooltip-content.svelte +51 -0
  226. package/src/svelte/lib/components/ui/tooltip/tooltip-portal.svelte +7 -0
  227. package/src/svelte/lib/components/ui/tooltip/tooltip-provider.svelte +7 -0
  228. package/src/svelte/lib/components/ui/tooltip/tooltip-trigger.svelte +7 -0
  229. package/src/svelte/lib/components/ui/tooltip/tooltip.svelte +7 -0
  230. package/src/svelte/lib/countdown.svelte.ts +252 -0
  231. package/src/svelte/lib/format.ts +199 -0
  232. package/src/svelte/lib/i18n.svelte.ts +583 -0
  233. package/src/svelte/lib/media.ts +32 -0
  234. package/src/svelte/lib/pages.ts +18 -0
  235. package/src/svelte/lib/pomodoro.svelte.ts +223 -0
  236. package/src/svelte/lib/session-tree.ts +80 -0
  237. package/src/svelte/lib/state.svelte.ts +359 -0
  238. package/src/svelte/lib/timer-alarm.svelte.ts +319 -0
  239. package/src/svelte/lib/types.ts +306 -0
  240. package/src/svelte/lib/utils.ts +8 -0
  241. package/src/svelte/lib/weather.ts +84 -0
  242. package/src/svelte/lib/ws.ts +104 -0
  243. package/src/svelte/main.ts +13 -0
  244. package/src/svelte/styles/global.css +315 -0
  245. package/src/svelte/vite-env.d.ts +12 -0
  246. package/static/fonts/LICENSE.txt +287 -0
  247. package/static/icon.svg +24 -0
  248. package/static/manifest.webmanifest +18 -0
  249. package/svelte.config.js +5 -0
  250. package/tsconfig.dsh-client.json +18 -0
  251. package/tsconfig.dsh.json +21 -0
  252. package/tsconfig.json +20 -0
  253. package/vite.config.js +31 -0
  254. package/web/assets/index-DLdSfnJ3.css +1 -0
  255. package/web/assets/index-YNE8X0Uy.js +6 -0
  256. package/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  257. package/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  258. package/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  259. package/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  260. package/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  261. package/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  262. package/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  263. package/web/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  264. package/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  265. package/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  266. package/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  267. package/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  268. package/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  269. package/web/assets/noto-sans-sc-100-wght-normal-DluiXYzi.woff2 +0 -0
  270. package/web/assets/noto-sans-sc-101-wght-normal-CPkZHnw0.woff2 +0 -0
  271. package/web/assets/noto-sans-sc-102-wght-normal-gzIulUH6.woff2 +0 -0
  272. package/web/assets/noto-sans-sc-103-wght-normal-CJfSBxFo.woff2 +0 -0
  273. package/web/assets/noto-sans-sc-104-wght-normal-B3QMglgE.woff2 +0 -0
  274. package/web/assets/noto-sans-sc-105-wght-normal-xXh_OMfk.woff2 +0 -0
  275. package/web/assets/noto-sans-sc-106-wght-normal-B9n3jMjP.woff2 +0 -0
  276. package/web/assets/noto-sans-sc-107-wght-normal-D7fvVprN.woff2 +0 -0
  277. package/web/assets/noto-sans-sc-108-wght-normal-J0_LhxVo.woff2 +0 -0
  278. package/web/assets/noto-sans-sc-109-wght-normal-Dsp-De_0.woff2 +0 -0
  279. package/web/assets/noto-sans-sc-110-wght-normal-DE_6NUrb.woff2 +0 -0
  280. package/web/assets/noto-sans-sc-111-wght-normal-C4S-TlTn.woff2 +0 -0
  281. package/web/assets/noto-sans-sc-112-wght-normal-BcFuP3w6.woff2 +0 -0
  282. package/web/assets/noto-sans-sc-113-wght-normal-Dk932kmj.woff2 +0 -0
  283. package/web/assets/noto-sans-sc-114-wght-normal-CO_5Xo-i.woff2 +0 -0
  284. package/web/assets/noto-sans-sc-115-wght-normal-BWy07H14.woff2 +0 -0
  285. package/web/assets/noto-sans-sc-116-wght-normal-B4HVAfHo.woff2 +0 -0
  286. package/web/assets/noto-sans-sc-117-wght-normal-siZzFqgK.woff2 +0 -0
  287. package/web/assets/noto-sans-sc-118-wght-normal-xhBB7f6V.woff2 +0 -0
  288. package/web/assets/noto-sans-sc-119-wght-normal-Ck_g2FGz.woff2 +0 -0
  289. package/web/assets/noto-sans-sc-21-wght-normal-D0x9-yBt.woff2 +0 -0
  290. package/web/assets/noto-sans-sc-22-wght-normal-BBUVTShg.woff2 +0 -0
  291. package/web/assets/noto-sans-sc-23-wght-normal-Du1oYWkE.woff2 +0 -0
  292. package/web/assets/noto-sans-sc-24-wght-normal-CmWjJ0gt.woff2 +0 -0
  293. package/web/assets/noto-sans-sc-25-wght-normal-BFHiqilg.woff2 +0 -0
  294. package/web/assets/noto-sans-sc-26-wght-normal-C0sF2Z2f.woff2 +0 -0
  295. package/web/assets/noto-sans-sc-27-wght-normal-COFdrquJ.woff2 +0 -0
  296. package/web/assets/noto-sans-sc-28-wght-normal-DMgdNu1o.woff2 +0 -0
  297. package/web/assets/noto-sans-sc-29-wght-normal-Cmq8em30.woff2 +0 -0
  298. package/web/assets/noto-sans-sc-30-wght-normal-CZWqZ3yU.woff2 +0 -0
  299. package/web/assets/noto-sans-sc-31-wght-normal-Wfi_lutW.woff2 +0 -0
  300. package/web/assets/noto-sans-sc-32-wght-normal-BRJQ4PY5.woff2 +0 -0
  301. package/web/assets/noto-sans-sc-33-wght-normal-CUVQXNAJ.woff2 +0 -0
  302. package/web/assets/noto-sans-sc-34-wght-normal-Bspba0wC.woff2 +0 -0
  303. package/web/assets/noto-sans-sc-35-wght-normal-K8fX_YPG.woff2 +0 -0
  304. package/web/assets/noto-sans-sc-36-wght-normal-CkITyjoT.woff2 +0 -0
  305. package/web/assets/noto-sans-sc-37-wght-normal-WCZ2zfEe.woff2 +0 -0
  306. package/web/assets/noto-sans-sc-38-wght-normal-DeM7CUce.woff2 +0 -0
  307. package/web/assets/noto-sans-sc-39-wght-normal-BMZNLWQU.woff2 +0 -0
  308. package/web/assets/noto-sans-sc-4-wght-normal-AR5bdDjz.woff2 +0 -0
  309. package/web/assets/noto-sans-sc-40-wght-normal-BQ5xpS5j.woff2 +0 -0
  310. package/web/assets/noto-sans-sc-41-wght-normal-Hthm7FFi.woff2 +0 -0
  311. package/web/assets/noto-sans-sc-42-wght-normal-eaS2gKds.woff2 +0 -0
  312. package/web/assets/noto-sans-sc-43-wght-normal-o-T53XXV.woff2 +0 -0
  313. package/web/assets/noto-sans-sc-44-wght-normal-BJH5J4Ez.woff2 +0 -0
  314. package/web/assets/noto-sans-sc-45-wght-normal-BKuxSCOJ.woff2 +0 -0
  315. package/web/assets/noto-sans-sc-46-wght-normal-BR_5Mu6e.woff2 +0 -0
  316. package/web/assets/noto-sans-sc-47-wght-normal-CyY7gKNX.woff2 +0 -0
  317. package/web/assets/noto-sans-sc-48-wght-normal-BNMfTR6k.woff2 +0 -0
  318. package/web/assets/noto-sans-sc-49-wght-normal-DX-0y7wg.woff2 +0 -0
  319. package/web/assets/noto-sans-sc-5-wght-normal-CfWugllB.woff2 +0 -0
  320. package/web/assets/noto-sans-sc-50-wght-normal-DAOGW-Dr.woff2 +0 -0
  321. package/web/assets/noto-sans-sc-51-wght-normal-BlFLOjcP.woff2 +0 -0
  322. package/web/assets/noto-sans-sc-52-wght-normal-OZjrybnw.woff2 +0 -0
  323. package/web/assets/noto-sans-sc-53-wght-normal-CwyWNT6x.woff2 +0 -0
  324. package/web/assets/noto-sans-sc-54-wght-normal-DM0nRe18.woff2 +0 -0
  325. package/web/assets/noto-sans-sc-55-wght-normal-DBK2wUOR.woff2 +0 -0
  326. package/web/assets/noto-sans-sc-56-wght-normal-C-VKK1LS.woff2 +0 -0
  327. package/web/assets/noto-sans-sc-57-wght-normal-Cdbfh5kq.woff2 +0 -0
  328. package/web/assets/noto-sans-sc-58-wght-normal-CflMKARZ.woff2 +0 -0
  329. package/web/assets/noto-sans-sc-59-wght-normal-BS4Kwzxx.woff2 +0 -0
  330. package/web/assets/noto-sans-sc-6-wght-normal-ChnZIQfQ.woff2 +0 -0
  331. package/web/assets/noto-sans-sc-60-wght-normal-B7QOYsXJ.woff2 +0 -0
  332. package/web/assets/noto-sans-sc-61-wght-normal-C8JjQS7w.woff2 +0 -0
  333. package/web/assets/noto-sans-sc-62-wght-normal-bS4wq1Gg.woff2 +0 -0
  334. package/web/assets/noto-sans-sc-63-wght-normal-BJr8P-OB.woff2 +0 -0
  335. package/web/assets/noto-sans-sc-64-wght-normal-DVB11t7j.woff2 +0 -0
  336. package/web/assets/noto-sans-sc-65-wght-normal-DMPCXBvK.woff2 +0 -0
  337. package/web/assets/noto-sans-sc-66-wght-normal-BobaG10V.woff2 +0 -0
  338. package/web/assets/noto-sans-sc-67-wght-normal-Dzk7WhCR.woff2 +0 -0
  339. package/web/assets/noto-sans-sc-68-wght-normal-DGbCD9xu.woff2 +0 -0
  340. package/web/assets/noto-sans-sc-69-wght-normal-Ctrv1EqA.woff2 +0 -0
  341. package/web/assets/noto-sans-sc-70-wght-normal-RgTO941D.woff2 +0 -0
  342. package/web/assets/noto-sans-sc-71-wght-normal-D9sG-CWv.woff2 +0 -0
  343. package/web/assets/noto-sans-sc-72-wght-normal-C4oWy3ul.woff2 +0 -0
  344. package/web/assets/noto-sans-sc-73-wght-normal-B1ZoOTgM.woff2 +0 -0
  345. package/web/assets/noto-sans-sc-74-wght-normal-F7dH_rZJ.woff2 +0 -0
  346. package/web/assets/noto-sans-sc-75-wght-normal-Cp2wp4si.woff2 +0 -0
  347. package/web/assets/noto-sans-sc-76-wght-normal-B494D7xS.woff2 +0 -0
  348. package/web/assets/noto-sans-sc-77-wght-normal-B3MVY9Ax.woff2 +0 -0
  349. package/web/assets/noto-sans-sc-78-wght-normal-5jX7iNMC.woff2 +0 -0
  350. package/web/assets/noto-sans-sc-79-wght-normal-BIoZy8fr.woff2 +0 -0
  351. package/web/assets/noto-sans-sc-80-wght-normal-Bje1Fxy1.woff2 +0 -0
  352. package/web/assets/noto-sans-sc-81-wght-normal-B2NN7Qi3.woff2 +0 -0
  353. package/web/assets/noto-sans-sc-82-wght-normal-CUggNZi4.woff2 +0 -0
  354. package/web/assets/noto-sans-sc-83-wght-normal-99rD8YO-.woff2 +0 -0
  355. package/web/assets/noto-sans-sc-84-wght-normal-zMBmUocm.woff2 +0 -0
  356. package/web/assets/noto-sans-sc-85-wght-normal-CjI3qdxG.woff2 +0 -0
  357. package/web/assets/noto-sans-sc-86-wght-normal-BHY8TVEH.woff2 +0 -0
  358. package/web/assets/noto-sans-sc-87-wght-normal-DioZSHnU.woff2 +0 -0
  359. package/web/assets/noto-sans-sc-88-wght-normal-BXYxwf2t.woff2 +0 -0
  360. package/web/assets/noto-sans-sc-89-wght-normal-ClMFrFaL.woff2 +0 -0
  361. package/web/assets/noto-sans-sc-90-wght-normal-BnOlPreP.woff2 +0 -0
  362. package/web/assets/noto-sans-sc-91-wght-normal-DTr9RQcS.woff2 +0 -0
  363. package/web/assets/noto-sans-sc-97-wght-normal-9u6Aiav-.woff2 +0 -0
  364. package/web/assets/noto-sans-sc-98-wght-normal-BR-H2C0V.woff2 +0 -0
  365. package/web/assets/noto-sans-sc-99-wght-normal-BI6XPoxp.woff2 +0 -0
  366. package/web/assets/noto-sans-sc-cyrillic-wght-normal-BGmsQlw-.woff2 +0 -0
  367. package/web/assets/noto-sans-sc-latin-ext-wght-normal-DuJTpTg_.woff2 +0 -0
  368. package/web/assets/noto-sans-sc-latin-wght-normal-DDW8mKAR.woff2 +0 -0
  369. package/web/assets/noto-sans-sc-vietnamese-wght-normal-Bi3ATJ76.woff2 +0 -0
  370. package/web/fonts/LICENSE.txt +287 -0
  371. package/web/icon.svg +24 -0
  372. package/web/index.html +19 -0
  373. package/web/manifest.webmanifest +18 -0
@@ -0,0 +1,366 @@
1
+ //! macOS media probe.
2
+ //!
3
+ //! MediaRemote's private framework is not linkable without entitlements, so the
4
+ //! sidecar uses the same two public routes as the JavaScript adapter:
5
+ //! 1. `nowplaying-cli` when installed (system-wide Now Playing, artwork).
6
+ //! 2. AppleScript against Music/Spotify as a fallback (no artwork).
7
+ //!
8
+ //! This module is source-only: it is compiled and type-checked with
9
+ //! `cargo check --features macos-check` but has not been run on macOS.
10
+
11
+ use super::catalog::{self, Tier};
12
+ use super::{Media, ReadOptions};
13
+ use crate::util;
14
+
15
+ #[path = "macos/accessibility.rs"]
16
+ mod accessibility;
17
+
18
+ pub const BACKEND: &str = "macos-nowplaying";
19
+
20
+ const MAX_ARTWORK_BYTES: usize = 8 * 1024 * 1024;
21
+
22
+ fn nowplaying_cli() -> Option<std::path::PathBuf> {
23
+ util::find_executable("nowplaying-cli")
24
+ }
25
+
26
+ /// Best-effort client identity for nowplaying, which does not expose the
27
+ /// bundle id. This keeps the vendor-first dispatch working on macOS.
28
+ fn playing_app_id() -> Option<&'static str> {
29
+ for (process, app_id) in [
30
+ ("Music", "Music"),
31
+ ("Spotify", "Spotify"),
32
+ ("Cider", "Cider"),
33
+ ] {
34
+ if util::command_output("pgrep", &["-x", process]).is_ok() {
35
+ return Some(app_id);
36
+ }
37
+ }
38
+ None
39
+ }
40
+
41
+ fn parse_key_values(text: &str) -> std::collections::HashMap<String, String> {
42
+ let mut values = std::collections::HashMap::new();
43
+ for line in text.lines() {
44
+ if let Some((key, value)) = line.split_once('=') {
45
+ let key = key.trim();
46
+ if !key.is_empty() {
47
+ values.insert(key.to_string(), value.trim().to_string());
48
+ }
49
+ }
50
+ }
51
+ values
52
+ }
53
+
54
+ fn read_nowplaying(path: &std::path::Path) -> Result<Option<Media>, String> {
55
+ let output = util::run_path_command(
56
+ path,
57
+ &[
58
+ "get",
59
+ "title",
60
+ "artist",
61
+ "album",
62
+ "duration",
63
+ "elapsedTime",
64
+ "playbackRate",
65
+ "artworkData",
66
+ "artworkMIMEType",
67
+ ],
68
+ )?;
69
+ let values = parse_key_values(&output);
70
+ let title = values.get("title").cloned().unwrap_or_default();
71
+ let artist = values.get("artist").cloned().unwrap_or_default();
72
+ if title.trim().is_empty() && artist.trim().is_empty() {
73
+ return Ok(None);
74
+ }
75
+ let duration = values
76
+ .get("duration")
77
+ .and_then(|value| value.parse::<f64>().ok())
78
+ .unwrap_or(0.0);
79
+ let position = values
80
+ .get("elapsedTime")
81
+ .and_then(|value| value.parse::<f64>().ok())
82
+ .unwrap_or(0.0);
83
+ let rate = values
84
+ .get("playbackRate")
85
+ .and_then(|value| value.parse::<f64>().ok())
86
+ .unwrap_or(0.0);
87
+
88
+ let mut media = Media::new("macOS");
89
+ media.provider_detail = "nowplaying".to_string();
90
+ media.title = if title.trim().is_empty() {
91
+ "未命名媒体".to_string()
92
+ } else {
93
+ title
94
+ };
95
+ media.artist = artist;
96
+ media.album = values.get("album").cloned().unwrap_or_default();
97
+ media.playback_status = if rate > 0.0 { "Playing" } else { "Paused" }.to_string();
98
+ media.position = position.max(0.0);
99
+ media.start_time = 0.0;
100
+ media.end_time = duration.max(0.0);
101
+ media.min_seek_time = 0.0;
102
+ media.max_seek_time = duration.max(0.0);
103
+ media.last_updated_at = util::now_ms();
104
+ media.can_play_pause = true;
105
+ media.can_next = true;
106
+ media.can_previous = true;
107
+ media.can_seek = catalog::seek_allowed(Tier::System, duration > 0.0, false);
108
+
109
+ if let Some(data) = values.get("artworkData") {
110
+ if !data.is_empty() && data.len() < MAX_ARTWORK_BYTES {
111
+ let mime = values
112
+ .get("artworkMIMEType")
113
+ .filter(|value| !value.is_empty())
114
+ .cloned()
115
+ .unwrap_or_else(|| "image/jpeg".to_string());
116
+ media.thumbnail = Some(format!(
117
+ "data:{mime};base64,{}",
118
+ data.replace(char::is_whitespace, "")
119
+ ));
120
+ media.thumbnail_type = Some(mime);
121
+ }
122
+ }
123
+ Ok(Some(media))
124
+ }
125
+
126
+ fn applescript(script: &str) -> Result<String, String> {
127
+ util::command_output("/usr/bin/osascript", &["-e", script])
128
+ }
129
+
130
+ /// AppleScript fallback: query Music first, then Spotify. Returns `None` when
131
+ /// neither app is running or neither is playing something.
132
+ fn read_applescript() -> Result<Option<Media>, String> {
133
+ let script = r#"
134
+ set out to ""
135
+ tell application "System Events"
136
+ set musicRunning to (exists process "Music")
137
+ set spotifyRunning to (exists process "Spotify")
138
+ end tell
139
+ if musicRunning then
140
+ tell application "Music"
141
+ try
142
+ set st to player state as string
143
+ if st is not "stopped" then
144
+ set out to "Music" & tab & st & tab & (name of current track) & tab & (artist of current track) & tab & (album of current track) & tab & (duration of current track) & tab & (player position)
145
+ end if
146
+ end try
147
+ end tell
148
+ end if
149
+ if out is "" and spotifyRunning then
150
+ tell application "Spotify"
151
+ try
152
+ set st to player state as string
153
+ if st is not "stopped" then
154
+ set out to "Spotify" & tab & st & tab & (name of current track) & tab & (artist of current track) & tab & (album of current track) & tab & ((duration of current track) / 1000) & tab & (player position)
155
+ end if
156
+ end try
157
+ end tell
158
+ end if
159
+ return out
160
+ "#;
161
+ let output = applescript(script)?;
162
+ let output = output.trim();
163
+ if output.is_empty() {
164
+ return Ok(None);
165
+ }
166
+ let fields: Vec<&str> = output.split('\t').collect();
167
+ if fields.len() < 7 {
168
+ return Ok(None);
169
+ }
170
+ let app = fields[0];
171
+ let state = fields[1].to_ascii_lowercase();
172
+ let mut media = Media::new(app);
173
+ media.provider_detail = "applescript".to_string();
174
+ media.title = fields[2].to_string();
175
+ media.artist = fields[3].to_string();
176
+ media.album = fields[4].to_string();
177
+ media.playback_status = match state.as_str() {
178
+ "playing" => "Playing",
179
+ "paused" => "Paused",
180
+ _ => "Stopped",
181
+ }
182
+ .to_string();
183
+ media.position = fields[6].parse::<f64>().unwrap_or(0.0).max(0.0);
184
+ let duration = fields[5].parse::<f64>().unwrap_or(0.0).max(0.0);
185
+ media.end_time = duration;
186
+ media.max_seek_time = duration;
187
+ media.last_updated_at = util::now_ms();
188
+ media.can_play_pause = true;
189
+ media.can_next = true;
190
+ media.can_previous = true;
191
+ media.can_seek = catalog::seek_allowed(Tier::System, duration > 0.0, false);
192
+ Ok(Some(media))
193
+ }
194
+
195
+ /// Cider's private title file. Cider is the Apple Music client used by the
196
+ /// reference project when MediaRemote cannot identify the track.
197
+ fn read_cider() -> Option<Media> {
198
+ if util::command_output("pgrep", &["-x", "Cider"]).is_err() {
199
+ return None;
200
+ }
201
+ let parsed = crate::media::vendor::apple_music::cider_title()?;
202
+ let mut media = Media::new("Cider");
203
+ media.provider = Tier::Private.as_str().to_string();
204
+ media.provider_detail = "cider-title".to_string();
205
+ media.title = parsed.title;
206
+ media.artist = parsed.artist;
207
+ media.playback_status = "Paused".to_string();
208
+ media.can_play_pause = false;
209
+ media.can_next = false;
210
+ media.can_previous = false;
211
+ media.can_seek = false;
212
+ media.last_updated_at = util::now_ms();
213
+ Some(media)
214
+ }
215
+
216
+ /// System-branch validity exposed to vendor modules.
217
+ pub(crate) fn system_valid(media: &Media) -> bool {
218
+ !media.title.trim().is_empty()
219
+ && media.playback_status != "Closed"
220
+ && media.end_time > media.start_time
221
+ && media.end_time > 0.0
222
+ && media.can_seek
223
+ }
224
+
225
+ pub fn read(options: &ReadOptions) -> Result<Option<Media>, String> {
226
+ let mut media = if let Some(path) = nowplaying_cli() {
227
+ read_nowplaying(&path).unwrap_or(None)
228
+ } else {
229
+ None
230
+ };
231
+ if media.is_none() {
232
+ // If the system route failed, try AppleScript before the private
233
+ // Cider title, then return an empty reading.
234
+ media = read_applescript().unwrap_or(None);
235
+ }
236
+ if media.is_none() {
237
+ media = read_cider();
238
+ }
239
+ if media.is_none() {
240
+ // No Now Playing/AppleScript/Cider source: try the NetEase CDP page.
241
+ return Ok(crate::media::vendor::netease::cdp_media());
242
+ }
243
+ if let Some(media) = media.as_mut() {
244
+ if media.app_id == "macOS" {
245
+ if let Some(app_id) = playing_app_id() {
246
+ media.app_id = app_id.to_string();
247
+ }
248
+ }
249
+ // Accessibility is the last resort for a read-only progress display.
250
+ // It is only attempted when the system/private tier did not provide a
251
+ // duration; it never changes `canSeek`.
252
+ if media.end_time <= 0.0 {
253
+ if let Some(process) = accessibility_process(&media.app_id) {
254
+ if let Some((position, total)) = accessibility::progress(process) {
255
+ if total > 0.0 {
256
+ media.position = position;
257
+ media.start_time = 0.0;
258
+ media.end_time = total;
259
+ media.min_seek_time = 0.0;
260
+ media.max_seek_time = total;
261
+ media.provider_detail = format!("{}+ax", media.provider_detail);
262
+ }
263
+ }
264
+ }
265
+ }
266
+ if !options.thumbnail {
267
+ media.thumbnail = None;
268
+ media.thumbnail_type = None;
269
+ }
270
+
271
+ // Vendor-first: map the macOS app id to the catalog and let the vendor
272
+ // combine the Now Playing/AppleScript session with CDP when applicable.
273
+ let profile = catalog::profile_for_id(&media.app_id)
274
+ .or_else(|| catalog::profile_for_process(&media.app_id))
275
+ .or_else(|| match media.app_id.to_ascii_lowercase().as_str() {
276
+ "music" => catalog::profile_for_app(catalog::App::AppleMusic),
277
+ _ => None,
278
+ });
279
+ if let Some(profile) = profile {
280
+ if profile.app != catalog::App::Browser {
281
+ if let Some(resolved) = crate::media::vendor::resolve(
282
+ crate::media::vendor::Platform::MacOS,
283
+ profile.app,
284
+ Some(media.clone()),
285
+ None,
286
+ || None,
287
+ ) {
288
+ *media = resolved;
289
+ }
290
+ }
291
+ }
292
+ }
293
+ Ok(media)
294
+ }
295
+
296
+ fn accessibility_process(app_id: &str) -> Option<&'static str> {
297
+ match app_id.to_lowercase().as_str() {
298
+ "cider" => Some("Cider"),
299
+ "music" | "applemusic" | "com.apple.music" => Some("Music"),
300
+ "spotify" => Some("Spotify"),
301
+ _ => None,
302
+ }
303
+ }
304
+
305
+ pub fn control(action: &str, position: f64, options: &ReadOptions) -> Result<(), String> {
306
+ // A CDP-only NetEase session has no Now Playing entry; route its transport
307
+ // through the page before trying the AppleScript fallback.
308
+ let check = ReadOptions {
309
+ thumbnail: false,
310
+ ..options.clone()
311
+ };
312
+ if let Ok(Some(current)) = read(&check) {
313
+ if current.provider_detail.contains("cdp") {
314
+ return crate::media::vendor::netease::cdp_control(action, position);
315
+ }
316
+ }
317
+ if let Some(path) = nowplaying_cli() {
318
+ let args: Vec<String> = match action {
319
+ "playpause" => vec!["togglePlayPause".to_string()],
320
+ "next" => vec!["next".to_string()],
321
+ "previous" => vec!["previous".to_string()],
322
+ "seek" => vec!["seek".to_string(), position.max(0.0).to_string()],
323
+ other => return Err(format!("unsupported media action: {other}")),
324
+ };
325
+ let refs: Vec<&str> = args.iter().map(String::as_str).collect();
326
+ util::run_path_command(&path, &refs)?;
327
+ return Ok(());
328
+ }
329
+
330
+ let command = match action {
331
+ "playpause" => "playpause",
332
+ "next" => "next track",
333
+ "previous" => "previous track",
334
+ "seek" => {
335
+ let seconds = position.max(0.0);
336
+ let script = format!(
337
+ "if application \"Music\" is running then tell application \"Music\" to set player position to {seconds}\nif application \"Spotify\" is running then tell application \"Spotify\" to set player position to {seconds}"
338
+ );
339
+ applescript(&script)?;
340
+ return Ok(());
341
+ }
342
+ other => return Err(format!("unsupported media action: {other}")),
343
+ };
344
+ let script = format!(
345
+ "if application \"Music\" is running then tell application \"Music\" to {command}\nif application \"Spotify\" is running then tell application \"Spotify\" to {command}"
346
+ );
347
+ applescript(&script)?;
348
+ Ok(())
349
+ }
350
+
351
+ pub fn like(liked: Option<bool>, _options: &ReadOptions) -> Result<(), String> {
352
+ crate::media::vendor::netease::cdp_set_liked(liked).map(|_| ())
353
+ }
354
+
355
+ #[cfg(test)]
356
+ mod tests {
357
+ use super::*;
358
+
359
+ #[test]
360
+ fn parses_nowplaying_output() {
361
+ let values = parse_key_values("title = Song\nartist = Artist\nduration = 120.5\n");
362
+ assert_eq!(values.get("title").map(String::as_str), Some("Song"));
363
+ assert_eq!(values.get("artist").map(String::as_str), Some("Artist"));
364
+ assert_eq!(values.get("duration").map(String::as_str), Some("120.5"));
365
+ }
366
+ }
@@ -0,0 +1,298 @@
1
+ //! Media metadata + transport control.
2
+ //!
3
+ //! The JSON shape intentionally mirrors the old PowerShell SMTC result so
4
+ //! `lib/media.js` can keep its progress smoothing, NetEase enrichment and seek
5
+ //! rules unchanged.
6
+
7
+ use serde::Serialize;
8
+ use serde_json::{json, Value};
9
+
10
+ use crate::cli::{action_of, has_flag, option_number, option_value, CliError, CliResult};
11
+ use crate::util;
12
+
13
+ pub(crate) mod catalog;
14
+ pub(crate) mod vendor;
15
+
16
+ #[cfg(windows)]
17
+ #[path = "windows.rs"]
18
+ mod platform;
19
+ #[cfg(target_os = "linux")]
20
+ #[path = "linux.rs"]
21
+ mod platform;
22
+ #[cfg(target_os = "macos")]
23
+ #[path = "macos.rs"]
24
+ mod platform;
25
+
26
+ // Compile the macOS implementation on other hosts for syntax/type checking.
27
+ // This is not a macOS build and does not enable the code path.
28
+ #[cfg(all(feature = "macos-check", not(target_os = "macos")))]
29
+ #[allow(dead_code)]
30
+ #[path = "macos.rs"]
31
+ mod macos_preview;
32
+
33
+ /// One media session, using the same field names as the SMTC PowerShell probe.
34
+ #[derive(Debug, Clone, Serialize)]
35
+ #[serde(rename_all = "camelCase")]
36
+ pub struct Media {
37
+ pub app_id: String,
38
+ pub title: String,
39
+ pub artist: String,
40
+ pub album: String,
41
+ pub album_artist: String,
42
+ pub track_number: i32,
43
+ pub playback_status: String,
44
+ pub auto_repeat_mode: String,
45
+ pub shuffle_active: bool,
46
+ pub position: f64,
47
+ pub start_time: f64,
48
+ pub end_time: f64,
49
+ pub min_seek_time: f64,
50
+ pub max_seek_time: f64,
51
+ pub last_updated_at: i64,
52
+ pub can_play_pause: bool,
53
+ pub can_next: bool,
54
+ pub can_previous: bool,
55
+ pub can_seek: bool,
56
+ pub thumbnail: Option<String>,
57
+ pub thumbnail_type: Option<String>,
58
+ pub thumbnail_error: Option<String>,
59
+ /// HTTP(S) artwork reference (Linux MPRIS). The Node bridge downloads it and
60
+ /// turns it into `thumbnail` so the sidecar needs no HTTP client.
61
+ pub thumbnail_url: Option<String>,
62
+ /// NetEase liked state from CDP. `Some(true)`/`Some(false)` when the
63
+ /// remote-debugging page reports the heart state, `None` when CDP is
64
+ /// unavailable or the state is unknown.
65
+ pub liked: Option<bool>,
66
+ /// Display-only flag: the current app supports liked state.
67
+ pub can_like: bool,
68
+ /// Whether the liked state can be changed through the current provider.
69
+ pub can_set_liked: bool,
70
+ pub track_id: Option<String>,
71
+ /// Which layer produced this payload: `system`, `private` or
72
+ /// `accessibility`. Only `system` may expose transport seeking.
73
+ pub provider: String,
74
+ /// Concrete provider within the layer (`smtc`, `mpris`, `nowplaying`,
75
+ /// `window-title`, `cider-title`, `uia`, `atspi`, `ax`, ...).
76
+ pub provider_detail: String,
77
+ }
78
+
79
+ impl Media {
80
+ pub fn new(app_id: impl Into<String>) -> Self {
81
+ Self {
82
+ app_id: app_id.into(),
83
+ title: String::new(),
84
+ artist: String::new(),
85
+ album: String::new(),
86
+ album_artist: String::new(),
87
+ track_number: 0,
88
+ playback_status: "Closed".to_string(),
89
+ auto_repeat_mode: String::new(),
90
+ shuffle_active: false,
91
+ position: 0.0,
92
+ start_time: 0.0,
93
+ end_time: 0.0,
94
+ min_seek_time: 0.0,
95
+ max_seek_time: 0.0,
96
+ last_updated_at: 0,
97
+ can_play_pause: true,
98
+ can_next: true,
99
+ can_previous: true,
100
+ can_seek: false,
101
+ thumbnail: None,
102
+ thumbnail_type: None,
103
+ thumbnail_error: None,
104
+ thumbnail_url: None,
105
+ liked: None,
106
+ can_like: false,
107
+ can_set_liked: false,
108
+ track_id: None,
109
+ provider: catalog::Tier::System.as_str().to_string(),
110
+ provider_detail: String::new(),
111
+ }
112
+ }
113
+
114
+ pub fn finish(mut self) -> Self {
115
+ if self.last_updated_at <= 0 {
116
+ self.last_updated_at = util::now_ms();
117
+ }
118
+ crate::media::vendor::netease::enrich(&mut self);
119
+ self
120
+ }
121
+ }
122
+
123
+ /// Options shared by read and control commands.
124
+ #[derive(Debug, Clone, Default)]
125
+ pub struct ReadOptions {
126
+ pub app_id: Option<String>,
127
+ pub player: Option<String>,
128
+ pub thumbnail: bool,
129
+ }
130
+
131
+ impl ReadOptions {
132
+ pub fn from_args(args: &[String]) -> Self {
133
+ Self {
134
+ app_id: option_value(args, "--app-id").filter(|value| !value.is_empty()),
135
+ player: option_value(args, "--player").filter(|value| !value.is_empty()),
136
+ thumbnail: !has_flag(args, "--no-thumbnail"),
137
+ }
138
+ }
139
+ }
140
+
141
+ /// Read the active session. `Ok(None)` means "no player", not an error.
142
+ pub fn read(options: &ReadOptions) -> Value {
143
+ let started_at = util::now_ms();
144
+ match platform::read(options) {
145
+ Ok(Some(media)) => json!({
146
+ "ok": true,
147
+ "available": true,
148
+ "media": media.finish(),
149
+ "error": null,
150
+ "backend": platform::BACKEND,
151
+ "sampledAt": started_at,
152
+ }),
153
+ Ok(None) => json!({
154
+ "ok": true,
155
+ "available": true,
156
+ "media": null,
157
+ "error": null,
158
+ "backend": platform::BACKEND,
159
+ "sampledAt": started_at,
160
+ }),
161
+ Err(error) => json!({
162
+ "ok": false,
163
+ "available": false,
164
+ "media": null,
165
+ "error": error,
166
+ "backend": platform::BACKEND,
167
+ "sampledAt": started_at,
168
+ }),
169
+ }
170
+ }
171
+
172
+ /// Run a transport action, wait briefly for the player to settle, then re-read.
173
+ pub fn control(action: &str, position: f64, options: &ReadOptions) -> Value {
174
+ let started_at = util::now_ms();
175
+ let result = platform::control(action, position, options);
176
+ match result {
177
+ Ok(()) => {
178
+ // The PowerShell probe waited 140 ms so SMTC reflects the change.
179
+ std::thread::sleep(std::time::Duration::from_millis(140));
180
+ let (media, media_error) = match platform::read(options) {
181
+ Ok(Some(media)) => (Some(media.finish()), None),
182
+ Ok(None) => (None, None),
183
+ Err(error) => (None, Some(error)),
184
+ };
185
+ json!({
186
+ "ok": true,
187
+ "action": action,
188
+ "actionError": media_error,
189
+ "media": media,
190
+ "backend": platform::BACKEND,
191
+ "sampledAt": util::now_ms(),
192
+ })
193
+ }
194
+ Err(error) => json!({
195
+ "ok": false,
196
+ "action": action,
197
+ "actionError": error,
198
+ "media": null,
199
+ "backend": platform::BACKEND,
200
+ "sampledAt": started_at,
201
+ }),
202
+ }
203
+ }
204
+
205
+ /// Change the liked state through the provider's private interface (NetEase
206
+ /// CDP). The client is re-read after the action so the caller gets the new
207
+ /// snapshot. `liked = None` toggles.
208
+ pub fn like(liked: Option<bool>, options: &ReadOptions) -> Value {
209
+ let started_at = util::now_ms();
210
+ match platform::like(liked, options) {
211
+ Ok(()) => {
212
+ std::thread::sleep(std::time::Duration::from_millis(160));
213
+ let (media, media_error) = match platform::read(options) {
214
+ Ok(Some(media)) => (Some(media.finish()), None),
215
+ Ok(None) => (None, None),
216
+ Err(error) => (None, Some(error)),
217
+ };
218
+ json!({
219
+ "ok": true,
220
+ "action": "like",
221
+ "actionError": media_error,
222
+ "media": media,
223
+ "backend": platform::BACKEND,
224
+ "sampledAt": util::now_ms(),
225
+ })
226
+ }
227
+ Err(error) => json!({
228
+ "ok": false,
229
+ "action": "like",
230
+ "actionError": error,
231
+ "media": null,
232
+ "backend": platform::BACKEND,
233
+ "sampledAt": started_at,
234
+ }),
235
+ }
236
+ }
237
+
238
+ pub fn cli(args: &[String], _started_at: i64) -> CliResult {
239
+ let action = action_of(args);
240
+ let options = ReadOptions::from_args(args);
241
+ match action.as_str() {
242
+ "read" => Ok(read(&options)),
243
+ "playpause" | "next" | "previous" | "seek" => {
244
+ let position = if action == "seek" {
245
+ option_number(args, "--position").unwrap_or(0.0).max(0.0)
246
+ } else {
247
+ 0.0
248
+ };
249
+ Ok(control(&action, position, &options))
250
+ }
251
+ "like" => {
252
+ let liked = option_value(args, "--value").and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
253
+ "1" | "true" | "yes" | "on" | "like" => Some(true),
254
+ "0" | "false" | "no" | "off" | "unlike" => Some(false),
255
+ _ => None,
256
+ });
257
+ Ok(like(liked, &options))
258
+ }
259
+ "cdp" => Ok(crate::media::vendor::netease::cdp_diagnostic()),
260
+ other => Err(CliError::usage(format!(
261
+ "unsupported media action: {other} (expected read, playpause, next, previous, seek, like)"
262
+ ))),
263
+ }
264
+ }
265
+
266
+ #[cfg(test)]
267
+ mod tests {
268
+ use super::*;
269
+
270
+ #[test]
271
+ fn media_serializes_camel_case() {
272
+ let mut media = Media::new("MSEdge");
273
+ media.title = "Title".to_string();
274
+ media.can_seek = true;
275
+ let value = serde_json::to_value(media).unwrap();
276
+ assert_eq!(value["appId"], "MSEdge");
277
+ assert_eq!(value["playbackStatus"], "Closed");
278
+ assert_eq!(value["canPlayPause"], true);
279
+ assert_eq!(value["startTime"], 0.0);
280
+ assert_eq!(value["provider"], "system");
281
+ assert_eq!(value["canSetLiked"], false);
282
+ assert!(value.get("lastUpdatedAt").is_some());
283
+ assert!(value.get("thumbnailUrl").is_some());
284
+ }
285
+
286
+ #[test]
287
+ fn parses_media_options() {
288
+ let args = vec![
289
+ "read".to_string(),
290
+ "--app-id".to_string(),
291
+ "MSEdge".to_string(),
292
+ "--no-thumbnail".to_string(),
293
+ ];
294
+ let options = ReadOptions::from_args(&args);
295
+ assert_eq!(options.app_id.as_deref(), Some("MSEdge"));
296
+ assert!(!options.thumbnail);
297
+ }
298
+ }