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

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,775 @@
1
+ //! Windows media probe: SMTC first, then per-app private interfaces, then UI
2
+ //! Automation as a read-only last resort.
3
+ //!
4
+ //! Tier rules (from `catalog`):
5
+ //! * A *complete* SMTC session — metadata, a seekable timeline and a real
6
+ //! position — is used directly; only that session may expose seek.
7
+ //! * When SMTC is missing/incomplete, the player's window title (and Cider's
8
+ //! local title file) is used. This tier is read-only: it never exposes seek.
9
+ //! * UI Automation is consulted only for NetEase/WeSing progress, again
10
+ //! read-only and behind a timeout.
11
+ //!
12
+ //! No network API is used. Cover art still comes from the SMTC thumbnail stream
13
+ //! or the local NetEase cache.
14
+
15
+ #[path = "windows/accessibility.rs"]
16
+ mod accessibility;
17
+ #[path = "windows/audio.rs"]
18
+ mod audio;
19
+ #[path = "windows/private.rs"]
20
+ mod private;
21
+
22
+ use std::time::Duration;
23
+
24
+ use windows::Media::Control::{
25
+ GlobalSystemMediaTransportControlsSession, GlobalSystemMediaTransportControlsSessionManager,
26
+ GlobalSystemMediaTransportControlsSessionMediaProperties,
27
+ GlobalSystemMediaTransportControlsSessionPlaybackStatus,
28
+ };
29
+ use windows::Media::MediaPlaybackAutoRepeatMode;
30
+ use windows::Storage::Streams::{DataReader, IRandomAccessStreamReference};
31
+
32
+ use super::catalog::{self, Profile, Tier};
33
+ use super::{Media, ReadOptions};
34
+ use crate::util;
35
+
36
+ pub const BACKEND: &str = "rust-smtc";
37
+
38
+ const MAX_THUMBNAIL_BYTES: u64 = 8 * 1024 * 1024;
39
+
40
+ fn failure(error: impl std::fmt::Display) -> String {
41
+ error.to_string()
42
+ }
43
+
44
+ fn ensure_com() {
45
+ static ONCE: std::sync::Once = std::sync::Once::new();
46
+ ONCE.call_once(|| unsafe {
47
+ // S_FALSE / RPC_E_CHANGED_MODE are harmless: another library already
48
+ // initialized this apartment.
49
+ let _ = windows::Win32::System::Com::CoInitializeEx(
50
+ None,
51
+ windows::Win32::System::Com::COINIT_MULTITHREADED,
52
+ );
53
+ });
54
+ }
55
+
56
+ fn manager() -> Result<GlobalSystemMediaTransportControlsSessionManager, String> {
57
+ ensure_com();
58
+ GlobalSystemMediaTransportControlsSessionManager::RequestAsync()
59
+ .map_err(failure)?
60
+ .join()
61
+ .map_err(failure)
62
+ }
63
+
64
+ fn status_of(
65
+ session: &GlobalSystemMediaTransportControlsSession,
66
+ ) -> Option<GlobalSystemMediaTransportControlsSessionPlaybackStatus> {
67
+ session
68
+ .GetPlaybackInfo()
69
+ .ok()
70
+ .and_then(|info| info.PlaybackStatus().ok())
71
+ }
72
+
73
+ fn is_playing(session: &GlobalSystemMediaTransportControlsSession) -> bool {
74
+ status_of(session) == Some(GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing)
75
+ }
76
+
77
+ fn app_id_of(session: &GlobalSystemMediaTransportControlsSession) -> String {
78
+ session
79
+ .SourceAppUserModelId()
80
+ .map(|value| value.to_string())
81
+ .unwrap_or_default()
82
+ }
83
+
84
+ /// Pick a session exactly like the PowerShell probe: explicit app id, then the
85
+ /// current session, then the first playing session, then the first session.
86
+ fn select(
87
+ app_id: Option<&str>,
88
+ ) -> Result<Option<GlobalSystemMediaTransportControlsSession>, String> {
89
+ let manager = manager()?;
90
+ let sessions: Vec<GlobalSystemMediaTransportControlsSession> = manager
91
+ .GetSessions()
92
+ .map_err(failure)?
93
+ .into_iter()
94
+ .collect();
95
+ if sessions.is_empty() {
96
+ return Ok(None);
97
+ }
98
+
99
+ if let Some(app_id) = app_id.filter(|value| !value.is_empty()) {
100
+ if let Some(session) = sessions
101
+ .iter()
102
+ .find(|session| app_id_of(session).eq_ignore_ascii_case(app_id))
103
+ {
104
+ return Ok(Some(session.clone()));
105
+ }
106
+ // A private app id ("cloudmusic.exe") may not equal the SMTC AUMID;
107
+ // match it through the app catalog as a fallback.
108
+ if let Some(profile) =
109
+ catalog::profile_for_id(app_id).or_else(|| catalog::profile_for_process(app_id))
110
+ {
111
+ if let Some(session) = sessions.iter().find(|session| {
112
+ let session_id = app_id_of(session);
113
+ catalog::profile_for_id(&session_id)
114
+ .or_else(|| catalog::profile_for_process(&session_id))
115
+ .map(|candidate| candidate.app == profile.app)
116
+ .unwrap_or(false)
117
+ }) {
118
+ return Ok(Some(session.clone()));
119
+ }
120
+ }
121
+ }
122
+
123
+ let mut session = manager.GetCurrentSession().ok();
124
+ if session
125
+ .as_ref()
126
+ .map(|value| !is_playing(value))
127
+ .unwrap_or(false)
128
+ {
129
+ if let Some(playing) = sessions.iter().find(|value| is_playing(value)) {
130
+ session = Some(playing.clone());
131
+ }
132
+ }
133
+ if session.is_none() {
134
+ session = sessions.iter().find(|value| is_playing(value)).cloned();
135
+ }
136
+ if session.is_none() {
137
+ session = sessions.first().cloned();
138
+ }
139
+ Ok(session)
140
+ }
141
+
142
+ fn status_name(status: GlobalSystemMediaTransportControlsSessionPlaybackStatus) -> &'static str {
143
+ use GlobalSystemMediaTransportControlsSessionPlaybackStatus as Status;
144
+ if status == Status::Playing {
145
+ "Playing"
146
+ } else if status == Status::Paused {
147
+ "Paused"
148
+ } else if status == Status::Stopped {
149
+ "Stopped"
150
+ } else {
151
+ "Closed"
152
+ }
153
+ }
154
+
155
+ fn read_thumbnail_stream(reference: &IRandomAccessStreamReference) -> Result<String, String> {
156
+ let stream = reference
157
+ .OpenReadAsync()
158
+ .map_err(failure)?
159
+ .join()
160
+ .map_err(failure)?;
161
+ let size = stream.Size().map_err(failure)?;
162
+ if size == 0 {
163
+ return Err("thumbnail stream is empty".to_string());
164
+ }
165
+ if size > MAX_THUMBNAIL_BYTES {
166
+ return Err(format!("thumbnail stream is too large ({size} bytes)"));
167
+ }
168
+ let reader = DataReader::CreateDataReader(&stream).map_err(failure)?;
169
+ reader
170
+ .LoadAsync(size as u32)
171
+ .map_err(failure)?
172
+ .join()
173
+ .map_err(failure)?;
174
+ let mut bytes = vec![0u8; size as usize];
175
+ reader.ReadBytes(&mut bytes).map_err(failure)?;
176
+ util::image_data_url(&bytes).ok_or_else(|| "thumbnail is not a supported image".to_string())
177
+ }
178
+
179
+ /// Read the artwork, retrying briefly because Edge/YouTube populates the
180
+ /// thumbnail reference shortly after metadata becomes available.
181
+ fn read_thumbnail(
182
+ session: &GlobalSystemMediaTransportControlsSession,
183
+ first: &GlobalSystemMediaTransportControlsSessionMediaProperties,
184
+ wanted: bool,
185
+ ) -> (Option<String>, Option<String>, Option<String>) {
186
+ if !wanted {
187
+ return (None, None, None);
188
+ }
189
+ let mut last_error = String::new();
190
+ let mut props = first.clone();
191
+ let mut saw_reference = false;
192
+ for attempt in 0..4 {
193
+ if attempt > 0 {
194
+ std::thread::sleep(Duration::from_millis(140));
195
+ match session
196
+ .TryGetMediaPropertiesAsync()
197
+ .map_err(failure)
198
+ .and_then(|op| op.join().map_err(failure))
199
+ {
200
+ Ok(refreshed) => props = refreshed,
201
+ Err(error) => {
202
+ last_error = error;
203
+ continue;
204
+ }
205
+ }
206
+ }
207
+ let reference = match props.Thumbnail() {
208
+ Ok(reference) => reference,
209
+ Err(_) => {
210
+ if !saw_reference {
211
+ return (None, None, Some("thumbnail reference is null".to_string()));
212
+ }
213
+ last_error = "thumbnail reference is null".to_string();
214
+ break;
215
+ }
216
+ };
217
+ saw_reference = true;
218
+ match read_thumbnail_stream(&reference) {
219
+ Ok(data) => return (Some(data), Some("data-url".to_string()), None),
220
+ Err(error) => last_error = error,
221
+ }
222
+ }
223
+ (
224
+ None,
225
+ None,
226
+ Some(if last_error.is_empty() {
227
+ "thumbnail unavailable".to_string()
228
+ } else {
229
+ last_error
230
+ }),
231
+ )
232
+ }
233
+
234
+ fn media_from_session(
235
+ session: &GlobalSystemMediaTransportControlsSession,
236
+ options: &ReadOptions,
237
+ ) -> Result<Media, String> {
238
+ let props = session
239
+ .TryGetMediaPropertiesAsync()
240
+ .map_err(failure)?
241
+ .join()
242
+ .map_err(failure)?;
243
+ let info = session.GetPlaybackInfo().map_err(failure)?;
244
+ let timeline = session.GetTimelineProperties().map_err(failure)?;
245
+ let controls = info.Controls().ok();
246
+
247
+ let mut media = Media::new(app_id_of(session));
248
+ media.provider = Tier::System.as_str().to_string();
249
+ media.provider_detail = "smtc".to_string();
250
+ media.title = props
251
+ .Title()
252
+ .map(|value| value.to_string())
253
+ .unwrap_or_default();
254
+ media.artist = props
255
+ .Artist()
256
+ .map(|value| value.to_string())
257
+ .unwrap_or_default();
258
+ media.album = props
259
+ .AlbumTitle()
260
+ .map(|value| value.to_string())
261
+ .unwrap_or_default();
262
+ media.album_artist = props
263
+ .AlbumArtist()
264
+ .map(|value| value.to_string())
265
+ .unwrap_or_default();
266
+ media.track_number = props.TrackNumber().unwrap_or(0);
267
+ media.playback_status = info
268
+ .PlaybackStatus()
269
+ .map(status_name)
270
+ .unwrap_or("Closed")
271
+ .to_string();
272
+ media.auto_repeat_mode = match info
273
+ .AutoRepeatMode()
274
+ .ok()
275
+ .and_then(|value| value.Value().ok())
276
+ {
277
+ Some(MediaPlaybackAutoRepeatMode::Track) => "Track",
278
+ Some(MediaPlaybackAutoRepeatMode::List) => "List",
279
+ _ => "None",
280
+ }
281
+ .to_string();
282
+ media.shuffle_active = info
283
+ .IsShuffleActive()
284
+ .ok()
285
+ .and_then(|value| value.Value().ok())
286
+ .unwrap_or(false);
287
+ media.position = timeline
288
+ .Position()
289
+ .map(|value| util::ticks_to_seconds(value.Duration))
290
+ .unwrap_or(0.0);
291
+ media.start_time = timeline
292
+ .StartTime()
293
+ .map(|value| util::ticks_to_seconds(value.Duration))
294
+ .unwrap_or(0.0);
295
+ media.end_time = timeline
296
+ .EndTime()
297
+ .map(|value| util::ticks_to_seconds(value.Duration))
298
+ .unwrap_or(0.0);
299
+ media.min_seek_time = timeline
300
+ .MinSeekTime()
301
+ .map(|value| util::ticks_to_seconds(value.Duration))
302
+ .unwrap_or(0.0);
303
+ media.max_seek_time = timeline
304
+ .MaxSeekTime()
305
+ .map(|value| util::ticks_to_seconds(value.Duration))
306
+ .unwrap_or(0.0);
307
+ media.last_updated_at = timeline
308
+ .LastUpdatedTime()
309
+ .map(|value| util::winrt_time_to_unix_ms(value.UniversalTime))
310
+ .unwrap_or_else(|_| util::now_ms());
311
+ media.can_play_pause = controls
312
+ .as_ref()
313
+ .and_then(|controls| controls.IsPlayPauseToggleEnabled().ok())
314
+ .unwrap_or(true);
315
+ media.can_next = controls
316
+ .as_ref()
317
+ .and_then(|controls| controls.IsNextEnabled().ok())
318
+ .unwrap_or(true);
319
+ media.can_previous = controls
320
+ .as_ref()
321
+ .and_then(|controls| controls.IsPreviousEnabled().ok())
322
+ .unwrap_or(true);
323
+ media.can_seek = controls
324
+ .as_ref()
325
+ .and_then(|controls| controls.IsPlaybackPositionEnabled().ok())
326
+ .unwrap_or(false);
327
+
328
+ let (thumbnail, thumbnail_type, thumbnail_error) =
329
+ read_thumbnail(session, &props, options.thumbnail);
330
+ media.thumbnail = thumbnail;
331
+ media.thumbnail_type = thumbnail_type;
332
+ media.thumbnail_error = thumbnail_error;
333
+ Ok(media)
334
+ }
335
+
336
+ /// A complete SMTC session is the only source allowed to expose seek.
337
+ fn is_complete(profile: Option<&Profile>, media: &Media) -> bool {
338
+ catalog::system_complete(profile, media)
339
+ }
340
+
341
+ /// System-branch validity exposed to vendor modules.
342
+ pub(crate) fn system_valid(media: &Media) -> bool {
343
+ let profile = catalog::profile_for_id(&media.app_id)
344
+ .or_else(|| catalog::profile_for_process(&media.app_id));
345
+ is_complete(profile, media)
346
+ }
347
+
348
+ #[derive(Clone)]
349
+ struct Candidate {
350
+ media: Media,
351
+ raw_app_id: String,
352
+ profile: Option<&'static Profile>,
353
+ tier: Tier,
354
+ complete: bool,
355
+ order: usize,
356
+ current: bool,
357
+ }
358
+
359
+ fn rank(candidate: &Candidate) -> (u8, u8, u8, u8, usize) {
360
+ let playing = if candidate.media.playback_status == "Playing" {
361
+ 0
362
+ } else {
363
+ 1
364
+ };
365
+ let current = if candidate.current { 0 } else { 1 };
366
+ let complete = if candidate.complete { 0 } else { 1 };
367
+ let tier = match candidate.tier {
368
+ Tier::System => 0,
369
+ Tier::Private => 1,
370
+ Tier::Accessibility => 2,
371
+ };
372
+ (playing, current, complete, tier, candidate.order)
373
+ }
374
+
375
+ fn matches_preferred(candidate: &Candidate, preferred: &str) -> bool {
376
+ let wanted = preferred.trim().to_lowercase();
377
+ if wanted.is_empty() {
378
+ return false;
379
+ }
380
+ if candidate.media.app_id.to_lowercase().contains(&wanted)
381
+ || candidate.raw_app_id.to_lowercase().contains(&wanted)
382
+ {
383
+ return true;
384
+ }
385
+ candidate
386
+ .profile
387
+ .map(|profile| {
388
+ profile.name.to_lowercase() == wanted
389
+ || profile.app_id.to_lowercase().contains(&wanted)
390
+ || profile
391
+ .process_names
392
+ .iter()
393
+ .any(|name| name.contains(&wanted))
394
+ })
395
+ .unwrap_or(false)
396
+ }
397
+
398
+ pub fn read(options: &ReadOptions) -> Result<Option<Media>, String> {
399
+ ensure_com();
400
+ let windows = private::enumerate_windows();
401
+ let activities = audio::snapshot();
402
+
403
+ let mut system_candidates: Vec<Candidate> = Vec::new();
404
+ let mut current_app_id = String::new();
405
+
406
+ match manager() {
407
+ Ok(manager) => {
408
+ current_app_id = manager
409
+ .GetCurrentSession()
410
+ .ok()
411
+ .map(|session| app_id_of(&session))
412
+ .unwrap_or_default();
413
+ match manager.GetSessions() {
414
+ Ok(sessions) => {
415
+ for (order, session) in sessions.into_iter().enumerate() {
416
+ let app_id = app_id_of(&session);
417
+ let profile = catalog::profile_for_id(&app_id)
418
+ .or_else(|| catalog::profile_for_process(&app_id));
419
+ let Ok(mut media) = media_from_session(&session, options) else {
420
+ continue;
421
+ };
422
+ if media.playback_status == "Closed"
423
+ || (media.title.trim().is_empty() && media.artist.trim().is_empty())
424
+ {
425
+ continue;
426
+ }
427
+ let complete = catalog::system_complete(profile, &media);
428
+ media.can_seek = catalog::seek_allowed(Tier::System, complete, false);
429
+ if !complete {
430
+ // An incomplete system session may still give us the
431
+ // metadata/controls, but never the seek grant.
432
+ media.provider_detail = "smtc-partial".to_string();
433
+ }
434
+ system_candidates.push(Candidate {
435
+ raw_app_id: app_id,
436
+ media,
437
+ profile,
438
+ tier: Tier::System,
439
+ complete,
440
+ order,
441
+ current: false,
442
+ });
443
+ }
444
+ }
445
+ Err(_) => {}
446
+ }
447
+ }
448
+ Err(_) => {}
449
+ }
450
+
451
+ // Mark the session Windows currently reports as the system-wide current
452
+ // one; it breaks ties after the "is playing" check.
453
+ for candidate in system_candidates.iter_mut() {
454
+ candidate.current = !current_app_id.is_empty()
455
+ && candidate.raw_app_id.eq_ignore_ascii_case(&current_app_id);
456
+ }
457
+
458
+ // NetEase sometimes exposes a generated AUMID (for example
459
+ // `Microsoft.AutoGenerated.{GUID}`) instead of `cloudmusic.exe`. Match that
460
+ // unknown SMTC session against the current CDP page so it still enters the
461
+ // NetEase vendor path and gets the heart/progress combination.
462
+ if system_candidates
463
+ .iter()
464
+ .any(|candidate| candidate.profile.is_none())
465
+ {
466
+ if let Some((title, artist)) = crate::media::vendor::netease::cdp_identity() {
467
+ for candidate in system_candidates
468
+ .iter_mut()
469
+ .filter(|candidate| candidate.profile.is_none())
470
+ {
471
+ let same_title = !title.is_empty()
472
+ && candidate
473
+ .media
474
+ .title
475
+ .trim()
476
+ .eq_ignore_ascii_case(title.trim());
477
+ let same_artist = artist.is_empty()
478
+ || candidate.media.artist.trim().is_empty()
479
+ || candidate
480
+ .media
481
+ .artist
482
+ .trim()
483
+ .eq_ignore_ascii_case(artist.trim());
484
+ if !same_title || !same_artist {
485
+ continue;
486
+ }
487
+ let Some(profile) = catalog::profile_for_app(catalog::App::Netease) else {
488
+ continue;
489
+ };
490
+ candidate.profile = Some(profile);
491
+ candidate.complete = catalog::system_complete(Some(profile), &candidate.media);
492
+ candidate.media.can_seek =
493
+ catalog::seek_allowed(Tier::System, candidate.complete, false);
494
+ if !candidate.complete {
495
+ candidate.media.provider_detail = "smtc-partial".to_string();
496
+ }
497
+ }
498
+ }
499
+ }
500
+
501
+ // Build the private (window-title / Cider file) candidate for every known
502
+ // vendor. The vendor dispatcher decides whether it or the system session is
503
+ // used; generic callers never see this detail.
504
+ let mut private_candidates: Vec<Candidate> = Vec::new();
505
+ for (index, profile) in catalog::PROFILES.iter().enumerate() {
506
+ if matches!(profile.private, catalog::PrivateKind::None) {
507
+ continue;
508
+ }
509
+ let smtc_media =
510
+ best_for_app(&system_candidates, profile.app).map(|candidate| &candidate.media);
511
+ if let Some(media) = private::read(profile, &windows, &activities, smtc_media, options) {
512
+ private_candidates.push(Candidate {
513
+ raw_app_id: media.app_id.clone(),
514
+ media,
515
+ profile: Some(profile),
516
+ tier: Tier::Private,
517
+ complete: false,
518
+ order: 10_000 + index,
519
+ current: false,
520
+ });
521
+ }
522
+ }
523
+
524
+ let mut candidates: Vec<Candidate> = Vec::new();
525
+
526
+ // Applications without a vendor profile only have the system API. Browsers
527
+ // have no private interface either, so keep every browser session.
528
+ for candidate in system_candidates.iter() {
529
+ let is_browser = candidate
530
+ .profile
531
+ .map(|profile| profile.app == catalog::App::Browser)
532
+ .unwrap_or(false);
533
+ if candidate.profile.is_none() || is_browser {
534
+ candidates.push(candidate.clone());
535
+ }
536
+ }
537
+
538
+ // Vendor-first dispatch for every known application.
539
+ for (index, profile) in catalog::PROFILES.iter().enumerate() {
540
+ if profile.app == catalog::App::Browser {
541
+ continue;
542
+ }
543
+ let system = best_for_app(&system_candidates, profile.app);
544
+ let private = best_for_app(&private_candidates, profile.app);
545
+ if system.is_none() && private.is_none() && profile.app != catalog::App::Netease {
546
+ continue;
547
+ }
548
+ let system_media = system.map(|candidate| candidate.media.clone());
549
+ let private_media = private.map(|candidate| candidate.media.clone());
550
+ let windows_ref = &windows;
551
+ let resolved = crate::media::vendor::resolve(
552
+ crate::media::vendor::Platform::Windows,
553
+ profile.app,
554
+ system_media,
555
+ private_media,
556
+ || private::progress_for_profile(profile, windows_ref),
557
+ );
558
+ let Some(mut media) = resolved else {
559
+ continue;
560
+ };
561
+ let complete = catalog::system_complete(Some(profile), &media);
562
+ if media.provider_detail.contains("cdp") {
563
+ // CDP-only mode: the page slider is a transport, but it must not
564
+ // report a seekable timeline. Keep a seek grant only when the SMTC
565
+ // session itself is complete and explicitly seekable.
566
+ media.can_seek = complete && media.can_seek && media.end_time > media.start_time;
567
+ } else {
568
+ media.can_seek = catalog::seek_allowed(Tier::System, complete, false);
569
+ }
570
+ let tier = if media.provider == "private" || media.provider == "accessibility" {
571
+ Tier::Private
572
+ } else {
573
+ Tier::System
574
+ };
575
+ candidates.push(Candidate {
576
+ raw_app_id: media.app_id.clone(),
577
+ media,
578
+ profile: Some(profile),
579
+ tier,
580
+ complete,
581
+ order: system
582
+ .map(|candidate| candidate.order)
583
+ .unwrap_or(10_000 + index),
584
+ current: system.map(|candidate| candidate.current).unwrap_or(false),
585
+ });
586
+ }
587
+
588
+ // The reference project uses the audio meter to repair a stale status for
589
+ // clients that do not refresh SMTC unless the media flyout is open.
590
+ for candidate in candidates.iter_mut() {
591
+ if candidate.complete || candidate.tier != Tier::System {
592
+ continue;
593
+ }
594
+ let Some(profile) = candidate.profile else {
595
+ continue;
596
+ };
597
+ if let Some(activity) = private::profile_activity(profile, &windows, &activities) {
598
+ candidate.media.playback_status = if activity.is_playing() {
599
+ "Playing".to_string()
600
+ } else {
601
+ "Paused".to_string()
602
+ };
603
+ }
604
+ }
605
+
606
+ if candidates.is_empty() {
607
+ // No system API, no vendor interface, no accessibility tree: this is
608
+ // an empty reading, not an error. The Node bridge clears the media card.
609
+ return Ok(None);
610
+ }
611
+
612
+ let preferred = options
613
+ .app_id
614
+ .as_deref()
615
+ .or(options.player.as_deref())
616
+ .filter(|value| !value.is_empty());
617
+ let selected = if let Some(preferred) = preferred {
618
+ candidates
619
+ .iter()
620
+ .filter(|candidate| matches_preferred(candidate, preferred))
621
+ .min_by_key(|candidate| rank(candidate))
622
+ .cloned()
623
+ } else {
624
+ candidates
625
+ .iter()
626
+ .min_by_key(|candidate| rank(candidate))
627
+ .cloned()
628
+ };
629
+ Ok(selected.map(|candidate| candidate.media))
630
+ }
631
+
632
+ fn best_for_app<'a>(candidates: &'a [Candidate], app: catalog::App) -> Option<&'a Candidate> {
633
+ candidates
634
+ .iter()
635
+ .filter(|candidate| candidate.profile.map(|profile| profile.app) == Some(app))
636
+ .min_by_key(|candidate| rank(candidate))
637
+ }
638
+
639
+ pub fn control(action: &str, position: f64, options: &ReadOptions) -> Result<(), String> {
640
+ let preferred = options.app_id.as_deref().or(options.player.as_deref());
641
+ let session = match select(preferred) {
642
+ Ok(session) => session,
643
+ Err(_) => None,
644
+ };
645
+ let Some(session) = session else {
646
+ // No SMTC session: CDP-only NetEase still has its page transport.
647
+ let netease = preferred
648
+ .map(|value| {
649
+ catalog::profile_for_id(value)
650
+ .or_else(|| catalog::profile_for_process(value))
651
+ .map(|profile| profile.app == catalog::App::Netease)
652
+ .unwrap_or_else(|| value.to_ascii_lowercase().contains("cloudmusic"))
653
+ })
654
+ .unwrap_or(true);
655
+ if netease && crate::media::vendor::netease::cdp_control(action, position).is_ok() {
656
+ return Ok(());
657
+ }
658
+ return Err(
659
+ "no active SMTC session; this player is read-only in the private tier".to_string(),
660
+ );
661
+ };
662
+ ensure_com();
663
+
664
+ if action == "seek" {
665
+ let profile = catalog::profile_for_id(&app_id_of(&session))
666
+ .or_else(|| catalog::profile_for_process(&app_id_of(&session)));
667
+ let check_options = ReadOptions {
668
+ thumbnail: false,
669
+ ..options.clone()
670
+ };
671
+ let media = media_from_session(&session, &check_options)?;
672
+ if !is_complete(profile, &media) {
673
+ return Err(
674
+ "this player only reports progress; seek requires a complete SMTC timeline"
675
+ .to_string(),
676
+ );
677
+ }
678
+ }
679
+
680
+ let accepted = match action {
681
+ "playpause" => session
682
+ .TryTogglePlayPauseAsync()
683
+ .map_err(failure)?
684
+ .join()
685
+ .map_err(failure)?,
686
+ "next" => session
687
+ .TrySkipNextAsync()
688
+ .map_err(failure)?
689
+ .join()
690
+ .map_err(failure)?,
691
+ "previous" => session
692
+ .TrySkipPreviousAsync()
693
+ .map_err(failure)?
694
+ .join()
695
+ .map_err(failure)?,
696
+ "seek" => {
697
+ let ticks = (position.max(0.0) * 10_000_000.0).round() as i64;
698
+ session
699
+ .TryChangePlaybackPositionAsync(ticks)
700
+ .map_err(failure)?
701
+ .join()
702
+ .map_err(failure)?
703
+ }
704
+ other => return Err(format!("unsupported media action: {other}")),
705
+ };
706
+ if !accepted {
707
+ return Err(format!("SMTC rejected {action}"));
708
+ }
709
+ Ok(())
710
+ }
711
+
712
+ pub fn like(liked: Option<bool>, _options: &ReadOptions) -> Result<(), String> {
713
+ crate::media::vendor::netease::cdp_set_liked(liked).map(|_| ())
714
+ }
715
+
716
+ #[cfg(test)]
717
+ mod tests {
718
+ use super::*;
719
+
720
+ #[test]
721
+ fn maps_status_names() {
722
+ assert_eq!(
723
+ status_name(GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing),
724
+ "Playing"
725
+ );
726
+ assert_eq!(
727
+ status_name(GlobalSystemMediaTransportControlsSessionPlaybackStatus::Closed),
728
+ "Closed"
729
+ );
730
+ }
731
+
732
+ fn playable(app_id: &str) -> Media {
733
+ let mut media = Media::new(app_id);
734
+ media.title = "Song".to_string();
735
+ media.playback_status = "Playing".to_string();
736
+ media.start_time = 0.0;
737
+ media.end_time = 200.0;
738
+ media.position = 12.0;
739
+ media.can_seek = true;
740
+ media
741
+ }
742
+
743
+ #[test]
744
+ fn complete_requires_a_timeline_and_seek_grant() {
745
+ let trusted = playable("MSEdge");
746
+ assert!(is_complete(None, &trusted));
747
+
748
+ let mut no_timeline = playable("MSEdge");
749
+ no_timeline.end_time = 0.0;
750
+ assert!(!is_complete(None, &no_timeline));
751
+
752
+ let mut no_seek = playable("MSEdge");
753
+ no_seek.can_seek = false;
754
+ assert!(!is_complete(None, &no_seek));
755
+ }
756
+
757
+ #[test]
758
+ fn netease_smtc_is_metadata_only() {
759
+ let profile = catalog::profile_for_id("cloudmusic.exe");
760
+ let mut media = playable("cloudmusic.exe");
761
+ // Even a claimed timeline/position must not turn NetEase's SMTC into a
762
+ // seekable session; progress comes from UIA/local data.
763
+ assert!(!is_complete(profile, &media));
764
+ media.position = 42.0;
765
+ assert!(!is_complete(profile, &media));
766
+ }
767
+
768
+ #[test]
769
+ fn trusted_players_accept_spotify_like_seek() {
770
+ let profile = catalog::profile_for_id("Spotify.exe");
771
+ let mut media = playable("Spotify.exe");
772
+ media.position = 0.0;
773
+ assert!(is_complete(profile, &media));
774
+ }
775
+ }