mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
098375c647
|
|||
|
83fdccb752
|
|||
|
58fd648185
|
|||
|
536d99251e
|
|||
|
1a7f015f4e
|
|||
|
3a2d7a282d
|
@@ -18,12 +18,11 @@
|
||||
"ws": "^8.19.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.2.0",
|
||||
"electron": "39.8.6",
|
||||
"electron-builder": "26.8.2",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
@@ -53,7 +52,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/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/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/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=="],
|
||||
|
||||
@@ -117,34 +116,10 @@
|
||||
|
||||
"@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-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/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
@@ -191,21 +166,15 @@
|
||||
|
||||
"@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/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/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
|
||||
|
||||
@@ -225,10 +194,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -329,8 +294,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
||||
@@ -363,7 +326,7 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"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": ["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-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=="],
|
||||
|
||||
@@ -381,7 +344,7 @@
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
|
||||
|
||||
@@ -401,22 +364,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -427,22 +374,12 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
@@ -467,8 +404,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
@@ -507,8 +442,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
@@ -517,12 +450,8 @@
|
||||
|
||||
"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-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-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
|
||||
@@ -543,8 +472,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
@@ -559,12 +486,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
@@ -617,8 +540,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
|
||||
@@ -639,22 +560,16 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -673,8 +588,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
|
||||
@@ -787,15 +700,13 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
"undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
|
||||
|
||||
@@ -815,8 +726,6 @@
|
||||
|
||||
"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-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=="],
|
||||
@@ -839,12 +748,14 @@
|
||||
|
||||
"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/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/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
|
||||
@@ -853,8 +764,6 @@
|
||||
|
||||
"@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/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
@@ -865,20 +774,6 @@
|
||||
|
||||
"@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/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
@@ -891,6 +786,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
@@ -903,36 +800,22 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -943,6 +826,8 @@
|
||||
|
||||
"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-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Refreshed Linux overlay placement after leaving mpv fullscreen so Hyprland keeps the visible overlay aligned to the player.
|
||||
@@ -1,5 +0,0 @@
|
||||
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.
|
||||
@@ -1,11 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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,13 +3,40 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { withProcessExitIntercept } from '../test-support/exit-intercept.js';
|
||||
import {
|
||||
applyInvocationsToArgs,
|
||||
applyRootOptionsToArgs,
|
||||
createDefaultArgs,
|
||||
} 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 {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
|
||||
try {
|
||||
@@ -79,7 +106,6 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => {
|
||||
|
||||
assert.equal(error.code, 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', () => {
|
||||
@@ -205,7 +231,6 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.stderr, /Unknown config action: \(none\)/);
|
||||
});
|
||||
|
||||
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
|
||||
+28
-2
@@ -7,7 +7,6 @@ import net from 'node:net';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Args } from './types';
|
||||
import { getAppControlSocketPath } from '../src/shared/app-control';
|
||||
import { withProcessExitIntercept } from './test-support/exit-intercept.js';
|
||||
import {
|
||||
buildConfiguredMpvDefaultArgs,
|
||||
buildMpvBackendArgs,
|
||||
@@ -29,6 +28,34 @@ import {
|
||||
} 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 } {
|
||||
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests');
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
@@ -366,7 +393,6 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', ()
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.stderr, /Failed to launch texthooker mode/);
|
||||
});
|
||||
|
||||
test('launchTexthookerOnly forwards browser-open request to app command', () => {
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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', () => {
|
||||
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
|
||||
@@ -104,9 +131,7 @@ test('parseArgs rejects removed config open and launch actions', () => {
|
||||
});
|
||||
|
||||
assert.equal(openExit.code, 1);
|
||||
assert.match(openExit.stderr, /Unknown config action: open/);
|
||||
assert.equal(exit.code, 1);
|
||||
assert.match(exit.stderr, /Unknown config action: launch/);
|
||||
});
|
||||
|
||||
test('parseArgs requires an explicit action for the config subcommand', () => {
|
||||
@@ -115,7 +140,6 @@ test('parseArgs requires an explicit action for the config subcommand', () => {
|
||||
});
|
||||
|
||||
assert.equal(exit.code, 1);
|
||||
assert.match(exit.stderr, /Unknown config action: \(none\)/);
|
||||
});
|
||||
|
||||
test('parseArgs maps mpv idle action', () => {
|
||||
@@ -156,7 +180,6 @@ test('parseArgs rejects conflicting dictionary candidate and selection modes', (
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -220,7 +243,6 @@ test('parseArgs rejects cleanup-only stats flags without cleanup action', () =>
|
||||
|
||||
assert.equal(error.code, 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', () => {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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');
|
||||
}
|
||||
+2
-3
@@ -121,12 +121,11 @@
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.2.0",
|
||||
"electron": "39.8.6",
|
||||
"electron-builder": "26.8.2",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
@@ -114,13 +114,6 @@ function M.create(ctx)
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
@@ -33,20 +33,6 @@ function M.create(ctx)
|
||||
return nil
|
||||
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)
|
||||
return reason == "reload" or reason == "redirect"
|
||||
end
|
||||
@@ -139,10 +125,6 @@ function M.create(ctx)
|
||||
|
||||
local function on_start_file()
|
||||
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
|
||||
end
|
||||
rearm_managed_subtitle_load_defaults()
|
||||
@@ -150,23 +132,12 @@ function M.create(ctx)
|
||||
|
||||
local function on_file_loaded()
|
||||
local media_identity = resolve_media_identity()
|
||||
local media_title = resolve_media_title()
|
||||
local retry_generation = next_auto_start_retry_generation()
|
||||
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 = (
|
||||
media_identity ~= nil
|
||||
and state.pending_reload_media_identity ~= nil
|
||||
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 = (
|
||||
media_identity ~= nil
|
||||
@@ -175,10 +146,7 @@ function M.create(ctx)
|
||||
)
|
||||
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_title = nil
|
||||
state.pending_reload_reason = nil
|
||||
state.current_media_identity = media_identity
|
||||
state.current_media_title = media_title
|
||||
if new_media_loaded then
|
||||
state.suppress_ready_overlay_restore = false
|
||||
end
|
||||
@@ -223,10 +191,7 @@ function M.create(ctx)
|
||||
hover.clear_hover_overlay()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
state.current_media_identity = nil
|
||||
state.current_media_title = nil
|
||||
state.pending_reload_media_identity = nil
|
||||
state.pending_reload_media_title = nil
|
||||
state.pending_reload_reason = nil
|
||||
end
|
||||
|
||||
local function register_lifecycle_hooks()
|
||||
@@ -242,16 +207,11 @@ function M.create(ctx)
|
||||
local reason = type(event) == "table" and event.reason or nil
|
||||
if is_reload_end_file(reason) then
|
||||
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
|
||||
end
|
||||
next_auto_start_retry_generation()
|
||||
state.current_media_identity = nil
|
||||
state.current_media_title = 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
|
||||
process.hide_visible_overlay()
|
||||
end
|
||||
|
||||
@@ -17,12 +17,6 @@ function M.create(ctx)
|
||||
mp.register_script_message("subminer-toggle", function()
|
||||
process.toggle_overlay()
|
||||
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()
|
||||
ui.show_menu()
|
||||
end)
|
||||
|
||||
@@ -7,7 +7,6 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
|
||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
@@ -78,49 +77,6 @@ function M.create(ctx)
|
||||
return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS
|
||||
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)
|
||||
if type(path) ~= "string" then
|
||||
return nil
|
||||
@@ -361,7 +317,6 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
run_control_command_async = function(action, overrides, callback)
|
||||
record_visible_overlay_action(action)
|
||||
local args = build_command_args(action, overrides)
|
||||
local command = build_subprocess_command(args)
|
||||
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||
@@ -602,8 +557,7 @@ function M.create(ctx)
|
||||
show_osd("Stopped")
|
||||
end
|
||||
|
||||
local function hide_visible_overlay(options)
|
||||
options = options or {}
|
||||
local function hide_visible_overlay()
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
return
|
||||
@@ -623,9 +577,7 @@ function M.create(ctx)
|
||||
end
|
||||
end)
|
||||
|
||||
disarm_auto_play_ready_gate({
|
||||
resume_playback = options.resume_playback ~= false,
|
||||
})
|
||||
disarm_auto_play_ready_gate()
|
||||
end
|
||||
|
||||
local function toggle_overlay()
|
||||
@@ -634,26 +586,6 @@ function M.create(ctx)
|
||||
show_osd("Error: binary not found")
|
||||
return
|
||||
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
|
||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||
|
||||
@@ -785,7 +717,6 @@ function M.create(ctx)
|
||||
build_command_args = build_command_args,
|
||||
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
||||
run_control_command_async = run_control_command_async,
|
||||
record_visible_overlay_visibility = record_visible_overlay_visibility,
|
||||
run_binary_command_async = run_binary_command_async,
|
||||
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
||||
ensure_texthooker_running = ensure_texthooker_running,
|
||||
|
||||
@@ -312,11 +312,6 @@ function M.create(ctx)
|
||||
return
|
||||
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
|
||||
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
|
||||
return
|
||||
|
||||
@@ -35,13 +35,8 @@ function M.new()
|
||||
auto_play_ready_osd_timer = nil,
|
||||
suppress_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_title = nil,
|
||||
pending_reload_media_identity = nil,
|
||||
pending_reload_media_title = nil,
|
||||
pending_reload_reason = nil,
|
||||
auto_start_retry_generation = 0,
|
||||
session_binding_generation = 0,
|
||||
session_binding_names = {},
|
||||
|
||||
@@ -68,31 +68,6 @@ local function create_binary_module(config)
|
||||
return binary
|
||||
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
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
|
||||
@@ -23,7 +23,6 @@ local recorded = {
|
||||
async_calls = {},
|
||||
mpv_commands = {},
|
||||
osd = {},
|
||||
overlay_toggles = 0,
|
||||
}
|
||||
|
||||
local mp = {}
|
||||
@@ -69,14 +68,6 @@ local ctx = {
|
||||
return {
|
||||
numericSelectionTimeoutMs = 3000,
|
||||
bindings = {
|
||||
{
|
||||
key = {
|
||||
code = "KeyO",
|
||||
modifiers = { "alt", "shift" },
|
||||
},
|
||||
actionType = "session-action",
|
||||
actionId = "toggleVisibleOverlay",
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyS",
|
||||
@@ -262,9 +253,6 @@ local ctx = {
|
||||
run_binary_command_async = function(args)
|
||||
recorded.async_calls[#recorded.async_calls + 1] = args
|
||||
end,
|
||||
toggle_overlay = function()
|
||||
recorded.overlay_toggles = recorded.overlay_toggles + 1
|
||||
end,
|
||||
},
|
||||
environment = {
|
||||
resolve_session_bindings_artifact_path = function()
|
||||
@@ -330,11 +318,6 @@ local expected_cli_bindings = {
|
||||
{ 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
|
||||
local binding = find_binding(expected.keys)
|
||||
assert_true(binding ~= nil, "default session action should register " .. expected.keys)
|
||||
|
||||
@@ -201,7 +201,7 @@ local function run_plugin_scenario(config)
|
||||
end
|
||||
function mp.set_osd_ass(...) end
|
||||
function mp.get_time()
|
||||
return config.now or 0
|
||||
return 0
|
||||
end
|
||||
function mp.commandv(...) end
|
||||
function mp.set_property_native(name, value)
|
||||
@@ -623,18 +623,16 @@ local binary_path = "/tmp/subminer-binary"
|
||||
local appimage_path = "/tmp/SubMiner.AppImage"
|
||||
|
||||
do
|
||||
local scenario = {
|
||||
local recorded, err = run_plugin_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 cold-start scenario: " .. tostring(err))
|
||||
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
|
||||
recorded.script_messages["subminer-start"]("texthooker=no")
|
||||
@@ -685,174 +683,6 @@ do
|
||||
)
|
||||
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
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1720,12 +1550,8 @@ do
|
||||
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
|
||||
recorded.script_messages["subminer-toggle"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
|
||||
"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"
|
||||
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
|
||||
"manual toggle should use explicit visible-overlay toggle command"
|
||||
)
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
@@ -1838,53 +1664,6 @@ do
|
||||
)
|
||||
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
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
|
||||
@@ -60,10 +60,7 @@ test('buildHyprlandPlacementDispatches floats tiled overlay windows without pinn
|
||||
floating: false,
|
||||
pinned: false,
|
||||
}),
|
||||
[
|
||||
['dispatch', 'setfloating', 'address:0xabc'],
|
||||
['dispatch', 'alterzorder', 'top,address:0xabc'],
|
||||
],
|
||||
[['dispatch', 'setfloating', 'address:0xabc']],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -90,7 +87,6 @@ test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to
|
||||
['dispatch', 'setprop', 'address:0xabc no_shadow 1'],
|
||||
['dispatch', 'setprop', 'address:0xabc no_blur 1'],
|
||||
['dispatch', 'setprop', 'address:0xabc decorate 0'],
|
||||
['dispatch', 'alterzorder', 'top,address:0xabc'],
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -102,7 +98,7 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win
|
||||
floating: true,
|
||||
pinned: false,
|
||||
}),
|
||||
[['dispatch', 'alterzorder', 'top,address:0xabc']],
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,10 +109,7 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
|
||||
floating: true,
|
||||
pinned: true,
|
||||
}),
|
||||
[
|
||||
['dispatch', 'pin', 'address:0xabc'],
|
||||
['dispatch', 'alterzorder', 'top,address:0xabc'],
|
||||
],
|
||||
[['dispatch', 'pin', 'address:0xabc']],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -153,7 +146,6 @@ test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for ma
|
||||
[
|
||||
['-j', 'clients'],
|
||||
['dispatch', 'setfloating', 'address:0xmatch'],
|
||||
['dispatch', 'alterzorder', 'top,address:0xmatch'],
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -203,7 +195,6 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe
|
||||
['dispatch', 'setprop', 'address:0xmatch no_shadow 1'],
|
||||
['dispatch', 'setprop', 'address:0xmatch no_blur 1'],
|
||||
['dispatch', 'setprop', 'address:0xmatch decorate 0'],
|
||||
['dispatch', 'alterzorder', 'top,address:0xmatch'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,6 @@ export function buildHyprlandPlacementDispatches(
|
||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]);
|
||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]);
|
||||
}
|
||||
dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]);
|
||||
return dispatches;
|
||||
}
|
||||
|
||||
|
||||
@@ -289,44 +289,6 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
|
||||
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 () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface JellyfinTimelinePlaybackState {
|
||||
subtitleStreamIndex?: number | null;
|
||||
playlistItemId?: string | null;
|
||||
eventName?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePayload {
|
||||
@@ -37,7 +36,7 @@ export interface JellyfinTimelinePayload {
|
||||
AudioStreamIndex?: number | null;
|
||||
SubtitleStreamIndex?: number | null;
|
||||
PlaylistItemId?: string | null;
|
||||
Failed?: boolean;
|
||||
EventName: string;
|
||||
}
|
||||
|
||||
interface JellyfinRemoteSocket {
|
||||
@@ -169,7 +168,7 @@ export function buildJellyfinTimelinePayload(
|
||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||
PlaylistItemId: state.playlistItemId,
|
||||
Failed: state.failed,
|
||||
EventName: state.eventName || 'timeupdate',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,7 +269,10 @@ export class JellyfinRemoteSessionService {
|
||||
}
|
||||
|
||||
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||
return this.postTimeline('/Sessions/Playing', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
EventName: state.eventName || 'start',
|
||||
});
|
||||
}
|
||||
|
||||
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
@@ -280,7 +282,7 @@ export class JellyfinRemoteSessionService {
|
||||
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing/Stopped', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
Failed: state.failed === true,
|
||||
EventName: state.eventName || 'stop',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -197,54 +197,7 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
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', () => {
|
||||
test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
|
||||
@@ -279,49 +232,11 @@ test('untracked non-macOS overlay shows passively when no tracker exists', () =>
|
||||
} as never);
|
||||
|
||||
assert.equal(trackerWarning, false);
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(calls.includes('focus'));
|
||||
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', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -355,8 +270,8 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'),
|
||||
['update-bounds', 'show-inactive', 'update-bounds'],
|
||||
calls.filter((call) => call === 'update-bounds' || call === 'show'),
|
||||
['update-bounds', 'show', 'update-bounds'],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
visibleOverlayVisible: boolean;
|
||||
modalActive?: boolean;
|
||||
forceMousePassthrough?: boolean;
|
||||
suspendVisibleOverlay?: boolean;
|
||||
overlayInteractionActive?: boolean;
|
||||
mainWindow: BrowserWindow | null;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
@@ -105,18 +104,6 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
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 forceMousePassthrough = args.forceMousePassthrough === true;
|
||||
const wasVisible = mainWindow.isVisible();
|
||||
@@ -185,8 +172,6 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
shouldUseMacOSMousePassthrough ||
|
||||
forceMousePassthrough ||
|
||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||
const isNonNativePassiveOverlay =
|
||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||
!args.isWindowsPlatform ||
|
||||
@@ -229,10 +214,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
||||
// callback will trigger another visibility update when the renderer
|
||||
// has painted its first frame.
|
||||
} else if (
|
||||
((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) ||
|
||||
isNonNativePassiveOverlay
|
||||
) {
|
||||
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
|
||||
if (args.isWindowsPlatform) {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
}
|
||||
@@ -276,12 +258,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
if (
|
||||
!args.isWindowsPlatform &&
|
||||
!args.isMacOSPlatform &&
|
||||
!forceMousePassthrough &&
|
||||
overlayInteractionActive
|
||||
) {
|
||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
MessageBoxSyncOptions,
|
||||
} from 'electron';
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
import type { WindowGeometry } from '../../types';
|
||||
|
||||
const DEFAULT_STATS_WINDOW_WIDTH = 900;
|
||||
@@ -11,17 +7,6 @@ export const STATS_WINDOW_TITLE = 'SubMiner Stats';
|
||||
|
||||
type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> &
|
||||
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 StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||
@@ -121,57 +106,6 @@ export function promoteStatsWindowLevel(
|
||||
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(
|
||||
window: StatsWindowPresentationController,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
|
||||
@@ -3,13 +3,9 @@ import test from 'node:test';
|
||||
import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
buildStatsNativeConfirmDialogOptions,
|
||||
demoteVisibleStatsWindowBelowDialogs,
|
||||
presentStatsWindow,
|
||||
promoteVisibleStatsWindowAboveOverlay,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
} from './stats-window-runtime';
|
||||
|
||||
@@ -236,131 +232,6 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
|
||||
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', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
import * as path from 'path';
|
||||
import type { WindowGeometry } from '../../types.js';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
||||
import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
demoteVisibleStatsWindowBelowDialogs,
|
||||
presentStatsWindow,
|
||||
promoteStatsWindowLevel,
|
||||
promoteVisibleStatsWindowAboveOverlay,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
STATS_WINDOW_TITLE,
|
||||
} from './stats-window-runtime.js';
|
||||
@@ -18,8 +15,6 @@ import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement
|
||||
|
||||
let statsWindow: BrowserWindow | null = null;
|
||||
let toggleRegistered = false;
|
||||
let nativeDialogLayerRegistered = false;
|
||||
let nativeDialogLayerSuspensionCount = 0;
|
||||
|
||||
export interface StatsWindowOptions {
|
||||
/** Absolute path to stats/dist/ directory */
|
||||
@@ -63,88 +58,7 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||
}
|
||||
options.onVisibilityChanged?.(true);
|
||||
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();
|
||||
});
|
||||
promoteStatsWindowLevel(window);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +104,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
promoteStatsOverlayAbovePlayback();
|
||||
promoteStatsWindowLevel(statsWindow);
|
||||
});
|
||||
} else if (statsWindow.isVisible()) {
|
||||
statsWindow.hide();
|
||||
@@ -205,7 +119,6 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
* Call this once during app initialization.
|
||||
*/
|
||||
export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
||||
registerStatsNativeDialogLayerHandlers();
|
||||
if (toggleRegistered) return;
|
||||
toggleRegistered = true;
|
||||
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
|
||||
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
hasTransportedStartupArgs,
|
||||
shouldForwardStartupArgvViaAppControl,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
resolveLinuxPasswordStoreValue,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||
@@ -109,64 +106,6 @@ test('hasTransportedStartupArgs detects env-carried app args', () => {
|
||||
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', () => {
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||
import { CliArgs, parseArgs, shouldStartApp } from './cli/args';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
|
||||
const BACKGROUND_ARG = '--background';
|
||||
const START_ARG = '--start';
|
||||
const PASSWORD_STORE_ARG = '--password-store';
|
||||
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
|
||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
@@ -35,10 +34,6 @@ type EarlyAppLike = {
|
||||
setPath: (name: 'userData', value: string) => void;
|
||||
};
|
||||
|
||||
type CommandLineLike = {
|
||||
appendSwitch: (name: string, value?: string) => void;
|
||||
};
|
||||
|
||||
type EarlyAppPathOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
appDataDir?: string;
|
||||
@@ -78,58 +73,6 @@ function removePassiveStartupArgs(argv: string[]): string[] {
|
||||
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 {
|
||||
return (
|
||||
token.startsWith('--') &&
|
||||
@@ -147,20 +90,6 @@ export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
|
||||
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 {
|
||||
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
||||
if (rawCount === undefined) {
|
||||
|
||||
+12
-51
@@ -9,20 +9,17 @@ import {
|
||||
normalizeLaunchMpvExtraArgs,
|
||||
normalizeLaunchMpvTargets,
|
||||
normalizeStartupArgv,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
sanitizeHelpEnv,
|
||||
sanitizeLaunchMpvEnv,
|
||||
hasTransportedStartupArgs,
|
||||
shouldForwardStartupArgvViaAppControl,
|
||||
shouldDetachBackgroundLaunch,
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { sendAppControlCommand } from './shared/app-control-client';
|
||||
import {
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
@@ -176,7 +173,6 @@ function readConfiguredWindowsMpvLaunch(configDir: string): {
|
||||
}
|
||||
|
||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
|
||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||
const userDataPath = configureEarlyAppPaths(app);
|
||||
const reportFatalError = createFatalErrorReporter({
|
||||
@@ -188,44 +184,6 @@ registerFatalErrorHandlers({
|
||||
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)) {
|
||||
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||
const child = spawn(process.execPath, childArgs, {
|
||||
@@ -275,14 +233,17 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
app.exit(exitCode);
|
||||
});
|
||||
} else {
|
||||
void forwardStartupArgvViaAppControlIfAvailable()
|
||||
.then((forwarded) => {
|
||||
if (!forwarded) {
|
||||
startMainProcess();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.exit(0);
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
} catch (error) {
|
||||
reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed while loading the main process.',
|
||||
});
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-52
@@ -351,13 +351,8 @@ import {
|
||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||
import { startStatsServer } from './core/services/stats-server';
|
||||
import {
|
||||
destroyStatsWindow,
|
||||
promoteStatsOverlayAbovePlayback,
|
||||
registerStatsOverlayToggle,
|
||||
toggleStatsOverlay as toggleStatsOverlayWindow,
|
||||
withStatsWindowLayerSuspendedForNativeDialog,
|
||||
} from './core/services/stats-window.js';
|
||||
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
||||
import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/stats-window.js';
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
getFirstRunSetupCompletionMessage,
|
||||
@@ -505,7 +500,6 @@ import {
|
||||
} from './main/jlpt-runtime';
|
||||
import { createMediaRuntimeService } from './main/media-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 { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
||||
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
|
||||
@@ -649,7 +643,6 @@ let jellyfinPlayQuitOnDisconnectArmed = false;
|
||||
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
|
||||
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
||||
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
|
||||
const JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS = 10_000;
|
||||
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
|
||||
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
||||
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
||||
@@ -2254,7 +2247,6 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||
getSuspendVisibleOverlay: () => appState.statsOverlayVisible,
|
||||
getOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||
@@ -2317,17 +2309,6 @@ let macOSVisibleOverlayForegroundProbeActive = false;
|
||||
let macOSVisibleOverlayForegroundProbeToken = 0;
|
||||
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 {
|
||||
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
@@ -2933,11 +2914,7 @@ const {
|
||||
},
|
||||
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
|
||||
setActivePlayback: (state) => {
|
||||
activeJellyfinRemotePlayback = {
|
||||
...(state as ActiveJellyfinRemotePlaybackState),
|
||||
stopReportsAfterMs:
|
||||
state.stopReportsAfterMs ?? Date.now() + JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS,
|
||||
};
|
||||
activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState;
|
||||
},
|
||||
setLastProgressAtMs: (value) => {
|
||||
jellyfinRemoteLastProgressAtMs = value;
|
||||
@@ -3946,7 +3923,8 @@ const immersionTrackerStartupMainDeps: Parameters<
|
||||
getToggleKey: () => getResolvedConfig().stats.toggleKey,
|
||||
resolveBounds: () => getCurrentOverlayGeometry(),
|
||||
onVisibilityChanged: (visible) => {
|
||||
handleStatsOverlayVisibilityChanged(visible);
|
||||
appState.statsOverlayVisible = visible;
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4417,11 +4395,6 @@ const {
|
||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
||||
},
|
||||
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
|
||||
markJellyfinRemotePlaybackLoaded: (path) => {
|
||||
if (activeJellyfinRemotePlayback) {
|
||||
activeJellyfinRemotePlayback.loadedMediaPath = path;
|
||||
}
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
|
||||
return;
|
||||
@@ -4741,12 +4714,7 @@ const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||
|
||||
const buildEnsureOverlayWindowLevelMainDepsHandler =
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||
shouldSuppressOverlayWindowLevel: (window) =>
|
||||
appState.statsOverlayVisible && window === overlayManager.getMainWindow(),
|
||||
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
|
||||
afterEnsureOverlayWindowLevel: () => {
|
||||
promoteStatsOverlayAbovePlayback();
|
||||
},
|
||||
});
|
||||
const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler();
|
||||
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
|
||||
@@ -5124,8 +5092,6 @@ function getUpdateService() {
|
||||
});
|
||||
app.focus({ steal: true });
|
||||
},
|
||||
withStatsWindowLayerSuspended: (showDialog) =>
|
||||
withStatsWindowLayerSuspendedForNativeDialog(showDialog),
|
||||
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||
});
|
||||
updateService = createUpdateService({
|
||||
@@ -5469,7 +5435,8 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
getToggleKey: () => getResolvedConfig().stats.toggleKey,
|
||||
resolveBounds: () => getCurrentOverlayGeometry(),
|
||||
onVisibilityChanged: (visible) => {
|
||||
handleStatsOverlayVisibilityChanged(visible);
|
||||
appState.statsOverlayVisible = visible;
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
},
|
||||
}),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
@@ -6328,13 +6295,6 @@ 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 {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
@@ -6345,21 +6305,18 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
setVisibleOverlayVisibleHandler(visible);
|
||||
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
if (!nextVisible) {
|
||||
if (overlayManager.getVisibleOverlayVisible()) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
} else {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
toggleVisibleOverlayHandler();
|
||||
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
@@ -6371,7 +6328,6 @@ function setOverlayVisible(visible: boolean): void {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
setOverlayVisibleHandler(visible);
|
||||
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function handleOverlayModalClosed(modal: OverlayHostedModal): void {
|
||||
|
||||
@@ -70,22 +70,6 @@ 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', () => {
|
||||
const source = readMainSource();
|
||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||
|
||||
@@ -9,7 +9,6 @@ type MockWindow = {
|
||||
ignoreMouseEvents: boolean;
|
||||
forwardedIgnoreMouseEvents: boolean;
|
||||
webContentsFocused: boolean;
|
||||
alwaysOnTopCalls: string[];
|
||||
showCount: number;
|
||||
hideCount: number;
|
||||
sent: unknown[][];
|
||||
@@ -54,7 +53,6 @@ function createMockWindow(): MockWindow & {
|
||||
ignoreMouseEvents: false,
|
||||
forwardedIgnoreMouseEvents: false,
|
||||
webContentsFocused: false,
|
||||
alwaysOnTopCalls: [],
|
||||
showCount: 0,
|
||||
hideCount: 0,
|
||||
sent: [],
|
||||
@@ -74,9 +72,7 @@ function createMockWindow(): MockWindow & {
|
||||
state.ignoreMouseEvents = ignore;
|
||||
state.forwardedIgnoreMouseEvents = options?.forward === true;
|
||||
},
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
|
||||
state.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
|
||||
},
|
||||
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
|
||||
moveTop: () => {},
|
||||
getShowCount: () => state.showCount,
|
||||
getHideCount: () => state.hideCount,
|
||||
@@ -159,13 +155,6 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'alwaysOnTopCalls', {
|
||||
get: () => state.alwaysOnTopCalls,
|
||||
set: (value: string[]) => {
|
||||
state.alwaysOnTopCalls = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'url', {
|
||||
get: () => state.url,
|
||||
set: (value: string) => {
|
||||
@@ -230,7 +219,6 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
assert.equal(window.isFocused(), true);
|
||||
assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
const elevateModalWindow = (window: BrowserWindow): void => {
|
||||
if (window.isDestroyed()) return;
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
window.moveTop();
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
getModalActive: () => boolean;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getSuspendVisibleOverlay?: () => boolean;
|
||||
getOverlayInteractionActive?: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||
@@ -45,7 +44,6 @@ export function createOverlayVisibilityRuntimeService(
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
const visibleOverlayVisible = deps.getVisibleOverlayVisible();
|
||||
const forceMousePassthrough = deps.getForceMousePassthrough();
|
||||
const suspendVisibleOverlay = deps.getSuspendVisibleOverlay?.() ?? false;
|
||||
const windowTracker = deps.getWindowTracker();
|
||||
const mainWindow = deps.getMainWindow();
|
||||
|
||||
@@ -53,7 +51,6 @@ export function createOverlayVisibilityRuntimeService(
|
||||
visibleOverlayVisible,
|
||||
modalActive: deps.getModalActive(),
|
||||
forceMousePassthrough,
|
||||
suspendVisibleOverlay,
|
||||
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
|
||||
mainWindow,
|
||||
windowTracker,
|
||||
|
||||
@@ -87,9 +87,6 @@ export function composeJellyfinRemoteHandlers(
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
clearActivePlayback: options.clearActivePlayback,
|
||||
getSession: options.getSession,
|
||||
getMpvClient: options.getMpvClient,
|
||||
getNow: options.getNow,
|
||||
ticksPerSecond: options.ticksPerSecond,
|
||||
logDebug: options.logDebug,
|
||||
});
|
||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
||||
|
||||
@@ -121,8 +121,6 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
|
||||
assert.equal(reportPayloads.length, 1);
|
||||
assert.equal(reportPayloads[0]?.eventName, 'start');
|
||||
assert.equal(reportPayloads[0]?.positionTicks, 12_000_000);
|
||||
assert.equal(reportPayloads[0]?.isPaused, false);
|
||||
assert.deepEqual(statsMetadata, [
|
||||
{
|
||||
mediaPath: 'https://stream.example/video.m3u8',
|
||||
@@ -182,47 +180,6 @@ test('playback handler publishes Jellyfin title before loading tokenized stream
|
||||
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 () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
|
||||
@@ -14,8 +14,6 @@ type ActivePlaybackState = {
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
loadedMediaPath?: string | null;
|
||||
stopReportsAfterMs?: number;
|
||||
};
|
||||
|
||||
export type JellyfinPlaybackStatsMetadata = {
|
||||
@@ -71,8 +69,6 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
itemId: string;
|
||||
mediaSourceId: undefined;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
positionTicks?: number;
|
||||
isPaused?: boolean;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'start';
|
||||
@@ -111,7 +107,6 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
||||
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
|
||||
try {
|
||||
deps.updateCurrentMediaTitle?.(plan.title);
|
||||
deps.recordJellyfinPlaybackMetadata?.({
|
||||
@@ -126,15 +121,6 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
} catch {
|
||||
// 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']);
|
||||
if (params.setQuitOnDisconnectArm !== false) {
|
||||
deps.armQuitOnDisconnect();
|
||||
@@ -157,12 +143,19 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
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({
|
||||
itemId: params.itemId,
|
||||
mediaSourceId: undefined,
|
||||
playMethod,
|
||||
positionTicks: startTimeTicks,
|
||||
isPaused: false,
|
||||
audioStreamIndex: plan.audioStreamIndex,
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
eventName: 'start',
|
||||
|
||||
@@ -4,8 +4,6 @@ export type ActiveJellyfinRemotePlaybackState = {
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
loadedMediaPath?: string | null;
|
||||
stopReportsAfterMs?: number;
|
||||
};
|
||||
|
||||
type JellyfinSession = {
|
||||
|
||||
@@ -103,16 +103,12 @@ test('jellyfin remote stopped main deps builder maps callbacks', () => {
|
||||
getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }),
|
||||
clearActivePlayback: () => calls.push('clear'),
|
||||
getSession: () => session as never,
|
||||
getMpvClient: () => ({ id: 2, currentTimePos: 4 }) as never,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' });
|
||||
deps.clearActivePlayback();
|
||||
assert.equal(deps.getSession(), session);
|
||||
assert.deepEqual(deps.getMpvClient(), { id: 2, currentTimePos: 4 });
|
||||
assert.equal(deps.ticksPerSecond, 10_000_000);
|
||||
deps.logDebug('stopped', null);
|
||||
assert.deepEqual(calls, ['clear', 'debug:stopped']);
|
||||
});
|
||||
|
||||
@@ -71,9 +71,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
||||
getActivePlayback: () => deps.getActivePlayback(),
|
||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,42 +61,6 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
||||
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 () => {
|
||||
const reportPayloads: Array<{ isPaused: boolean }> = [];
|
||||
|
||||
@@ -159,61 +123,9 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy
|
||||
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 () => {
|
||||
let cleared = false;
|
||||
let stoppedPayload: {
|
||||
itemId: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
} | null = null;
|
||||
let stoppedItemId: string | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-2',
|
||||
@@ -229,143 +141,13 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async (payload) => {
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
stoppedItemId = payload.itemId;
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 12.5,
|
||||
requestProperty: async () => {
|
||||
throw new Error('unloaded');
|
||||
},
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
assert.deepEqual(stoppedPayload, {
|
||||
itemId: 'item-2',
|
||||
positionTicks: 125_000_000,
|
||||
failed: false,
|
||||
});
|
||||
assert.equal(stoppedItemId, 'item-2');
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -10,13 +10,11 @@ type JellyfinRemoteSessionLike = {
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'TimeUpdate';
|
||||
eventName: 'timeupdate';
|
||||
}) => Promise<unknown>;
|
||||
reportStopped: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
@@ -25,8 +23,7 @@ type JellyfinRemoteSessionLike = {
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
currentTimePos?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number {
|
||||
@@ -47,45 +44,6 @@ function isMpvPauseEnabled(value: unknown): boolean {
|
||||
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 = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
@@ -102,42 +60,29 @@ export type JellyfinRemoteProgressReporterDeps = {
|
||||
export function createReportJellyfinRemoteProgressHandler(
|
||||
deps: JellyfinRemoteProgressReporterDeps,
|
||||
) {
|
||||
let lastReportedPositionSeconds: number | null = null;
|
||||
|
||||
return async (force = false): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
const session = deps.getSession();
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) return;
|
||||
if (!session || !session.isConnected()) return;
|
||||
const now = deps.getNow();
|
||||
if (!force && now - deps.getLastProgressAtMs() < deps.progressIntervalMs) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
const positionSeconds = await readMpvPositionSeconds(mpvClient);
|
||||
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');
|
||||
const position = await mpvClient?.requestProperty('time-pos');
|
||||
const paused = await mpvClient?.requestProperty('pause');
|
||||
await session.reportProgress({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
|
||||
isPaused: isMpvPauseEnabled(paused),
|
||||
playMethod: playback.playMethod,
|
||||
audioStreamIndex: playback.audioStreamIndex,
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
eventName: 'TimeUpdate',
|
||||
eventName: 'timeupdate',
|
||||
});
|
||||
lastReportedPositionSeconds = positionSeconds;
|
||||
deps.setLastProgressAtMs(now);
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote progress', error);
|
||||
@@ -149,9 +94,6 @@ export type JellyfinRemoteStoppedReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
getSession: () => JellyfinRemoteSessionLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
getNow?: () => number;
|
||||
ticksPerSecond: number;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -159,27 +101,15 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
return async (): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
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();
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) {
|
||||
if (!session || !session.isConnected()) {
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const positionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
|
||||
await session.reportStopped({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||
failed: false,
|
||||
playMethod: playback.playMethod,
|
||||
audioStreamIndex: playback.audioStreamIndex,
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
|
||||
@@ -201,94 +201,6 @@ 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 () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let requestCount = 0;
|
||||
@@ -421,18 +333,12 @@ test('preload jellyfin subtitles cleans previous cached subtitles before a new p
|
||||
|
||||
test('preload jellyfin subtitles continues after cleanup failures', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const cleanupCalls: string[][] = [];
|
||||
const logs: string[] = [];
|
||||
let cleanupShouldFail = false;
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async (_session, _clientInfo, itemId) => [
|
||||
{
|
||||
index: itemId === 'item-1' ? 0 : 1,
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
deliveryUrl: `https://sub/${itemId}.srt`,
|
||||
},
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'eng', title: 'English', deliveryUrl: 'https://sub/a.srt' },
|
||||
],
|
||||
getMpvClient: () => ({ requestProperty: async () => [] }),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
@@ -440,8 +346,7 @@ test('preload jellyfin subtitles continues after cleanup failures', async () =>
|
||||
cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`,
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
cleanupCachedSubtitles: (dirs) => {
|
||||
cleanupCalls.push(dirs);
|
||||
cleanupCachedSubtitles: () => {
|
||||
if (cleanupShouldFail) {
|
||||
throw new Error('cleanup failed');
|
||||
}
|
||||
@@ -453,19 +358,13 @@ test('preload jellyfin subtitles continues after cleanup failures', async () =>
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
cleanupShouldFail = true;
|
||||
await assert.doesNotReject(() => preload({ session, clientInfo, itemId: 'item-2' }));
|
||||
cleanupShouldFail = false;
|
||||
preload.cleanupCachedSubtitles();
|
||||
|
||||
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(
|
||||
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-1/track.srt', 'auto', 'English', 'eng'],
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles-0/track.srt', 'auto', 'English', 'eng'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -151,16 +151,18 @@ function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
|
||||
? trackListRaw
|
||||
.filter(
|
||||
(track): track is Record<string, unknown> =>
|
||||
Boolean(track) && typeof track === 'object' && track.type === 'sub',
|
||||
Boolean(track) &&
|
||||
typeof track === 'object' &&
|
||||
track.type === 'sub' &&
|
||||
typeof track.id === 'number',
|
||||
)
|
||||
.map((track) => ({
|
||||
id: parseTrackId(track.id),
|
||||
id: track.id as number,
|
||||
lang: String(track.lang || ''),
|
||||
title: String(track.title || ''),
|
||||
external: track.external === true,
|
||||
externalFilename: String(track['external-filename'] || ''),
|
||||
}))
|
||||
.filter((track): track is MpvSubtitleTrack => track.id !== null)
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -177,15 +179,6 @@ function hasExpectedExternalSubtitleTracks(
|
||||
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: {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
}): Promise<MpvSubtitleTrack[] | null> {
|
||||
@@ -193,12 +186,7 @@ async function readMpvSubtitleTracks(deps: {
|
||||
if (!client || client.connected === false) {
|
||||
return null;
|
||||
}
|
||||
let trackListRaw: unknown;
|
||||
try {
|
||||
trackListRaw = await client.requestProperty('track-list');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const trackListRaw = await client.requestProperty('track-list');
|
||||
return parseMpvSubtitleTracks(trackListRaw);
|
||||
}
|
||||
|
||||
@@ -247,11 +235,9 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
|
||||
function cleanupActiveCache(): void {
|
||||
const dirs = [...activeCacheDirs];
|
||||
activeCacheDirs.clear();
|
||||
if (dirs.length === 0) return;
|
||||
deps.cleanupCachedSubtitles(dirs);
|
||||
for (const dir of dirs) {
|
||||
activeCacheDirs.delete(dir);
|
||||
}
|
||||
}
|
||||
|
||||
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 schedules a fresh burst when fullscreen exits', async () => {
|
||||
test('linux mpv fullscreen overlay refresh update cancels burst when fullscreen exits', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
@@ -82,11 +82,8 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
|
||||
assert.equal(typeof nextCancel, 'function');
|
||||
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(calls.includes('showInactive'));
|
||||
assert.ok(calls.includes('ensureOverlayWindowLevel'));
|
||||
assert.equal(nextCancel, null);
|
||||
assert.deepEqual(calls, []);
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
|
||||
@@ -68,11 +68,14 @@ export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
|
||||
}
|
||||
|
||||
export function updateLinuxMpvFullscreenOverlayRefreshBurst(
|
||||
_isFullscreen: boolean,
|
||||
isFullscreen: boolean,
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
cancelCurrentBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null,
|
||||
): CancelLinuxMpvFullscreenOverlayRefreshBurst | null {
|
||||
cancelCurrentBurst?.();
|
||||
if (!isFullscreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(deps);
|
||||
}
|
||||
|
||||
@@ -168,28 +168,6 @@ 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', () => {
|
||||
const calls: string[] = [];
|
||||
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {
|
||||
@@ -244,36 +222,6 @@ 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 () => {
|
||||
const watchedSeconds: unknown[] = [];
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
|
||||
@@ -4,15 +4,6 @@ type AnilistPostWatchRunOptions = {
|
||||
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: {
|
||||
setCurrentSubText: (text: string) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
@@ -68,7 +59,6 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
syncImmersionMediaState: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
}) {
|
||||
@@ -91,7 +81,6 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
}
|
||||
deps.syncImmersionMediaState();
|
||||
if (normalizedPath.trim().length > 0) {
|
||||
deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath);
|
||||
deps.scheduleCharacterDictionarySync?.();
|
||||
deps.signalAutoplayReadyIfWarm?.(normalizedPath);
|
||||
}
|
||||
@@ -124,15 +113,9 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
}) {
|
||||
let lastObservedTime: number | null = null;
|
||||
|
||||
return ({ time }: { time: number }): void => {
|
||||
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
|
||||
if (Number.isFinite(time)) {
|
||||
lastObservedTime = time;
|
||||
}
|
||||
deps.recordPlaybackPosition(time);
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate);
|
||||
deps.reportJellyfinRemoteProgress(false);
|
||||
deps.refreshDiscordPresence();
|
||||
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||
|
||||
@@ -63,7 +63,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
@@ -143,7 +142,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
flushPlaybackPositionOnMediaPathClear: (mediaPath) =>
|
||||
deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath),
|
||||
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||
markJellyfinRemotePlaybackLoaded: (path) => deps.markJellyfinRemotePlaybackLoaded?.(path),
|
||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
});
|
||||
|
||||
@@ -65,7 +65,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||
syncImmersionMediaState: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
updateCurrentMediaTitle: (title: string) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
@@ -174,8 +173,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||
signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||
markJellyfinRemotePlaybackLoaded: (path: string) =>
|
||||
deps.markJellyfinRemotePlaybackLoaded?.(path),
|
||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
|
||||
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
||||
|
||||
@@ -63,7 +63,7 @@ test('overlay modal input state activates modal window interactivity and syncs d
|
||||
assert.deepEqual(modalWindow.calls, [
|
||||
'focusable:true',
|
||||
'ignore:false',
|
||||
'top:true:screen-saver:3',
|
||||
'top:true:screen-saver:1',
|
||||
'focus',
|
||||
'web-focus',
|
||||
]);
|
||||
|
||||
@@ -42,7 +42,7 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
setWindowFocusable(modalWindow);
|
||||
requestOverlayApplicationFocus();
|
||||
modalWindow.setIgnoreMouseEvents(false);
|
||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
modalWindow.focus();
|
||||
if (!modalWindow.webContents.isFocused()) {
|
||||
modalWindow.webContents.focus();
|
||||
|
||||
@@ -15,7 +15,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
getModalActive: () => true,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getForceMousePassthrough: () => true,
|
||||
getSuspendVisibleOverlay: () => true,
|
||||
getOverlayInteractionActive: () => true,
|
||||
getWindowTracker: () => tracker,
|
||||
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||
@@ -43,7 +42,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
assert.equal(deps.getModalActive(), true);
|
||||
assert.equal(deps.getVisibleOverlayVisible(), true);
|
||||
assert.equal(deps.getForceMousePassthrough(), true);
|
||||
assert.equal(deps.getSuspendVisibleOverlay?.(), true);
|
||||
assert.equal(deps.getOverlayInteractionActive?.(), true);
|
||||
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
||||
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
||||
|
||||
@@ -10,7 +10,6 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
getModalActive: () => deps.getModalActive(),
|
||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||
getSuspendVisibleOverlay: () => deps.getSuspendVisibleOverlay?.() ?? false,
|
||||
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
|
||||
getWindowTracker: () => deps.getWindowTracker(),
|
||||
getLastKnownWindowsForegroundProcessName: () =>
|
||||
|
||||
@@ -15,16 +15,9 @@ test('overlay window layout main deps builders map callbacks', () => {
|
||||
visible.setOverlayWindowBounds({ x: 0, y: 0, width: 1, height: 1 });
|
||||
|
||||
const level = createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||
shouldSuppressOverlayWindowLevel: () => {
|
||||
calls.push('ensure-suppressed-check');
|
||||
return false;
|
||||
},
|
||||
ensureOverlayWindowLevelCore: () => calls.push('ensure'),
|
||||
afterEnsureOverlayWindowLevel: () => calls.push('ensure-after'),
|
||||
})();
|
||||
assert.equal(level.shouldSuppressOverlayWindowLevel?.({}), false);
|
||||
level.ensureOverlayWindowLevelCore({});
|
||||
level.afterEnsureOverlayWindowLevel?.({});
|
||||
|
||||
const order = createBuildEnforceOverlayLayerOrderMainDepsHandler({
|
||||
enforceOverlayLayerOrderCore: () => calls.push('order'),
|
||||
@@ -41,12 +34,5 @@ test('overlay window layout main deps builders map callbacks', () => {
|
||||
assert.deepEqual(order.getMainWindow(), { kind: 'main' });
|
||||
order.ensureOverlayWindowLevel({});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'visible',
|
||||
'ensure-suppressed-check',
|
||||
'ensure',
|
||||
'ensure-after',
|
||||
'order',
|
||||
'order-level',
|
||||
]);
|
||||
assert.deepEqual(calls, ['visible', 'ensure', 'order', 'order-level']);
|
||||
});
|
||||
|
||||
@@ -23,11 +23,7 @@ export function createBuildEnsureOverlayWindowLevelMainDepsHandler(
|
||||
deps: EnsureOverlayWindowLevelMainDeps,
|
||||
) {
|
||||
return (): EnsureOverlayWindowLevelMainDeps => ({
|
||||
shouldSuppressOverlayWindowLevel: (window: unknown) =>
|
||||
deps.shouldSuppressOverlayWindowLevel?.(window) ?? false,
|
||||
ensureOverlayWindowLevelCore: (window: unknown) => deps.ensureOverlayWindowLevelCore(window),
|
||||
afterEnsureOverlayWindowLevel: (window: unknown) =>
|
||||
deps.afterEnsureOverlayWindowLevel?.(window),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -36,28 +36,9 @@ test('ensure overlay window level handler delegates to core', () => {
|
||||
const calls: string[] = [];
|
||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||
ensureOverlayWindowLevelCore: () => calls.push('core'),
|
||||
afterEnsureOverlayWindowLevel: () => calls.push('after'),
|
||||
});
|
||||
ensureLevel({});
|
||||
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']);
|
||||
assert.deepEqual(calls, ['core']);
|
||||
});
|
||||
|
||||
test('enforce overlay layer order handler forwards resolved state', () => {
|
||||
|
||||
@@ -11,16 +11,10 @@ export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
||||
}
|
||||
|
||||
export function createEnsureOverlayWindowLevelHandler(deps: {
|
||||
shouldSuppressOverlayWindowLevel?: (window: unknown) => boolean;
|
||||
ensureOverlayWindowLevelCore: (window: unknown) => void;
|
||||
afterEnsureOverlayWindowLevel?: (window: unknown) => void;
|
||||
}) {
|
||||
return (window: unknown): void => {
|
||||
if (deps.shouldSuppressOverlayWindowLevel?.(window) === true) {
|
||||
return;
|
||||
}
|
||||
deps.ensureOverlayWindowLevelCore(window);
|
||||
deps.afterEnsureOverlayWindowLevel?.(window);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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',
|
||||
]);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
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,57 +28,6 @@ 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)']);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface UpdateDialogPresenterDeps {
|
||||
showMessageBox: ShowMessageBox;
|
||||
focusApp?: () => void | Promise<void>;
|
||||
yieldToRunLoop?: () => Promise<void>;
|
||||
withStatsWindowLayerSuspended?: <T>(showDialog: () => Promise<T>) => Promise<T>;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
@@ -47,18 +46,12 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<
|
||||
|
||||
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
||||
const showDialog = async (): Promise<MessageBoxResultLike> => {
|
||||
try {
|
||||
await maybeFocusAppForDialog(deps);
|
||||
} catch {
|
||||
// Best-effort focus only; never block the dialog itself.
|
||||
}
|
||||
return deps.showMessageBox(options);
|
||||
};
|
||||
|
||||
return deps.withStatsWindowLayerSuspended
|
||||
? deps.withStatsWindowLayerSuspended(showDialog)
|
||||
: showDialog();
|
||||
try {
|
||||
await maybeFocusAppForDialog(deps);
|
||||
} catch {
|
||||
// Best-effort focus only; never block the dialog itself.
|
||||
}
|
||||
return deps.showMessageBox(options);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,18 +43,6 @@ const statsAPI = {
|
||||
hideOverlay: (): void => {
|
||||
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 });
|
||||
|
||||
@@ -993,38 +993,6 @@ 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 () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -204,11 +204,6 @@ export function createKeyboardHandlers(
|
||||
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') {
|
||||
options.openControllerSelectModal?.();
|
||||
return;
|
||||
|
||||
@@ -27,9 +27,6 @@ export const IPC_CHANNELS = {
|
||||
toggleDevTools: 'toggle-dev-tools',
|
||||
toggleOverlay: 'toggle-overlay',
|
||||
saveSubtitlePosition: 'save-subtitle-position',
|
||||
statsNativeConfirmDialog: 'stats:native-confirm-dialog',
|
||||
statsNativeDialogOpened: 'stats:native-dialog-opened',
|
||||
statsNativeDialogClosed: 'stats:native-dialog-closed',
|
||||
saveControllerConfig: 'save-controller-config',
|
||||
saveControllerPreference: 'save-controller-preference',
|
||||
setMecabEnabled: 'set-mecab-enabled',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
HyprlandWindowTracker,
|
||||
isHyprlandGeometryEvent,
|
||||
parseHyprctlClients,
|
||||
parseHyprctlMonitors,
|
||||
@@ -178,22 +177,3 @@ test('resolveHyprlandWindowGeometry uses monitor bounds for client-requested ful
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -295,12 +295,8 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
const data = rawData.trim();
|
||||
|
||||
if (name === 'activewindowv2') {
|
||||
const wasFocused = this.isTargetWindowFocused();
|
||||
this.activeWindowAddress = data || null;
|
||||
this.pollGeometry();
|
||||
if (this.isTargetWindowFocused() === wasFocused) {
|
||||
this.onWindowFocusChange?.(this.isTargetWindowFocused());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -340,12 +336,9 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
const mpvWindow = this.findTargetWindow(clients);
|
||||
|
||||
if (mpvWindow) {
|
||||
const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress;
|
||||
this.updateGeometry(
|
||||
resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)),
|
||||
focused,
|
||||
);
|
||||
this.updateTargetWindowFocused(focused);
|
||||
} else {
|
||||
this.updateGeometry(null);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Suspense, lazy, useCallback, useState } from 'react';
|
||||
import { DeleteConfirmDialog } from './components/layout/DeleteConfirmDialog';
|
||||
import { TabBar } from './components/layout/TabBar';
|
||||
import { OverviewTab } from './components/overview/OverviewTab';
|
||||
import { useExcludedWords } from './hooks/useExcludedWords';
|
||||
@@ -273,7 +272,6 @@ export function App() {
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<DeleteConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
}, [videoId]);
|
||||
|
||||
const handleDeleteSession = async (sessionId: number) => {
|
||||
if (!(await confirmSessionDelete())) return;
|
||||
if (!confirmSessionDelete()) return;
|
||||
await apiClient.deleteSession(sessionId);
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
@@ -44,7 +44,7 @@ export function EpisodeList({
|
||||
};
|
||||
|
||||
const handleDeleteEpisode = async (videoId: number, title: string) => {
|
||||
if (!(await confirmEpisodeDelete(title))) return;
|
||||
if (!confirmEpisodeDelete(title)) return;
|
||||
await apiClient.deleteVideo(videoId);
|
||||
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
|
||||
if (expandedVideoId === videoId) setExpandedVideoId(null);
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
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;
|
||||
title: string;
|
||||
apiClient: { deleteVideo: (id: number) => Promise<void> };
|
||||
confirmFn: (title: string) => boolean | Promise<boolean>;
|
||||
confirmFn: (title: string) => boolean;
|
||||
onBack: () => void;
|
||||
setDeleteError: (msg: string | null) => void;
|
||||
/**
|
||||
@@ -27,7 +27,7 @@ interface DeleteEpisodeHandlerOptions {
|
||||
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
||||
return async () => {
|
||||
if (opts.isDeletingRef?.current) return;
|
||||
if (!(await opts.confirmFn(opts.title))) return;
|
||||
if (!opts.confirmFn(opts.title)) return;
|
||||
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
|
||||
opts.setIsDeleting?.(true);
|
||||
opts.setDeleteError(null);
|
||||
@@ -101,7 +101,7 @@ export function MediaDetailView({
|
||||
const relatedCollectionLabel = getRelatedCollectionLabel(detail);
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!(await confirmSessionDelete())) return;
|
||||
if (!confirmSessionDelete()) return;
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingSessionId(session.sessionId);
|
||||
|
||||
@@ -47,7 +47,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
||||
}, []);
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!(await confirmSessionDelete())) return;
|
||||
if (!confirmSessionDelete()) return;
|
||||
setDeleteError(null);
|
||||
setDeletingIds((prev) => new Set(prev).add(session.sessionId));
|
||||
try {
|
||||
@@ -65,7 +65,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
||||
};
|
||||
|
||||
const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => {
|
||||
if (!(await confirmDayGroupDelete(dayLabel, daySessions.length))) return;
|
||||
if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return;
|
||||
setDeleteError(null);
|
||||
const ids = daySessions.map((s) => s.sessionId);
|
||||
setDeletingIds((prev) => {
|
||||
@@ -91,7 +91,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
||||
const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => {
|
||||
const title =
|
||||
groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media';
|
||||
if (!(await confirmAnimeGroupDelete(title, groupSessions.length))) return;
|
||||
if (!confirmAnimeGroupDelete(title, groupSessions.length)) return;
|
||||
setDeleteError(null);
|
||||
const ids = groupSessions.map((s) => s.sessionId);
|
||||
setDeletingIds((prev) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
|
||||
export interface BucketDeleteDeps {
|
||||
bucket: SessionBucket;
|
||||
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
||||
confirm: (title: string, count: number) => boolean | Promise<boolean>;
|
||||
confirm: (title: string, count: number) => boolean;
|
||||
onSuccess: (deletedIds: number[]) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
|
||||
return async () => {
|
||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
||||
if (!(await confirm(title, ids.length))) return;
|
||||
if (!confirm(title, ids.length)) return;
|
||||
try {
|
||||
await client.deleteSessions(ids);
|
||||
onSuccess(ids);
|
||||
@@ -120,7 +120,7 @@ export function SessionsTab({
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!(await confirmSessionDelete())) return;
|
||||
if (!confirmSessionDelete()) return;
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingSessionId(session.sessionId);
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
confirmDayGroupDelete,
|
||||
confirmEpisodeDelete,
|
||||
confirmSessionDelete,
|
||||
setDeleteConfirmPresenter,
|
||||
} from './delete-confirm';
|
||||
|
||||
test('confirmSessionDelete uses the shared session delete warning copy', async () => {
|
||||
test('confirmSessionDelete uses the shared session delete warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
@@ -17,183 +16,14 @@ test('confirmSessionDelete uses the shared session delete warning copy', async (
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(await confirmSessionDelete(), true);
|
||||
assert.equal(confirmSessionDelete(), true);
|
||||
assert.deepEqual(calls, ['Delete this session and all associated data?']);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
test('confirmDayGroupDelete includes the day label and count in the warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
@@ -202,14 +32,14 @@ test('confirmDayGroupDelete includes the day label and count in the warning copy
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(await confirmDayGroupDelete('Today', 3), true);
|
||||
assert.equal(confirmDayGroupDelete('Today', 3), true);
|
||||
assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmDayGroupDelete uses singular for one session', async () => {
|
||||
test('confirmDayGroupDelete uses singular for one session', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
@@ -218,14 +48,14 @@ test('confirmDayGroupDelete uses singular for one session', async () => {
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(await confirmDayGroupDelete('Yesterday', 1), true);
|
||||
assert.equal(confirmDayGroupDelete('Yesterday', 1), true);
|
||||
assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmBucketDelete asks about merging multiple sessions of the same episode', async () => {
|
||||
test('confirmBucketDelete asks about merging multiple sessions of the same episode', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
@@ -234,7 +64,7 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(await confirmBucketDelete('My Episode', 3), true);
|
||||
assert.equal(confirmBucketDelete('My Episode', 3), true);
|
||||
assert.deepEqual(calls, [
|
||||
'Delete all 3 sessions of "My Episode" from this day and all associated data?',
|
||||
]);
|
||||
@@ -243,7 +73,7 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmBucketDelete uses a clean singular form for one session', async () => {
|
||||
test('confirmBucketDelete uses a clean singular form for one session', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
@@ -252,7 +82,7 @@ test('confirmBucketDelete uses a clean singular form for one session', async ()
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(await confirmBucketDelete('Solo Episode', 1), false);
|
||||
assert.equal(confirmBucketDelete('Solo Episode', 1), false);
|
||||
assert.deepEqual(calls, [
|
||||
'Delete this session of "Solo Episode" from this day and all associated data?',
|
||||
]);
|
||||
@@ -261,7 +91,7 @@ test('confirmBucketDelete uses a clean singular form for one session', async ()
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmEpisodeDelete includes the episode title in the shared warning copy', async () => {
|
||||
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
@@ -270,7 +100,7 @@ test('confirmEpisodeDelete includes the episode title in the shared warning copy
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(await confirmEpisodeDelete('Episode 4'), false);
|
||||
assert.equal(confirmEpisodeDelete('Episode 4'), false);
|
||||
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
|
||||
@@ -1,71 +1,30 @@
|
||||
type NativeDialogBridge = {
|
||||
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 confirmSessionDelete(): boolean {
|
||||
return globalThis.confirm('Delete this session and all associated data?');
|
||||
}
|
||||
|
||||
async function confirmWithStatsNativeDialogLayer(message: string): Promise<boolean> {
|
||||
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(
|
||||
export function confirmDayGroupDelete(dayLabel: string, count: number): boolean {
|
||||
return globalThis.confirm(
|
||||
`Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`,
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmAnimeGroupDelete(title: string, count: number): Promise<boolean> {
|
||||
return confirmWithStatsNativeDialogLayer(
|
||||
export function confirmAnimeGroupDelete(title: string, count: number): boolean {
|
||||
return globalThis.confirm(
|
||||
`Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`,
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmEpisodeDelete(title: string): Promise<boolean> {
|
||||
return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`);
|
||||
export function confirmEpisodeDelete(title: string): boolean {
|
||||
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
|
||||
}
|
||||
|
||||
export function confirmBucketDelete(title: string, count: number): Promise<boolean> {
|
||||
export function confirmBucketDelete(title: string, count: number): boolean {
|
||||
if (count === 1) {
|
||||
return confirmWithStatsNativeDialogLayer(
|
||||
return globalThis.confirm(
|
||||
`Delete this session of "${title}" from this day and all associated data?`,
|
||||
);
|
||||
}
|
||||
return confirmWithStatsNativeDialogLayer(
|
||||
return globalThis.confirm(
|
||||
`Delete all ${count} sessions of "${title}" from this day and all associated data?`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,9 +62,6 @@ interface StatsElectronAPI {
|
||||
ankiBrowse: (noteId: number) => Promise<void>;
|
||||
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
||||
hideOverlay: () => void;
|
||||
confirmNativeDialog?: (message: string) => boolean;
|
||||
beginNativeDialog?: () => void;
|
||||
endNativeDialog?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user