@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.
- package/AGENTS.md +556 -0
- package/INSTALLATION.md +310 -0
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/client.js +3 -0
- package/components.json +20 -0
- package/cordis.patch.yml +3 -0
- package/docs/README.en.md +196 -0
- package/docs/screenshots/en/overview.png +0 -0
- package/docs/screenshots/en/question.png +0 -0
- package/docs/screenshots/zh/overview.png +0 -0
- package/docs/screenshots/zh/question.png +0 -0
- package/icon.svg +24 -0
- package/index.js +2 -0
- package/lib/account.js +1 -0
- package/lib/app-mapping.js +1 -0
- package/lib/i18n.js +1 -0
- package/lib/media-enrich.js +1 -0
- package/lib/notifications.js +1 -0
- package/lib/performance.js +1 -0
- package/lib/platform/index.js +1 -0
- package/lib/probe-adapter.js +1 -0
- package/lib/probe-daemon.js +1 -0
- package/lib/probe.js +1 -0
- package/lib/qr.js +1 -0
- package/lib/runtime.js +1 -0
- package/lib/service.js +1 -0
- package/lib/sessions.js +1 -0
- package/lib/settings.js +1 -0
- package/lib/types.js +1 -0
- package/lib/util.js +1 -0
- package/lib/weather.js +1 -0
- package/lib/ws.js +1 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +118 -4
- package/scripts/browser-shot.mjs +217 -0
- package/scripts/build-dsh.mjs +228 -0
- package/scripts/build-rust.mjs +319 -0
- package/scripts/build-web.mjs +17 -0
- package/scripts/check-npm-package.mjs +322 -0
- package/scripts/mock-preview.py +574 -0
- package/scripts/screenshot-compose.py +89 -0
- package/scripts/screenshot-device.sh +75 -0
- package/src/dsh/client/api.ts +68 -0
- package/src/dsh/client/i18n.ts +573 -0
- package/src/dsh/client/icons.ts +56 -0
- package/src/dsh/client/index.ts +95 -0
- package/src/dsh/client/logic.ts +310 -0
- package/src/dsh/client/panel.ts +483 -0
- package/src/dsh/client/sections/apps.ts +40 -0
- package/src/dsh/client/sections/balance.ts +38 -0
- package/src/dsh/client/sections/connection.ts +97 -0
- package/src/dsh/client/sections/notifications.ts +43 -0
- package/src/dsh/client/sections/pomodoro.ts +184 -0
- package/src/dsh/client/sections/preferences.ts +138 -0
- package/src/dsh/client/sections/system.ts +120 -0
- package/src/dsh/client/sections/weather.ts +276 -0
- package/src/dsh/client/styles.ts +1119 -0
- package/src/dsh/client/types.ts +366 -0
- package/src/dsh/client/ui.ts +142 -0
- package/src/dsh/index.ts +108 -0
- package/src/dsh/lib/account.ts +520 -0
- package/src/dsh/lib/app-mapping.ts +366 -0
- package/src/dsh/lib/i18n.ts +82 -0
- package/src/dsh/lib/media-enrich.ts +202 -0
- package/src/dsh/lib/notifications.ts +322 -0
- package/src/dsh/lib/performance.ts +279 -0
- package/src/dsh/lib/platform/index.ts +43 -0
- package/src/dsh/lib/probe-adapter.ts +275 -0
- package/src/dsh/lib/probe-daemon.ts +359 -0
- package/src/dsh/lib/probe.ts +395 -0
- package/src/dsh/lib/qr.ts +42 -0
- package/src/dsh/lib/runtime.ts +126 -0
- package/src/dsh/lib/service.ts +1645 -0
- package/src/dsh/lib/sessions.ts +907 -0
- package/src/dsh/lib/settings.ts +468 -0
- package/src/dsh/lib/types.ts +231 -0
- package/src/dsh/lib/util.ts +115 -0
- package/src/dsh/lib/weather.ts +532 -0
- package/src/dsh/lib/ws.ts +328 -0
- package/src/dsh/types/qrcode-generator.d.ts +16 -0
- package/src/rust/Cargo.lock +1431 -0
- package/src/rust/Cargo.toml +68 -0
- package/src/rust/src/cli.rs +233 -0
- package/src/rust/src/daemon.rs +140 -0
- package/src/rust/src/location/linux.rs +118 -0
- package/src/rust/src/location/macos.rs +26 -0
- package/src/rust/src/location/mod.rs +170 -0
- package/src/rust/src/location/windows.rs +205 -0
- package/src/rust/src/main.rs +42 -0
- package/src/rust/src/media/catalog.rs +454 -0
- package/src/rust/src/media/linux.rs +350 -0
- package/src/rust/src/media/macos/accessibility.rs +59 -0
- package/src/rust/src/media/macos.rs +366 -0
- package/src/rust/src/media/mod.rs +298 -0
- package/src/rust/src/media/vendor/apple_music.rs +90 -0
- package/src/rust/src/media/vendor/common.rs +186 -0
- package/src/rust/src/media/vendor/kugou.rs +52 -0
- package/src/rust/src/media/vendor/kuwo.rs +49 -0
- package/src/rust/src/media/vendor/mod.rs +75 -0
- package/src/rust/src/media/vendor/netease.rs +1715 -0
- package/src/rust/src/media/vendor/qishui.rs +36 -0
- package/src/rust/src/media/vendor/qqmusic.rs +33 -0
- package/src/rust/src/media/vendor/spotify.rs +46 -0
- package/src/rust/src/media/vendor/wesing.rs +39 -0
- package/src/rust/src/media/vendor/youtube_music.rs +49 -0
- package/src/rust/src/media/windows/accessibility.rs +85 -0
- package/src/rust/src/media/windows/audio.rs +116 -0
- package/src/rust/src/media/windows/private.rs +291 -0
- package/src/rust/src/media/windows.rs +775 -0
- package/src/rust/src/notifications/linux.rs +276 -0
- package/src/rust/src/notifications/macos.rs +382 -0
- package/src/rust/src/notifications/mod.rs +244 -0
- package/src/rust/src/notifications/windows.rs +565 -0
- package/src/rust/src/perf/common.rs +109 -0
- package/src/rust/src/perf/linux.rs +282 -0
- package/src/rust/src/perf/macos.rs +28 -0
- package/src/rust/src/perf/mod.rs +157 -0
- package/src/rust/src/perf/windows.rs +981 -0
- package/src/rust/src/util.rs +360 -0
- package/src/rust/src/volume/linux.rs +195 -0
- package/src/rust/src/volume/macos.rs +54 -0
- package/src/rust/src/volume/mod.rs +116 -0
- package/src/rust/src/volume/windows.rs +75 -0
- package/src/rust/tests/cli.rs +79 -0
- package/src/rust/tests/daemon.rs +107 -0
- package/src/svelte/App.svelte +385 -0
- package/src/svelte/index.html +18 -0
- package/src/svelte/lib/billing.svelte.ts +228 -0
- package/src/svelte/lib/components/BalanceCard.svelte +173 -0
- package/src/svelte/lib/components/ClockCard.svelte +183 -0
- package/src/svelte/lib/components/CountdownCard.svelte +261 -0
- package/src/svelte/lib/components/CpuMemoryCard.svelte +238 -0
- package/src/svelte/lib/components/EmptyState.svelte +34 -0
- package/src/svelte/lib/components/GpuCard.svelte +245 -0
- package/src/svelte/lib/components/HomePage.svelte +34 -0
- package/src/svelte/lib/components/MusicControls.svelte +256 -0
- package/src/svelte/lib/components/MusicCover.svelte +88 -0
- package/src/svelte/lib/components/MusicPage.svelte +116 -0
- package/src/svelte/lib/components/NotificationsCard.svelte +109 -0
- package/src/svelte/lib/components/Panel.svelte +22 -0
- package/src/svelte/lib/components/PerformancePage.svelte +55 -0
- package/src/svelte/lib/components/PomodoroCard.svelte +250 -0
- package/src/svelte/lib/components/QuestionSheet.svelte +354 -0
- package/src/svelte/lib/components/RadialGauge.svelte +99 -0
- package/src/svelte/lib/components/Rail.svelte +217 -0
- package/src/svelte/lib/components/SectionHeader.svelte +40 -0
- package/src/svelte/lib/components/SeekBar.svelte +421 -0
- package/src/svelte/lib/components/SessionList.svelte +364 -0
- package/src/svelte/lib/components/SessionStats.svelte +98 -0
- package/src/svelte/lib/components/SessionsPage.svelte +61 -0
- package/src/svelte/lib/components/SpendCard.svelte +105 -0
- package/src/svelte/lib/components/ThermalCard.svelte +249 -0
- package/src/svelte/lib/components/Toast.svelte +32 -0
- package/src/svelte/lib/components/WeatherIcon.svelte +40 -0
- package/src/svelte/lib/components/WeatherPanel.svelte +420 -0
- package/src/svelte/lib/components/ui/alert/alert-action.svelte +20 -0
- package/src/svelte/lib/components/ui/alert/alert-description.svelte +23 -0
- package/src/svelte/lib/components/ui/alert/alert-title.svelte +23 -0
- package/src/svelte/lib/components/ui/alert/alert.svelte +43 -0
- package/src/svelte/lib/components/ui/alert/index.ts +17 -0
- package/src/svelte/lib/components/ui/avatar/avatar-badge.svelte +26 -0
- package/src/svelte/lib/components/ui/avatar/avatar-fallback.svelte +20 -0
- package/src/svelte/lib/components/ui/avatar/avatar-group-count.svelte +23 -0
- package/src/svelte/lib/components/ui/avatar/avatar-group.svelte +23 -0
- package/src/svelte/lib/components/ui/avatar/avatar-image.svelte +17 -0
- package/src/svelte/lib/components/ui/avatar/avatar.svelte +26 -0
- package/src/svelte/lib/components/ui/avatar/index.ts +22 -0
- package/src/svelte/lib/components/ui/badge/badge.svelte +49 -0
- package/src/svelte/lib/components/ui/badge/index.ts +2 -0
- package/src/svelte/lib/components/ui/button/button.svelte +82 -0
- package/src/svelte/lib/components/ui/button/index.ts +17 -0
- package/src/svelte/lib/components/ui/card/card-action.svelte +23 -0
- package/src/svelte/lib/components/ui/card/card-content.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card-description.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card-footer.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card-header.svelte +23 -0
- package/src/svelte/lib/components/ui/card/card-title.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card.svelte +22 -0
- package/src/svelte/lib/components/ui/card/index.ts +25 -0
- package/src/svelte/lib/components/ui/dialog/dialog-close.svelte +11 -0
- package/src/svelte/lib/components/ui/dialog/dialog-content.svelte +48 -0
- package/src/svelte/lib/components/ui/dialog/dialog-description.svelte +17 -0
- package/src/svelte/lib/components/ui/dialog/dialog-footer.svelte +32 -0
- package/src/svelte/lib/components/ui/dialog/dialog-header.svelte +20 -0
- package/src/svelte/lib/components/ui/dialog/dialog-overlay.svelte +17 -0
- package/src/svelte/lib/components/ui/dialog/dialog-portal.svelte +7 -0
- package/src/svelte/lib/components/ui/dialog/dialog-title.svelte +17 -0
- package/src/svelte/lib/components/ui/dialog/dialog-trigger.svelte +11 -0
- package/src/svelte/lib/components/ui/dialog/dialog.svelte +7 -0
- package/src/svelte/lib/components/ui/dialog/index.ts +34 -0
- package/src/svelte/lib/components/ui/empty/empty-content.svelte +23 -0
- package/src/svelte/lib/components/ui/empty/empty-description.svelte +23 -0
- package/src/svelte/lib/components/ui/empty/empty-header.svelte +20 -0
- package/src/svelte/lib/components/ui/empty/empty-media.svelte +41 -0
- package/src/svelte/lib/components/ui/empty/empty-title.svelte +20 -0
- package/src/svelte/lib/components/ui/empty/empty.svelte +23 -0
- package/src/svelte/lib/components/ui/empty/index.ts +22 -0
- package/src/svelte/lib/components/ui/input/index.ts +7 -0
- package/src/svelte/lib/components/ui/input/input.svelte +48 -0
- package/src/svelte/lib/components/ui/progress/index.ts +7 -0
- package/src/svelte/lib/components/ui/progress/progress.svelte +27 -0
- package/src/svelte/lib/components/ui/scroll-area/index.ts +10 -0
- package/src/svelte/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte +30 -0
- package/src/svelte/lib/components/ui/scroll-area/scroll-area.svelte +43 -0
- package/src/svelte/lib/components/ui/separator/index.ts +7 -0
- package/src/svelte/lib/components/ui/separator/separator.svelte +21 -0
- package/src/svelte/lib/components/ui/sheet/index.ts +34 -0
- package/src/svelte/lib/components/ui/sheet/sheet-close.svelte +7 -0
- package/src/svelte/lib/components/ui/sheet/sheet-content.svelte +55 -0
- package/src/svelte/lib/components/ui/sheet/sheet-description.svelte +17 -0
- package/src/svelte/lib/components/ui/sheet/sheet-footer.svelte +20 -0
- package/src/svelte/lib/components/ui/sheet/sheet-header.svelte +20 -0
- package/src/svelte/lib/components/ui/sheet/sheet-overlay.svelte +20 -0
- package/src/svelte/lib/components/ui/sheet/sheet-portal.svelte +7 -0
- package/src/svelte/lib/components/ui/sheet/sheet-title.svelte +17 -0
- package/src/svelte/lib/components/ui/sheet/sheet-trigger.svelte +7 -0
- package/src/svelte/lib/components/ui/sheet/sheet.svelte +7 -0
- package/src/svelte/lib/components/ui/skeleton/index.ts +7 -0
- package/src/svelte/lib/components/ui/skeleton/skeleton.svelte +17 -0
- package/src/svelte/lib/components/ui/slider/index.ts +7 -0
- package/src/svelte/lib/components/ui/slider/slider.svelte +52 -0
- package/src/svelte/lib/components/ui/tooltip/index.ts +19 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-content.svelte +51 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-portal.svelte +7 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-provider.svelte +7 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-trigger.svelte +7 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip.svelte +7 -0
- package/src/svelte/lib/countdown.svelte.ts +252 -0
- package/src/svelte/lib/format.ts +199 -0
- package/src/svelte/lib/i18n.svelte.ts +583 -0
- package/src/svelte/lib/media.ts +32 -0
- package/src/svelte/lib/pages.ts +18 -0
- package/src/svelte/lib/pomodoro.svelte.ts +223 -0
- package/src/svelte/lib/session-tree.ts +80 -0
- package/src/svelte/lib/state.svelte.ts +359 -0
- package/src/svelte/lib/timer-alarm.svelte.ts +319 -0
- package/src/svelte/lib/types.ts +306 -0
- package/src/svelte/lib/utils.ts +8 -0
- package/src/svelte/lib/weather.ts +84 -0
- package/src/svelte/lib/ws.ts +104 -0
- package/src/svelte/main.ts +13 -0
- package/src/svelte/styles/global.css +315 -0
- package/src/svelte/vite-env.d.ts +12 -0
- package/static/fonts/LICENSE.txt +287 -0
- package/static/icon.svg +24 -0
- package/static/manifest.webmanifest +18 -0
- package/svelte.config.js +5 -0
- package/tsconfig.dsh-client.json +18 -0
- package/tsconfig.dsh.json +21 -0
- package/tsconfig.json +20 -0
- package/vite.config.js +31 -0
- package/web/assets/index-DLdSfnJ3.css +1 -0
- package/web/assets/index-YNE8X0Uy.js +6 -0
- package/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/web/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/web/assets/noto-sans-sc-100-wght-normal-DluiXYzi.woff2 +0 -0
- package/web/assets/noto-sans-sc-101-wght-normal-CPkZHnw0.woff2 +0 -0
- package/web/assets/noto-sans-sc-102-wght-normal-gzIulUH6.woff2 +0 -0
- package/web/assets/noto-sans-sc-103-wght-normal-CJfSBxFo.woff2 +0 -0
- package/web/assets/noto-sans-sc-104-wght-normal-B3QMglgE.woff2 +0 -0
- package/web/assets/noto-sans-sc-105-wght-normal-xXh_OMfk.woff2 +0 -0
- package/web/assets/noto-sans-sc-106-wght-normal-B9n3jMjP.woff2 +0 -0
- package/web/assets/noto-sans-sc-107-wght-normal-D7fvVprN.woff2 +0 -0
- package/web/assets/noto-sans-sc-108-wght-normal-J0_LhxVo.woff2 +0 -0
- package/web/assets/noto-sans-sc-109-wght-normal-Dsp-De_0.woff2 +0 -0
- package/web/assets/noto-sans-sc-110-wght-normal-DE_6NUrb.woff2 +0 -0
- package/web/assets/noto-sans-sc-111-wght-normal-C4S-TlTn.woff2 +0 -0
- package/web/assets/noto-sans-sc-112-wght-normal-BcFuP3w6.woff2 +0 -0
- package/web/assets/noto-sans-sc-113-wght-normal-Dk932kmj.woff2 +0 -0
- package/web/assets/noto-sans-sc-114-wght-normal-CO_5Xo-i.woff2 +0 -0
- package/web/assets/noto-sans-sc-115-wght-normal-BWy07H14.woff2 +0 -0
- package/web/assets/noto-sans-sc-116-wght-normal-B4HVAfHo.woff2 +0 -0
- package/web/assets/noto-sans-sc-117-wght-normal-siZzFqgK.woff2 +0 -0
- package/web/assets/noto-sans-sc-118-wght-normal-xhBB7f6V.woff2 +0 -0
- package/web/assets/noto-sans-sc-119-wght-normal-Ck_g2FGz.woff2 +0 -0
- package/web/assets/noto-sans-sc-21-wght-normal-D0x9-yBt.woff2 +0 -0
- package/web/assets/noto-sans-sc-22-wght-normal-BBUVTShg.woff2 +0 -0
- package/web/assets/noto-sans-sc-23-wght-normal-Du1oYWkE.woff2 +0 -0
- package/web/assets/noto-sans-sc-24-wght-normal-CmWjJ0gt.woff2 +0 -0
- package/web/assets/noto-sans-sc-25-wght-normal-BFHiqilg.woff2 +0 -0
- package/web/assets/noto-sans-sc-26-wght-normal-C0sF2Z2f.woff2 +0 -0
- package/web/assets/noto-sans-sc-27-wght-normal-COFdrquJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-28-wght-normal-DMgdNu1o.woff2 +0 -0
- package/web/assets/noto-sans-sc-29-wght-normal-Cmq8em30.woff2 +0 -0
- package/web/assets/noto-sans-sc-30-wght-normal-CZWqZ3yU.woff2 +0 -0
- package/web/assets/noto-sans-sc-31-wght-normal-Wfi_lutW.woff2 +0 -0
- package/web/assets/noto-sans-sc-32-wght-normal-BRJQ4PY5.woff2 +0 -0
- package/web/assets/noto-sans-sc-33-wght-normal-CUVQXNAJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-34-wght-normal-Bspba0wC.woff2 +0 -0
- package/web/assets/noto-sans-sc-35-wght-normal-K8fX_YPG.woff2 +0 -0
- package/web/assets/noto-sans-sc-36-wght-normal-CkITyjoT.woff2 +0 -0
- package/web/assets/noto-sans-sc-37-wght-normal-WCZ2zfEe.woff2 +0 -0
- package/web/assets/noto-sans-sc-38-wght-normal-DeM7CUce.woff2 +0 -0
- package/web/assets/noto-sans-sc-39-wght-normal-BMZNLWQU.woff2 +0 -0
- package/web/assets/noto-sans-sc-4-wght-normal-AR5bdDjz.woff2 +0 -0
- package/web/assets/noto-sans-sc-40-wght-normal-BQ5xpS5j.woff2 +0 -0
- package/web/assets/noto-sans-sc-41-wght-normal-Hthm7FFi.woff2 +0 -0
- package/web/assets/noto-sans-sc-42-wght-normal-eaS2gKds.woff2 +0 -0
- package/web/assets/noto-sans-sc-43-wght-normal-o-T53XXV.woff2 +0 -0
- package/web/assets/noto-sans-sc-44-wght-normal-BJH5J4Ez.woff2 +0 -0
- package/web/assets/noto-sans-sc-45-wght-normal-BKuxSCOJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-46-wght-normal-BR_5Mu6e.woff2 +0 -0
- package/web/assets/noto-sans-sc-47-wght-normal-CyY7gKNX.woff2 +0 -0
- package/web/assets/noto-sans-sc-48-wght-normal-BNMfTR6k.woff2 +0 -0
- package/web/assets/noto-sans-sc-49-wght-normal-DX-0y7wg.woff2 +0 -0
- package/web/assets/noto-sans-sc-5-wght-normal-CfWugllB.woff2 +0 -0
- package/web/assets/noto-sans-sc-50-wght-normal-DAOGW-Dr.woff2 +0 -0
- package/web/assets/noto-sans-sc-51-wght-normal-BlFLOjcP.woff2 +0 -0
- package/web/assets/noto-sans-sc-52-wght-normal-OZjrybnw.woff2 +0 -0
- package/web/assets/noto-sans-sc-53-wght-normal-CwyWNT6x.woff2 +0 -0
- package/web/assets/noto-sans-sc-54-wght-normal-DM0nRe18.woff2 +0 -0
- package/web/assets/noto-sans-sc-55-wght-normal-DBK2wUOR.woff2 +0 -0
- package/web/assets/noto-sans-sc-56-wght-normal-C-VKK1LS.woff2 +0 -0
- package/web/assets/noto-sans-sc-57-wght-normal-Cdbfh5kq.woff2 +0 -0
- package/web/assets/noto-sans-sc-58-wght-normal-CflMKARZ.woff2 +0 -0
- package/web/assets/noto-sans-sc-59-wght-normal-BS4Kwzxx.woff2 +0 -0
- package/web/assets/noto-sans-sc-6-wght-normal-ChnZIQfQ.woff2 +0 -0
- package/web/assets/noto-sans-sc-60-wght-normal-B7QOYsXJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-61-wght-normal-C8JjQS7w.woff2 +0 -0
- package/web/assets/noto-sans-sc-62-wght-normal-bS4wq1Gg.woff2 +0 -0
- package/web/assets/noto-sans-sc-63-wght-normal-BJr8P-OB.woff2 +0 -0
- package/web/assets/noto-sans-sc-64-wght-normal-DVB11t7j.woff2 +0 -0
- package/web/assets/noto-sans-sc-65-wght-normal-DMPCXBvK.woff2 +0 -0
- package/web/assets/noto-sans-sc-66-wght-normal-BobaG10V.woff2 +0 -0
- package/web/assets/noto-sans-sc-67-wght-normal-Dzk7WhCR.woff2 +0 -0
- package/web/assets/noto-sans-sc-68-wght-normal-DGbCD9xu.woff2 +0 -0
- package/web/assets/noto-sans-sc-69-wght-normal-Ctrv1EqA.woff2 +0 -0
- package/web/assets/noto-sans-sc-70-wght-normal-RgTO941D.woff2 +0 -0
- package/web/assets/noto-sans-sc-71-wght-normal-D9sG-CWv.woff2 +0 -0
- package/web/assets/noto-sans-sc-72-wght-normal-C4oWy3ul.woff2 +0 -0
- package/web/assets/noto-sans-sc-73-wght-normal-B1ZoOTgM.woff2 +0 -0
- package/web/assets/noto-sans-sc-74-wght-normal-F7dH_rZJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-75-wght-normal-Cp2wp4si.woff2 +0 -0
- package/web/assets/noto-sans-sc-76-wght-normal-B494D7xS.woff2 +0 -0
- package/web/assets/noto-sans-sc-77-wght-normal-B3MVY9Ax.woff2 +0 -0
- package/web/assets/noto-sans-sc-78-wght-normal-5jX7iNMC.woff2 +0 -0
- package/web/assets/noto-sans-sc-79-wght-normal-BIoZy8fr.woff2 +0 -0
- package/web/assets/noto-sans-sc-80-wght-normal-Bje1Fxy1.woff2 +0 -0
- package/web/assets/noto-sans-sc-81-wght-normal-B2NN7Qi3.woff2 +0 -0
- package/web/assets/noto-sans-sc-82-wght-normal-CUggNZi4.woff2 +0 -0
- package/web/assets/noto-sans-sc-83-wght-normal-99rD8YO-.woff2 +0 -0
- package/web/assets/noto-sans-sc-84-wght-normal-zMBmUocm.woff2 +0 -0
- package/web/assets/noto-sans-sc-85-wght-normal-CjI3qdxG.woff2 +0 -0
- package/web/assets/noto-sans-sc-86-wght-normal-BHY8TVEH.woff2 +0 -0
- package/web/assets/noto-sans-sc-87-wght-normal-DioZSHnU.woff2 +0 -0
- package/web/assets/noto-sans-sc-88-wght-normal-BXYxwf2t.woff2 +0 -0
- package/web/assets/noto-sans-sc-89-wght-normal-ClMFrFaL.woff2 +0 -0
- package/web/assets/noto-sans-sc-90-wght-normal-BnOlPreP.woff2 +0 -0
- package/web/assets/noto-sans-sc-91-wght-normal-DTr9RQcS.woff2 +0 -0
- package/web/assets/noto-sans-sc-97-wght-normal-9u6Aiav-.woff2 +0 -0
- package/web/assets/noto-sans-sc-98-wght-normal-BR-H2C0V.woff2 +0 -0
- package/web/assets/noto-sans-sc-99-wght-normal-BI6XPoxp.woff2 +0 -0
- package/web/assets/noto-sans-sc-cyrillic-wght-normal-BGmsQlw-.woff2 +0 -0
- package/web/assets/noto-sans-sc-latin-ext-wght-normal-DuJTpTg_.woff2 +0 -0
- package/web/assets/noto-sans-sc-latin-wght-normal-DDW8mKAR.woff2 +0 -0
- package/web/assets/noto-sans-sc-vietnamese-wght-normal-Bi3ATJ76.woff2 +0 -0
- package/web/fonts/LICENSE.txt +287 -0
- package/web/icon.svg +24 -0
- package/web/index.html +19 -0
- 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
|
+
}
|