Compare commits

..

10 Commits

Author SHA1 Message Date
sudacode 81b941fe8c fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi
- Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift
- Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress
- Preserve manual hide across Jellyfin path-changing redirects even when media-title drops
- Rearm managed subtitle defaults on path-changing redirects
- Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC
- Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus
- Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary
- Add stats window layer management so delete/update dialogs appear above stats window
- Fix Jellyfin remote progress sync during Linux websocket reconnect windows
2026-05-23 01:45:09 -07:00
sudacode 80d05aef27 fix(jellyfin): fix remote progress sync, seek reporting, and startup sto
- arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events
- force immediate progress report on seek-like position jumps at the mpv time-pos level
- send positionTicks and failed=false in reportStopped payload
- remove EventName from HTTP timeline payloads (websocket-only field)
- add startup grace window to drop stop events before media finishes loading
2026-05-22 23:41:05 -07:00
sudacode d1998797e9 fix(jellyfin): send explicit hide/show overlay instead of toggle
- Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known
- Prevent paused Jellyfin playback from resuming on overlay hide
- Fix subtitle cache cleanup to only remove dirs after successful cleanup
2026-05-22 23:41:05 -07:00
sudacode 8de2613e4b fix(tokenizer): preserve known-word highlight when POS filters suppress
- Known-word cache matches now set isKnown=true even for tokens excluded by POS filters
- POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate
- Jellyfin subtitle preload continues after cleanup failures instead of aborting
- Update config docs and option description to document the known-word bypass behavior
2026-05-22 23:41:05 -07:00
sudacode e8831bfbb8 fix(config): remove trailing commas from config.example.jsonc
- Strip trailing commas throughout both config.example.jsonc copies
- Reformat inline arrays to multi-line for JSON strictness
- Update Jellyfin subtitle preload and playback launch tests and impl
2026-05-22 23:41:05 -07:00
sudacode add09213bf docs(release): trim and consolidate prerelease notes for 0.15.0
- Remove breaking changes section and several redundant bullet points
- Consolidate per-platform updater notes into a single entry
- Normalize em-dash separators to hyphens in section headers
2026-05-22 23:41:05 -07:00
sudacode 2f2dfa3e91 fix(jellyfin): fix discovery loop, device identity, tray state, and Disc
- Derive device identity from OS hostname; remove legacy configurable client/device fields
- Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores
- Restart stale tray discovery sessions without re-login when server drops SubMiner cast target
- Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes
- Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads
- Fix picker library discovery when log level is above info
- Fix config.example.jsonc trailing commas and array formatting
2026-05-22 23:41:05 -07:00
sudacode 85d838ac96 fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause
- Keep overlay visible during macOS foreground probe after overlay blur
- Hold sidebar hover-pause while a Yomitan lookup popup remains open
2026-05-22 23:41:05 -07:00
sudacode d373de7a92 fix(jellyfin): show overlay, inject plugin, and fix stats title on playb
- Show visible overlay automatically during Jellyfin playback so subtitleStyle applies
- Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus
- Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles
- Mark ffsubsync unavailable in subsync modal for remote media paths
- Drain queued second-instance commands even when onReady throws
2026-05-22 23:41:05 -07:00
sudacode c4f99fec2f upgrade Electron 39→42 and fix Hyprland overlay z-order/placement (#79) 2026-05-22 23:22:51 -07:00
82 changed files with 2563 additions and 243 deletions
+135 -20
View File
@@ -18,11 +18,12 @@
"ws": "^8.19.0", "ws": "^8.19.0",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.3.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "39.8.6", "electron": "42.2.0",
"electron-builder": "26.8.2", "electron-builder": "26.8.2",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
}, },
@@ -52,7 +53,7 @@
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], "@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="],
"@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
@@ -116,10 +117,34 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="], "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="], "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
@@ -166,15 +191,21 @@
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
@@ -194,6 +225,10 @@
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
@@ -294,6 +329,8 @@
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
@@ -326,7 +363,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@39.8.6", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA=="], "electron": ["electron@42.2.0", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw=="],
"electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="], "electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
@@ -344,7 +381,7 @@
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
@@ -364,6 +401,22 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
@@ -374,12 +427,22 @@
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
@@ -404,6 +467,8 @@
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -442,6 +507,8 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
@@ -450,8 +517,12 @@
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
@@ -472,6 +543,8 @@
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -486,8 +559,12 @@
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="], "libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="], "lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
@@ -540,6 +617,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="], "node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
@@ -560,16 +639,22 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -588,6 +673,8 @@
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
@@ -700,13 +787,15 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
@@ -726,6 +815,8 @@
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -748,14 +839,12 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@discordjs/rest/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
@@ -764,6 +853,8 @@
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], "@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -774,6 +865,20 @@
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/fs-extra/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/keyv/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/plist/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/responselike/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/yauzl/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
@@ -786,8 +891,6 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"electron/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -800,22 +903,36 @@
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/fs-extra/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/keyv/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/plist/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/responselike/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/yauzl/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -826,8 +943,6 @@
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: runtime
- Updated the bundled Electron runtime from 39.8.6 to 42.2.0, moving SubMiner back onto a supported Electron release line.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Refreshed Linux overlay placement after leaving mpv fullscreen so Hyprland keeps the visible overlay aligned to the player.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: overlay
- Kept the Hyprland visible overlay stacked above mpv after mpv receives focus from clicks or overlay movement.
- Suspended the visible overlay while the in-player stats window is open, then restored it mouse-passive after stats closes.
+11
View File
@@ -0,0 +1,11 @@
type: fixed
area: jellyfin
- Fixed Jellyfin `y-t` overlay hide so the plugin sends an explicit hide command when it knows the overlay is visible, avoiding overlay reloads and paused playback resumes.
- Kept that manual hide sticky across Jellyfin stream redirects that change mpv's path, even when the redirected URL drops mpv's media title.
- Re-armed managed subtitle defaults during those path-changing redirects so Japanese primary subtitles can load on the redirected stream.
- Routed visible-overlay shortcuts and app-side visibility changes back through the mpv plugin so SubMiner overlay toggling stays independent of Jellyfin playback controls.
- Collapsed duplicate visible-overlay toggle events so Hyprland does not process one physical shortcut as hide-then-show.
- Kept passive Linux/Hyprland visible-overlay shows from taking keyboard focus away from mpv/Jellyfin.
- Made Jellyfin external subtitle selection tolerate transient mpv `track-list` read failures and numeric string track IDs so Japanese subtitles are selected after preload on Linux.
- Fixed AppImage-launched Jellyfin playback controls so mpv sends overlay commands to the running SubMiner app-control socket instead of the mounted Electron binary.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed Jellyfin remote controller visibility and progress syncing for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Stats: Fixed in-player stats layering so delete confirmations, overlay modals, and update-check dialogs appear above the stats window.
+3 -28
View File
@@ -3,40 +3,13 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { withProcessExitIntercept } from '../test-support/exit-intercept.js';
import { import {
applyInvocationsToArgs, applyInvocationsToArgs,
applyRootOptionsToArgs, applyRootOptionsToArgs,
createDefaultArgs, createDefaultArgs,
} from './args-normalizer.js'; } from './args-normalizer.js';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected process.exit');
}
function withTempDir<T>(fn: (dir: string) => T): T { function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
try { try {
@@ -106,6 +79,7 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => {
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.message, /exit:1/); assert.match(error.message, /exit:1/);
assert.match(error.stderr, /Not a file, directory, or supported URL/);
}); });
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
@@ -231,6 +205,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
}); });
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.stderr, /Unknown config action: \(none\)/);
}); });
test('applyInvocationsToArgs maps texthooker browser-open request', () => { test('applyInvocationsToArgs maps texthooker browser-open request', () => {
+2 -28
View File
@@ -7,6 +7,7 @@ import net from 'node:net';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import type { Args } from './types'; import type { Args } from './types';
import { getAppControlSocketPath } from '../src/shared/app-control'; import { getAppControlSocketPath } from '../src/shared/app-control';
import { withProcessExitIntercept } from './test-support/exit-intercept.js';
import { import {
buildConfiguredMpvDefaultArgs, buildConfiguredMpvDefaultArgs,
buildMpvBackendArgs, buildMpvBackendArgs,
@@ -28,34 +29,6 @@ import {
} from './mpv'; } from './mpv';
import * as mpvModule from './mpv'; import * as mpvModule from './mpv';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected process.exit');
}
function createTempSocketPath(): { dir: string; socketPath: string } { function createTempSocketPath(): { dir: string; socketPath: string } {
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests'); const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests');
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
@@ -393,6 +366,7 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', ()
}); });
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.stderr, /Failed to launch texthooker mode/);
}); });
test('launchTexthookerOnly forwards browser-open request to app command', () => { test('launchTexthookerOnly forwards browser-open request to app command', () => {
+6 -28
View File
@@ -1,34 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseArgs } from './config'; import { parseArgs } from './config';
import { withProcessExitIntercept } from './test-support/exit-intercept.js';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected parseArgs to exit');
}
test('parseArgs captures passthrough args for app subcommand', () => { test('parseArgs captures passthrough args for app subcommand', () => {
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {}); const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
@@ -131,7 +104,9 @@ test('parseArgs rejects removed config open and launch actions', () => {
}); });
assert.equal(openExit.code, 1); assert.equal(openExit.code, 1);
assert.match(openExit.stderr, /Unknown config action: open/);
assert.equal(exit.code, 1); assert.equal(exit.code, 1);
assert.match(exit.stderr, /Unknown config action: launch/);
}); });
test('parseArgs requires an explicit action for the config subcommand', () => { test('parseArgs requires an explicit action for the config subcommand', () => {
@@ -140,6 +115,7 @@ test('parseArgs requires an explicit action for the config subcommand', () => {
}); });
assert.equal(exit.code, 1); assert.equal(exit.code, 1);
assert.match(exit.stderr, /Unknown config action: \(none\)/);
}); });
test('parseArgs maps mpv idle action', () => { test('parseArgs maps mpv idle action', () => {
@@ -180,6 +156,7 @@ test('parseArgs rejects conflicting dictionary candidate and selection modes', (
}); });
assert.equal(exit.code, 1); assert.equal(exit.code, 1);
assert.match(exit.stderr, /Dictionary --candidates and --select cannot be combined/);
}); });
test('parseArgs maps stats command and log-level override', () => { test('parseArgs maps stats command and log-level override', () => {
@@ -243,6 +220,7 @@ test('parseArgs rejects cleanup-only stats flags without cleanup action', () =>
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.message, /exit:1/); assert.match(error.message, /exit:1/);
assert.match(error.stderr, /Stats --vocab and --lifetime flags require the cleanup action/);
}); });
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => { test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
+47
View File
@@ -0,0 +1,47 @@
export class ExitSignal extends Error {
code: number;
stderr: string;
constructor(code: number, stderr: string) {
super(`exit:${code}`);
this.code = code;
this.stderr = stderr;
}
}
function stderrChunkToString(chunk: string | Uint8Array, encoding?: BufferEncoding): string {
if (typeof chunk === 'string') return chunk;
return Buffer.from(chunk).toString(encoding);
}
export function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
const originalStderrWrite = process.stderr.write;
let stderr = '';
try {
process.stderr.write = ((chunk: string | Uint8Array, ...args: unknown[]) => {
const encoding = typeof args[0] === 'string' ? (args[0] as BufferEncoding) : undefined;
stderr += stderrChunkToString(chunk, encoding);
const writeCallback = args.find((arg): arg is (error?: Error | null) => void => {
return typeof arg === 'function';
});
writeCallback?.();
return true;
}) as typeof process.stderr.write;
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0, stderr);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.stderr.write = originalStderrWrite;
process.exit = originalExit;
}
throw new Error('expected process.exit');
}
+3 -2
View File
@@ -121,11 +121,12 @@
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.3.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "39.8.6", "electron": "42.2.0",
"electron-builder": "26.8.2", "electron-builder": "26.8.2",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
+7
View File
@@ -114,6 +114,13 @@ function M.create(ctx)
end end
end end
if not environment.is_windows() then
local appimage_path = resolve_binary_candidate(os.getenv("APPIMAGE"))
if appimage_path and appimage_path ~= "" then
return appimage_path
end
end
return nil return nil
end end
+40
View File
@@ -33,6 +33,20 @@ function M.create(ctx)
return nil return nil
end end
local function resolve_media_title()
local media_title = mp.get_property("media-title")
if type(media_title) == "string" and media_title ~= "" then
return media_title
end
local filename = mp.get_property("filename")
if type(filename) == "string" and filename ~= "" then
return filename
end
return nil
end
local function is_reload_end_file(reason) local function is_reload_end_file(reason)
return reason == "reload" or reason == "redirect" return reason == "reload" or reason == "redirect"
end end
@@ -125,6 +139,10 @@ function M.create(ctx)
local function on_start_file() local function on_start_file()
if state.pending_reload_media_identity ~= nil then if state.pending_reload_media_identity ~= nil then
local media_identity = resolve_media_identity()
if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then
rearm_managed_subtitle_load_defaults()
end
return return
end end
rearm_managed_subtitle_load_defaults() rearm_managed_subtitle_load_defaults()
@@ -132,12 +150,23 @@ function M.create(ctx)
local function on_file_loaded() local function on_file_loaded()
local media_identity = resolve_media_identity() local media_identity = resolve_media_identity()
local media_title = resolve_media_title()
local retry_generation = next_auto_start_retry_generation() local retry_generation = next_auto_start_retry_generation()
local previous_media_identity = state.current_media_identity local previous_media_identity = state.current_media_identity
local pending_reload_title = state.pending_reload_media_title
local pending_reload_reason = state.pending_reload_reason
local same_media_reload = ( local same_media_reload = (
media_identity ~= nil media_identity ~= nil
and state.pending_reload_media_identity ~= nil and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity and media_identity == state.pending_reload_media_identity
) or (
state.pending_reload_media_identity ~= nil
and media_title ~= nil
and pending_reload_title ~= nil
and media_title == pending_reload_title
) or (
pending_reload_reason == "redirect"
and state.pending_reload_media_identity ~= nil
) )
local same_media_loaded = ( local same_media_loaded = (
media_identity ~= nil media_identity ~= nil
@@ -146,7 +175,10 @@ function M.create(ctx)
) )
local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
state.current_media_identity = media_identity state.current_media_identity = media_identity
state.current_media_title = media_title
if new_media_loaded then if new_media_loaded then
state.suppress_ready_overlay_restore = false state.suppress_ready_overlay_restore = false
end end
@@ -191,7 +223,10 @@ function M.create(ctx)
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
state.current_media_identity = nil state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
end end
local function register_lifecycle_hooks() local function register_lifecycle_hooks()
@@ -207,11 +242,16 @@ function M.create(ctx)
local reason = type(event) == "table" and event.reason or nil local reason = type(event) == "table" and event.reason or nil
if is_reload_end_file(reason) then if is_reload_end_file(reason) then
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity() state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
state.pending_reload_media_title = state.current_media_title or resolve_media_title()
state.pending_reload_reason = reason
return return
end end
next_auto_start_retry_generation() next_auto_start_retry_generation()
state.current_media_identity = nil state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
if state.overlay_running and reason ~= "quit" then if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay() process.hide_visible_overlay()
end end
+6
View File
@@ -17,6 +17,12 @@ function M.create(ctx)
mp.register_script_message("subminer-toggle", function() mp.register_script_message("subminer-toggle", function()
process.toggle_overlay() process.toggle_overlay()
end) end)
mp.register_script_message("subminer-visible-overlay-hidden", function()
process.record_visible_overlay_visibility(false)
end)
mp.register_script_message("subminer-visible-overlay-shown", function()
process.record_visible_overlay_visibility(true)
end)
mp.register_script_message("subminer-menu", function() mp.register_script_message("subminer-menu", function()
ui.show_menu() ui.show_menu()
end) end)
+71 -2
View File
@@ -7,6 +7,7 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
@@ -77,6 +78,49 @@ function M.create(ctx)
return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS
end end
local function record_visible_overlay_action(action)
if action == "show-visible-overlay" then
state.visible_overlay_requested = true
state.suppress_ready_overlay_restore = false
elseif action == "hide-visible-overlay" then
state.visible_overlay_requested = false
elseif action == "toggle-visible-overlay" and state.visible_overlay_requested ~= nil then
state.visible_overlay_requested = not state.visible_overlay_requested
if state.visible_overlay_requested then
state.suppress_ready_overlay_restore = false
end
end
end
local function record_visible_overlay_visibility(visible)
if visible then
state.visible_overlay_requested = true
state.suppress_ready_overlay_restore = false
return
end
state.visible_overlay_requested = false
state.suppress_ready_overlay_restore = true
end
local function should_ignore_duplicate_visible_overlay_toggle()
if type(mp.get_time) ~= "function" then
return false
end
local now = mp.get_time()
if type(now) ~= "number" then
return false
end
local previous = state.last_visible_overlay_toggle_time
state.last_visible_overlay_toggle_time = now
if type(previous) ~= "number" then
return false
end
local elapsed = now - previous
return elapsed >= 0 and elapsed < DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS
end
local function normalize_socket_path(path) local function normalize_socket_path(path)
if type(path) ~= "string" then if type(path) ~= "string" then
return nil return nil
@@ -317,6 +361,7 @@ function M.create(ctx)
end end
run_control_command_async = function(action, overrides, callback) run_control_command_async = function(action, overrides, callback)
record_visible_overlay_action(action)
local args = build_command_args(action, overrides) local args = build_command_args(action, overrides)
local command = build_subprocess_command(args) local command = build_subprocess_command(args)
subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
@@ -557,7 +602,8 @@ function M.create(ctx)
show_osd("Stopped") show_osd("Stopped")
end end
local function hide_visible_overlay() local function hide_visible_overlay(options)
options = options or {}
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
return return
@@ -577,7 +623,9 @@ function M.create(ctx)
end end
end) end)
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate({
resume_playback = options.resume_playback ~= false,
})
end end
local function toggle_overlay() local function toggle_overlay()
@@ -586,6 +634,26 @@ function M.create(ctx)
show_osd("Error: binary not found") show_osd("Error: binary not found")
return return
end end
if should_ignore_duplicate_visible_overlay_toggle() then
subminer_log("debug", "process", "Ignoring duplicate visible overlay toggle")
return
end
if state.visible_overlay_requested == true then
state.suppress_ready_overlay_restore = true
hide_visible_overlay({ resume_playback = false })
return
end
if state.visible_overlay_requested == false then
state.suppress_ready_overlay_restore = false
disarm_auto_play_ready_gate({ resume_playback = false })
run_control_command_async("show-visible-overlay", nil, function(ok)
if not ok then
subminer_log("warn", "process", "Show-visible-overlay command failed")
show_osd("Toggle failed")
end
end)
return
end
state.suppress_ready_overlay_restore = true state.suppress_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false }) disarm_auto_play_ready_gate({ resume_playback = false })
@@ -717,6 +785,7 @@ function M.create(ctx)
build_command_args = build_command_args, build_command_args = build_command_args,
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
run_control_command_async = run_control_command_async, run_control_command_async = run_control_command_async,
record_visible_overlay_visibility = record_visible_overlay_visibility,
run_binary_command_async = run_binary_command_async, run_binary_command_async = run_binary_command_async,
parse_start_script_message_overrides = parse_start_script_message_overrides, parse_start_script_message_overrides = parse_start_script_message_overrides,
ensure_texthooker_running = ensure_texthooker_running, ensure_texthooker_running = ensure_texthooker_running,
+5
View File
@@ -312,6 +312,11 @@ function M.create(ctx)
return return
end end
if binding.actionId == "toggleVisibleOverlay" and type(process.toggle_overlay) == "function" then
process.toggle_overlay()
return
end
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers) start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
return return
+5
View File
@@ -35,8 +35,13 @@ function M.new()
auto_play_ready_osd_timer = nil, auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false, suppress_ready_overlay_restore = false,
force_ready_overlay_restore = false, force_ready_overlay_restore = false,
visible_overlay_requested = nil,
last_visible_overlay_toggle_time = nil,
current_media_identity = nil, current_media_identity = nil,
current_media_title = nil,
pending_reload_media_identity = nil, pending_reload_media_identity = nil,
pending_reload_media_title = nil,
pending_reload_reason = nil,
auto_start_retry_generation = 0, auto_start_retry_generation = 0,
session_binding_generation = 0, session_binding_generation = 0,
session_binding_names = {}, session_binding_names = {},
+25
View File
@@ -68,6 +68,31 @@ local function create_binary_module(config)
return binary return binary
end end
do
local appimage_path = "/home/tester/.local/bin/SubMiner.AppImage"
local mounted_binary_path = "/tmp/.mount_SubMiner/SubMiner"
local resolved = with_env({
APPIMAGE = appimage_path,
}, function()
local binary = create_binary_module({
is_windows = false,
binary_path = mounted_binary_path,
entries = {
[appimage_path] = "file",
[mounted_binary_path] = "file",
},
})
return binary.find_binary()
end)
assert_equal(
resolved,
appimage_path,
"linux resolver should prefer APPIMAGE over the mounted AppImage inner binary"
)
end
do do
local binary = create_binary_module({ local binary = create_binary_module({
is_windows = true, is_windows = true,
+17
View File
@@ -23,6 +23,7 @@ local recorded = {
async_calls = {}, async_calls = {},
mpv_commands = {}, mpv_commands = {},
osd = {}, osd = {},
overlay_toggles = 0,
} }
local mp = {} local mp = {}
@@ -68,6 +69,14 @@ local ctx = {
return { return {
numericSelectionTimeoutMs = 3000, numericSelectionTimeoutMs = 3000,
bindings = { bindings = {
{
key = {
code = "KeyO",
modifiers = { "alt", "shift" },
},
actionType = "session-action",
actionId = "toggleVisibleOverlay",
},
{ {
key = { key = {
code = "KeyS", code = "KeyS",
@@ -253,6 +262,9 @@ local ctx = {
run_binary_command_async = function(args) run_binary_command_async = function(args)
recorded.async_calls[#recorded.async_calls + 1] = args recorded.async_calls[#recorded.async_calls + 1] = args
end, end,
toggle_overlay = function()
recorded.overlay_toggles = recorded.overlay_toggles + 1
end,
}, },
environment = { environment = {
resolve_session_bindings_artifact_path = function() resolve_session_bindings_artifact_path = function()
@@ -318,6 +330,11 @@ local expected_cli_bindings = {
{ keys = "w", flag = "--mark-watched" }, { keys = "w", flag = "--mark-watched" },
} }
local visible_overlay_toggle = find_binding("Alt+O")
assert_true(visible_overlay_toggle ~= nil, "visible overlay session binding should register")
visible_overlay_toggle.fn()
assert_true(recorded.overlay_toggles == 1, "visible overlay session binding should use plugin toggle")
for _, expected in ipairs(expected_cli_bindings) do for _, expected in ipairs(expected_cli_bindings) do
local binding = find_binding(expected.keys) local binding = find_binding(expected.keys)
assert_true(binding ~= nil, "default session action should register " .. expected.keys) assert_true(binding ~= nil, "default session action should register " .. expected.keys)
+226 -5
View File
@@ -201,7 +201,7 @@ local function run_plugin_scenario(config)
end end
function mp.set_osd_ass(...) end function mp.set_osd_ass(...) end
function mp.get_time() function mp.get_time()
return 0 return config.now or 0
end end
function mp.commandv(...) end function mp.commandv(...) end
function mp.set_property_native(name, value) function mp.set_property_native(name, value)
@@ -623,16 +623,18 @@ local binary_path = "/tmp/subminer-binary"
local appimage_path = "/tmp/SubMiner.AppImage" local appimage_path = "/tmp/SubMiner.AppImage"
do do
local recorded, err = run_plugin_scenario({ local scenario = {
process_list = "", process_list = "",
option_overrides = { option_overrides = {
binary_path = binary_path, binary_path = binary_path,
auto_start = "no", auto_start = "no",
}, },
now = 20,
files = { files = {
[binary_path] = true, [binary_path] = true,
}, },
}) }
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err)) assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered") assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
recorded.script_messages["subminer-start"]("texthooker=no") recorded.script_messages["subminer-start"]("texthooker=no")
@@ -683,6 +685,174 @@ do
) )
end end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-app-toggle-initial.m3u8",
media_title = "Jellyfin App Toggle",
paused = true,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for app-side hide Jellyfin redirect: " .. tostring(err))
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-visible-overlay-hidden"]()
fire_event(recorded, "end-file", { reason = "redirect" })
scenario.path = "/media/jellyfin-app-toggle-final.m3u8"
scenario.media_title = ""
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"app-side hide sync should suppress path-changing Jellyfin redirect visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"app-side hide sync followed by Jellyfin redirect should keep paused playback paused"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-duplicate-toggle.m3u8",
media_title = "Jellyfin Duplicate Toggle",
paused = true,
now = 10,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for duplicate visible overlay toggle: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-toggle"]()
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"duplicate same-tick visible overlay toggles should hide once"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"duplicate same-tick visible overlay toggles should not immediately show the overlay again"
)
scenario.now = 10.5
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"later visible overlay toggle should still show after duplicate suppression window"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "no",
},
now = 20,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for visible overlay state sync scenario: " .. tostring(err))
assert_true(
recorded.script_messages["subminer-visible-overlay-hidden"] ~= nil,
"hidden visibility sync message should be registered"
)
assert_true(
recorded.script_messages["subminer-visible-overlay-shown"] ~= nil,
"shown visibility sync message should be registered"
)
recorded.script_messages["subminer-visible-overlay-hidden"]()
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"toggle after app-side hide should explicitly show SubMiner overlay through plugin state"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"toggle after app-side hide should avoid app-side visible overlay toggle"
)
scenario.now = 20.5
recorded.script_messages["subminer-visible-overlay-shown"]()
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"toggle after app-side show should explicitly hide SubMiner overlay through plugin state"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-stream.m3u8",
media_title = "Jellyfin Episode",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for y-t hide visible overlay scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
local toggle_binding = nil
for _, candidate in ipairs(recorded.key_bindings) do
if candidate.name == "subminer-toggle" then
toggle_binding = candidate
break
end
end
assert_true(toggle_binding ~= nil, "y-t toggle binding should be registered")
toggle_binding.fn()
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"y-t should hide the known visible overlay explicitly instead of app-side toggle"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"y-t should avoid app-side toggle when plugin knows the overlay is visible"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual y-t hide should suppress duplicate auto-start and ready-time visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual y-t hide should not resume paused Jellyfin playback"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1550,8 +1720,12 @@ do
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]() recorded.script_messages["subminer-toggle"]()
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1, count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"manual toggle should use explicit visible-overlay toggle command" "manual toggle-off should hide a known visible overlay explicitly"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"manual toggle-off should avoid app-side toggle when plugin knows the overlay is visible"
) )
recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]()
assert_true( assert_true(
@@ -1664,6 +1838,53 @@ do
) )
end end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-redirect-initial.m3u8",
media_title = "Jellyfin Redirect",
paused = true,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for manual hide path-changing Jellyfin redirect: " .. tostring(err))
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-toggle"]()
fire_event(recorded, "end-file", { reason = "redirect" })
scenario.path = "/media/jellyfin-redirect-final.m3u8"
scenario.media_title = ""
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"manual toggle-off should suppress path-changing Jellyfin redirect visible overlay reassertion even if media-title drops"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off followed by path-changing Jellyfin reload should keep paused playback paused"
)
assert_true(
count_property_set(recorded.property_sets, "sid", "auto") == 2,
"path-changing Jellyfin redirect should rearm primary subtitle selection before mpv loads tracks"
)
assert_true(
count_property_set(recorded.property_sets, "secondary-sid", "auto") == 2,
"path-changing Jellyfin redirect should rearm secondary subtitle selection before mpv loads tracks"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -60,7 +60,10 @@ test('buildHyprlandPlacementDispatches floats tiled overlay windows without pinn
floating: false, floating: false,
pinned: false, pinned: false,
}), }),
[['dispatch', 'setfloating', 'address:0xabc']], [
['dispatch', 'setfloating', 'address:0xabc'],
['dispatch', 'alterzorder', 'top,address:0xabc'],
],
); );
}); });
@@ -87,6 +90,7 @@ test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to
['dispatch', 'setprop', 'address:0xabc no_shadow 1'], ['dispatch', 'setprop', 'address:0xabc no_shadow 1'],
['dispatch', 'setprop', 'address:0xabc no_blur 1'], ['dispatch', 'setprop', 'address:0xabc no_blur 1'],
['dispatch', 'setprop', 'address:0xabc decorate 0'], ['dispatch', 'setprop', 'address:0xabc decorate 0'],
['dispatch', 'alterzorder', 'top,address:0xabc'],
], ],
); );
}); });
@@ -98,7 +102,7 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win
floating: true, floating: true,
pinned: false, pinned: false,
}), }),
[], [['dispatch', 'alterzorder', 'top,address:0xabc']],
); );
}); });
@@ -109,7 +113,10 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
floating: true, floating: true,
pinned: true, pinned: true,
}), }),
[['dispatch', 'pin', 'address:0xabc']], [
['dispatch', 'pin', 'address:0xabc'],
['dispatch', 'alterzorder', 'top,address:0xabc'],
],
); );
}); });
@@ -146,6 +153,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for ma
[ [
['-j', 'clients'], ['-j', 'clients'],
['dispatch', 'setfloating', 'address:0xmatch'], ['dispatch', 'setfloating', 'address:0xmatch'],
['dispatch', 'alterzorder', 'top,address:0xmatch'],
], ],
); );
}); });
@@ -195,6 +203,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe
['dispatch', 'setprop', 'address:0xmatch no_shadow 1'], ['dispatch', 'setprop', 'address:0xmatch no_shadow 1'],
['dispatch', 'setprop', 'address:0xmatch no_blur 1'], ['dispatch', 'setprop', 'address:0xmatch no_blur 1'],
['dispatch', 'setprop', 'address:0xmatch decorate 0'], ['dispatch', 'setprop', 'address:0xmatch decorate 0'],
['dispatch', 'alterzorder', 'top,address:0xmatch'],
], ],
); );
}); });
@@ -95,6 +95,7 @@ export function buildHyprlandPlacementDispatches(
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]); dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]); dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]);
} }
dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]);
return dispatches; return dispatches;
} }
+38
View File
@@ -289,6 +289,44 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload); assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
}); });
test('timeline payload omits websocket-only event names', () => {
const payload = buildJellyfinTimelinePayload({
itemId: 'movie-2',
positionTicks: 123456,
eventName: 'TimeUpdate',
});
assert.equal('EventName' in payload, false);
});
test('reportStopped posts final position and explicit non-failed state', async () => {
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-stop-payload',
deviceId: 'device-stop-payload',
webSocketFactory: () => new FakeWebSocket() as unknown as any,
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
const ok = await service.reportStopped({
itemId: 'movie-stop',
positionTicks: 7654321,
failed: false,
});
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
assert.equal(ok, true);
assert.ok(stoppedCall);
assert.ok(typeof stoppedCall.init.body === 'string');
const posted = JSON.parse(String(stoppedCall.init.body));
assert.equal(posted.PositionTicks, 7654321);
assert.equal(posted.Failed, false);
});
test('advertiseNow validates server registration using Sessions endpoint', async () => { test('advertiseNow validates server registration using Sessions endpoint', async () => {
const sockets: FakeWebSocket[] = []; const sockets: FakeWebSocket[] = [];
const calls: string[] = []; const calls: string[] = [];
+5 -7
View File
@@ -20,6 +20,7 @@ export interface JellyfinTimelinePlaybackState {
subtitleStreamIndex?: number | null; subtitleStreamIndex?: number | null;
playlistItemId?: string | null; playlistItemId?: string | null;
eventName?: string; eventName?: string;
failed?: boolean;
} }
export interface JellyfinTimelinePayload { export interface JellyfinTimelinePayload {
@@ -36,7 +37,7 @@ export interface JellyfinTimelinePayload {
AudioStreamIndex?: number | null; AudioStreamIndex?: number | null;
SubtitleStreamIndex?: number | null; SubtitleStreamIndex?: number | null;
PlaylistItemId?: string | null; PlaylistItemId?: string | null;
EventName: string; Failed?: boolean;
} }
interface JellyfinRemoteSocket { interface JellyfinRemoteSocket {
@@ -168,7 +169,7 @@ export function buildJellyfinTimelinePayload(
AudioStreamIndex: asNullableInteger(state.audioStreamIndex), AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex), SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
PlaylistItemId: state.playlistItemId, PlaylistItemId: state.playlistItemId,
EventName: state.eventName || 'timeupdate', Failed: state.failed,
}; };
} }
@@ -269,10 +270,7 @@ export class JellyfinRemoteSessionService {
} }
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> { public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing', { return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'start',
});
} }
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> { public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
@@ -282,7 +280,7 @@ export class JellyfinRemoteSessionService {
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> { public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing/Stopped', { return this.postTimeline('/Sessions/Playing/Stopped', {
...buildJellyfinTimelinePayload(state), ...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'stop', Failed: state.failed === true,
}); });
} }
+90 -5
View File
@@ -197,7 +197,54 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
assert.ok(!calls.includes('osd')); assert.ok(!calls.includes('osd'));
}); });
test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => { test('suspended visible overlay hides without refreshing bounds or z-order', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
suspendVisibleOverlay: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('update-bounds'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('untracked non-macOS overlay shows passively when no tracker exists', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
let trackerWarning = false; let trackerWarning = false;
@@ -232,11 +279,49 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
} as never); } as never);
assert.equal(trackerWarning, false); assert.equal(trackerWarning, false);
assert.ok(calls.includes('show')); assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('focus')); assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('osd')); assert.ok(!calls.includes('osd'));
}); });
test('passive Linux visible overlay does not take keyboard focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('tracked non-macOS overlay reapplies bounds after first show', () => { test('tracked non-macOS overlay reapplies bounds after first show', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
@@ -270,8 +355,8 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
} as never); } as never);
assert.deepEqual( assert.deepEqual(
calls.filter((call) => call === 'update-bounds' || call === 'show'), calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'),
['update-bounds', 'show', 'update-bounds'], ['update-bounds', 'show-inactive', 'update-bounds'],
); );
}); });
+25 -2
View File
@@ -64,6 +64,7 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean; visibleOverlayVisible: boolean;
modalActive?: boolean; modalActive?: boolean;
forceMousePassthrough?: boolean; forceMousePassthrough?: boolean;
suspendVisibleOverlay?: boolean;
overlayInteractionActive?: boolean; overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null; mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null; windowTracker: BaseWindowTracker | null;
@@ -104,6 +105,18 @@ export function updateVisibleOverlayVisibility(args: {
return; return;
} }
if (args.suspendVisibleOverlay) {
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.setIgnoreMouseEvents(true, { forward: true });
releaseOverlayWindowLevel(mainWindow);
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
const showPassiveVisibleOverlay = (): boolean => { const showPassiveVisibleOverlay = (): boolean => {
const forceMousePassthrough = args.forceMousePassthrough === true; const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible(); const wasVisible = mainWindow.isVisible();
@@ -172,6 +185,8 @@ export function updateVisibleOverlayVisibility(args: {
shouldUseMacOSMousePassthrough || shouldUseMacOSMousePassthrough ||
forceMousePassthrough || forceMousePassthrough ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow)); (shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const isNonNativePassiveOverlay =
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost = const shouldKeepTrackedWindowsOverlayTopmost =
!args.isWindowsPlatform || !args.isWindowsPlatform ||
@@ -214,7 +229,10 @@ export function updateVisibleOverlayVisibility(args: {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady // skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer // callback will trigger another visibility update when the renderer
// has painted its first frame. // has painted its first frame.
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) { } else if (
((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) ||
isNonNativePassiveOverlay
) {
if (args.isWindowsPlatform) { if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
} }
@@ -258,7 +276,12 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.focus(); mainWindow.focus();
} }
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { if (
!args.isWindowsPlatform &&
!args.isMacOSPlatform &&
!forceMousePassthrough &&
overlayInteractionActive
) {
mainWindow.focus(); mainWindow.focus();
} }
+67 -1
View File
@@ -1,4 +1,8 @@
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; import type {
BrowserWindow,
BrowserWindowConstructorOptions,
MessageBoxSyncOptions,
} from 'electron';
import type { WindowGeometry } from '../../types'; import type { WindowGeometry } from '../../types';
const DEFAULT_STATS_WINDOW_WIDTH = 900; const DEFAULT_STATS_WINDOW_WIDTH = 900;
@@ -7,6 +11,17 @@ export const STATS_WINDOW_TITLE = 'SubMiner Stats';
type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> & type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> &
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>; Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type VisibleStatsWindowLevelController = StatsWindowLevelController &
Pick<BrowserWindow, 'isDestroyed' | 'isVisible'>;
type VisibleStatsWindowDialogLayerController = Pick<
BrowserWindow,
'isDestroyed' | 'isVisible' | 'setAlwaysOnTop'
>;
type StatsNativeConfirmDialogWindow = Pick<BrowserWindow, 'isDestroyed'>;
type StatsNativeConfirmDialogPresenter<WindowT> = {
showWithParent: (window: WindowT, options: MessageBoxSyncOptions) => number;
showWithoutParent: (options: MessageBoxSyncOptions) => number;
};
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>; type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> & type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
@@ -106,6 +121,57 @@ export function promoteStatsWindowLevel(
window.moveTop(); window.moveTop();
} }
export function promoteVisibleStatsWindowAboveOverlay(
window: VisibleStatsWindowLevelController,
options: {
platform?: NodeJS.Platform;
promoteHyprlandWindow?: () => void;
} = {},
): boolean {
if (window.isDestroyed() || !window.isVisible()) {
return false;
}
promoteStatsWindowLevel(window, options.platform);
options.promoteHyprlandWindow?.();
return true;
}
export function demoteVisibleStatsWindowBelowDialogs(
window: VisibleStatsWindowDialogLayerController,
): boolean {
if (window.isDestroyed() || !window.isVisible()) {
return false;
}
window.setAlwaysOnTop(false);
return true;
}
export function buildStatsNativeConfirmDialogOptions(message: string): MessageBoxSyncOptions {
return {
type: 'warning',
message,
buttons: ['Delete', 'Cancel'],
defaultId: 1,
cancelId: 1,
noLink: true,
};
}
export function showStatsNativeConfirmDialog<WindowT extends StatsNativeConfirmDialogWindow>(
window: WindowT | null,
message: string,
presenter: StatsNativeConfirmDialogPresenter<WindowT>,
): boolean {
const options = buildStatsNativeConfirmDialogOptions(message);
const response =
window && !window.isDestroyed()
? presenter.showWithParent(window, options)
: presenter.showWithoutParent(options);
return response === 0;
}
export function presentStatsWindow( export function presentStatsWindow(
window: StatsWindowPresentationController, window: StatsWindowPresentationController,
platform: NodeJS.Platform = process.platform, platform: NodeJS.Platform = process.platform,
+129
View File
@@ -3,9 +3,13 @@ import test from 'node:test';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
buildStatsNativeConfirmDialogOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow, presentStatsWindow,
promoteVisibleStatsWindowAboveOverlay,
promoteStatsWindowLevel, promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
} from './stats-window-runtime'; } from './stats-window-runtime';
@@ -232,6 +236,131 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']); assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
}); });
test('promoteVisibleStatsWindowAboveOverlay reasserts stats above overlay on Hyprland', () => {
const calls: string[] = [];
const promoted = promoteVisibleStatsWindowAboveOverlay(
{
isDestroyed: () => false,
isVisible: () => true,
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
},
moveTop: () => {
calls.push('move-top');
},
} as never,
{
platform: 'linux',
promoteHyprlandWindow: () => calls.push('hyprland-top'),
},
);
assert.equal(promoted, true);
assert.deepEqual(calls, ['always-on-top:true:none:0', 'move-top', 'hyprland-top']);
});
test('promoteVisibleStatsWindowAboveOverlay skips hidden stats windows', () => {
const calls: string[] = [];
const promoted = promoteVisibleStatsWindowAboveOverlay(
{
isDestroyed: () => false,
isVisible: () => false,
setAlwaysOnTop: () => calls.push('always-on-top'),
moveTop: () => calls.push('move-top'),
} as never,
{
promoteHyprlandWindow: () => calls.push('hyprland-top'),
},
);
assert.equal(promoted, false);
assert.deepEqual(calls, []);
});
test('demoteVisibleStatsWindowBelowDialogs lowers visible stats below native dialogs', () => {
const calls: string[] = [];
const demoted = demoteVisibleStatsWindowBelowDialogs({
isDestroyed: () => false,
isVisible: () => true,
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
},
} as never);
assert.equal(demoted, true);
assert.deepEqual(calls, ['always-on-top:false:none:0']);
});
test('demoteVisibleStatsWindowBelowDialogs skips hidden stats windows', () => {
const calls: string[] = [];
const demoted = demoteVisibleStatsWindowBelowDialogs({
isDestroyed: () => false,
isVisible: () => false,
setAlwaysOnTop: () => calls.push('always-on-top'),
} as never);
assert.equal(demoted, false);
assert.deepEqual(calls, []);
});
test('buildStatsNativeConfirmDialogOptions makes delete the explicit destructive action', () => {
assert.deepEqual(buildStatsNativeConfirmDialogOptions('Delete this session?'), {
type: 'warning',
message: 'Delete this session?',
buttons: ['Delete', 'Cancel'],
defaultId: 1,
cancelId: 1,
noLink: true,
});
});
test('showStatsNativeConfirmDialog parents the native dialog to live stats windows', () => {
const calls: string[] = [];
const parent = { isDestroyed: () => false };
const confirmed = showStatsNativeConfirmDialog(parent, 'Delete this session?', {
showWithParent: (window, options) => {
assert.equal(window, parent);
calls.push(`${options.message}:${options.defaultId}:${options.cancelId}`);
return 0;
},
showWithoutParent: () => {
calls.push('unparented');
return 1;
},
});
assert.equal(confirmed, true);
assert.deepEqual(calls, ['Delete this session?:1:1']);
});
test('showStatsNativeConfirmDialog treats cancel as not confirmed', () => {
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => false }, 'Delete?', {
showWithParent: () => 1,
showWithoutParent: () => 0,
});
assert.equal(confirmed, false);
});
test('showStatsNativeConfirmDialog falls back to an unparented dialog without a live stats window', () => {
const calls: string[] = [];
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => true }, 'Delete?', {
showWithParent: () => {
calls.push('parented');
return 0;
},
showWithoutParent: (options) => {
calls.push(options.message);
return 0;
},
});
assert.equal(confirmed, true);
assert.deepEqual(calls, ['Delete?']);
});
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => { test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
const calls: string[] = []; const calls: string[] = [];
+90 -3
View File
@@ -1,13 +1,16 @@
import { BrowserWindow, ipcMain } from 'electron'; import { BrowserWindow, dialog, ipcMain } from 'electron';
import * as path from 'path'; import * as path from 'path';
import type { WindowGeometry } from '../../types.js'; import type { WindowGeometry } from '../../types.js';
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow, presentStatsWindow,
promoteStatsWindowLevel, promoteStatsWindowLevel,
promoteVisibleStatsWindowAboveOverlay,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
STATS_WINDOW_TITLE, STATS_WINDOW_TITLE,
} from './stats-window-runtime.js'; } from './stats-window-runtime.js';
@@ -15,6 +18,8 @@ import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement
let statsWindow: BrowserWindow | null = null; let statsWindow: BrowserWindow | null = null;
let toggleRegistered = false; let toggleRegistered = false;
let nativeDialogLayerRegistered = false;
let nativeDialogLayerSuspensionCount = 0;
export interface StatsWindowOptions { export interface StatsWindowOptions {
/** Absolute path to stats/dist/ directory */ /** Absolute path to stats/dist/ directory */
@@ -58,7 +63,88 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
} }
options.onVisibilityChanged?.(true); options.onVisibilityChanged?.(true);
promoteStatsWindowLevel(window); promoteStatsOverlayAbovePlayback();
}
export function promoteStatsOverlayAbovePlayback(): boolean {
if (nativeDialogLayerSuspensionCount > 0) {
return false;
}
if (!statsWindow) {
return false;
}
return promoteVisibleStatsWindowAboveOverlay(statsWindow, {
promoteHyprlandWindow: () => {
ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE });
},
});
}
export function demoteStatsOverlayBelowDialogs(): boolean {
if (!statsWindow) {
return false;
}
return demoteVisibleStatsWindowBelowDialogs(statsWindow);
}
export function suspendStatsWindowLayerForNativeDialog(): void {
nativeDialogLayerSuspensionCount += 1;
if (nativeDialogLayerSuspensionCount !== 1) {
return;
}
demoteStatsOverlayBelowDialogs();
}
export function restoreStatsWindowLayerAfterNativeDialog(): void {
if (nativeDialogLayerSuspensionCount <= 0) {
return;
}
nativeDialogLayerSuspensionCount -= 1;
if (nativeDialogLayerSuspensionCount === 0) {
promoteStatsOverlayAbovePlayback();
}
}
export async function withStatsWindowLayerSuspendedForNativeDialog<T>(
showDialog: () => Promise<T>,
): Promise<T> {
suspendStatsWindowLayerForNativeDialog();
try {
return await showDialog();
} finally {
restoreStatsWindowLayerAfterNativeDialog();
}
}
function confirmStatsNativeDialog(message: unknown): boolean {
const dialogMessage =
typeof message === 'string' && message.trim().length > 0 ? message : 'Confirm deletion?';
return showStatsNativeConfirmDialog(statsWindow, dialogMessage, {
showWithParent: (parentWindow, options) => dialog.showMessageBoxSync(parentWindow, options),
showWithoutParent: (options) => dialog.showMessageBoxSync(options),
});
}
function registerStatsNativeDialogLayerHandlers(): void {
if (nativeDialogLayerRegistered) return;
nativeDialogLayerRegistered = true;
ipcMain.on(IPC_CHANNELS.command.statsNativeConfirmDialog, (event, message) => {
event.returnValue = confirmStatsNativeDialog(message);
});
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogOpened, (event) => {
suspendStatsWindowLayerForNativeDialog();
event.returnValue = true;
});
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogClosed, () => {
restoreStatsWindowLayerAfterNativeDialog();
});
} }
/** /**
@@ -104,7 +190,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) { if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
return; return;
} }
promoteStatsWindowLevel(statsWindow); promoteStatsOverlayAbovePlayback();
}); });
} else if (statsWindow.isVisible()) { } else if (statsWindow.isVisible()) {
statsWindow.hide(); statsWindow.hide();
@@ -119,6 +205,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
* Call this once during app initialization. * Call this once during app initialization.
*/ */
export function registerStatsOverlayToggle(options: StatsWindowOptions): void { export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
registerStatsNativeDialogLayerHandlers();
if (toggleRegistered) return; if (toggleRegistered) return;
toggleRegistered = true; toggleRegistered = true;
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => { ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
+61
View File
@@ -15,6 +15,9 @@ import {
shouldHandleLaunchMpvAtEntry, shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry, shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs, hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
applyEarlyLinuxCommandLineSwitches,
resolveLinuxPasswordStoreValue,
} from './main-entry-runtime'; } from './main-entry-runtime';
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
@@ -106,6 +109,64 @@ test('hasTransportedStartupArgs detects env-carried app args', () => {
assert.equal(hasTransportedStartupArgs({}), false); assert.equal(hasTransportedStartupArgs({}), false);
}); });
test('resolveLinuxPasswordStoreValue defaults Linux safeStorage to gnome-libsecret', () => {
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.AppImage'], 'linux'), 'gnome-libsecret');
assert.equal(
resolveLinuxPasswordStoreValue(['SubMiner.AppImage', '--password-store', 'gnome'], 'linux'),
'gnome-libsecret',
);
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.exe'], 'win32'), null);
});
test('applyEarlyLinuxCommandLineSwitches appends password store before main startup', () => {
const switches: Array<[string, string | undefined]> = [];
applyEarlyLinuxCommandLineSwitches(
{
appendSwitch: (name, value) => {
switches.push([name, value]);
},
},
['SubMiner.AppImage', '--password-store=kwallet6'],
'linux',
);
assert.deepEqual(switches, [
['enable-features', 'GlobalShortcutsPortal'],
['password-store', 'kwallet6'],
]);
});
test('transported AppImage visibility commands should forward through app control', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
SUBMINER_APP_ARGC: '1',
SUBMINER_APP_ARG_0: '--hide-visible-overlay',
}),
true,
);
});
test('app control forwarding is only for transported runtime commands', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--app-ping'], {
SUBMINER_APP_ARGC: '1',
SUBMINER_APP_ARG_0: '--app-ping',
}),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--launch-mpv'], {
SUBMINER_APP_ARGC: '1',
SUBMINER_APP_ARG_0: '--launch-mpv',
}),
false,
);
});
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => { test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true); assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false); assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
+72 -1
View File
@@ -1,11 +1,12 @@
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import { CliArgs, parseArgs, shouldStartApp } from './cli/args'; import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
import { resolveConfigDir } from './config/path-resolution'; import { resolveConfigDir } from './config/path-resolution';
const BACKGROUND_ARG = '--background'; const BACKGROUND_ARG = '--background';
const START_ARG = '--start'; const START_ARG = '--start';
const PASSWORD_STORE_ARG = '--password-store'; const PASSWORD_STORE_ARG = '--password-store';
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
@@ -34,6 +35,10 @@ type EarlyAppLike = {
setPath: (name: 'userData', value: string) => void; setPath: (name: 'userData', value: string) => void;
}; };
type CommandLineLike = {
appendSwitch: (name: string, value?: string) => void;
};
type EarlyAppPathOptions = { type EarlyAppPathOptions = {
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
appDataDir?: string; appDataDir?: string;
@@ -73,6 +78,58 @@ function removePassiveStartupArgs(argv: string[]): string[] {
return filtered; return filtered;
} }
function getPasswordStoreArg(argv: string[]): string | null {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
continue;
}
if (arg === PASSWORD_STORE_ARG) {
const value = argv[i + 1];
if (value && !value.startsWith('--')) {
return value;
}
return null;
}
const [prefix, value] = arg.split('=', 2);
if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
function normalizePasswordStoreArg(value: string): string {
const normalized = value.trim();
if (normalized.toLowerCase() === 'gnome') {
return DEFAULT_LINUX_PASSWORD_STORE;
}
return normalized;
}
export function resolveLinuxPasswordStoreValue(
argv: string[],
platform: NodeJS.Platform = process.platform,
): string | null {
if (platform !== 'linux') return null;
return normalizePasswordStoreArg(getPasswordStoreArg(argv) ?? DEFAULT_LINUX_PASSWORD_STORE);
}
export function applyEarlyLinuxCommandLineSwitches(
commandLine: CommandLineLike,
argv: string[],
platform: NodeJS.Platform = process.platform,
): void {
if (platform !== 'linux') return;
commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
commandLine.appendSwitch(
'password-store',
resolveLinuxPasswordStoreValue(argv, platform) ?? DEFAULT_LINUX_PASSWORD_STORE,
);
}
function consumesLaunchMpvValue(token: string): boolean { function consumesLaunchMpvValue(token: string): boolean {
return ( return (
token.startsWith('--') && token.startsWith('--') &&
@@ -90,6 +147,20 @@ export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string'; return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
} }
export function shouldForwardStartupArgvViaAppControl(
argv: string[],
env: NodeJS.ProcessEnv,
): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
if (!hasTransportedStartupArgs(env)) return false;
const args = parseCliArgs(argv);
if (args.help || args.appPing || args.launchMpv) return false;
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
return hasExplicitCommand(args);
}
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null { function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
const rawCount = env[TRANSPORTED_APP_ARGC_ENV]; const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
if (rawCount === undefined) { if (rawCount === undefined) {
+51 -12
View File
@@ -9,17 +9,20 @@ import {
normalizeLaunchMpvExtraArgs, normalizeLaunchMpvExtraArgs,
normalizeLaunchMpvTargets, normalizeLaunchMpvTargets,
normalizeStartupArgv, normalizeStartupArgv,
applyEarlyLinuxCommandLineSwitches,
sanitizeStartupEnv, sanitizeStartupEnv,
sanitizeBackgroundEnv, sanitizeBackgroundEnv,
sanitizeHelpEnv, sanitizeHelpEnv,
sanitizeLaunchMpvEnv, sanitizeLaunchMpvEnv,
hasTransportedStartupArgs, hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
shouldDetachBackgroundLaunch, shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry, shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry, shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry, shouldHandleStatsDaemonCommandAtEntry,
} from './main-entry-runtime'; } from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance'; import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { sendAppControlCommand } from './shared/app-control-client';
import { import {
detectInstalledFirstRunPluginCandidates, detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
@@ -173,6 +176,7 @@ function readConfiguredWindowsMpvLaunch(configDir: string): {
} }
process.argv = normalizeStartupArgv(process.argv, process.env); process.argv = normalizeStartupArgv(process.argv, process.env);
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
applySanitizedEnv(sanitizeStartupEnv(process.env)); applySanitizedEnv(sanitizeStartupEnv(process.env));
const userDataPath = configureEarlyAppPaths(app); const userDataPath = configureEarlyAppPaths(app);
const reportFatalError = createFatalErrorReporter({ const reportFatalError = createFatalErrorReporter({
@@ -184,6 +188,44 @@ registerFatalErrorHandlers({
exit: (code) => app.exit(code), exit: (code) => app.exit(code),
}); });
function startMainProcess(): void {
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
if (!gotSingleInstanceLock) {
app.exit(0);
return;
}
try {
require('./main.js');
} catch (error) {
reportFatalError(error, {
title: 'SubMiner startup failed',
context: 'SubMiner failed while loading the main process.',
});
app.exit(1);
}
}
async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
if (!shouldForwardStartupArgvViaAppControl(process.argv, process.env)) {
return false;
}
const result = await sendAppControlCommand(process.argv, {
configDir: userDataPath,
timeoutMs: 500,
});
if (result.ok) {
app.exit(0);
return true;
}
if (!result.unavailable) {
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
app.exit(1);
return true;
}
return false;
}
if (shouldDetachBackgroundLaunch(process.argv, process.env)) { if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1); const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const child = spawn(process.execPath, childArgs, { const child = spawn(process.execPath, childArgs, {
@@ -233,17 +275,14 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
app.exit(exitCode); app.exit(exitCode);
}); });
} else { } else {
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); void forwardStartupArgvViaAppControlIfAvailable()
if (!gotSingleInstanceLock) { .then((forwarded) => {
app.exit(0); if (!forwarded) {
} startMainProcess();
try { }
require('./main.js'); })
} catch (error) { .catch((error) => {
reportFatalError(error, { console.error('SubMiner app-control handoff failed:', error);
title: 'SubMiner startup failed', startMainProcess();
context: 'SubMiner failed while loading the main process.',
}); });
app.exit(1);
}
} }
+52 -8
View File
@@ -351,8 +351,13 @@ import {
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server'; import { startStatsServer } from './core/services/stats-server';
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; import {
import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/stats-window.js'; destroyStatsWindow,
promoteStatsOverlayAbovePlayback,
registerStatsOverlayToggle,
toggleStatsOverlay as toggleStatsOverlayWindow,
withStatsWindowLayerSuspendedForNativeDialog,
} from './core/services/stats-window.js';
import { import {
createFirstRunSetupService, createFirstRunSetupService,
getFirstRunSetupCompletionMessage, getFirstRunSetupCompletionMessage,
@@ -500,6 +505,7 @@ import {
} from './main/jlpt-runtime'; } from './main/jlpt-runtime';
import { createMediaRuntimeService } from './main/media-runtime'; import { createMediaRuntimeService } from './main/media-runtime';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime'; import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-overlay-visibility';
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime'; import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime'; import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync'; import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
@@ -643,6 +649,7 @@ let jellyfinPlayQuitOnDisconnectArmed = false;
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US'; const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000; const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000; const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
const JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS = 10_000;
const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000; const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000; const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
@@ -2247,6 +2254,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getModalActive: () => overlayModalInputState.getModalInputExclusive(), getModalActive: () => overlayModalInputState.getModalInputExclusive(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible, getForceMousePassthrough: () => appState.statsOverlayVisible,
getSuspendVisibleOverlay: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () => visibleOverlayInteractionActive, getOverlayInteractionActive: () => visibleOverlayInteractionActive,
getWindowTracker: () => appState.windowTracker, getWindowTracker: () => appState.windowTracker,
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
@@ -2309,6 +2317,17 @@ let macOSVisibleOverlayForegroundProbeActive = false;
let macOSVisibleOverlayForegroundProbeToken = 0; let macOSVisibleOverlayForegroundProbeToken = 0;
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null; let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
setStatsOverlayVisibleState: (visible) => {
appState.statsOverlayVisible = visible;
},
resetVisibleOverlayInteraction: () => {
visibleOverlayInteractionActive = false;
},
getMainWindow: () => overlayManager.getMainWindow(),
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
});
function clearVisibleOverlayBlurRefreshTimeouts(): void { function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) { for (const timeout of visibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout); clearTimeout(timeout);
@@ -2914,7 +2933,11 @@ const {
}, },
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
setActivePlayback: (state) => { setActivePlayback: (state) => {
activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState; activeJellyfinRemotePlayback = {
...(state as ActiveJellyfinRemotePlaybackState),
stopReportsAfterMs:
state.stopReportsAfterMs ?? Date.now() + JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS,
};
}, },
setLastProgressAtMs: (value) => { setLastProgressAtMs: (value) => {
jellyfinRemoteLastProgressAtMs = value; jellyfinRemoteLastProgressAtMs = value;
@@ -3923,8 +3946,7 @@ const immersionTrackerStartupMainDeps: Parameters<
getToggleKey: () => getResolvedConfig().stats.toggleKey, getToggleKey: () => getResolvedConfig().stats.toggleKey,
resolveBounds: () => getCurrentOverlayGeometry(), resolveBounds: () => getCurrentOverlayGeometry(),
onVisibilityChanged: (visible) => { onVisibilityChanged: (visible) => {
appState.statsOverlayVisible = visible; handleStatsOverlayVisibilityChanged(visible);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, },
}); });
} }
@@ -4395,6 +4417,11 @@ const {
immersionMediaRuntime.syncFromCurrentMediaState(); immersionMediaRuntime.syncFromCurrentMediaState();
}, },
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path), signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
markJellyfinRemotePlaybackLoaded: (path) => {
if (activeJellyfinRemotePlayback) {
activeJellyfinRemotePlayback.loadedMediaPath = path;
}
},
scheduleCharacterDictionarySync: () => { scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
return; return;
@@ -4714,7 +4741,12 @@ const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
const buildEnsureOverlayWindowLevelMainDepsHandler = const buildEnsureOverlayWindowLevelMainDepsHandler =
createBuildEnsureOverlayWindowLevelMainDepsHandler({ createBuildEnsureOverlayWindowLevelMainDepsHandler({
shouldSuppressOverlayWindowLevel: (window) =>
appState.statsOverlayVisible && window === overlayManager.getMainWindow(),
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
afterEnsureOverlayWindowLevel: () => {
promoteStatsOverlayAbovePlayback();
},
}); });
const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler(); const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler();
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler( const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
@@ -5092,6 +5124,8 @@ function getUpdateService() {
}); });
app.focus({ steal: true }); app.focus({ steal: true });
}, },
withStatsWindowLayerSuspended: (showDialog) =>
withStatsWindowLayerSuspendedForNativeDialog(showDialog),
showMessageBox: (options) => dialog.showMessageBox(options), showMessageBox: (options) => dialog.showMessageBox(options),
}); });
updateService = createUpdateService({ updateService = createUpdateService({
@@ -5435,8 +5469,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
getToggleKey: () => getResolvedConfig().stats.toggleKey, getToggleKey: () => getResolvedConfig().stats.toggleKey,
resolveBounds: () => getCurrentOverlayGeometry(), resolveBounds: () => getCurrentOverlayGeometry(),
onVisibilityChanged: (visible) => { onVisibilityChanged: (visible) => {
appState.statsOverlayVisible = visible; handleStatsOverlayVisibilityChanged(visible);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, },
}), }),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
@@ -6295,6 +6328,13 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
} }
} }
function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden',
]);
}
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) { if (!visible) {
@@ -6305,18 +6345,21 @@ function setVisibleOverlayVisible(visible: boolean): void {
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
} }
setVisibleOverlayVisibleHandler(visible); setVisibleOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
function toggleVisibleOverlay(): void { function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
const nextVisible = !overlayManager.getVisibleOverlayVisible();
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
if (overlayManager.getVisibleOverlayVisible()) { if (!nextVisible) {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} else { } else {
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
} }
toggleVisibleOverlayHandler(); toggleVisibleOverlayHandler();
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
@@ -6328,6 +6371,7 @@ function setOverlayVisible(visible: boolean): void {
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
} }
setOverlayVisibleHandler(visible); setOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
function handleOverlayModalClosed(modal: OverlayHostedModal): void { function handleOverlayModalClosed(modal: OverlayHostedModal): void {
+16
View File
@@ -70,6 +70,22 @@ test('manual visible overlay toggles suppress current-media autoplay release', (
); );
}); });
test('manual visible overlay changes notify mpv plugin visibility state', () => {
const source = readMainSource();
const setBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(setBlock);
assert.ok(toggleBlock);
assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/);
assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/);
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
});
test('main process uses one shared mpv plugin runtime config helper', () => { test('main process uses one shared mpv plugin runtime config helper', () => {
const source = readMainSource(); const source = readMainSource();
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/); assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
+13 -1
View File
@@ -9,6 +9,7 @@ type MockWindow = {
ignoreMouseEvents: boolean; ignoreMouseEvents: boolean;
forwardedIgnoreMouseEvents: boolean; forwardedIgnoreMouseEvents: boolean;
webContentsFocused: boolean; webContentsFocused: boolean;
alwaysOnTopCalls: string[];
showCount: number; showCount: number;
hideCount: number; hideCount: number;
sent: unknown[][]; sent: unknown[][];
@@ -53,6 +54,7 @@ function createMockWindow(): MockWindow & {
ignoreMouseEvents: false, ignoreMouseEvents: false,
forwardedIgnoreMouseEvents: false, forwardedIgnoreMouseEvents: false,
webContentsFocused: false, webContentsFocused: false,
alwaysOnTopCalls: [],
showCount: 0, showCount: 0,
hideCount: 0, hideCount: 0,
sent: [], sent: [],
@@ -72,7 +74,9 @@ function createMockWindow(): MockWindow & {
state.ignoreMouseEvents = ignore; state.ignoreMouseEvents = ignore;
state.forwardedIgnoreMouseEvents = options?.forward === true; state.forwardedIgnoreMouseEvents = options?.forward === true;
}, },
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {}, setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
state.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
},
moveTop: () => {}, moveTop: () => {},
getShowCount: () => state.showCount, getShowCount: () => state.showCount,
getHideCount: () => state.hideCount, getHideCount: () => state.hideCount,
@@ -155,6 +159,13 @@ function createMockWindow(): MockWindow & {
}, },
}); });
Object.defineProperty(window, 'alwaysOnTopCalls', {
get: () => state.alwaysOnTopCalls,
set: (value: string[]) => {
state.alwaysOnTopCalls = value;
},
});
Object.defineProperty(window, 'url', { Object.defineProperty(window, 'url', {
get: () => state.url, get: () => state.url,
set: (value: string) => { set: (value: string) => {
@@ -219,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
runtime.notifyOverlayModalOpened('runtime-options'); runtime.notifyOverlayModalOpened('runtime-options');
assert.equal(window.getShowCount(), 1); assert.equal(window.getShowCount(), 1);
assert.equal(window.isFocused(), true); assert.equal(window.isFocused(), true);
assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']);
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, [['runtime-options:open']]);
}); });
+1 -1
View File
@@ -138,7 +138,7 @@ export function createOverlayModalRuntimeService(
const elevateModalWindow = (window: BrowserWindow): void => { const elevateModalWindow = (window: BrowserWindow): void => {
if (window.isDestroyed()) return; if (window.isDestroyed()) return;
window.setAlwaysOnTop(true, 'screen-saver', 1); window.setAlwaysOnTop(true, 'screen-saver', 3);
window.moveTop(); window.moveTop();
}; };
+3
View File
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
getModalActive: () => boolean; getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean; getForceMousePassthrough: () => boolean;
getSuspendVisibleOverlay?: () => boolean;
getOverlayInteractionActive?: () => boolean; getOverlayInteractionActive?: () => boolean;
getWindowTracker: () => BaseWindowTracker | null; getWindowTracker: () => BaseWindowTracker | null;
getLastKnownWindowsForegroundProcessName?: () => string | null; getLastKnownWindowsForegroundProcessName?: () => string | null;
@@ -44,6 +45,7 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayVisibility(): void { updateVisibleOverlayVisibility(): void {
const visibleOverlayVisible = deps.getVisibleOverlayVisible(); const visibleOverlayVisible = deps.getVisibleOverlayVisible();
const forceMousePassthrough = deps.getForceMousePassthrough(); const forceMousePassthrough = deps.getForceMousePassthrough();
const suspendVisibleOverlay = deps.getSuspendVisibleOverlay?.() ?? false;
const windowTracker = deps.getWindowTracker(); const windowTracker = deps.getWindowTracker();
const mainWindow = deps.getMainWindow(); const mainWindow = deps.getMainWindow();
@@ -51,6 +53,7 @@ export function createOverlayVisibilityRuntimeService(
visibleOverlayVisible, visibleOverlayVisible,
modalActive: deps.getModalActive(), modalActive: deps.getModalActive(),
forceMousePassthrough, forceMousePassthrough,
suspendVisibleOverlay,
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false, overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
mainWindow, mainWindow,
windowTracker, windowTracker,
@@ -87,6 +87,9 @@ export function composeJellyfinRemoteHandlers(
getActivePlayback: options.getActivePlayback, getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback, clearActivePlayback: options.clearActivePlayback,
getSession: options.getSession, getSession: options.getSession,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug, logDebug: options.logDebug,
}); });
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
@@ -121,6 +121,8 @@ test('playback handler drives mpv commands and playback state', async () => {
assert.equal(activeStates[0]?.playMethod, 'DirectPlay'); assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
assert.equal(reportPayloads.length, 1); assert.equal(reportPayloads.length, 1);
assert.equal(reportPayloads[0]?.eventName, 'start'); assert.equal(reportPayloads[0]?.eventName, 'start');
assert.equal(reportPayloads[0]?.positionTicks, 12_000_000);
assert.equal(reportPayloads[0]?.isPaused, false);
assert.deepEqual(statsMetadata, [ assert.deepEqual(statsMetadata, [
{ {
mediaPath: 'https://stream.example/video.m3u8', mediaPath: 'https://stream.example/video.m3u8',
@@ -180,6 +182,47 @@ test('playback handler publishes Jellyfin title before loading tokenized stream
assert.equal(timeline[titleIndex]?.includes('api_key'), false); assert.equal(timeline[titleIndex]?.includes('api_key'), false);
}); });
test('playback handler arms unloaded active playback before loading mpv media', async () => {
const timeline: string[] = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}`),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: (state) => timeline.push(`active:${String(state.loadedMediaPath)}`),
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
});
assert.ok(timeline.indexOf('active:null') >= 0);
assert.ok(timeline.indexOf('active:null') < timeline.indexOf('cmd:loadfile'));
});
test('playback handler applies start override to stream url for remote resume', async () => { test('playback handler applies start override to stream url for remote resume', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({ const handler = createPlayJellyfinItemInMpvHandler({
+16 -9
View File
@@ -14,6 +14,8 @@ type ActivePlaybackState = {
audioStreamIndex?: number | null; audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null; subtitleStreamIndex?: number | null;
playMethod: 'DirectPlay' | 'Transcode'; playMethod: 'DirectPlay' | 'Transcode';
loadedMediaPath?: string | null;
stopReportsAfterMs?: number;
}; };
export type JellyfinPlaybackStatsMetadata = { export type JellyfinPlaybackStatsMetadata = {
@@ -69,6 +71,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
itemId: string; itemId: string;
mediaSourceId: undefined; mediaSourceId: undefined;
playMethod: 'DirectPlay' | 'Transcode'; playMethod: 'DirectPlay' | 'Transcode';
positionTicks?: number;
isPaused?: boolean;
audioStreamIndex?: number | null; audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null; subtitleStreamIndex?: number | null;
eventName: 'start'; eventName: 'start';
@@ -107,6 +111,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
deps.applyJellyfinMpvDefaults(mpvClient); deps.applyJellyfinMpvDefaults(mpvClient);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride); const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
try { try {
deps.updateCurrentMediaTitle?.(plan.title); deps.updateCurrentMediaTitle?.(plan.title);
deps.recordJellyfinPlaybackMetadata?.({ deps.recordJellyfinPlaybackMetadata?.({
@@ -121,6 +126,15 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
} catch { } catch {
// Best-effort metadata/title hooks must not block playback startup. // Best-effort metadata/title hooks must not block playback startup.
} }
deps.setActivePlayback({
itemId: params.itemId,
mediaSourceId: undefined,
audioStreamIndex: plan.audioStreamIndex,
subtitleStreamIndex: plan.subtitleStreamIndex,
playMethod,
loadedMediaPath: null,
});
deps.setLastProgressAtMs(0);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']); deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
if (params.setQuitOnDisconnectArm !== false) { if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect(); deps.armQuitOnDisconnect();
@@ -143,19 +157,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
itemId: params.itemId, itemId: params.itemId,
}); });
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
deps.setActivePlayback({
itemId: params.itemId,
mediaSourceId: undefined,
audioStreamIndex: plan.audioStreamIndex,
subtitleStreamIndex: plan.subtitleStreamIndex,
playMethod,
});
deps.setLastProgressAtMs(0);
deps.reportPlaying({ deps.reportPlaying({
itemId: params.itemId, itemId: params.itemId,
mediaSourceId: undefined, mediaSourceId: undefined,
playMethod, playMethod,
positionTicks: startTimeTicks,
isPaused: false,
audioStreamIndex: plan.audioStreamIndex, audioStreamIndex: plan.audioStreamIndex,
subtitleStreamIndex: plan.subtitleStreamIndex, subtitleStreamIndex: plan.subtitleStreamIndex,
eventName: 'start', eventName: 'start',
@@ -4,6 +4,8 @@ export type ActiveJellyfinRemotePlaybackState = {
audioStreamIndex?: number | null; audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null; subtitleStreamIndex?: number | null;
playMethod: 'DirectPlay' | 'Transcode'; playMethod: 'DirectPlay' | 'Transcode';
loadedMediaPath?: string | null;
stopReportsAfterMs?: number;
}; };
type JellyfinSession = { type JellyfinSession = {
@@ -103,12 +103,16 @@ test('jellyfin remote stopped main deps builder maps callbacks', () => {
getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }), getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }),
clearActivePlayback: () => calls.push('clear'), clearActivePlayback: () => calls.push('clear'),
getSession: () => session as never, getSession: () => session as never,
getMpvClient: () => ({ id: 2, currentTimePos: 4 }) as never,
ticksPerSecond: 10_000_000,
logDebug: (message) => calls.push(`debug:${message}`), logDebug: (message) => calls.push(`debug:${message}`),
})(); })();
assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' }); assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' });
deps.clearActivePlayback(); deps.clearActivePlayback();
assert.equal(deps.getSession(), session); assert.equal(deps.getSession(), session);
assert.deepEqual(deps.getMpvClient(), { id: 2, currentTimePos: 4 });
assert.equal(deps.ticksPerSecond, 10_000_000);
deps.logDebug('stopped', null); deps.logDebug('stopped', null);
assert.deepEqual(calls, ['clear', 'debug:stopped']); assert.deepEqual(calls, ['clear', 'debug:stopped']);
}); });
@@ -71,6 +71,9 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
getActivePlayback: () => deps.getActivePlayback(), getActivePlayback: () => deps.getActivePlayback(),
clearActivePlayback: () => deps.clearActivePlayback(), clearActivePlayback: () => deps.clearActivePlayback(),
getSession: () => deps.getSession(), getSession: () => deps.getSession(),
getMpvClient: () => deps.getMpvClient(),
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
ticksPerSecond: deps.ticksPerSecond,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error), logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
}); });
} }
@@ -61,6 +61,42 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
assert.equal(lastProgressAtMs, 5000); assert.equal(lastProgressAtMs, 5000);
}); });
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => false,
reportProgress: async (payload) => {
reportPayloads.push({
positionTicks: payload.positionTicks,
isPaused: payload.isPaused,
});
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
currentTimePos: 42,
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
}),
getNow: () => 5000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(true);
assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]);
});
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => { test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
const reportPayloads: Array<{ isPaused: boolean }> = []; const reportPayloads: Array<{ isPaused: boolean }> = [];
@@ -123,9 +159,61 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy
assert.equal(called, false); assert.equal(called, false);
}); });
test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during debounce', async () => {
let now = 5000;
let lastProgressAtMs = 0;
let position = 10;
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
reportPayloads.push({
positionTicks: payload.positionTicks,
eventName: payload.eventName,
});
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
currentTimePos: position,
requestProperty: async (name: string) => (name === 'pause' ? false : position),
}),
getNow: () => now,
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(true);
now = 5500;
position = 90;
await reportProgress(false);
assert.deepEqual(reportPayloads, [
{ positionTicks: 100_000_000, eventName: 'TimeUpdate' },
{ positionTicks: 900_000_000, eventName: 'TimeUpdate' },
]);
assert.equal(lastProgressAtMs, 5500);
});
test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => { test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => {
let cleared = false; let cleared = false;
let stoppedItemId: string | null = null; let stoppedPayload: {
itemId: string;
positionTicks?: number;
failed?: boolean;
} | null = null;
const reportStopped = createReportJellyfinRemoteStoppedHandler({ const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () => ({ getActivePlayback: () => ({
itemId: 'item-2', itemId: 'item-2',
@@ -141,13 +229,143 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
isConnected: () => true, isConnected: () => true,
reportProgress: async () => {}, reportProgress: async () => {},
reportStopped: async (payload) => { reportStopped: async (payload) => {
stoppedItemId = payload.itemId; stoppedPayload = {
itemId: payload.itemId,
positionTicks: payload.positionTicks,
failed: payload.failed,
};
}, },
}), }),
getMpvClient: () => ({
currentTimePos: 12.5,
requestProperty: async () => {
throw new Error('unloaded');
},
}),
ticksPerSecond: 10_000_000,
logDebug: () => {}, logDebug: () => {},
}); });
await reportStopped(); await reportStopped();
assert.equal(stoppedItemId, 'item-2'); assert.deepEqual(stoppedPayload, {
itemId: 'item-2',
positionTicks: 125_000_000,
failed: false,
});
assert.equal(cleared, true); assert.equal(cleared, true);
}); });
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
let cleared = false;
let stoppedPayload: {
itemId: string;
positionTicks?: number;
failed?: boolean;
} | null = null;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () => ({
itemId: 'item-2',
mediaSourceId: undefined,
playMethod: 'Transcode',
audioStreamIndex: null,
subtitleStreamIndex: null,
loadedMediaPath: 'https://stream.example/video.m3u8',
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => false,
reportProgress: async () => {},
reportStopped: async (payload) => {
stoppedPayload = {
itemId: payload.itemId,
positionTicks: payload.positionTicks,
failed: payload.failed,
};
},
}),
getMpvClient: () => ({
currentTimePos: 12.5,
}),
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportStopped();
assert.deepEqual(stoppedPayload, {
itemId: 'item-2',
positionTicks: 125_000_000,
failed: false,
});
assert.equal(cleared, true);
});
test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => {
let cleared = false;
let stopped = false;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () =>
({
itemId: 'item-2',
playMethod: 'Transcode',
loadedMediaPath: null,
}) as never,
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {
stopped = true;
},
}),
getMpvClient: () => ({
currentTimePos: 0,
}),
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportStopped();
assert.equal(stopped, false);
assert.equal(cleared, false);
});
test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before grace expires', async () => {
let cleared = false;
let stopped = false;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () =>
({
itemId: 'item-2',
playMethod: 'DirectPlay',
loadedMediaPath: 'https://stream.example/video.m3u8',
stopReportsAfterMs: 20_000,
}) as never,
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {
stopped = true;
},
}),
getMpvClient: () => ({
currentTimePos: 0,
}),
getNow: () => 12_000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportStopped();
assert.equal(stopped, false);
assert.equal(cleared, false);
});
+81 -11
View File
@@ -10,11 +10,13 @@ type JellyfinRemoteSessionLike = {
playMethod: 'DirectPlay' | 'Transcode'; playMethod: 'DirectPlay' | 'Transcode';
audioStreamIndex?: number | null; audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null; subtitleStreamIndex?: number | null;
eventName: 'timeupdate'; eventName: 'TimeUpdate';
}) => Promise<unknown>; }) => Promise<unknown>;
reportStopped: (payload: { reportStopped: (payload: {
itemId: string; itemId: string;
mediaSourceId?: string; mediaSourceId?: string;
positionTicks?: number;
failed?: boolean;
playMethod: 'DirectPlay' | 'Transcode'; playMethod: 'DirectPlay' | 'Transcode';
audioStreamIndex?: number | null; audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null; subtitleStreamIndex?: number | null;
@@ -23,7 +25,8 @@ type JellyfinRemoteSessionLike = {
}; };
type MpvClientLike = { type MpvClientLike = {
requestProperty: (name: string) => Promise<unknown>; currentTimePos?: number;
requestProperty?: (name: string) => Promise<unknown>;
}; };
export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number { export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number {
@@ -44,6 +47,45 @@ function isMpvPauseEnabled(value: unknown): boolean {
return false; return false;
} }
function normalizeMpvPositionSeconds(value: unknown): number {
const seconds = Number(value);
if (!Number.isFinite(seconds)) return 0;
return Math.max(0, seconds);
}
function getCachedMpvPositionSeconds(client: MpvClientLike | null): number | null {
if (!client) return null;
const seconds = Number(client.currentTimePos);
return Number.isFinite(seconds) ? Math.max(0, seconds) : null;
}
async function readMpvPositionSeconds(client: MpvClientLike | null): Promise<number> {
const cached = getCachedMpvPositionSeconds(client);
if (cached !== null) return cached;
const position = await client?.requestProperty?.('time-pos');
return normalizeMpvPositionSeconds(position);
}
async function readMpvPositionSecondsOrFallback(
client: MpvClientLike | null,
fallback = 0,
): Promise<number> {
try {
return await readMpvPositionSeconds(client);
} catch {
return fallback;
}
}
function isSeekLikePositionJump(
previousPositionSeconds: number | null,
nextPositionSeconds: number,
thresholdSeconds: number,
): boolean {
if (previousPositionSeconds === null) return false;
return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds;
}
export type JellyfinRemoteProgressReporterDeps = { export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void; clearActivePlayback: () => void;
@@ -60,29 +102,42 @@ export type JellyfinRemoteProgressReporterDeps = {
export function createReportJellyfinRemoteProgressHandler( export function createReportJellyfinRemoteProgressHandler(
deps: JellyfinRemoteProgressReporterDeps, deps: JellyfinRemoteProgressReporterDeps,
) { ) {
let lastReportedPositionSeconds: number | null = null;
return async (force = false): Promise<void> => { return async (force = false): Promise<void> => {
const playback = deps.getActivePlayback(); const playback = deps.getActivePlayback();
if (!playback) return; if (!playback) return;
const session = deps.getSession(); const session = deps.getSession();
if (!session || !session.isConnected()) return; // Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
if (!session) return;
const now = deps.getNow(); const now = deps.getNow();
if (!force && now - deps.getLastProgressAtMs() < deps.progressIntervalMs) {
return;
}
try { try {
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
const position = await mpvClient?.requestProperty('time-pos'); const positionSeconds = await readMpvPositionSeconds(mpvClient);
const paused = await mpvClient?.requestProperty('pause'); const forceForSeekJump = isSeekLikePositionJump(
lastReportedPositionSeconds,
positionSeconds,
Math.max(2, deps.progressIntervalMs / 1000),
);
if (
!force &&
!forceForSeekJump &&
now - deps.getLastProgressAtMs() < deps.progressIntervalMs
) {
return;
}
const paused = await mpvClient?.requestProperty?.('pause');
await session.reportProgress({ await session.reportProgress({
itemId: playback.itemId, itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId, mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond), positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
isPaused: isMpvPauseEnabled(paused), isPaused: isMpvPauseEnabled(paused),
playMethod: playback.playMethod, playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex, audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex, subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'timeupdate', eventName: 'TimeUpdate',
}); });
lastReportedPositionSeconds = positionSeconds;
deps.setLastProgressAtMs(now); deps.setLastProgressAtMs(now);
} catch (error) { } catch (error) {
deps.logDebug('Failed to report Jellyfin remote progress', error); deps.logDebug('Failed to report Jellyfin remote progress', error);
@@ -94,6 +149,9 @@ export type JellyfinRemoteStoppedReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void; clearActivePlayback: () => void;
getSession: () => JellyfinRemoteSessionLike | null; getSession: () => JellyfinRemoteSessionLike | null;
getMpvClient: () => MpvClientLike | null;
getNow?: () => number;
ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void; logDebug: (message: string, error: unknown) => void;
}; };
@@ -101,15 +159,27 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
return async (): Promise<void> => { return async (): Promise<void> => {
const playback = deps.getActivePlayback(); const playback = deps.getActivePlayback();
if (!playback) return; if (!playback) return;
if (playback.loadedMediaPath === null) return;
if (
typeof playback.stopReportsAfterMs === 'number' &&
Number.isFinite(playback.stopReportsAfterMs) &&
(deps.getNow?.() ?? Date.now()) < playback.stopReportsAfterMs
) {
return;
}
const session = deps.getSession(); const session = deps.getSession();
if (!session || !session.isConnected()) { // Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
if (!session) {
deps.clearActivePlayback(); deps.clearActivePlayback();
return; return;
} }
try { try {
const positionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
await session.reportStopped({ await session.reportStopped({
itemId: playback.itemId, itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId, mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
failed: false,
playMethod: playback.playMethod, playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex, audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex, subtitleStreamIndex: playback.subtitleStreamIndex,
@@ -201,6 +201,94 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
); );
}); });
test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: ' ',
lang: 'jpn',
title: 'Invalid empty id',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/invalid.srt',
},
{
type: 'sub',
id: '10',
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: '11',
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 10],
['set_property', 'secondary-sid', 11],
],
);
});
test('preload jellyfin subtitles retries transient mpv track-list read failures', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
],
getMpvClient: () => ({
connected: true,
requestProperty: async () => {
requestCount += 1;
if (requestCount === 1) {
throw new Error('MPV request timed out');
}
return [
{
type: 'sub',
id: 10,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 2);
assert.deepEqual(commands.at(-1), ['set_property', 'sid', 10]);
});
test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => { test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
let requestCount = 0; let requestCount = 0;
@@ -333,12 +421,18 @@ test('preload jellyfin subtitles cleans previous cached subtitles before a new p
test('preload jellyfin subtitles continues after cleanup failures', async () => { test('preload jellyfin subtitles continues after cleanup failures', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const cleanupCalls: string[][] = [];
const logs: string[] = []; const logs: string[] = [];
let cleanupShouldFail = false; let cleanupShouldFail = false;
const preload = createPreloadJellyfinExternalSubtitlesHandler( const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({ makeDeps({
listJellyfinSubtitleTracks: async () => [ listJellyfinSubtitleTracks: async (_session, _clientInfo, itemId) => [
{ index: 0, language: 'eng', title: 'English', deliveryUrl: 'https://sub/a.srt' }, {
index: itemId === 'item-1' ? 0 : 1,
language: 'eng',
title: 'English',
deliveryUrl: `https://sub/${itemId}.srt`,
},
], ],
getMpvClient: () => ({ requestProperty: async () => [] }), getMpvClient: () => ({ requestProperty: async () => [] }),
cacheSubtitleTrack: async (track) => ({ cacheSubtitleTrack: async (track) => ({
@@ -346,7 +440,8 @@ test('preload jellyfin subtitles continues after cleanup failures', async () =>
cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`, cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`,
}), }),
sendMpvCommand: (command) => commands.push(command), sendMpvCommand: (command) => commands.push(command),
cleanupCachedSubtitles: () => { cleanupCachedSubtitles: (dirs) => {
cleanupCalls.push(dirs);
if (cleanupShouldFail) { if (cleanupShouldFail) {
throw new Error('cleanup failed'); throw new Error('cleanup failed');
} }
@@ -358,13 +453,19 @@ test('preload jellyfin subtitles continues after cleanup failures', async () =>
await preload({ session, clientInfo, itemId: 'item-1' }); await preload({ session, clientInfo, itemId: 'item-1' });
cleanupShouldFail = true; cleanupShouldFail = true;
await assert.doesNotReject(() => preload({ session, clientInfo, itemId: 'item-2' })); await assert.doesNotReject(() => preload({ session, clientInfo, itemId: 'item-2' }));
cleanupShouldFail = false;
preload.cleanupCachedSubtitles();
assert.deepEqual(logs, ['Failed to cleanup Jellyfin cached subtitles']); assert.deepEqual(logs, ['Failed to cleanup Jellyfin cached subtitles']);
assert.deepEqual(cleanupCalls, [
['/tmp/subminer-jellyfin-subtitles-0'],
['/tmp/subminer-jellyfin-subtitles-0', '/tmp/subminer-jellyfin-subtitles-1'],
]);
assert.deepEqual( assert.deepEqual(
commands.filter((command) => command[0] === 'sub-add'), commands.filter((command) => command[0] === 'sub-add'),
[ [
['sub-add', '/tmp/subminer-jellyfin-subtitles-0/track.srt', 'auto', 'English', 'eng'], ['sub-add', '/tmp/subminer-jellyfin-subtitles-0/track.srt', 'auto', 'English', 'eng'],
['sub-add', '/tmp/subminer-jellyfin-subtitles-0/track.srt', 'auto', 'English', 'eng'], ['sub-add', '/tmp/subminer-jellyfin-subtitles-1/track.srt', 'auto', 'English', 'eng'],
], ],
); );
}); });
+21 -7
View File
@@ -151,18 +151,16 @@ function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
? trackListRaw ? trackListRaw
.filter( .filter(
(track): track is Record<string, unknown> => (track): track is Record<string, unknown> =>
Boolean(track) && Boolean(track) && typeof track === 'object' && track.type === 'sub',
typeof track === 'object' &&
track.type === 'sub' &&
typeof track.id === 'number',
) )
.map((track) => ({ .map((track) => ({
id: track.id as number, id: parseTrackId(track.id),
lang: String(track.lang || ''), lang: String(track.lang || ''),
title: String(track.title || ''), title: String(track.title || ''),
external: track.external === true, external: track.external === true,
externalFilename: String(track['external-filename'] || ''), externalFilename: String(track['external-filename'] || ''),
})) }))
.filter((track): track is MpvSubtitleTrack => track.id !== null)
: []; : [];
} }
@@ -179,6 +177,15 @@ function hasExpectedExternalSubtitleTracks(
return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath)); return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath));
} }
function parseTrackId(value: unknown): number | null {
if (typeof value === 'string' && value.trim() === '') {
return null;
}
const numeric =
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
return Number.isFinite(numeric) ? numeric : null;
}
async function readMpvSubtitleTracks(deps: { async function readMpvSubtitleTracks(deps: {
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
}): Promise<MpvSubtitleTrack[] | null> { }): Promise<MpvSubtitleTrack[] | null> {
@@ -186,7 +193,12 @@ async function readMpvSubtitleTracks(deps: {
if (!client || client.connected === false) { if (!client || client.connected === false) {
return null; return null;
} }
const trackListRaw = await client.requestProperty('track-list'); let trackListRaw: unknown;
try {
trackListRaw = await client.requestProperty('track-list');
} catch {
return null;
}
return parseMpvSubtitleTracks(trackListRaw); return parseMpvSubtitleTracks(trackListRaw);
} }
@@ -235,9 +247,11 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
function cleanupActiveCache(): void { function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs]; const dirs = [...activeCacheDirs];
activeCacheDirs.clear();
if (dirs.length === 0) return; if (dirs.length === 0) return;
deps.cleanupCachedSubtitles(dirs); deps.cleanupCachedSubtitles(dirs);
for (const dir of dirs) {
activeCacheDirs.delete(dir);
}
} }
const runPreload = async (params: { const runPreload = async (params: {
@@ -50,7 +50,7 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
} }
}); });
test('linux mpv fullscreen overlay refresh update cancels burst when fullscreen exits', async () => { test('linux mpv fullscreen overlay refresh update schedules a fresh burst when fullscreen exits', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
configurable: true, configurable: true,
@@ -82,8 +82,11 @@ test('linux mpv fullscreen overlay refresh update cancels burst when fullscreen
await new Promise((resolve) => setTimeout(resolve, 80)); await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(nextCancel, null); assert.equal(typeof nextCancel, 'function');
assert.deepEqual(calls, []); assert.ok(calls.includes('updateVisibleOverlayVisibility'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('showInactive'));
assert.ok(calls.includes('ensureOverlayWindowLevel'));
} finally { } finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts(); clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) { if (originalPlatformDescriptor) {
@@ -68,14 +68,11 @@ export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
} }
export function updateLinuxMpvFullscreenOverlayRefreshBurst( export function updateLinuxMpvFullscreenOverlayRefreshBurst(
isFullscreen: boolean, _isFullscreen: boolean,
deps: LinuxMpvFullscreenOverlayRefreshDeps, deps: LinuxMpvFullscreenOverlayRefreshDeps,
cancelCurrentBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null, cancelCurrentBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null,
): CancelLinuxMpvFullscreenOverlayRefreshBurst | null { ): CancelLinuxMpvFullscreenOverlayRefreshBurst | null {
cancelCurrentBurst?.(); cancelCurrentBurst?.();
if (!isFullscreen) {
return null;
}
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(deps); return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(deps);
} }
@@ -168,6 +168,28 @@ test('media path change handler signals autoplay readiness from warm media path'
]); ]);
}); });
test('media path change handler marks Jellyfin remote playback loaded from media path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
markJellyfinRemotePlaybackLoaded: (path) => calls.push(`jellyfin-loaded:${path}`),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ path: 'https://stream.example/video.m3u8' });
assert.ok(calls.includes('jellyfin-loaded:https://stream.example/video.m3u8'));
assert.equal(calls.includes('stopped'), false);
});
test('media title change handler clears guess state without re-scheduling character dictionary sync', () => { test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
const calls: string[] = []; const calls: string[] = [];
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & { const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {
@@ -222,6 +244,36 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
]); ]);
}); });
test('time-pos handler forces Jellyfin progress when mpv position jumps', () => {
const calls: string[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
maybeRunAnilistPostWatchUpdate: async () => {},
});
timeHandler({ time: 10 });
timeHandler({ time: 11 });
timeHandler({ time: 90 });
timeHandler({ time: 30 });
assert.deepEqual(calls, [
'time:10',
'progress:normal',
'presence',
'time:11',
'progress:normal',
'presence',
'time:90',
'progress:force',
'presence',
'time:30',
'progress:force',
'presence',
]);
});
test('time-pos handler passes fresh playback time to AniList post-watch', async () => { test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
const watchedSeconds: unknown[] = []; const watchedSeconds: unknown[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({ const timeHandler = createHandleMpvTimePosChangeHandler({
+18 -1
View File
@@ -4,6 +4,15 @@ type AnilistPostWatchRunOptions = {
watchedSeconds?: number; watchedSeconds?: number;
}; };
const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
return false;
}
return Math.abs(nextTime - previousTime) >= SEEK_LIKE_TIME_DELTA_SECONDS;
}
export function createHandleMpvSubtitleChangeHandler(deps: { export function createHandleMpvSubtitleChangeHandler(deps: {
setCurrentSubText: (text: string) => void; setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
@@ -59,6 +68,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
syncImmersionMediaState: () => void; syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void; scheduleCharacterDictionarySync?: () => void;
signalAutoplayReadyIfWarm?: (path: string) => void; signalAutoplayReadyIfWarm?: (path: string) => void;
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void; flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
}) { }) {
@@ -81,6 +91,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
} }
deps.syncImmersionMediaState(); deps.syncImmersionMediaState();
if (normalizedPath.trim().length > 0) { if (normalizedPath.trim().length > 0) {
deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath);
deps.scheduleCharacterDictionarySync?.(); deps.scheduleCharacterDictionarySync?.();
deps.signalAutoplayReadyIfWarm?.(normalizedPath); deps.signalAutoplayReadyIfWarm?.(normalizedPath);
} }
@@ -113,9 +124,15 @@ export function createHandleMpvTimePosChangeHandler(deps: {
logError?: (message: string, error: unknown) => void; logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number) => void; onTimePosUpdate?: (time: number) => void;
}) { }) {
let lastObservedTime: number | null = null;
return ({ time }: { time: number }): void => { return ({ time }: { time: number }): void => {
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
if (Number.isFinite(time)) {
lastObservedTime = time;
}
deps.recordPlaybackPosition(time); deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false); deps.reportJellyfinRemoteProgress(forceImmediate);
deps.refreshDiscordPresence(); deps.refreshDiscordPresence();
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => { void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
deps.logError?.('AniList post-watch update failed unexpectedly', error); deps.logError?.('AniList post-watch update failed unexpectedly', error);
@@ -63,6 +63,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
ensureAnilistMediaGuess: (mediaKey: string) => void; ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void; syncImmersionMediaState: () => void;
signalAutoplayReadyIfWarm?: (path: string) => void; signalAutoplayReadyIfWarm?: (path: string) => void;
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void; flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
updateCurrentMediaTitle: (title: string) => void; updateCurrentMediaTitle: (title: string) => void;
@@ -142,6 +143,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
flushPlaybackPositionOnMediaPathClear: (mediaPath) => flushPlaybackPositionOnMediaPathClear: (mediaPath) =>
deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath), deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath),
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path), signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
markJellyfinRemotePlaybackLoaded: (path) => deps.markJellyfinRemotePlaybackLoaded?.(path),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(), scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),
}); });
@@ -65,6 +65,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
ensureAnilistMediaGuess: (mediaKey: string) => void; ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void; syncImmersionMediaState: () => void;
signalAutoplayReadyIfWarm?: (path: string) => void; signalAutoplayReadyIfWarm?: (path: string) => void;
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
scheduleCharacterDictionarySync?: () => void; scheduleCharacterDictionarySync?: () => void;
updateCurrentMediaTitle: (title: string) => void; updateCurrentMediaTitle: (title: string) => void;
resetAnilistMediaGuessState: () => void; resetAnilistMediaGuessState: () => void;
@@ -173,6 +174,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey), ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(), syncImmersionMediaState: () => deps.syncImmersionMediaState(),
signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path), signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path),
markJellyfinRemotePlaybackLoaded: (path: string) =>
deps.markJellyfinRemotePlaybackLoaded?.(path),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(), scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title), updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
@@ -63,7 +63,7 @@ test('overlay modal input state activates modal window interactivity and syncs d
assert.deepEqual(modalWindow.calls, [ assert.deepEqual(modalWindow.calls, [
'focusable:true', 'focusable:true',
'ignore:false', 'ignore:false',
'top:true:screen-saver:1', 'top:true:screen-saver:3',
'focus', 'focus',
'web-focus', 'web-focus',
]); ]);
@@ -42,7 +42,7 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
setWindowFocusable(modalWindow); setWindowFocusable(modalWindow);
requestOverlayApplicationFocus(); requestOverlayApplicationFocus();
modalWindow.setIgnoreMouseEvents(false); modalWindow.setIgnoreMouseEvents(false);
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1); modalWindow.setAlwaysOnTop(true, 'screen-saver', 3);
modalWindow.focus(); modalWindow.focus();
if (!modalWindow.webContents.isFocused()) { if (!modalWindow.webContents.isFocused()) {
modalWindow.webContents.focus(); modalWindow.webContents.focus();
@@ -15,6 +15,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
getModalActive: () => true, getModalActive: () => true,
getVisibleOverlayVisible: () => true, getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true, getForceMousePassthrough: () => true,
getSuspendVisibleOverlay: () => true,
getOverlayInteractionActive: () => true, getOverlayInteractionActive: () => true,
getWindowTracker: () => tracker, getWindowTracker: () => tracker,
getLastKnownWindowsForegroundProcessName: () => 'mpv', getLastKnownWindowsForegroundProcessName: () => 'mpv',
@@ -42,6 +43,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
assert.equal(deps.getModalActive(), true); assert.equal(deps.getModalActive(), true);
assert.equal(deps.getVisibleOverlayVisible(), true); assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getForceMousePassthrough(), true); assert.equal(deps.getForceMousePassthrough(), true);
assert.equal(deps.getSuspendVisibleOverlay?.(), true);
assert.equal(deps.getOverlayInteractionActive?.(), true); assert.equal(deps.getOverlayInteractionActive?.(), true);
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv'); assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer'); assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
@@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
getModalActive: () => deps.getModalActive(), getModalActive: () => deps.getModalActive(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(), getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getSuspendVisibleOverlay: () => deps.getSuspendVisibleOverlay?.() ?? false,
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false, getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
getWindowTracker: () => deps.getWindowTracker(), getWindowTracker: () => deps.getWindowTracker(),
getLastKnownWindowsForegroundProcessName: () => getLastKnownWindowsForegroundProcessName: () =>
@@ -15,9 +15,16 @@ test('overlay window layout main deps builders map callbacks', () => {
visible.setOverlayWindowBounds({ x: 0, y: 0, width: 1, height: 1 }); visible.setOverlayWindowBounds({ x: 0, y: 0, width: 1, height: 1 });
const level = createBuildEnsureOverlayWindowLevelMainDepsHandler({ const level = createBuildEnsureOverlayWindowLevelMainDepsHandler({
shouldSuppressOverlayWindowLevel: () => {
calls.push('ensure-suppressed-check');
return false;
},
ensureOverlayWindowLevelCore: () => calls.push('ensure'), ensureOverlayWindowLevelCore: () => calls.push('ensure'),
afterEnsureOverlayWindowLevel: () => calls.push('ensure-after'),
})(); })();
assert.equal(level.shouldSuppressOverlayWindowLevel?.({}), false);
level.ensureOverlayWindowLevelCore({}); level.ensureOverlayWindowLevelCore({});
level.afterEnsureOverlayWindowLevel?.({});
const order = createBuildEnforceOverlayLayerOrderMainDepsHandler({ const order = createBuildEnforceOverlayLayerOrderMainDepsHandler({
enforceOverlayLayerOrderCore: () => calls.push('order'), enforceOverlayLayerOrderCore: () => calls.push('order'),
@@ -34,5 +41,12 @@ test('overlay window layout main deps builders map callbacks', () => {
assert.deepEqual(order.getMainWindow(), { kind: 'main' }); assert.deepEqual(order.getMainWindow(), { kind: 'main' });
order.ensureOverlayWindowLevel({}); order.ensureOverlayWindowLevel({});
assert.deepEqual(calls, ['visible', 'ensure', 'order', 'order-level']); assert.deepEqual(calls, [
'visible',
'ensure-suppressed-check',
'ensure',
'ensure-after',
'order',
'order-level',
]);
}); });
@@ -23,7 +23,11 @@ export function createBuildEnsureOverlayWindowLevelMainDepsHandler(
deps: EnsureOverlayWindowLevelMainDeps, deps: EnsureOverlayWindowLevelMainDeps,
) { ) {
return (): EnsureOverlayWindowLevelMainDeps => ({ return (): EnsureOverlayWindowLevelMainDeps => ({
shouldSuppressOverlayWindowLevel: (window: unknown) =>
deps.shouldSuppressOverlayWindowLevel?.(window) ?? false,
ensureOverlayWindowLevelCore: (window: unknown) => deps.ensureOverlayWindowLevelCore(window), ensureOverlayWindowLevelCore: (window: unknown) => deps.ensureOverlayWindowLevelCore(window),
afterEnsureOverlayWindowLevel: (window: unknown) =>
deps.afterEnsureOverlayWindowLevel?.(window),
}); });
} }
+20 -1
View File
@@ -36,9 +36,28 @@ test('ensure overlay window level handler delegates to core', () => {
const calls: string[] = []; const calls: string[] = [];
const ensureLevel = createEnsureOverlayWindowLevelHandler({ const ensureLevel = createEnsureOverlayWindowLevelHandler({
ensureOverlayWindowLevelCore: () => calls.push('core'), ensureOverlayWindowLevelCore: () => calls.push('core'),
afterEnsureOverlayWindowLevel: () => calls.push('after'),
}); });
ensureLevel({}); ensureLevel({});
assert.deepEqual(calls, ['core']); assert.deepEqual(calls, ['core', 'after']);
});
test('ensure overlay window level handler skips while top reassertion is suppressed', () => {
const calls: string[] = [];
const window = {};
const ensureLevel = createEnsureOverlayWindowLevelHandler({
shouldSuppressOverlayWindowLevel: (nextWindow) => {
assert.equal(nextWindow, window);
calls.push('suppress-check');
return true;
},
ensureOverlayWindowLevelCore: () => calls.push('core'),
afterEnsureOverlayWindowLevel: () => calls.push('after'),
});
ensureLevel(window);
assert.deepEqual(calls, ['suppress-check']);
}); });
test('enforce overlay layer order handler forwards resolved state', () => { test('enforce overlay layer order handler forwards resolved state', () => {
@@ -11,10 +11,16 @@ export function createUpdateVisibleOverlayBoundsHandler(deps: {
} }
export function createEnsureOverlayWindowLevelHandler(deps: { export function createEnsureOverlayWindowLevelHandler(deps: {
shouldSuppressOverlayWindowLevel?: (window: unknown) => boolean;
ensureOverlayWindowLevelCore: (window: unknown) => void; ensureOverlayWindowLevelCore: (window: unknown) => void;
afterEnsureOverlayWindowLevel?: (window: unknown) => void;
}) { }) {
return (window: unknown): void => { return (window: unknown): void => {
if (deps.shouldSuppressOverlayWindowLevel?.(window) === true) {
return;
}
deps.ensureOverlayWindowLevelCore(window); deps.ensureOverlayWindowLevelCore(window);
deps.afterEnsureOverlayWindowLevel?.(window);
}; };
} }
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
test('stats overlay visibility handler makes overlay mouse-passive before opening stats', () => {
const calls: string[] = [];
const handler = createStatsOverlayVisibilityChangeHandler({
setStatsOverlayVisibleState: (visible) => calls.push(`state:${visible}`),
resetVisibleOverlayInteraction: () => calls.push('reset-interaction'),
getMainWindow: () =>
({
isDestroyed: () => false,
isVisible: () => true,
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
}) as never,
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
});
handler(true);
assert.deepEqual(calls, [
'state:true',
'reset-interaction',
'mouse-ignore:true:forward',
'update-visible',
]);
});
test('stats overlay visibility handler restores overlay then leaves mpv mouse-responsive after close', () => {
const calls: string[] = [];
const handler = createStatsOverlayVisibilityChangeHandler({
setStatsOverlayVisibleState: (visible) => calls.push(`state:${visible}`),
resetVisibleOverlayInteraction: () => calls.push('reset-interaction'),
getMainWindow: () =>
({
isDestroyed: () => false,
isVisible: () => true,
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
}) as never,
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
});
handler(false);
assert.deepEqual(calls, [
'state:false',
'reset-interaction',
'update-visible',
'mouse-ignore:true:forward',
]);
});
@@ -0,0 +1,33 @@
type StatsOverlayVisibilityWindow = {
isDestroyed: () => boolean;
isVisible: () => boolean;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
};
function makeOverlayMousePassive(window: StatsOverlayVisibilityWindow | null): void {
if (!window || window.isDestroyed() || !window.isVisible()) {
return;
}
window.setIgnoreMouseEvents(true, { forward: true });
}
export function createStatsOverlayVisibilityChangeHandler(deps: {
setStatsOverlayVisibleState: (visible: boolean) => void;
resetVisibleOverlayInteraction: () => void;
getMainWindow: () => StatsOverlayVisibilityWindow | null;
updateVisibleOverlayVisibility: () => void;
}) {
return (visible: boolean): void => {
deps.setStatsOverlayVisibleState(visible);
deps.resetVisibleOverlayInteraction();
if (visible) {
makeOverlayMousePassive(deps.getMainWindow());
deps.updateVisibleOverlayVisibility();
return;
}
deps.updateVisibleOverlayVisibility();
makeOverlayMousePassive(deps.getMainWindow());
};
}
@@ -28,6 +28,57 @@ test('update dialog presenter focuses app and yields the run loop before showing
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']); assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
}); });
test('update dialog presenter suspends stats window layer while showing dialogs', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
return { response: 0 };
};
const presenter = createUpdateDialogPresenter({
platform: 'linux',
withStatsWindowLayerSuspended: async (showDialog) => {
calls.push('suspend-stats-window');
try {
return await showDialog();
} finally {
calls.push('restore-stats-window');
}
},
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, [
'suspend-stats-window',
'dialog:SubMiner is up to date (v0.14.0)',
'restore-stats-window',
]);
});
test('update dialog presenter restores stats window layer when dialog fails', async () => {
const calls: string[] = [];
const presenter = createUpdateDialogPresenter({
platform: 'linux',
withStatsWindowLayerSuspended: async (showDialog) => {
calls.push('suspend-stats-window');
try {
return await showDialog();
} finally {
calls.push('restore-stats-window');
}
},
showMessageBox: async () => {
calls.push('dialog');
throw new Error('dialog failed');
},
});
await assert.rejects(() => presenter.showNoUpdateDialog('0.14.0'), /dialog failed/);
assert.deepEqual(calls, ['suspend-stats-window', 'dialog', 'restore-stats-window']);
});
test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => { test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => {
const calls: string[] = []; const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => { const showMessageBox: ShowMessageBox = async (options) => {
+13 -6
View File
@@ -19,6 +19,7 @@ export interface UpdateDialogPresenterDeps {
showMessageBox: ShowMessageBox; showMessageBox: ShowMessageBox;
focusApp?: () => void | Promise<void>; focusApp?: () => void | Promise<void>;
yieldToRunLoop?: () => Promise<void>; yieldToRunLoop?: () => Promise<void>;
withStatsWindowLayerSuspended?: <T>(showDialog: () => Promise<T>) => Promise<T>;
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
} }
@@ -46,12 +47,18 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) { export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
const showFocusedMessageBox: ShowMessageBox = async (options) => { const showFocusedMessageBox: ShowMessageBox = async (options) => {
try { const showDialog = async (): Promise<MessageBoxResultLike> => {
await maybeFocusAppForDialog(deps); try {
} catch { await maybeFocusAppForDialog(deps);
// Best-effort focus only; never block the dialog itself. } catch {
} // Best-effort focus only; never block the dialog itself.
return deps.showMessageBox(options); }
return deps.showMessageBox(options);
};
return deps.withStatsWindowLayerSuspended
? deps.withStatsWindowLayerSuspended(showDialog)
: showDialog();
}; };
return { return {
+12
View File
@@ -43,6 +43,18 @@ const statsAPI = {
hideOverlay: (): void => { hideOverlay: (): void => {
ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay); ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay);
}, },
confirmNativeDialog: (message: string): boolean => {
return ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeConfirmDialog, message) === true;
},
beginNativeDialog: (): void => {
ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeDialogOpened);
},
endNativeDialog: (): void => {
ipcRenderer.send(IPC_CHANNELS.command.statsNativeDialogClosed);
},
}; };
contextBridge.exposeInMainWorld('electronAPI', { stats: statsAPI }); contextBridge.exposeInMainWorld('electronAPI', { stats: statsAPI });
+32
View File
@@ -993,6 +993,38 @@ test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus',
} }
}); });
test('visible-layer configured overlay toggle dispatches mpv plugin toggle', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
originalKey: 'Alt+Shift+O',
key: { code: 'KeyO', modifiers: ['alt', 'shift'] },
actionType: 'session-action',
actionId: 'toggleVisibleOverlay',
},
] as never);
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', altKey: true, shiftKey: true });
assert.equal(
testGlobals.mpvCommands.some(
(command) => command[0] === 'script-message' && command[1] === 'subminer-toggle',
),
true,
);
assert.equal(
testGlobals.sessionActions.some((action) => action.actionId === 'toggleVisibleOverlay'),
false,
);
} finally {
testGlobals.restore();
}
});
test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => { test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
+5
View File
@@ -204,6 +204,11 @@ export function createKeyboardHandlers(
return; return;
} }
if (binding.actionType === 'session-action' && binding.actionId === 'toggleVisibleOverlay') {
window.electronAPI.sendMpvCommand(['script-message', 'subminer-toggle']);
return;
}
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') { if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
options.openControllerSelectModal?.(); options.openControllerSelectModal?.();
return; return;
+3
View File
@@ -27,6 +27,9 @@ export const IPC_CHANNELS = {
toggleDevTools: 'toggle-dev-tools', toggleDevTools: 'toggle-dev-tools',
toggleOverlay: 'toggle-overlay', toggleOverlay: 'toggle-overlay',
saveSubtitlePosition: 'save-subtitle-position', saveSubtitlePosition: 'save-subtitle-position',
statsNativeConfirmDialog: 'stats:native-confirm-dialog',
statsNativeDialogOpened: 'stats:native-dialog-opened',
statsNativeDialogClosed: 'stats:native-dialog-closed',
saveControllerConfig: 'save-controller-config', saveControllerConfig: 'save-controller-config',
saveControllerPreference: 'save-controller-preference', saveControllerPreference: 'save-controller-preference',
setMecabEnabled: 'set-mecab-enabled', setMecabEnabled: 'set-mecab-enabled',
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
HyprlandWindowTracker,
isHyprlandGeometryEvent, isHyprlandGeometryEvent,
parseHyprctlClients, parseHyprctlClients,
parseHyprctlMonitors, parseHyprctlMonitors,
@@ -177,3 +178,22 @@ test('resolveHyprlandWindowGeometry uses monitor bounds for client-requested ful
height: 1080, height: 1080,
}); });
}); });
test('HyprlandWindowTracker re-emits focus callback on active window events for z-order refresh', () => {
const calls: string[] = [];
const tracker = new HyprlandWindowTracker();
const privateTracker = tracker as unknown as {
handleSocketEvent: (event: string) => void;
pollGeometry: () => void;
};
privateTracker.pollGeometry = () => {
calls.push('poll');
};
tracker.onWindowFocusChange = (focused) => {
calls.push(`focus:${focused}`);
};
privateTracker.handleSocketEvent('activewindowv2>>0xmpv');
assert.deepEqual(calls, ['poll', 'focus:false']);
});
+7
View File
@@ -295,8 +295,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
const data = rawData.trim(); const data = rawData.trim();
if (name === 'activewindowv2') { if (name === 'activewindowv2') {
const wasFocused = this.isTargetWindowFocused();
this.activeWindowAddress = data || null; this.activeWindowAddress = data || null;
this.pollGeometry(); this.pollGeometry();
if (this.isTargetWindowFocused() === wasFocused) {
this.onWindowFocusChange?.(this.isTargetWindowFocused());
}
return; return;
} }
@@ -336,9 +340,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
const mpvWindow = this.findTargetWindow(clients); const mpvWindow = this.findTargetWindow(clients);
if (mpvWindow) { if (mpvWindow) {
const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress;
this.updateGeometry( this.updateGeometry(
resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)), resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)),
focused,
); );
this.updateTargetWindowFocused(focused);
} else { } else {
this.updateGeometry(null); this.updateGeometry(null);
} }
+2
View File
@@ -1,4 +1,5 @@
import { Suspense, lazy, useCallback, useState } from 'react'; import { Suspense, lazy, useCallback, useState } from 'react';
import { DeleteConfirmDialog } from './components/layout/DeleteConfirmDialog';
import { TabBar } from './components/layout/TabBar'; import { TabBar } from './components/layout/TabBar';
import { OverviewTab } from './components/overview/OverviewTab'; import { OverviewTab } from './components/overview/OverviewTab';
import { useExcludedWords } from './hooks/useExcludedWords'; import { useExcludedWords } from './hooks/useExcludedWords';
@@ -272,6 +273,7 @@ export function App() {
/> />
</Suspense> </Suspense>
) : null} ) : null}
<DeleteConfirmDialog />
</div> </div>
); );
} }
+1 -1
View File
@@ -85,7 +85,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
}, [videoId]); }, [videoId]);
const handleDeleteSession = async (sessionId: number) => { const handleDeleteSession = async (sessionId: number) => {
if (!confirmSessionDelete()) return; if (!(await confirmSessionDelete())) return;
await apiClient.deleteSession(sessionId); await apiClient.deleteSession(sessionId);
setData((prev) => { setData((prev) => {
if (!prev) return prev; if (!prev) return prev;
+1 -1
View File
@@ -44,7 +44,7 @@ export function EpisodeList({
}; };
const handleDeleteEpisode = async (videoId: number, title: string) => { const handleDeleteEpisode = async (videoId: number, title: string) => {
if (!confirmEpisodeDelete(title)) return; if (!(await confirmEpisodeDelete(title))) return;
await apiClient.deleteVideo(videoId); await apiClient.deleteVideo(videoId);
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId)); setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
if (expandedVideoId === videoId) setExpandedVideoId(null); if (expandedVideoId === videoId) setExpandedVideoId(null);
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { setDeleteConfirmPresenter } from '../../lib/delete-confirm';
interface PendingDeleteConfirm {
message: string;
resolve: (confirmed: boolean) => void;
}
export function DeleteConfirmDialog() {
const [pendingConfirm, setPendingConfirm] = useState<PendingDeleteConfirm | null>(null);
const pendingRef = useRef<PendingDeleteConfirm | null>(null);
const cancelButtonRef = useRef<HTMLButtonElement>(null);
const finish = useCallback((confirmed: boolean) => {
const pending = pendingRef.current;
pendingRef.current = null;
setPendingConfirm(null);
pending?.resolve(confirmed);
}, []);
useEffect(() => {
return setDeleteConfirmPresenter(
(message) =>
new Promise<boolean>((resolve) => {
pendingRef.current?.resolve(false);
const next = { message, resolve };
pendingRef.current = next;
setPendingConfirm(next);
}),
);
}, []);
useEffect(() => {
if (!pendingConfirm) return;
cancelButtonRef.current?.focus();
}, [pendingConfirm]);
useEffect(() => {
if (!pendingConfirm) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
finish(false);
};
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [finish, pendingConfirm]);
useEffect(() => {
return () => {
pendingRef.current?.resolve(false);
pendingRef.current = null;
};
}, []);
if (!pendingConfirm) return null;
return (
<div className="fixed inset-0 z-[2147483647] flex items-center justify-center bg-ctp-crust/55 p-4 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-confirm-title"
className="w-full max-w-md rounded-lg border border-ctp-surface1 bg-ctp-mantle shadow-2xl"
>
<div className="border-b border-ctp-surface1 px-4 py-3">
<h2 id="delete-confirm-title" className="text-sm font-semibold text-ctp-text">
Delete?
</h2>
</div>
<div className="px-4 py-4 text-sm leading-6 text-ctp-subtext0">
{pendingConfirm.message}
</div>
<div className="grid grid-cols-2 border-t border-ctp-surface1">
<button
ref={cancelButtonRef}
type="button"
onClick={() => finish(false)}
className="border-r border-ctp-surface1 px-4 py-3 text-sm text-ctp-subtext0 transition-colors hover:bg-ctp-surface0 hover:text-ctp-text focus:outline-none focus:bg-ctp-surface0 focus:text-ctp-text"
>
Cancel
</button>
<button
type="button"
onClick={() => finish(true)}
className="px-4 py-3 text-sm font-semibold text-ctp-red transition-colors hover:bg-ctp-surface0 focus:outline-none focus:bg-ctp-surface0"
>
Delete
</button>
</div>
</div>
</div>
);
}
@@ -11,7 +11,7 @@ interface DeleteEpisodeHandlerOptions {
videoId: number; videoId: number;
title: string; title: string;
apiClient: { deleteVideo: (id: number) => Promise<void> }; apiClient: { deleteVideo: (id: number) => Promise<void> };
confirmFn: (title: string) => boolean; confirmFn: (title: string) => boolean | Promise<boolean>;
onBack: () => void; onBack: () => void;
setDeleteError: (msg: string | null) => void; setDeleteError: (msg: string | null) => void;
/** /**
@@ -27,7 +27,7 @@ interface DeleteEpisodeHandlerOptions {
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> { export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
return async () => { return async () => {
if (opts.isDeletingRef?.current) return; if (opts.isDeletingRef?.current) return;
if (!opts.confirmFn(opts.title)) return; if (!(await opts.confirmFn(opts.title))) return;
if (opts.isDeletingRef) opts.isDeletingRef.current = true; if (opts.isDeletingRef) opts.isDeletingRef.current = true;
opts.setIsDeleting?.(true); opts.setIsDeleting?.(true);
opts.setDeleteError(null); opts.setDeleteError(null);
@@ -101,7 +101,7 @@ export function MediaDetailView({
const relatedCollectionLabel = getRelatedCollectionLabel(detail); const relatedCollectionLabel = getRelatedCollectionLabel(detail);
const handleDeleteSession = async (session: SessionSummary) => { const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return; if (!(await confirmSessionDelete())) return;
setDeleteError(null); setDeleteError(null);
setDeletingSessionId(session.sessionId); setDeletingSessionId(session.sessionId);
@@ -47,7 +47,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
}, []); }, []);
const handleDeleteSession = async (session: SessionSummary) => { const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return; if (!(await confirmSessionDelete())) return;
setDeleteError(null); setDeleteError(null);
setDeletingIds((prev) => new Set(prev).add(session.sessionId)); setDeletingIds((prev) => new Set(prev).add(session.sessionId));
try { try {
@@ -65,7 +65,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
}; };
const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => { const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => {
if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return; if (!(await confirmDayGroupDelete(dayLabel, daySessions.length))) return;
setDeleteError(null); setDeleteError(null);
const ids = daySessions.map((s) => s.sessionId); const ids = daySessions.map((s) => s.sessionId);
setDeletingIds((prev) => { setDeletingIds((prev) => {
@@ -91,7 +91,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => { const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => {
const title = const title =
groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media'; groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media';
if (!confirmAnimeGroupDelete(title, groupSessions.length)) return; if (!(await confirmAnimeGroupDelete(title, groupSessions.length))) return;
setDeleteError(null); setDeleteError(null);
const ids = groupSessions.map((s) => s.sessionId); const ids = groupSessions.map((s) => s.sessionId);
setDeletingIds((prev) => { setDeletingIds((prev) => {
@@ -27,7 +27,7 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
export interface BucketDeleteDeps { export interface BucketDeleteDeps {
bucket: SessionBucket; bucket: SessionBucket;
apiClient: { deleteSessions: (ids: number[]) => Promise<void> }; apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
confirm: (title: string, count: number) => boolean; confirm: (title: string, count: number) => boolean | Promise<boolean>;
onSuccess: (deletedIds: number[]) => void; onSuccess: (deletedIds: number[]) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
@@ -43,7 +43,7 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
return async () => { return async () => {
const title = bucket.representativeSession.canonicalTitle ?? 'this episode'; const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
const ids = bucket.sessions.map((s) => s.sessionId); const ids = bucket.sessions.map((s) => s.sessionId);
if (!confirm(title, ids.length)) return; if (!(await confirm(title, ids.length))) return;
try { try {
await client.deleteSessions(ids); await client.deleteSessions(ids);
onSuccess(ids); onSuccess(ids);
@@ -120,7 +120,7 @@ export function SessionsTab({
}; };
const handleDeleteSession = async (session: SessionSummary) => { const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return; if (!(await confirmSessionDelete())) return;
setDeleteError(null); setDeleteError(null);
setDeletingSessionId(session.sessionId); setDeletingSessionId(session.sessionId);
+182 -12
View File
@@ -5,9 +5,10 @@ import {
confirmDayGroupDelete, confirmDayGroupDelete,
confirmEpisodeDelete, confirmEpisodeDelete,
confirmSessionDelete, confirmSessionDelete,
setDeleteConfirmPresenter,
} from './delete-confirm'; } from './delete-confirm';
test('confirmSessionDelete uses the shared session delete warning copy', () => { test('confirmSessionDelete uses the shared session delete warning copy', async () => {
const calls: string[] = []; const calls: string[] = [];
const originalConfirm = globalThis.confirm; const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => { globalThis.confirm = ((message?: string) => {
@@ -16,14 +17,183 @@ test('confirmSessionDelete uses the shared session delete warning copy', () => {
}) as typeof globalThis.confirm; }) as typeof globalThis.confirm;
try { try {
assert.equal(confirmSessionDelete(), true); assert.equal(await confirmSessionDelete(), true);
assert.deepEqual(calls, ['Delete this session and all associated data?']); assert.deepEqual(calls, ['Delete this session and all associated data?']);
} finally { } finally {
globalThis.confirm = originalConfirm; globalThis.confirm = originalConfirm;
} }
}); });
test('confirmDayGroupDelete includes the day label and count in the warning copy', () => { test('confirmSessionDelete suspends stats overlay layering around native confirm', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
const originalElectronAPI = (
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI = {
stats: {
beginNativeDialog: () => calls.push('begin-native-dialog'),
endNativeDialog: () => calls.push('end-native-dialog'),
},
};
globalThis.confirm = ((message?: string) => {
calls.push(`confirm:${message ?? ''}`);
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(await confirmSessionDelete(), true);
assert.deepEqual(calls, [
'begin-native-dialog',
'confirm:Delete this session and all associated data?',
'end-native-dialog',
]);
} finally {
globalThis.confirm = originalConfirm;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI = originalElectronAPI;
}
});
test('confirmSessionDelete uses parented Electron confirm when available', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
const originalElectronAPI = (
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI = {
stats: {
confirmNativeDialog: (message) => {
calls.push(`native-confirm:${message}`);
return false;
},
beginNativeDialog: () => calls.push('begin-native-dialog'),
endNativeDialog: () => calls.push('end-native-dialog'),
},
};
globalThis.confirm = ((message?: string) => {
calls.push(`browser-confirm:${message ?? ''}`);
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(await confirmSessionDelete(), false);
assert.deepEqual(calls, ['native-confirm:Delete this session and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI = originalElectronAPI;
}
});
test('confirmSessionDelete uses the registered stats presenter before native or browser confirm', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
const originalElectronAPI = (
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
};
};
}
).electronAPI;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
};
};
}
).electronAPI = {
stats: {
confirmNativeDialog: (message) => {
calls.push(`native-confirm:${message}`);
return true;
},
},
};
globalThis.confirm = ((message?: string) => {
calls.push(`browser-confirm:${message ?? ''}`);
return true;
}) as typeof globalThis.confirm;
const unregister = setDeleteConfirmPresenter(async (message) => {
calls.push(`presenter:${message}`);
return false;
});
try {
assert.equal(await confirmSessionDelete(), false);
assert.deepEqual(calls, ['presenter:Delete this session and all associated data?']);
} finally {
unregister();
globalThis.confirm = originalConfirm;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
};
};
}
).electronAPI = originalElectronAPI;
}
});
test('confirmDayGroupDelete includes the day label and count in the warning copy', async () => {
const calls: string[] = []; const calls: string[] = [];
const originalConfirm = globalThis.confirm; const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => { globalThis.confirm = ((message?: string) => {
@@ -32,14 +202,14 @@ test('confirmDayGroupDelete includes the day label and count in the warning copy
}) as typeof globalThis.confirm; }) as typeof globalThis.confirm;
try { try {
assert.equal(confirmDayGroupDelete('Today', 3), true); assert.equal(await confirmDayGroupDelete('Today', 3), true);
assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']); assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']);
} finally { } finally {
globalThis.confirm = originalConfirm; globalThis.confirm = originalConfirm;
} }
}); });
test('confirmDayGroupDelete uses singular for one session', () => { test('confirmDayGroupDelete uses singular for one session', async () => {
const calls: string[] = []; const calls: string[] = [];
const originalConfirm = globalThis.confirm; const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => { globalThis.confirm = ((message?: string) => {
@@ -48,14 +218,14 @@ test('confirmDayGroupDelete uses singular for one session', () => {
}) as typeof globalThis.confirm; }) as typeof globalThis.confirm;
try { try {
assert.equal(confirmDayGroupDelete('Yesterday', 1), true); assert.equal(await confirmDayGroupDelete('Yesterday', 1), true);
assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']); assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']);
} finally { } finally {
globalThis.confirm = originalConfirm; globalThis.confirm = originalConfirm;
} }
}); });
test('confirmBucketDelete asks about merging multiple sessions of the same episode', () => { test('confirmBucketDelete asks about merging multiple sessions of the same episode', async () => {
const calls: string[] = []; const calls: string[] = [];
const originalConfirm = globalThis.confirm; const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => { globalThis.confirm = ((message?: string) => {
@@ -64,7 +234,7 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo
}) as typeof globalThis.confirm; }) as typeof globalThis.confirm;
try { try {
assert.equal(confirmBucketDelete('My Episode', 3), true); assert.equal(await confirmBucketDelete('My Episode', 3), true);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'Delete all 3 sessions of "My Episode" from this day and all associated data?', 'Delete all 3 sessions of "My Episode" from this day and all associated data?',
]); ]);
@@ -73,7 +243,7 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo
} }
}); });
test('confirmBucketDelete uses a clean singular form for one session', () => { test('confirmBucketDelete uses a clean singular form for one session', async () => {
const calls: string[] = []; const calls: string[] = [];
const originalConfirm = globalThis.confirm; const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => { globalThis.confirm = ((message?: string) => {
@@ -82,7 +252,7 @@ test('confirmBucketDelete uses a clean singular form for one session', () => {
}) as typeof globalThis.confirm; }) as typeof globalThis.confirm;
try { try {
assert.equal(confirmBucketDelete('Solo Episode', 1), false); assert.equal(await confirmBucketDelete('Solo Episode', 1), false);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'Delete this session of "Solo Episode" from this day and all associated data?', 'Delete this session of "Solo Episode" from this day and all associated data?',
]); ]);
@@ -91,7 +261,7 @@ test('confirmBucketDelete uses a clean singular form for one session', () => {
} }
}); });
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => { test('confirmEpisodeDelete includes the episode title in the shared warning copy', async () => {
const calls: string[] = []; const calls: string[] = [];
const originalConfirm = globalThis.confirm; const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => { globalThis.confirm = ((message?: string) => {
@@ -100,7 +270,7 @@ test('confirmEpisodeDelete includes the episode title in the shared warning copy
}) as typeof globalThis.confirm; }) as typeof globalThis.confirm;
try { try {
assert.equal(confirmEpisodeDelete('Episode 4'), false); assert.equal(await confirmEpisodeDelete('Episode 4'), false);
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']); assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
} finally { } finally {
globalThis.confirm = originalConfirm; globalThis.confirm = originalConfirm;
+52 -11
View File
@@ -1,30 +1,71 @@
export function confirmSessionDelete(): boolean { type NativeDialogBridge = {
return globalThis.confirm('Delete this session and all associated data?'); electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
};
type DeleteConfirmPresenter = (message: string) => boolean | Promise<boolean>;
let deleteConfirmPresenter: DeleteConfirmPresenter | null = null;
export function setDeleteConfirmPresenter(presenter: DeleteConfirmPresenter): () => void {
deleteConfirmPresenter = presenter;
return () => {
if (deleteConfirmPresenter === presenter) {
deleteConfirmPresenter = null;
}
};
} }
export function confirmDayGroupDelete(dayLabel: string, count: number): boolean { async function confirmWithStatsNativeDialogLayer(message: string): Promise<boolean> {
return globalThis.confirm( if (deleteConfirmPresenter) {
return deleteConfirmPresenter(message);
}
const statsApi = (globalThis as typeof globalThis & NativeDialogBridge).electronAPI?.stats;
if (statsApi?.confirmNativeDialog) {
return statsApi.confirmNativeDialog(message);
}
statsApi?.beginNativeDialog?.();
try {
return globalThis.confirm(message);
} finally {
statsApi?.endNativeDialog?.();
}
}
export function confirmSessionDelete(): Promise<boolean> {
return confirmWithStatsNativeDialogLayer('Delete this session and all associated data?');
}
export function confirmDayGroupDelete(dayLabel: string, count: number): Promise<boolean> {
return confirmWithStatsNativeDialogLayer(
`Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`, `Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`,
); );
} }
export function confirmAnimeGroupDelete(title: string, count: number): boolean { export function confirmAnimeGroupDelete(title: string, count: number): Promise<boolean> {
return globalThis.confirm( return confirmWithStatsNativeDialogLayer(
`Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`, `Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`,
); );
} }
export function confirmEpisodeDelete(title: string): boolean { export function confirmEpisodeDelete(title: string): Promise<boolean> {
return globalThis.confirm(`Delete "${title}" and all its sessions?`); return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`);
} }
export function confirmBucketDelete(title: string, count: number): boolean { export function confirmBucketDelete(title: string, count: number): Promise<boolean> {
if (count === 1) { if (count === 1) {
return globalThis.confirm( return confirmWithStatsNativeDialogLayer(
`Delete this session of "${title}" from this day and all associated data?`, `Delete this session of "${title}" from this day and all associated data?`,
); );
} }
return globalThis.confirm( return confirmWithStatsNativeDialogLayer(
`Delete all ${count} sessions of "${title}" from this day and all associated data?`, `Delete all ${count} sessions of "${title}" from this day and all associated data?`,
); );
} }
+3
View File
@@ -62,6 +62,9 @@ interface StatsElectronAPI {
ankiBrowse: (noteId: number) => Promise<void>; ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>; ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
hideOverlay: () => void; hideOverlay: () => void;
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
}; };
} }