feat: add prettier and eslint with pre-commit hook
This commit is contained in:
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
184
bun.lock
184
bun.lock
@@ -10,13 +10,43 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.2.4",
|
||||
"@toolwind/anchors": "^1.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"lefthook": "^2.1.6",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@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.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -55,6 +85,8 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||
|
||||
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
|
||||
|
||||
"@tailwindcss/cli": ["@tailwindcss/cli@4.2.4", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.4" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
||||
@@ -87,22 +119,120 @@
|
||||
|
||||
"@toolwind/anchors": ["@toolwind/anchors@1.0.10", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0" } }, "sha512-F3J/lxGGPUy+GIpT49NmYMF1X7l0d7UzdDASni29il2ro5sT4cYfPBFHBAfOM0lpgKOr/HnqINlomngt8BcvnA=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"ajv": ["ajv@6.15.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-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.3.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.5.5", "@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-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
|
||||
|
||||
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||
|
||||
"eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"lefthook": ["lefthook@2.1.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.6", "lefthook-darwin-x64": "2.1.6", "lefthook-freebsd-arm64": "2.1.6", "lefthook-freebsd-x64": "2.1.6", "lefthook-linux-arm64": "2.1.6", "lefthook-linux-x64": "2.1.6", "lefthook-openbsd-arm64": "2.1.6", "lefthook-openbsd-x64": "2.1.6", "lefthook-windows-arm64": "2.1.6", "lefthook-windows-x64": "2.1.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q=="],
|
||||
|
||||
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="],
|
||||
@@ -125,6 +255,8 @@
|
||||
|
||||
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -149,24 +281,74 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
|
||||
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
@@ -178,5 +360,7 @@
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
}
|
||||
}
|
||||
|
||||
26
eslint.config.js
Normal file
26
eslint.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import prettier from "eslint-plugin-prettier";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "server", "*.js"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint,
|
||||
prettier,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
rules: {
|
||||
...eslintConfigPrettier.rules,
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
|
||||
"prettier/prettier": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
32
lefthook.yml
32
lefthook.yml
@@ -1,13 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/lefthook.json",
|
||||
"pre-push": {
|
||||
"commands": {
|
||||
"go-fmt": { "run": "go fmt ./..." },
|
||||
"go-vet": { "run": "go vet ./..." },
|
||||
"go-test": { "run": "go test ./..." },
|
||||
"ts-typecheck": { "run": "bunx tsc -p tsconfig.json --noEmit" },
|
||||
"build-assets": { "run": "bun run build:assets" },
|
||||
"go-build": { "run": "go build -o server ./cmd/server" }
|
||||
}
|
||||
}
|
||||
'$schema': 'https://json.schemastore.org/lefthook.json',
|
||||
'pre-commit':
|
||||
{
|
||||
'commands':
|
||||
{
|
||||
'prettier': { 'run': 'bunx prettier . --write' },
|
||||
'eslint': { 'run': 'bunx eslint . --fix' },
|
||||
},
|
||||
},
|
||||
'pre-push':
|
||||
{
|
||||
'commands':
|
||||
{
|
||||
'go-fmt': { 'run': 'go fmt ./...' },
|
||||
'go-vet': { 'run': 'go vet ./...' },
|
||||
'go-test': { 'run': 'go test ./...' },
|
||||
'ts-typecheck': { 'run': 'bunx tsc -p tsconfig.json --noEmit' },
|
||||
'build-assets': { 'run': 'bun run build:assets' },
|
||||
'go-build': { 'run': 'go build -o server ./cmd/server' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
11
package.json
11
package.json
@@ -1,17 +1,26 @@
|
||||
{
|
||||
"name": "myanimelist-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:css": "bunx @tailwindcss/cli -i ./static/style.css -o ./dist/tailwind.css",
|
||||
"watch:css": "bunx @tailwindcss/cli -i ./static/style.css -o ./dist/tailwind.css --watch",
|
||||
"build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser",
|
||||
"typecheck": "bunx tsc -p tsconfig.json --noEmit",
|
||||
"build:assets": "bun run build:css && bun run build:ts"
|
||||
"build:assets": "bun run build:css && bun run build:ts",
|
||||
"format": "bunx prettier . --write",
|
||||
"lint": "bunx eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.2.4",
|
||||
"@toolwind/anchors": "^1.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"lefthook": "^2.1.6",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
import { parseClassList } from './utils'
|
||||
import { parseClassList } from './utils';
|
||||
|
||||
const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => {
|
||||
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'))
|
||||
const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes'))
|
||||
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'));
|
||||
const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes'));
|
||||
|
||||
if (isOpen) {
|
||||
menu.classList.remove(...closedClasses)
|
||||
menu.classList.add(...openClasses)
|
||||
return
|
||||
menu.classList.remove(...closedClasses);
|
||||
menu.classList.add(...openClasses);
|
||||
return;
|
||||
}
|
||||
|
||||
menu.classList.remove(...openClasses)
|
||||
menu.classList.add(...closedClasses)
|
||||
}
|
||||
menu.classList.remove(...openClasses);
|
||||
menu.classList.add(...closedClasses);
|
||||
};
|
||||
|
||||
const setWatchlistDropdownState = (isOpen: boolean): void => {
|
||||
const dropdown = document.getElementById('watchlist-dropdown')
|
||||
const dropdown = document.getElementById('watchlist-dropdown');
|
||||
if (!dropdown) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.classList.toggle('open', isOpen)
|
||||
const menu = dropdown.querySelector('[data-dropdown-menu]')
|
||||
dropdown.classList.toggle('open', isOpen);
|
||||
const menu = dropdown.querySelector('[data-dropdown-menu]');
|
||||
if (menu instanceof HTMLElement) {
|
||||
setDropdownMenuState(menu, isOpen)
|
||||
setDropdownMenuState(menu, isOpen);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleWatchlistDropdown = (): void => {
|
||||
const dropdown = document.getElementById('watchlist-dropdown')
|
||||
const dropdown = document.getElementById('watchlist-dropdown');
|
||||
if (!dropdown) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
setWatchlistDropdownState(!dropdown.classList.contains('open'))
|
||||
}
|
||||
setWatchlistDropdownState(!dropdown.classList.contains('open'));
|
||||
};
|
||||
|
||||
const closeDropdownOnOutsideClick = (event: MouseEvent): void => {
|
||||
const dropdown = document.getElementById('watchlist-dropdown')
|
||||
const dropdown = document.getElementById('watchlist-dropdown');
|
||||
if (!dropdown) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dropdown.contains(target)) {
|
||||
setWatchlistDropdownState(false)
|
||||
setWatchlistDropdownState(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initWatchlistDropdown = (): void => {
|
||||
;(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleWatchlistDropdown
|
||||
document.addEventListener('click', closeDropdownOnOutsideClick)
|
||||
}
|
||||
(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleWatchlistDropdown;
|
||||
document.addEventListener('click', closeDropdownOnOutsideClick);
|
||||
};
|
||||
|
||||
initWatchlistDropdown()
|
||||
initWatchlistDropdown();
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
const dedupe = (): void => {
|
||||
const seen = new Set<string>()
|
||||
const elements = document.querySelectorAll('[data-id]')
|
||||
const seen = new Set<string>();
|
||||
const elements = document.querySelectorAll('[data-id]');
|
||||
|
||||
elements.forEach((item) => {
|
||||
const id = item.getAttribute('data-id')
|
||||
elements.forEach(item => {
|
||||
const id = item.getAttribute('data-id');
|
||||
if (!id) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (seen.has(id)) {
|
||||
item.remove()
|
||||
item.remove();
|
||||
} else {
|
||||
seen.add(id)
|
||||
seen.add(id);
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', dedupe)
|
||||
} else {
|
||||
dedupe()
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', dedupe);
|
||||
} else {
|
||||
dedupe();
|
||||
}
|
||||
|
||||
window.addEventListener('load', dedupe)
|
||||
window.addEventListener('load', dedupe)
|
||||
window.addEventListener('load', dedupe);
|
||||
window.addEventListener('load', dedupe);
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { parseClassList } from './utils'
|
||||
import { parseClassList } from './utils';
|
||||
|
||||
const setActiveDiscoverTab = (clickedTab: Element): void => {
|
||||
const group = clickedTab.closest('[data-tab-group="discover"]')
|
||||
const group = clickedTab.closest('[data-tab-group="discover"]');
|
||||
if (!group) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const triggers = group.querySelectorAll('[data-tab-trigger]')
|
||||
triggers.forEach((tab) => {
|
||||
const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'))
|
||||
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'))
|
||||
tab.classList.remove(...activeClasses)
|
||||
tab.classList.add(...inactiveClasses)
|
||||
})
|
||||
const triggers = group.querySelectorAll('[data-tab-trigger]');
|
||||
triggers.forEach(tab => {
|
||||
const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'));
|
||||
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'));
|
||||
tab.classList.remove(...activeClasses);
|
||||
tab.classList.add(...inactiveClasses);
|
||||
});
|
||||
|
||||
const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes'))
|
||||
const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes'))
|
||||
clickedTab.classList.remove(...inactiveClasses)
|
||||
clickedTab.classList.add(...activeClasses)
|
||||
}
|
||||
const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes'));
|
||||
const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes'));
|
||||
clickedTab.classList.remove(...inactiveClasses);
|
||||
clickedTab.classList.add(...activeClasses);
|
||||
};
|
||||
|
||||
const onDiscoverTabClick = (event: MouseEvent): void => {
|
||||
const target = event.target
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = target.closest('[data-tab-trigger]')
|
||||
const trigger = target.closest('[data-tab-trigger]');
|
||||
if (!trigger) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveDiscoverTab(trigger)
|
||||
}
|
||||
setActiveDiscoverTab(trigger);
|
||||
};
|
||||
|
||||
const initDiscoverTabs = (): void => {
|
||||
document.addEventListener('click', onDiscoverTabClick)
|
||||
}
|
||||
document.addEventListener('click', onDiscoverTabClick);
|
||||
};
|
||||
|
||||
initDiscoverTabs()
|
||||
initDiscoverTabs();
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
class UIDropdown extends HTMLElement {
|
||||
isOpen: boolean = false
|
||||
contentEl: HTMLElement | null = null
|
||||
isClosing: boolean = false
|
||||
isOpen: boolean = false;
|
||||
contentEl: HTMLElement | null = null;
|
||||
isClosing: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.toggle = this.toggle.bind(this)
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this)
|
||||
super();
|
||||
this.toggle = this.toggle.bind(this);
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
const trigger = this.querySelector('[data-trigger]')
|
||||
this.contentEl = this.querySelector('[data-content]')
|
||||
const trigger = this.querySelector('[data-trigger]');
|
||||
this.contentEl = this.querySelector('[data-content]');
|
||||
|
||||
if (trigger) {
|
||||
trigger.addEventListener('click', this.toggle)
|
||||
trigger.addEventListener('click', this.toggle);
|
||||
}
|
||||
|
||||
document.addEventListener('click', this.handleClickOutside)
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
const trigger = this.querySelector('[data-trigger]')
|
||||
const trigger = this.querySelector('[data-trigger]');
|
||||
if (trigger) {
|
||||
trigger.removeEventListener('click', this.toggle)
|
||||
trigger.removeEventListener('click', this.toggle);
|
||||
}
|
||||
document.removeEventListener('click', this.handleClickOutside)
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (this.isClosing) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
this.isOpen = !this.isOpen
|
||||
this.isOpen = !this.isOpen;
|
||||
if (this.contentEl) {
|
||||
if (this.isOpen) {
|
||||
this.contentEl.classList.remove('hidden')
|
||||
this.contentEl.classList.remove('hidden');
|
||||
} else {
|
||||
this.contentEl.classList.add('hidden')
|
||||
this.contentEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.isClosing) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
this.isClosing = true
|
||||
this.isOpen = false
|
||||
this.isClosing = true;
|
||||
this.isOpen = false;
|
||||
if (this.contentEl) {
|
||||
this.contentEl.classList.add('hidden')
|
||||
this.contentEl.classList.add('hidden');
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.isClosing = false
|
||||
}, 100)
|
||||
this.isClosing = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
handleClickOutside(event: MouseEvent): void {
|
||||
if (!this.contains(event.target as Node)) {
|
||||
this.close()
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ui-dropdown', UIDropdown)
|
||||
customElements.define('ui-dropdown', UIDropdown);
|
||||
|
||||
@@ -1,95 +1,98 @@
|
||||
import { state } from './state'
|
||||
import { state } from './state';
|
||||
|
||||
export const formatTime = (seconds: number): string => {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const showControls = (): void => {
|
||||
state.container.classList.add('show-controls')
|
||||
window.clearTimeout(state.playerControlsTimeout)
|
||||
state.container.classList.add('show-controls');
|
||||
window.clearTimeout(state.playerControlsTimeout);
|
||||
state.playerControlsTimeout = window.setTimeout(() => {
|
||||
if (!state.isScrubbing && !state.video.paused) {
|
||||
state.container.classList.remove('show-controls')
|
||||
state.container.classList.remove('show-controls');
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
export const seekBy = (delta: number): void => {
|
||||
if (state.video.duration <= 0) return
|
||||
state.video.currentTime = Math.max(0, Math.min(state.video.duration, state.video.currentTime + delta))
|
||||
showControls()
|
||||
}
|
||||
if (state.video.duration <= 0) return;
|
||||
state.video.currentTime = Math.max(
|
||||
0,
|
||||
Math.min(state.video.duration, state.video.currentTime + delta)
|
||||
);
|
||||
showControls();
|
||||
};
|
||||
|
||||
export const togglePlayPause = (): void => {
|
||||
if (state.video.paused) {
|
||||
state.video.play()
|
||||
state.video.play();
|
||||
} else {
|
||||
state.video.pause()
|
||||
state.video.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleMute = (): void => {
|
||||
if (state.video.muted || state.video.volume === 0) {
|
||||
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1
|
||||
state.video.muted = false
|
||||
state.video.volume = restored
|
||||
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1;
|
||||
state.video.muted = false;
|
||||
state.video.volume = restored;
|
||||
} else {
|
||||
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume
|
||||
state.video.muted = true
|
||||
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume;
|
||||
state.video.muted = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const setVolume = (value: number): void => {
|
||||
state.video.volume = Math.max(0, Math.min(1, value))
|
||||
state.video.muted = value === 0
|
||||
if (value > 0) state.lastKnownVolume = value
|
||||
}
|
||||
state.video.volume = Math.max(0, Math.min(1, value));
|
||||
state.video.muted = value === 0;
|
||||
if (value > 0) state.lastKnownVolume = value;
|
||||
};
|
||||
|
||||
export const toggleFullscreen = (): void => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
return
|
||||
document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
state.container.requestFullscreen?.()
|
||||
}
|
||||
state.container.requestFullscreen?.();
|
||||
};
|
||||
|
||||
export const syncVolumeUI = (): void => {
|
||||
const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls()
|
||||
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100)
|
||||
const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls();
|
||||
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100);
|
||||
if (volumeRange) {
|
||||
volumeRange.value = String(value)
|
||||
volumeRange.style.setProperty('--volume-percent', `${value}%`)
|
||||
volumeRange.value = String(value);
|
||||
volumeRange.style.setProperty('--volume-percent', `${value}%`);
|
||||
}
|
||||
if (volumeUnderline) volumeUnderline.style.height = `${value}%`
|
||||
updateMuteIcons(state.video.muted || state.video.volume === 0)
|
||||
}
|
||||
if (volumeUnderline) volumeUnderline.style.height = `${value}%`;
|
||||
updateMuteIcons(state.video.muted || state.video.volume === 0);
|
||||
};
|
||||
|
||||
interface Controls {
|
||||
playPause: HTMLButtonElement | null
|
||||
muteBtn: HTMLButtonElement | null
|
||||
volumePanel: HTMLElement | null
|
||||
volumeRange: HTMLInputElement | null
|
||||
volumeUnderline: HTMLElement | null
|
||||
backwardBtn: HTMLButtonElement | null
|
||||
forwardBtn: HTMLButtonElement | null
|
||||
fullscreenBtn: HTMLButtonElement | null
|
||||
iconPlay: SVGElement | null
|
||||
iconPause: SVGElement | null
|
||||
iconVolume: SVGElement | null
|
||||
iconMuted: SVGElement | null
|
||||
skipSegmentBtn: HTMLButtonElement | null
|
||||
subtitleText: HTMLElement | null
|
||||
autoplayBtn: HTMLInputElement | null
|
||||
playPause: HTMLButtonElement | null;
|
||||
muteBtn: HTMLButtonElement | null;
|
||||
volumePanel: HTMLElement | null;
|
||||
volumeRange: HTMLInputElement | null;
|
||||
volumeUnderline: HTMLElement | null;
|
||||
backwardBtn: HTMLButtonElement | null;
|
||||
forwardBtn: HTMLButtonElement | null;
|
||||
fullscreenBtn: HTMLButtonElement | null;
|
||||
iconPlay: SVGElement | null;
|
||||
iconPause: SVGElement | null;
|
||||
iconVolume: SVGElement | null;
|
||||
iconMuted: SVGElement | null;
|
||||
skipSegmentBtn: HTMLButtonElement | null;
|
||||
subtitleText: HTMLElement | null;
|
||||
autoplayBtn: HTMLInputElement | null;
|
||||
}
|
||||
|
||||
let controlsCache: Controls | null = null
|
||||
let controlsCache: Controls | null = null;
|
||||
|
||||
const getControls = (): Controls => {
|
||||
if (controlsCache) return controlsCache
|
||||
const c = state.container
|
||||
if (controlsCache) return controlsCache;
|
||||
const c = state.container;
|
||||
controlsCache = {
|
||||
playPause: c.querySelector('[data-play-pause]'),
|
||||
muteBtn: c.querySelector('[data-mute]'),
|
||||
@@ -106,64 +109,88 @@ const getControls = (): Controls => {
|
||||
skipSegmentBtn: c.querySelector('[data-skip]'),
|
||||
subtitleText: c.querySelector('[data-subtitle-text]'),
|
||||
autoplayBtn: document.querySelector('[data-autoplay]'),
|
||||
}
|
||||
return controlsCache
|
||||
}
|
||||
};
|
||||
return controlsCache;
|
||||
};
|
||||
|
||||
const updatePlayPauseIcons = (isPlaying: boolean): void => {
|
||||
const { iconPlay, iconPause } = getControls()
|
||||
iconPlay?.classList.toggle('hidden', isPlaying)
|
||||
iconPause?.classList.toggle('hidden', !isPlaying)
|
||||
}
|
||||
const { iconPlay, iconPause } = getControls();
|
||||
iconPlay?.classList.toggle('hidden', isPlaying);
|
||||
iconPause?.classList.toggle('hidden', !isPlaying);
|
||||
};
|
||||
|
||||
const updateMuteIcons = (isMuted: boolean): void => {
|
||||
const { iconVolume, iconMuted } = getControls()
|
||||
iconVolume?.classList.toggle('hidden', isMuted)
|
||||
iconMuted?.classList.toggle('hidden', !isMuted)
|
||||
}
|
||||
const { iconVolume, iconMuted } = getControls();
|
||||
iconVolume?.classList.toggle('hidden', isMuted);
|
||||
iconMuted?.classList.toggle('hidden', !isMuted);
|
||||
};
|
||||
|
||||
export const setupControls = (): void => {
|
||||
const {
|
||||
playPause, muteBtn, volumePanel, volumeRange,
|
||||
backwardBtn, forwardBtn, fullscreenBtn, skipSegmentBtn,
|
||||
} = getControls()
|
||||
playPause,
|
||||
muteBtn,
|
||||
volumePanel,
|
||||
volumeRange,
|
||||
backwardBtn,
|
||||
forwardBtn,
|
||||
fullscreenBtn,
|
||||
skipSegmentBtn,
|
||||
} = getControls();
|
||||
|
||||
playPause?.addEventListener('click', () => { togglePlayPause(); showControls() })
|
||||
state.video.addEventListener('click', () => { togglePlayPause(); showControls() })
|
||||
playPause?.addEventListener('click', () => {
|
||||
togglePlayPause();
|
||||
showControls();
|
||||
});
|
||||
state.video.addEventListener('click', () => {
|
||||
togglePlayPause();
|
||||
showControls();
|
||||
});
|
||||
|
||||
muteBtn?.addEventListener('click', () => { toggleMute(); showControls() })
|
||||
muteBtn?.addEventListener('click', () => {
|
||||
toggleMute();
|
||||
showControls();
|
||||
});
|
||||
|
||||
volumeRange?.addEventListener('input', () => {
|
||||
const value = Number(volumeRange.value) / 100
|
||||
setVolume(value)
|
||||
showControls()
|
||||
})
|
||||
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'))
|
||||
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'))
|
||||
const value = Number(volumeRange.value) / 100;
|
||||
setVolume(value);
|
||||
showControls();
|
||||
});
|
||||
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'));
|
||||
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'));
|
||||
|
||||
backwardBtn?.addEventListener('click', () => seekBy(-10))
|
||||
forwardBtn?.addEventListener('click', () => seekBy(10))
|
||||
backwardBtn?.addEventListener('click', () => seekBy(-10));
|
||||
forwardBtn?.addEventListener('click', () => seekBy(10));
|
||||
|
||||
fullscreenBtn?.addEventListener('click', () => { toggleFullscreen(); showControls() })
|
||||
fullscreenBtn?.addEventListener('click', () => {
|
||||
toggleFullscreen();
|
||||
showControls();
|
||||
});
|
||||
|
||||
skipSegmentBtn?.addEventListener('click', () => {
|
||||
if (!state.activeSkipSegment) return
|
||||
state.video.currentTime = state.activeSkipSegment.end + 0.01
|
||||
showControls()
|
||||
})
|
||||
if (!state.activeSkipSegment) return;
|
||||
state.video.currentTime = state.activeSkipSegment.end + 0.01;
|
||||
showControls();
|
||||
});
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
state.isFullscreen = !!document.fullscreenElement
|
||||
state.container.classList.toggle('fullscreen', state.isFullscreen)
|
||||
if (state.isFullscreen) showControls()
|
||||
})
|
||||
state.isFullscreen = !!document.fullscreenElement;
|
||||
state.container.classList.toggle('fullscreen', state.isFullscreen);
|
||||
if (state.isFullscreen) showControls();
|
||||
});
|
||||
|
||||
state.video.addEventListener('play', () => { updatePlayPauseIcons(true); showControls() })
|
||||
state.video.addEventListener('pause', () => { updatePlayPauseIcons(false); showControls() })
|
||||
state.video.addEventListener('volumechange', syncVolumeUI)
|
||||
state.video.addEventListener('play', () => {
|
||||
updatePlayPauseIcons(true);
|
||||
showControls();
|
||||
});
|
||||
state.video.addEventListener('pause', () => {
|
||||
updatePlayPauseIcons(false);
|
||||
showControls();
|
||||
});
|
||||
state.video.addEventListener('volumechange', syncVolumeUI);
|
||||
|
||||
state.container.addEventListener('mousemove', showControls)
|
||||
state.container.addEventListener('mousemove', showControls);
|
||||
|
||||
updatePlayPauseIcons(false)
|
||||
syncVolumeUI()
|
||||
}
|
||||
updatePlayPauseIcons(false);
|
||||
syncVolumeUI();
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { state } from '../state'
|
||||
import DOMPurify from 'dompurify';
|
||||
import { state } from '../state';
|
||||
|
||||
export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||
if (state.completionSent || !state.malID || !episodeNumber) return
|
||||
state.completionSent = true
|
||||
if (state.completionSent || !state.malID || !episodeNumber) return;
|
||||
state.completionSent = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/watch-complete', {
|
||||
@@ -11,27 +11,27 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
keepalive: true,
|
||||
body: JSON.stringify({ mal_id: state.malID, episode: episodeNumber }),
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
state.completionSent = false
|
||||
state.completionSent = false;
|
||||
if (state.completionAttempts < 2) {
|
||||
state.completionAttempts++
|
||||
setTimeout(() => completeAnime(episodeNumber), 1000)
|
||||
state.completionAttempts++;
|
||||
setTimeout(() => completeAnime(episodeNumber), 1000);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null
|
||||
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null;
|
||||
if (trigger) {
|
||||
trigger.textContent = 'Completed '
|
||||
const caret = document.createElement('span')
|
||||
caret.className = 'text-xs'
|
||||
caret.textContent = '▾'
|
||||
trigger.appendChild(caret)
|
||||
trigger.textContent = 'Completed ';
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'text-xs';
|
||||
caret.textContent = '▾';
|
||||
trigger.appendChild(caret);
|
||||
}
|
||||
|
||||
const dropdown = document.getElementById('watch-status-dropdown')
|
||||
const dropdown = document.getElementById('watch-status-dropdown');
|
||||
if (dropdown) {
|
||||
const payload = {
|
||||
anime_id: String(state.malID),
|
||||
@@ -41,27 +41,29 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||
anime_image: state.container.dataset.animeImage ?? '',
|
||||
status: 'completed',
|
||||
airing: state.container.dataset.animeAiring === 'true',
|
||||
}
|
||||
};
|
||||
|
||||
fetch('/api/watchlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'HX-Request': 'true' },
|
||||
body: `anime_id=${encodeURIComponent(payload.anime_id)}&anime_title=${encodeURIComponent(payload.anime_title)}&anime_title_english=${encodeURIComponent(payload.anime_title_english)}&anime_title_japanese=${encodeURIComponent(payload.anime_title_japanese)}&anime_image=${encodeURIComponent(payload.anime_image)}&status=${encodeURIComponent(payload.status)}&airing=${encodeURIComponent(String(payload.airing))}`,
|
||||
credentials: 'same-origin',
|
||||
}).then(async res => {
|
||||
if (!res.ok) return
|
||||
const html = await res.text()
|
||||
const wrapper = document.createElement('span')
|
||||
wrapper.id = 'watch-status-dropdown'
|
||||
wrapper.innerHTML = DOMPurify.sanitize(html)
|
||||
dropdown.replaceWith(wrapper)
|
||||
}).catch(() => {})
|
||||
})
|
||||
.then(async res => {
|
||||
if (!res.ok) return;
|
||||
const html = await res.text();
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.id = 'watch-status-dropdown';
|
||||
wrapper.innerHTML = DOMPurify.sanitize(html);
|
||||
dropdown.replaceWith(wrapper);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
state.completionSent = false
|
||||
state.completionSent = false;
|
||||
if (state.completionAttempts < 2) {
|
||||
state.completionAttempts++
|
||||
setTimeout(() => completeAnime(episodeNumber), 1000)
|
||||
state.completionAttempts++;
|
||||
setTimeout(() => completeAnime(episodeNumber), 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,95 +1,97 @@
|
||||
import { state } from '../state'
|
||||
import { SkipSegment } from '../types'
|
||||
import { displayTimeFromAbsolute } from '../timeline'
|
||||
import { resolveActiveSegments, renderSegments } from '../skip/segments'
|
||||
import { updateSubtitleOptions } from '../subtitles'
|
||||
import { updateQualityOptions } from '../quality'
|
||||
import { updateModeButtons } from '../mode'
|
||||
import { updateOverlay, isAutoplayEnabled, updateEpisodeHighlight, switchEpisodeRange } from './ui'
|
||||
import { markEpisodeTransition } from '../progress'
|
||||
import { state } from '../state';
|
||||
import { SkipSegment } from '../types';
|
||||
import { displayTimeFromAbsolute } from '../timeline';
|
||||
import { resolveActiveSegments, renderSegments } from '../skip/segments';
|
||||
import { updateSubtitleOptions } from '../subtitles';
|
||||
import { updateQualityOptions } from '../quality';
|
||||
import { updateModeButtons } from '../mode';
|
||||
import { updateOverlay, isAutoplayEnabled, updateEpisodeHighlight, switchEpisodeRange } from './ui';
|
||||
import { markEpisodeTransition } from '../progress';
|
||||
|
||||
export const goToNextEpisode = async (): Promise<void> => {
|
||||
const currentEp = Number.parseInt(state.currentEpisode, 10)
|
||||
if (!currentEp) return
|
||||
const currentEp = Number.parseInt(state.currentEpisode, 10);
|
||||
if (!currentEp) return;
|
||||
|
||||
if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) {
|
||||
import('./complete').then(m => m.completeAnime(currentEp))
|
||||
return
|
||||
import('./complete').then(m => m.completeAnime(currentEp));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAutoplayEnabled()) return
|
||||
if (!isAutoplayEnabled()) return;
|
||||
|
||||
const nextEp = currentEp + 1
|
||||
markEpisodeTransition(nextEp)
|
||||
const nextEp = currentEp + 1;
|
||||
markEpisodeTransition(nextEp);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/watch/episode/${state.malID}/${nextEp}`)
|
||||
const res = await fetch(`/api/watch/episode/${state.malID}/${nextEp}`);
|
||||
if (!res.ok) {
|
||||
sessionStorage.setItem('mal:autoplay-next', 'true')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('ep', String(nextEp))
|
||||
window.location.href = url.toString()
|
||||
return
|
||||
sessionStorage.setItem('mal:autoplay-next', 'true');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('ep', String(nextEp));
|
||||
window.location.href = url.toString();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const data = await res.json();
|
||||
|
||||
state.modeSources = data.mode_sources ?? {}
|
||||
state.availableModes = data.available_modes ?? []
|
||||
state.modeSources = data.mode_sources ?? {};
|
||||
state.availableModes = data.available_modes ?? [];
|
||||
|
||||
const fallback = state.availableModes.find(m => state.modeSources[m]?.token)
|
||||
const fallback = state.availableModes.find(m => state.modeSources[m]?.token);
|
||||
if (!fallback) {
|
||||
sessionStorage.setItem('mal:autoplay-next', 'true')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('ep', String(nextEp))
|
||||
window.location.href = url.toString()
|
||||
return
|
||||
sessionStorage.setItem('mal:autoplay-next', 'true');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('ep', String(nextEp));
|
||||
window.location.href = url.toString();
|
||||
return;
|
||||
}
|
||||
|
||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}`
|
||||
state.video.load()
|
||||
if (!state.video.paused) state.video.play().catch(() => {})
|
||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}`;
|
||||
state.video.load();
|
||||
if (!state.video.paused) state.video.play().catch(() => {});
|
||||
|
||||
state.currentEpisode = String(nextEp)
|
||||
state.pendingSeekTime = null
|
||||
state.completionSent = false
|
||||
state.completionAttempts = 0
|
||||
state.activeSubtitles = []
|
||||
state.currentEpisode = String(nextEp);
|
||||
state.pendingSeekTime = null;
|
||||
state.completionSent = false;
|
||||
state.completionAttempts = 0;
|
||||
state.activeSubtitles = [];
|
||||
|
||||
updateSubtitleOptions()
|
||||
updateQualityOptions()
|
||||
updateModeButtons()
|
||||
updateOverlay(state.currentEpisode, data.episode_title ?? '')
|
||||
updateSubtitleOptions();
|
||||
updateQualityOptions();
|
||||
updateModeButtons();
|
||||
updateOverlay(state.currentEpisode, data.episode_title ?? '');
|
||||
|
||||
if (data.segments?.length) {
|
||||
state.parsedSegments = data.segments
|
||||
.map((s: SkipSegment) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
|
||||
.filter((s: SkipSegment) => s.end > s.start)
|
||||
resolveActiveSegments()
|
||||
renderSegments()
|
||||
.filter((s: SkipSegment) => s.end > s.start);
|
||||
resolveActiveSegments();
|
||||
renderSegments();
|
||||
}
|
||||
|
||||
state.episodeList?.querySelectorAll('[data-episode-id]').forEach(el => el.classList.remove('bg-accent/20'))
|
||||
const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`)
|
||||
newListEl?.classList.add('bg-accent/20')
|
||||
state.episodeList
|
||||
?.querySelectorAll('[data-episode-id]')
|
||||
.forEach(el => el.classList.remove('bg-accent/20'));
|
||||
const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`);
|
||||
newListEl?.classList.add('bg-accent/20');
|
||||
|
||||
if (state.episodeGrid) {
|
||||
state.episodeGrid.querySelectorAll('[data-episode-id]').forEach(el => {
|
||||
el.classList.remove('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent')
|
||||
})
|
||||
switchEpisodeRange(Math.floor((nextEp - 1) / 100))
|
||||
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`)
|
||||
newGridEl?.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent')
|
||||
el.classList.remove('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent');
|
||||
});
|
||||
switchEpisodeRange(Math.floor((nextEp - 1) / 100));
|
||||
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`);
|
||||
newGridEl?.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent');
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('ep', String(nextEp))
|
||||
history.pushState(null, '', url.toString())
|
||||
state.transitionEpisode = null
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('ep', String(nextEp));
|
||||
history.pushState(null, '', url.toString());
|
||||
state.transitionEpisode = null;
|
||||
} catch {
|
||||
sessionStorage.setItem('mal:autoplay-next', 'true')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('ep', String(nextEp))
|
||||
window.location.href = url.toString()
|
||||
sessionStorage.setItem('mal:autoplay-next', 'true');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('ep', String(nextEp));
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
import { state } from '../state'
|
||||
import { state } from '../state';
|
||||
|
||||
export const setupThumbnails = (): void => {
|
||||
fetch(`/api/watch/thumbnails/${state.malID}`)
|
||||
.then(res => res.json())
|
||||
.then((data: Array<{ mal_id: number; url: string; title?: string }>) => {
|
||||
if (!state.episodeList) return
|
||||
if (!state.episodeList) return;
|
||||
data.forEach(item => {
|
||||
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`)
|
||||
if (!card) return
|
||||
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
|
||||
if (!card) return;
|
||||
|
||||
if (item.url) {
|
||||
const imgContainer = card.querySelector('.relative.aspect-video')
|
||||
const imgContainer = card.querySelector('.relative.aspect-video');
|
||||
if (imgContainer) {
|
||||
let img = imgContainer.querySelector('img')
|
||||
let img = imgContainer.querySelector('img');
|
||||
if (!img) {
|
||||
img = document.createElement('img')
|
||||
img.className = 'h-full w-full object-cover transition-transform group-hover:scale-105'
|
||||
img.loading = 'lazy'
|
||||
imgContainer.querySelector('.flex.h-full.w-full.items-center.justify-center')?.remove()
|
||||
imgContainer.prepend(img)
|
||||
img = document.createElement('img');
|
||||
img.className =
|
||||
'h-full w-full object-cover transition-transform group-hover:scale-105';
|
||||
img.loading = 'lazy';
|
||||
imgContainer
|
||||
.querySelector('.flex.h-full.w-full.items-center.justify-center')
|
||||
?.remove();
|
||||
imgContainer.prepend(img);
|
||||
}
|
||||
img.src = item.url
|
||||
img.alt = item.title ?? `Episode ${item.mal_id}`
|
||||
img.src = item.url;
|
||||
img.alt = item.title ?? `Episode ${item.mal_id}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.title) {
|
||||
const titleEl = card.querySelector('[data-episode-title]')
|
||||
if (titleEl) titleEl.textContent = item.title
|
||||
const titleEl = card.querySelector('[data-episode-title]');
|
||||
if (titleEl) titleEl.textContent = item.title;
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(err => console.error('Failed to fetch thumbnails:', err))
|
||||
}
|
||||
.catch(err => console.error('Failed to fetch thumbnails:', err));
|
||||
};
|
||||
|
||||
@@ -1,57 +1,61 @@
|
||||
import { state } from '../state'
|
||||
import { updateSubtitleOptions } from '../subtitles'
|
||||
import { updateQualityOptions } from '../quality'
|
||||
import { updateModeButtons } from '../mode'
|
||||
import { state } from '../state';
|
||||
import { updateSubtitleOptions } from '../subtitles';
|
||||
import { updateQualityOptions } from '../quality';
|
||||
import { updateModeButtons } from '../mode';
|
||||
|
||||
export const setupAutoplayButton = (): void => {
|
||||
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null
|
||||
if (!btn) return
|
||||
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false'
|
||||
}
|
||||
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
|
||||
if (!btn) return;
|
||||
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false';
|
||||
};
|
||||
|
||||
export const isAutoplayEnabled = (): boolean => localStorage.getItem('mal:autoplay-enabled') !== 'false'
|
||||
export const isAutoplayEnabled = (): boolean =>
|
||||
localStorage.getItem('mal:autoplay-enabled') !== 'false';
|
||||
|
||||
export const updateOverlay = (episode: string, title: string): void => {
|
||||
if (!state.videoOverlay) return
|
||||
const p = state.videoOverlay.querySelector('p')
|
||||
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`)
|
||||
}
|
||||
if (!state.videoOverlay) return;
|
||||
const p = state.videoOverlay.querySelector('p');
|
||||
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`);
|
||||
};
|
||||
|
||||
const getEpisodeEls = () => {
|
||||
const grid = state.episodeGrid
|
||||
const list = state.episodeList
|
||||
const grid = state.episodeGrid;
|
||||
const list = state.episodeList;
|
||||
return {
|
||||
gridEls: grid ? Array.from(grid.querySelectorAll('[data-episode-id]')) : [],
|
||||
listEls: list ? Array.from(list.querySelectorAll('[data-episode-id]')) : [],
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const updateEpisodeHighlight = (num: number): void => {
|
||||
const { gridEls, listEls } = getEpisodeEls()
|
||||
;[...gridEls, ...listEls].forEach(el => el.classList.remove('ring-2', 'ring-accent', 'bg-accent/20', 'text-accent'))
|
||||
const { gridEls, listEls } = getEpisodeEls();
|
||||
[...gridEls, ...listEls].forEach(el =>
|
||||
el.classList.remove('ring-2', 'ring-accent', 'bg-accent/20', 'text-accent')
|
||||
);
|
||||
|
||||
const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`)
|
||||
const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`)
|
||||
gridEl?.classList.add('ring-2', 'ring-accent')
|
||||
listEl?.classList.add('ring-2', 'ring-accent')
|
||||
;(gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`);
|
||||
const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`);
|
||||
gridEl?.classList.add('ring-2', 'ring-accent');
|
||||
listEl?.classList.add('ring-2', 'ring-accent');
|
||||
(gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
|
||||
export const switchEpisodeRange = (idx: number): void => {
|
||||
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null
|
||||
if (!dropdown) return
|
||||
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[]
|
||||
const target = btns[idx]
|
||||
if (!target) return
|
||||
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
|
||||
if (!dropdown) return;
|
||||
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[];
|
||||
const target = btns[idx];
|
||||
if (!target) return;
|
||||
|
||||
const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10)
|
||||
const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10)
|
||||
const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10);
|
||||
const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10);
|
||||
|
||||
const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null
|
||||
if (label) label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`
|
||||
const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null;
|
||||
if (label)
|
||||
label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`;
|
||||
|
||||
state.episodeGrid?.querySelectorAll('[data-episode-id]').forEach(el => {
|
||||
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10)
|
||||
el.classList.toggle('hidden', n < start || n > end)
|
||||
})
|
||||
}
|
||||
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10);
|
||||
el.classList.toggle('hidden', n < start || n > end);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,58 +1,72 @@
|
||||
import { state } from './state'
|
||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay, absoluteTimeFromRatio, getBounds } from './timeline'
|
||||
import { showControls, toggleMute, togglePlayPause, toggleFullscreen, seekBy, setVolume, formatTime } from './controls'
|
||||
import { state } from './state';
|
||||
import {
|
||||
displayTimeFromAbsolute,
|
||||
absoluteTimeFromDisplay,
|
||||
absoluteTimeFromRatio,
|
||||
getBounds,
|
||||
} from './timeline';
|
||||
import {
|
||||
showControls,
|
||||
toggleMute,
|
||||
togglePlayPause,
|
||||
toggleFullscreen,
|
||||
seekBy,
|
||||
setVolume,
|
||||
formatTime,
|
||||
} from './controls';
|
||||
|
||||
export const setupKeyboard = (): void => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
||||
document.addEventListener('keydown', e => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
return;
|
||||
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
case 'KeyK':
|
||||
e.preventDefault()
|
||||
togglePlayPause()
|
||||
showControls()
|
||||
break
|
||||
e.preventDefault();
|
||||
togglePlayPause();
|
||||
showControls();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'KeyJ':
|
||||
e.preventDefault()
|
||||
seekBy(-10)
|
||||
break
|
||||
e.preventDefault();
|
||||
seekBy(-10);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'KeyL':
|
||||
e.preventDefault()
|
||||
seekBy(10)
|
||||
break
|
||||
e.preventDefault();
|
||||
seekBy(10);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setVolume(state.video.volume + 0.05)
|
||||
showControls()
|
||||
break
|
||||
e.preventDefault();
|
||||
setVolume(state.video.volume + 0.05);
|
||||
showControls();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setVolume(state.video.volume - 0.05)
|
||||
showControls()
|
||||
break
|
||||
e.preventDefault();
|
||||
setVolume(state.video.volume - 0.05);
|
||||
showControls();
|
||||
break;
|
||||
case 'KeyM':
|
||||
e.preventDefault()
|
||||
toggleMute()
|
||||
showControls()
|
||||
break
|
||||
e.preventDefault();
|
||||
toggleMute();
|
||||
showControls();
|
||||
break;
|
||||
case 'KeyF':
|
||||
e.preventDefault()
|
||||
toggleFullscreen()
|
||||
showControls()
|
||||
break
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
showControls();
|
||||
break;
|
||||
default:
|
||||
if (/^\d$/.test(e.key)) {
|
||||
const b = getBounds()
|
||||
const b = getBounds();
|
||||
if (b.duration > 0) {
|
||||
e.preventDefault()
|
||||
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10)
|
||||
showControls()
|
||||
e.preventDefault();
|
||||
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10);
|
||||
showControls();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,193 +1,214 @@
|
||||
import { state, initState } from './state'
|
||||
import { invalidateBounds, updateTimeline } from './timeline'
|
||||
import { setupControls, showControls } from './controls'
|
||||
import { setupKeyboard } from './keyboard'
|
||||
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles'
|
||||
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip'
|
||||
import { setupQuality, updateQualityOptions } from './quality'
|
||||
import { setupMode, updateModeButtons } from './mode'
|
||||
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui'
|
||||
import { goToNextEpisode } from './episodes/nav'
|
||||
import { resolveActiveSegments, renderSegments } from './skip/segments'
|
||||
import { setupThumbnails } from './episodes/thumbnails'
|
||||
import { markEpisodeTransition, setupProgress } from './progress'
|
||||
import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline'
|
||||
import { formatTime } from './controls'
|
||||
import { state, initState } from './state';
|
||||
import { invalidateBounds, updateTimeline } from './timeline';
|
||||
import { setupControls, showControls } from './controls';
|
||||
import { setupKeyboard } from './keyboard';
|
||||
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles';
|
||||
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip';
|
||||
import { setupQuality, updateQualityOptions } from './quality';
|
||||
import { setupMode, updateModeButtons } from './mode';
|
||||
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui';
|
||||
import { goToNextEpisode } from './episodes/nav';
|
||||
import { resolveActiveSegments, renderSegments } from './skip/segments';
|
||||
import { setupThumbnails } from './episodes/thumbnails';
|
||||
import { markEpisodeTransition, setupProgress } from './progress';
|
||||
import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline';
|
||||
import { formatTime } from './controls';
|
||||
|
||||
let initialized = false
|
||||
let initialized = false;
|
||||
|
||||
const hidePreviewPopover = (): void => {
|
||||
state.previewPopover?.classList.remove('block')
|
||||
state.previewPopover?.classList.add('hidden')
|
||||
state.previewPopover!.style.left = '0px'
|
||||
}
|
||||
state.previewPopover?.classList.remove('block');
|
||||
state.previewPopover?.classList.add('hidden');
|
||||
state.previewPopover!.style.left = '0px';
|
||||
};
|
||||
|
||||
const showPreviewPopover = (): void => {
|
||||
state.previewPopover?.classList.remove('hidden')
|
||||
state.previewPopover?.classList.add('block')
|
||||
}
|
||||
state.previewPopover?.classList.remove('hidden');
|
||||
state.previewPopover?.classList.add('block');
|
||||
};
|
||||
|
||||
const updatePreviewUI = (ratio: number): void => {
|
||||
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null
|
||||
if (!progressWrap || !state.previewPopover || !state.previewTime) { hidePreviewPopover(); return }
|
||||
const b = getBounds()
|
||||
if (b.duration <= 0) { hidePreviewPopover(); return }
|
||||
|
||||
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)))
|
||||
|
||||
const barWidth = progressWrap.clientWidth
|
||||
if (barWidth <= 0) { hidePreviewPopover(); return }
|
||||
|
||||
showPreviewPopover()
|
||||
const popoverWidth = state.previewPopover.offsetWidth || 72
|
||||
state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`
|
||||
}
|
||||
|
||||
const initPlayer = (): void => {
|
||||
const container = document.querySelector('[data-video-player]') as HTMLElement | null
|
||||
if (!container || initialized) return
|
||||
initialized = true
|
||||
|
||||
initState(container)
|
||||
|
||||
const loading = container.querySelector('[data-loading]') as HTMLElement | null
|
||||
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null
|
||||
|
||||
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'
|
||||
const streamToken = state.modeSources[state.currentMode]?.token
|
||||
if (streamToken) {
|
||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`
|
||||
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
||||
if (!progressWrap || !state.previewPopover || !state.previewTime) {
|
||||
hidePreviewPopover();
|
||||
return;
|
||||
}
|
||||
const b = getBounds();
|
||||
if (b.duration <= 0) {
|
||||
hidePreviewPopover();
|
||||
return;
|
||||
}
|
||||
|
||||
setupProgress()
|
||||
setupControls()
|
||||
setupKeyboard()
|
||||
setupSkip()
|
||||
setupSubtitles()
|
||||
setupQuality()
|
||||
setupMode()
|
||||
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)));
|
||||
|
||||
updateSubtitleOptions()
|
||||
updateQualityOptions()
|
||||
updateModeButtons()
|
||||
setupAutoplayButton()
|
||||
updateAutoSkipButton()
|
||||
showControls()
|
||||
const barWidth = progressWrap.clientWidth;
|
||||
if (barWidth <= 0) {
|
||||
hidePreviewPopover();
|
||||
return;
|
||||
}
|
||||
|
||||
showPreviewPopover();
|
||||
const popoverWidth = state.previewPopover.offsetWidth || 72;
|
||||
state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`;
|
||||
};
|
||||
|
||||
const initPlayer = (): void => {
|
||||
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
|
||||
if (!container || initialized) return;
|
||||
initialized = true;
|
||||
|
||||
initState(container);
|
||||
|
||||
const loading = container.querySelector('[data-loading]') as HTMLElement | null;
|
||||
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
||||
|
||||
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
|
||||
const streamToken = state.modeSources[state.currentMode]?.token;
|
||||
if (streamToken) {
|
||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
|
||||
}
|
||||
|
||||
setupProgress();
|
||||
setupControls();
|
||||
setupKeyboard();
|
||||
setupSkip();
|
||||
setupSubtitles();
|
||||
setupQuality();
|
||||
setupMode();
|
||||
|
||||
updateSubtitleOptions();
|
||||
updateQualityOptions();
|
||||
updateModeButtons();
|
||||
setupAutoplayButton();
|
||||
updateAutoSkipButton();
|
||||
showControls();
|
||||
|
||||
state.video.addEventListener('loadedmetadata', () => {
|
||||
loading && (loading.style.display = 'none')
|
||||
invalidateBounds()
|
||||
loading && (loading.style.display = 'none');
|
||||
invalidateBounds();
|
||||
|
||||
resolveActiveSegments()
|
||||
renderSegments()
|
||||
resolveActiveSegments();
|
||||
renderSegments();
|
||||
|
||||
const startTime = Number(container.dataset.startTimeSeconds ?? '0')
|
||||
const startTime = Number(container.dataset.startTimeSeconds ?? '0');
|
||||
if (startTime > 0 && state.video.currentTime <= 0.5 && state.video.duration > startTime) {
|
||||
state.video.currentTime = startTime
|
||||
state.video.currentTime = startTime;
|
||||
}
|
||||
if (state.pendingSeekTime !== null) {
|
||||
state.video.currentTime = state.pendingSeekTime
|
||||
state.pendingSeekTime = null
|
||||
state.video.currentTime = state.pendingSeekTime;
|
||||
state.pendingSeekTime = null;
|
||||
}
|
||||
if (state.shouldAutoPlay) state.video.play().catch(() => {})
|
||||
if (state.shouldAutoPlay) state.video.play().catch(() => {});
|
||||
|
||||
updateTimeline(state.video.currentTime)
|
||||
updateSkipButton(state.video.currentTime)
|
||||
})
|
||||
updateTimeline(state.video.currentTime);
|
||||
updateSkipButton(state.video.currentTime);
|
||||
});
|
||||
|
||||
state.video.addEventListener('waiting', () => { loading && (loading.style.display = 'flex') })
|
||||
state.video.addEventListener('playing', () => { loading && (loading.style.display = 'none') })
|
||||
state.video.addEventListener('progress', () => { updateTimeline(state.video.currentTime) })
|
||||
state.video.addEventListener('waiting', () => {
|
||||
loading && (loading.style.display = 'flex');
|
||||
});
|
||||
state.video.addEventListener('playing', () => {
|
||||
loading && (loading.style.display = 'none');
|
||||
});
|
||||
state.video.addEventListener('progress', () => {
|
||||
updateTimeline(state.video.currentTime);
|
||||
});
|
||||
|
||||
state.video.addEventListener('timeupdate', () => {
|
||||
updateTimeline(state.video.currentTime)
|
||||
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime))
|
||||
updateSkipButton(state.video.currentTime)
|
||||
})
|
||||
updateTimeline(state.video.currentTime);
|
||||
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
||||
updateSkipButton(state.video.currentTime);
|
||||
});
|
||||
|
||||
state.video.addEventListener('ended', () => { goToNextEpisode() })
|
||||
state.video.addEventListener('ended', () => {
|
||||
goToNextEpisode();
|
||||
});
|
||||
|
||||
progressWrap?.addEventListener('mousedown', (e) => {
|
||||
state.isScrubbing = true
|
||||
const rect = progressWrap.getBoundingClientRect()
|
||||
state.video.currentTime = absoluteTimeFromRatio(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
|
||||
updateTimeline(state.video.currentTime)
|
||||
updateSkipButton(state.video.currentTime)
|
||||
showControls()
|
||||
})
|
||||
progressWrap?.addEventListener('mousedown', e => {
|
||||
state.isScrubbing = true;
|
||||
const rect = progressWrap.getBoundingClientRect();
|
||||
state.video.currentTime = absoluteTimeFromRatio(
|
||||
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
);
|
||||
updateTimeline(state.video.currentTime);
|
||||
updateSkipButton(state.video.currentTime);
|
||||
showControls();
|
||||
});
|
||||
|
||||
progressWrap?.addEventListener('mousemove', (e) => {
|
||||
const rect = progressWrap.getBoundingClientRect()
|
||||
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
|
||||
})
|
||||
progressWrap?.addEventListener('mousemove', e => {
|
||||
const rect = progressWrap.getBoundingClientRect();
|
||||
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
});
|
||||
|
||||
progressWrap?.addEventListener('mouseleave', hidePreviewPopover)
|
||||
progressWrap?.addEventListener('mouseleave', hidePreviewPopover);
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!state.isScrubbing || !progressWrap) return
|
||||
const rect = progressWrap.getBoundingClientRect()
|
||||
state.video.currentTime = absoluteTimeFromRatio(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
|
||||
updateTimeline(state.video.currentTime)
|
||||
updateSkipButton(state.video.currentTime)
|
||||
})
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (!state.isScrubbing || !progressWrap) return;
|
||||
const rect = progressWrap.getBoundingClientRect();
|
||||
state.video.currentTime = absoluteTimeFromRatio(
|
||||
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
);
|
||||
updateTimeline(state.video.currentTime);
|
||||
updateSkipButton(state.video.currentTime);
|
||||
});
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
const anchor = (e.target as Node).parentElement?.closest('a[href]')
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return
|
||||
const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean)
|
||||
container.addEventListener('click', e => {
|
||||
const anchor = (e.target as Node).parentElement?.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return;
|
||||
const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'watch' && Number.parseInt(parts[2], 10) > 0) {
|
||||
markEpisodeTransition(Number.parseInt(parts[2], 10))
|
||||
markEpisodeTransition(Number.parseInt(parts[2], 10));
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
state.video.addEventListener('click', showControls)
|
||||
state.video.addEventListener('click', showControls);
|
||||
|
||||
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null
|
||||
const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null
|
||||
let searchDebounce: number | undefined
|
||||
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null;
|
||||
const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
|
||||
let searchDebounce: number | undefined;
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
clearTimeout(searchDebounce)
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = window.setTimeout(() => {
|
||||
const val = searchInput.value.replace(/\D/g, '')
|
||||
const val = searchInput.value.replace(/\D/g, '');
|
||||
if (!val) {
|
||||
const cur = Number.parseInt(state.currentEpisode, 10)
|
||||
switchEpisodeRange(Math.floor((cur - 1) / 100))
|
||||
updateEpisodeHighlight(cur)
|
||||
return
|
||||
const cur = Number.parseInt(state.currentEpisode, 10);
|
||||
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
||||
updateEpisodeHighlight(cur);
|
||||
return;
|
||||
}
|
||||
const ep = Number.parseInt(val, 10)
|
||||
if (!ep || ep <= 0) return
|
||||
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500
|
||||
const clamped = Math.min(ep, maxEp)
|
||||
searchInput.value = String(clamped)
|
||||
const ep = Number.parseInt(val, 10);
|
||||
if (!ep || ep <= 0) return;
|
||||
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
|
||||
const clamped = Math.min(ep, maxEp);
|
||||
searchInput.value = String(clamped);
|
||||
if (state.episodeGrid) {
|
||||
switchEpisodeRange(Math.floor((clamped - 1) / 100))
|
||||
updateEpisodeHighlight(clamped)
|
||||
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
||||
updateEpisodeHighlight(clamped);
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
if (dropdown) {
|
||||
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10)
|
||||
switchEpisodeRange(idx)
|
||||
})
|
||||
})
|
||||
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
|
||||
switchEpisodeRange(idx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (state.episodeGrid && state.totalEpisodes > 100) {
|
||||
switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100))
|
||||
switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100));
|
||||
}
|
||||
|
||||
setupThumbnails()
|
||||
}
|
||||
setupThumbnails();
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initPlayer)
|
||||
document.addEventListener('DOMContentLoaded', initPlayer);
|
||||
document.body.addEventListener('htmx:afterSwap', (e: Event) => {
|
||||
const target = (e as CustomEvent).detail?.target as HTMLElement | null
|
||||
if (target?.querySelector('[data-video-player]')) initPlayer()
|
||||
})
|
||||
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
||||
if (target?.querySelector('[data-video-player]')) initPlayer();
|
||||
});
|
||||
|
||||
@@ -1,66 +1,79 @@
|
||||
import { state } from './state'
|
||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'
|
||||
import { showControls } from './controls'
|
||||
import { updateSubtitleOptions } from './subtitles'
|
||||
import { updateQualityOptions } from './quality'
|
||||
import { ModeSource } from './types'
|
||||
import { state } from './state';
|
||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline';
|
||||
import { showControls } from './controls';
|
||||
import { updateSubtitleOptions } from './subtitles';
|
||||
import { updateQualityOptions } from './quality';
|
||||
import { ModeSource } from './types';
|
||||
|
||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||
const src = state.modeSources[mode]
|
||||
if (!src?.token) return ''
|
||||
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`
|
||||
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`
|
||||
return url
|
||||
}
|
||||
const src = state.modeSources[mode];
|
||||
if (!src?.token) return '';
|
||||
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`;
|
||||
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`;
|
||||
return url;
|
||||
};
|
||||
|
||||
const loadVideo = (url: string): void => {
|
||||
if (!url) return
|
||||
const wasPlaying = !state.video.paused
|
||||
const prevTime = displayTimeFromAbsolute(state.video.currentTime)
|
||||
state.video.src = url
|
||||
state.video.load()
|
||||
state.pendingSeekTime = prevTime
|
||||
if (wasPlaying) state.video.play().catch(() => {})
|
||||
}
|
||||
if (!url) return;
|
||||
const wasPlaying = !state.video.paused;
|
||||
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||
state.video.src = url;
|
||||
state.video.load();
|
||||
state.pendingSeekTime = prevTime;
|
||||
if (wasPlaying) state.video.play().catch(() => {});
|
||||
};
|
||||
|
||||
export const switchMode = (mode: string): void => {
|
||||
if (!state.availableModes.includes(mode) || mode === state.currentMode) return
|
||||
state.currentMode = mode
|
||||
localStorage.setItem('player-audio-mode', mode)
|
||||
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value))
|
||||
updateSubtitleOptions()
|
||||
updateQualityOptions()
|
||||
updateModeButtons()
|
||||
}
|
||||
if (!state.availableModes.includes(mode) || mode === state.currentMode) return;
|
||||
state.currentMode = mode;
|
||||
localStorage.setItem('player-audio-mode', mode);
|
||||
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value));
|
||||
updateSubtitleOptions();
|
||||
updateQualityOptions();
|
||||
updateModeButtons();
|
||||
};
|
||||
|
||||
export const updateModeButtons = (): void => {
|
||||
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null
|
||||
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null
|
||||
const m = state.currentMode
|
||||
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null;
|
||||
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null;
|
||||
const m = state.currentMode;
|
||||
|
||||
dub?.classList.toggle('text-accent', m === 'dub')
|
||||
dub?.classList.toggle('text-white', m !== 'dub')
|
||||
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'))
|
||||
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'))
|
||||
dub && (dub.disabled = !state.availableModes.includes('dub'))
|
||||
dub?.classList.toggle('text-accent', m === 'dub');
|
||||
dub?.classList.toggle('text-white', m !== 'dub');
|
||||
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'));
|
||||
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'));
|
||||
dub && (dub.disabled = !state.availableModes.includes('dub'));
|
||||
|
||||
sub?.classList.toggle('text-accent', m === 'sub')
|
||||
sub?.classList.toggle('text-white', m !== 'sub')
|
||||
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'))
|
||||
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'))
|
||||
sub && (sub.disabled = !state.availableModes.includes('sub'))
|
||||
}
|
||||
sub?.classList.toggle('text-accent', m === 'sub');
|
||||
sub?.classList.toggle('text-white', m !== 'sub');
|
||||
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'));
|
||||
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'));
|
||||
sub && (sub.disabled = !state.availableModes.includes('sub'));
|
||||
};
|
||||
|
||||
export const setupMode = (): void => {
|
||||
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null
|
||||
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null
|
||||
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null;
|
||||
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null;
|
||||
|
||||
dub?.addEventListener('click', () => { if (state.availableModes.includes('dub')) { switchMode('dub'); showControls() } })
|
||||
sub?.addEventListener('click', () => { if (state.availableModes.includes('sub')) { switchMode('sub'); showControls() } })
|
||||
dub?.addEventListener('click', () => {
|
||||
if (state.availableModes.includes('dub')) {
|
||||
switchMode('dub');
|
||||
showControls();
|
||||
}
|
||||
});
|
||||
sub?.addEventListener('click', () => {
|
||||
if (state.availableModes.includes('sub')) {
|
||||
switchMode('sub');
|
||||
showControls();
|
||||
}
|
||||
});
|
||||
|
||||
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null
|
||||
autoplayBtn?.addEventListener('change', (e) => {
|
||||
localStorage.setItem('mal:autoplay-enabled', (e.target as HTMLInputElement).checked ? 'true' : 'false')
|
||||
showControls()
|
||||
})
|
||||
}
|
||||
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
|
||||
autoplayBtn?.addEventListener('change', e => {
|
||||
localStorage.setItem(
|
||||
'mal:autoplay-enabled',
|
||||
(e.target as HTMLInputElement).checked ? 'true' : 'false'
|
||||
);
|
||||
showControls();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,70 +1,89 @@
|
||||
import { state } from './state'
|
||||
import { displayTimeFromAbsolute } from './timeline'
|
||||
import { state } from './state';
|
||||
import { displayTimeFromAbsolute } from './timeline';
|
||||
|
||||
const buildPayload = (episode: number, seconds: number) => JSON.stringify({
|
||||
mal_id: state.malID,
|
||||
episode,
|
||||
time_seconds: seconds,
|
||||
})
|
||||
const buildPayload = (episode: number, seconds: number) =>
|
||||
JSON.stringify({
|
||||
mal_id: state.malID,
|
||||
episode,
|
||||
time_seconds: seconds,
|
||||
});
|
||||
|
||||
const sendBeacon = (payload: string) => {
|
||||
if (!navigator.sendBeacon) return false
|
||||
navigator.sendBeacon('/api/watch-progress', new Blob([payload], { type: 'application/json' }))
|
||||
return true
|
||||
}
|
||||
if (!navigator.sendBeacon) return false;
|
||||
navigator.sendBeacon('/api/watch-progress', new Blob([payload], { type: 'application/json' }));
|
||||
return true;
|
||||
};
|
||||
|
||||
export const saveProgress = async (): Promise<void> => {
|
||||
if (!state.malID || state.video.currentTime < 1) return
|
||||
const episode = Number.parseInt(state.currentEpisode, 10)
|
||||
if (!episode) return
|
||||
if (!state.malID || state.video.currentTime < 1) return;
|
||||
const episode = Number.parseInt(state.currentEpisode, 10);
|
||||
if (!episode) return;
|
||||
|
||||
const safeTime = displayTimeFromAbsolute(state.video.currentTime)
|
||||
if (state.lastSavedProgress.episode === state.currentEpisode &&
|
||||
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5) return
|
||||
const safeTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||
if (
|
||||
state.lastSavedProgress.episode === state.currentEpisode &&
|
||||
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5
|
||||
)
|
||||
return;
|
||||
|
||||
const payload = buildPayload(episode, safeTime)
|
||||
const payload = buildPayload(episode, safeTime);
|
||||
try {
|
||||
const res = await fetch('/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload })
|
||||
if (!res.ok) return
|
||||
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime }
|
||||
const res = await fetch('/api/watch-progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime };
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleProgressSave = (): void => {
|
||||
if (state.progressSaveTimer !== undefined) return
|
||||
if (state.progressSaveTimer !== undefined) return;
|
||||
state.progressSaveTimer = window.setTimeout(() => {
|
||||
state.progressSaveTimer = undefined
|
||||
saveProgress()
|
||||
}, 30000)
|
||||
}
|
||||
state.progressSaveTimer = undefined;
|
||||
saveProgress();
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
export const markEpisodeTransition = (episodeNumber: number): void => {
|
||||
if (!state.malID || !episodeNumber) return
|
||||
if (state.progressSaveTimer !== undefined) { window.clearTimeout(state.progressSaveTimer); state.progressSaveTimer = undefined }
|
||||
state.transitionEpisode = episodeNumber
|
||||
const payload = buildPayload(episodeNumber, 0)
|
||||
if (!sendBeacon(payload)) {
|
||||
fetch('/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, body: payload }).catch(() => {})
|
||||
if (!state.malID || !episodeNumber) return;
|
||||
if (state.progressSaveTimer !== undefined) {
|
||||
window.clearTimeout(state.progressSaveTimer);
|
||||
state.progressSaveTimer = undefined;
|
||||
}
|
||||
}
|
||||
state.transitionEpisode = episodeNumber;
|
||||
const payload = buildPayload(episodeNumber, 0);
|
||||
if (!sendBeacon(payload)) {
|
||||
fetch('/api/watch-progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
keepalive: true,
|
||||
body: payload,
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
export const setupProgress = (): void => {
|
||||
state.video.addEventListener('timeupdate', () => {
|
||||
scheduleProgressSave()
|
||||
})
|
||||
scheduleProgressSave();
|
||||
});
|
||||
|
||||
state.video.addEventListener('pause', () => {
|
||||
window.clearTimeout(state.progressSaveTimer)
|
||||
state.progressSaveTimer = undefined
|
||||
saveProgress()
|
||||
})
|
||||
window.clearTimeout(state.progressSaveTimer);
|
||||
state.progressSaveTimer = undefined;
|
||||
saveProgress();
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => { state.isScrubbing = false; saveProgress() })
|
||||
window.addEventListener('mouseup', () => {
|
||||
state.isScrubbing = false;
|
||||
saveProgress();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return
|
||||
const episode = Number.parseInt(state.currentEpisode, 10)
|
||||
if (!episode) return
|
||||
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)))
|
||||
})
|
||||
}
|
||||
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
|
||||
const episode = Number.parseInt(state.currentEpisode, 10);
|
||||
if (!episode) return;
|
||||
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
import { state } from './state'
|
||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'
|
||||
import { state } from './state';
|
||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline';
|
||||
|
||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||
const src = state.modeSources[mode]
|
||||
if (!src?.token) return ''
|
||||
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`
|
||||
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`
|
||||
return url
|
||||
}
|
||||
const src = state.modeSources[mode];
|
||||
if (!src?.token) return '';
|
||||
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`;
|
||||
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`;
|
||||
return url;
|
||||
};
|
||||
|
||||
const loadVideo = (url: string): void => {
|
||||
if (!url) return
|
||||
const wasPlaying = !state.video.paused
|
||||
const prevTime = displayTimeFromAbsolute(state.video.currentTime)
|
||||
state.video.src = url
|
||||
state.video.load()
|
||||
state.pendingSeekTime = prevTime
|
||||
if (wasPlaying) state.video.play().catch(() => {})
|
||||
}
|
||||
if (!url) return;
|
||||
const wasPlaying = !state.video.paused;
|
||||
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||
state.video.src = url;
|
||||
state.video.load();
|
||||
state.pendingSeekTime = prevTime;
|
||||
if (wasPlaying) state.video.play().catch(() => {});
|
||||
};
|
||||
|
||||
export const switchQuality = (quality: string): void => {
|
||||
const url = streamUrlForMode(state.currentMode, quality)
|
||||
if (!url) return
|
||||
localStorage.setItem('mal:preferred-quality', quality)
|
||||
loadVideo(url)
|
||||
}
|
||||
const url = streamUrlForMode(state.currentMode, quality);
|
||||
if (!url) return;
|
||||
localStorage.setItem('mal:preferred-quality', quality);
|
||||
loadVideo(url);
|
||||
};
|
||||
|
||||
export const updateQualityOptions = (): void => {
|
||||
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null
|
||||
if (!select) return
|
||||
const qualities = state.modeSources[state.currentMode]?.qualities ?? []
|
||||
select.innerHTML = ''
|
||||
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
const qualities = state.modeSources[state.currentMode]?.qualities ?? [];
|
||||
select.innerHTML = '';
|
||||
|
||||
const best = document.createElement('option')
|
||||
best.value = 'best'
|
||||
best.textContent = 'Auto / Best'
|
||||
select.appendChild(best)
|
||||
const best = document.createElement('option');
|
||||
best.value = 'best';
|
||||
best.textContent = 'Auto / Best';
|
||||
select.appendChild(best);
|
||||
|
||||
qualities.forEach(q => {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = q
|
||||
opt.textContent = q
|
||||
select.appendChild(opt)
|
||||
})
|
||||
const opt = document.createElement('option');
|
||||
opt.value = q;
|
||||
opt.textContent = q;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
const preferred = localStorage.getItem('mal:preferred-quality') || 'best'
|
||||
select.value = qualities.includes(preferred) ? preferred : 'best'
|
||||
const preferred = localStorage.getItem('mal:preferred-quality') || 'best';
|
||||
select.value = qualities.includes(preferred) ? preferred : 'best';
|
||||
|
||||
const wrapper = select.parentElement
|
||||
wrapper?.classList.toggle('hidden', qualities.length === 0)
|
||||
}
|
||||
const wrapper = select.parentElement;
|
||||
wrapper?.classList.toggle('hidden', qualities.length === 0);
|
||||
};
|
||||
|
||||
export const setupQuality = (): void => {
|
||||
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null
|
||||
select?.addEventListener('change', (e) => {
|
||||
switchQuality((e.target as HTMLSelectElement).value)
|
||||
})
|
||||
}
|
||||
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null;
|
||||
select?.addEventListener('change', e => {
|
||||
switchQuality((e.target as HTMLSelectElement).value);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,50 +1,53 @@
|
||||
import { state } from '../state'
|
||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline'
|
||||
import { showControls } from '../controls'
|
||||
import { resolveActiveSegments, renderSegments } from './segments'
|
||||
import { state } from '../state';
|
||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline';
|
||||
import { showControls } from '../controls';
|
||||
import { resolveActiveSegments, renderSegments } from './segments';
|
||||
|
||||
const skipLabel = (type: string): string => type === 'ed' ? 'Skip outro' : 'Skip intro'
|
||||
const skipLabel = (type: string): string => (type === 'ed' ? 'Skip outro' : 'Skip intro');
|
||||
|
||||
export const updateSkipButton = (currentTime: number): void => {
|
||||
const btn = state.container.querySelector('[data-skip]') as HTMLButtonElement | null
|
||||
const displayTime = displayTimeFromAbsolute(currentTime)
|
||||
const btn = state.container.querySelector('[data-skip]') as HTMLButtonElement | null;
|
||||
const displayTime = displayTimeFromAbsolute(currentTime);
|
||||
|
||||
const segment = state.activeSegments.find(s => {
|
||||
const delay = Math.min(1, Math.max(0.25, (s.end - s.start) * 0.02))
|
||||
return displayTime >= s.start + delay && displayTime < s.end
|
||||
})
|
||||
const delay = Math.min(1, Math.max(0.25, (s.end - s.start) * 0.02));
|
||||
return displayTime >= s.start + delay && displayTime < s.end;
|
||||
});
|
||||
|
||||
if (!segment) {
|
||||
state.activeSkipSegment = null
|
||||
btn?.classList.add('hidden')
|
||||
return
|
||||
state.activeSkipSegment = null;
|
||||
btn?.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true'
|
||||
const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true';
|
||||
if (autoSkip && displayTime >= segment.start && displayTime < segment.end) {
|
||||
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01)
|
||||
return
|
||||
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01);
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeSkipSegment = segment
|
||||
state.activeSkipSegment = segment;
|
||||
if (btn) {
|
||||
btn.textContent = skipLabel(segment.type)
|
||||
btn.title = skipLabel(segment.type)
|
||||
btn.classList.remove('hidden')
|
||||
btn.textContent = skipLabel(segment.type);
|
||||
btn.title = skipLabel(segment.type);
|
||||
btn.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateAutoSkipButton = (): void => {
|
||||
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null
|
||||
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true')
|
||||
}
|
||||
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null;
|
||||
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true');
|
||||
};
|
||||
|
||||
export const setupSkip = (): void => {
|
||||
document.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
document.addEventListener('change', e => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.hasAttribute('data-autoskip')) {
|
||||
localStorage.setItem('mal:autoskip-enabled', (target as HTMLInputElement).checked ? 'true' : 'false')
|
||||
showControls()
|
||||
localStorage.setItem(
|
||||
'mal:autoskip-enabled',
|
||||
(target as HTMLInputElement).checked ? 'true' : 'false'
|
||||
);
|
||||
showControls();
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
import { SkipSegment } from '../types'
|
||||
import { state } from '../state'
|
||||
import { SkipSegment } from '../types';
|
||||
import { state } from '../state';
|
||||
|
||||
const MIN_SEGMENT_DURATION = 20
|
||||
const MAX_SEGMENT_DURATION = 240
|
||||
const MAX_INTRO_START = 180
|
||||
const MIN_OUTRO_START_RATIO = 0.5
|
||||
const MIN_SEGMENT_DURATION = 20;
|
||||
const MAX_SEGMENT_DURATION = 240;
|
||||
const MAX_INTRO_START = 180;
|
||||
const MIN_OUTRO_START_RATIO = 0.5;
|
||||
|
||||
export const resolveActiveSegments = (): void => {
|
||||
const bounds = state.video.duration
|
||||
if (bounds <= 0) { state.activeSegments = []; return }
|
||||
const bounds = state.video.duration;
|
||||
if (bounds <= 0) {
|
||||
state.activeSegments = [];
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeSegments = state.parsedSegments
|
||||
.filter(s => {
|
||||
const len = s.end - s.start
|
||||
if (len < MIN_SEGMENT_DURATION || len > MAX_SEGMENT_DURATION) return false
|
||||
if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false
|
||||
state.activeSegments = state.parsedSegments.filter(s => {
|
||||
const len = s.end - s.start;
|
||||
if (len < MIN_SEGMENT_DURATION || len > MAX_SEGMENT_DURATION) return false;
|
||||
if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false;
|
||||
|
||||
if (s.type === 'op') {
|
||||
return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5
|
||||
}
|
||||
if (s.type === 'ed') {
|
||||
return s.start >= bounds * MIN_OUTRO_START_RATIO
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
if (s.type === 'op') {
|
||||
return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5;
|
||||
}
|
||||
if (s.type === 'ed') {
|
||||
return s.start >= bounds * MIN_OUTRO_START_RATIO;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const renderSegments = (): void => {
|
||||
const track = state.container.querySelector('[data-segments]') as HTMLElement | null
|
||||
if (!track) return
|
||||
track.innerHTML = ''
|
||||
const track = state.container.querySelector('[data-segments]') as HTMLElement | null;
|
||||
if (!track) return;
|
||||
track.innerHTML = '';
|
||||
|
||||
const bounds = state.video.duration
|
||||
if (bounds <= 0) return
|
||||
const bounds = state.video.duration;
|
||||
if (bounds <= 0) return;
|
||||
|
||||
state.activeSegments.forEach(s => {
|
||||
const bar = document.createElement('div')
|
||||
bar.className = 'absolute top-0 h-full bg-white/80'
|
||||
bar.style.left = `${(s.start / bounds) * 100}%`
|
||||
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`
|
||||
track.appendChild(bar)
|
||||
})
|
||||
}
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'absolute top-0 h-full bg-white/80';
|
||||
bar.style.left = `${(s.start / bounds) * 100}%`;
|
||||
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`;
|
||||
track.appendChild(bar);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types'
|
||||
import { q, qs, dataset } from '../q'
|
||||
import { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types';
|
||||
import { q, qs, dataset } from '../q';
|
||||
|
||||
export interface PlayerState {
|
||||
container: HTMLElement
|
||||
video: HTMLVideoElement
|
||||
progress: HTMLElement
|
||||
scrubber: HTMLElement
|
||||
buffered: HTMLElement
|
||||
timeDisplay: HTMLElement
|
||||
durationDisplay: HTMLElement
|
||||
modeSources: Record<string, ModeSource>
|
||||
availableModes: string[]
|
||||
currentMode: string
|
||||
currentEpisode: string
|
||||
totalEpisodes: number
|
||||
malID: number
|
||||
streamURL: string
|
||||
initialStreamToken: string
|
||||
shouldAutoPlay: boolean
|
||||
parsedSegments: SkipSegment[]
|
||||
activeSegments: ActiveSegment[]
|
||||
activeSkipSegment: ActiveSegment | null
|
||||
activeSubtitles: SubtitleCue[]
|
||||
currentSubtitleTracks: SubtitleTrack[]
|
||||
lastKnownVolume: number
|
||||
pendingSeekTime: number | null
|
||||
isScrubbing: boolean
|
||||
isFullscreen: boolean
|
||||
playerControlsTimeout: number | undefined
|
||||
progressSaveTimer: number | undefined
|
||||
transitionEpisode: number | null
|
||||
completionSent: boolean
|
||||
completionAttempts: number
|
||||
lastSavedProgress: { episode: string; seconds: number }
|
||||
episodeGrid: HTMLElement | null
|
||||
episodeList: HTMLElement | null
|
||||
previewPopover: HTMLElement | null
|
||||
previewTime: HTMLElement | null
|
||||
videoOverlay: HTMLElement | null
|
||||
container: HTMLElement;
|
||||
video: HTMLVideoElement;
|
||||
progress: HTMLElement;
|
||||
scrubber: HTMLElement;
|
||||
buffered: HTMLElement;
|
||||
timeDisplay: HTMLElement;
|
||||
durationDisplay: HTMLElement;
|
||||
modeSources: Record<string, ModeSource>;
|
||||
availableModes: string[];
|
||||
currentMode: string;
|
||||
currentEpisode: string;
|
||||
totalEpisodes: number;
|
||||
malID: number;
|
||||
streamURL: string;
|
||||
initialStreamToken: string;
|
||||
shouldAutoPlay: boolean;
|
||||
parsedSegments: SkipSegment[];
|
||||
activeSegments: ActiveSegment[];
|
||||
activeSkipSegment: ActiveSegment | null;
|
||||
activeSubtitles: SubtitleCue[];
|
||||
currentSubtitleTracks: SubtitleTrack[];
|
||||
lastKnownVolume: number;
|
||||
pendingSeekTime: number | null;
|
||||
isScrubbing: boolean;
|
||||
isFullscreen: boolean;
|
||||
playerControlsTimeout: number | undefined;
|
||||
progressSaveTimer: number | undefined;
|
||||
transitionEpisode: number | null;
|
||||
completionSent: boolean;
|
||||
completionAttempts: number;
|
||||
lastSavedProgress: { episode: string; seconds: number };
|
||||
episodeGrid: HTMLElement | null;
|
||||
episodeList: HTMLElement | null;
|
||||
previewPopover: HTMLElement | null;
|
||||
previewTime: HTMLElement | null;
|
||||
videoOverlay: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const state: PlayerState = {
|
||||
@@ -77,50 +77,53 @@ export const state: PlayerState = {
|
||||
previewPopover: null,
|
||||
previewTime: null,
|
||||
videoOverlay: null,
|
||||
}
|
||||
};
|
||||
|
||||
export const initState = (c: HTMLElement): void => {
|
||||
state.container = c
|
||||
state.video = q<HTMLVideoElement>(c, 'video')!
|
||||
state.progress = q<HTMLElement>(c, '[data-progress]')
|
||||
state.scrubber = q<HTMLElement>(c, '[data-scrubber]')
|
||||
state.buffered = q<HTMLElement>(c, '[data-buffered]')
|
||||
state.timeDisplay = q<HTMLElement>(c, '[data-time]')
|
||||
state.durationDisplay = q<HTMLElement>(c, '[data-duration]')
|
||||
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]')
|
||||
state.previewTime = q<HTMLElement>(c, '[data-preview-time]')
|
||||
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]')
|
||||
state.container = c;
|
||||
state.video = q<HTMLVideoElement>(c, 'video')!;
|
||||
state.progress = q<HTMLElement>(c, '[data-progress]');
|
||||
state.scrubber = q<HTMLElement>(c, '[data-scrubber]');
|
||||
state.buffered = q<HTMLElement>(c, '[data-buffered]');
|
||||
state.timeDisplay = q<HTMLElement>(c, '[data-time]');
|
||||
state.durationDisplay = q<HTMLElement>(c, '[data-duration]');
|
||||
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]');
|
||||
state.previewTime = q<HTMLElement>(c, '[data-preview-time]');
|
||||
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]');
|
||||
|
||||
state.malID = Number.parseInt(dataset(c, 'malId'), 10)
|
||||
state.currentEpisode = dataset(c, 'currentEpisode') || '1'
|
||||
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10)
|
||||
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream'
|
||||
state.initialStreamToken = dataset(c, 'streamToken') || ''
|
||||
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true'
|
||||
sessionStorage.removeItem('mal:autoplay-next')
|
||||
state.malID = Number.parseInt(dataset(c, 'malId'), 10);
|
||||
state.currentEpisode = dataset(c, 'currentEpisode') || '1';
|
||||
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10);
|
||||
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream';
|
||||
state.initialStreamToken = dataset(c, 'streamToken') || '';
|
||||
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true';
|
||||
sessionStorage.removeItem('mal:autoplay-next');
|
||||
|
||||
state.episodeGrid = qs<HTMLElement>('[data-episode-grid]')
|
||||
state.episodeList = qs<HTMLElement>('[data-episode-list]')
|
||||
state.episodeGrid = qs<HTMLElement>('[data-episode-grid]');
|
||||
state.episodeList = qs<HTMLElement>('[data-episode-list]');
|
||||
|
||||
const safeJson = <T>(raw: string | undefined, fallback: T): T => {
|
||||
try { return JSON.parse(raw ?? '') as T } catch { return fallback }
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw ?? '') as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record<string, ModeSource>)
|
||||
state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[])
|
||||
state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record<string, ModeSource>);
|
||||
state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[]);
|
||||
|
||||
const backendInitialMode = dataset(c, 'initialMode') || 'dub'
|
||||
const storedMode = localStorage.getItem('player-audio-mode')
|
||||
const initialMode = (storedMode && state.availableModes.includes(storedMode)) ? storedMode : backendInitialMode
|
||||
const fallbackMode = Object.keys(state.modeSources).find(
|
||||
m => state.modeSources[m]?.token
|
||||
)
|
||||
state.currentMode =
|
||||
(state.modeSources[initialMode]?.token) ? initialMode :
|
||||
(fallbackMode ?? state.availableModes[0] ?? 'dub')
|
||||
const backendInitialMode = dataset(c, 'initialMode') || 'dub';
|
||||
const storedMode = localStorage.getItem('player-audio-mode');
|
||||
const initialMode =
|
||||
storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode;
|
||||
const fallbackMode = Object.keys(state.modeSources).find(m => state.modeSources[m]?.token);
|
||||
state.currentMode = state.modeSources[initialMode]?.token
|
||||
? initialMode
|
||||
: (fallbackMode ?? state.availableModes[0] ?? 'dub');
|
||||
|
||||
const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[])
|
||||
const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[]);
|
||||
state.parsedSegments = segments
|
||||
.map(s => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
|
||||
.filter(s => s.end > s.start)
|
||||
}
|
||||
.filter(s => s.end > s.start);
|
||||
};
|
||||
|
||||
@@ -1,76 +1,99 @@
|
||||
import { SubtitleCue, SubtitleTrack } from '../types'
|
||||
import { state } from '../state'
|
||||
import { parseVtt } from './vtt'
|
||||
import { SubtitleCue, SubtitleTrack } from '../types';
|
||||
import { state } from '../state';
|
||||
import { parseVtt } from './vtt';
|
||||
|
||||
const proxyUrl = (token: string) => `/watch/proxy/subtitle?token=${encodeURIComponent(token)}`
|
||||
const proxyUrl = (token: string) => `/watch/proxy/subtitle?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const subtitlesForMode = (): SubtitleTrack[] => {
|
||||
const src = state.modeSources[state.currentMode]
|
||||
if (!src?.subtitles) return []
|
||||
const src = state.modeSources[state.currentMode];
|
||||
if (!src?.subtitles) return [];
|
||||
return src.subtitles
|
||||
.map(t => ({ lang: (t.lang || 'unknown').toLowerCase(), label: t.lang || 'Unknown', url: proxyUrl(t.token) }))
|
||||
.filter(t => t.url !== '')
|
||||
}
|
||||
.map(t => ({
|
||||
lang: (t.lang || 'unknown').toLowerCase(),
|
||||
label: t.lang || 'Unknown',
|
||||
url: proxyUrl(t.token),
|
||||
}))
|
||||
.filter(t => t.url !== '');
|
||||
};
|
||||
|
||||
const hideSubtitleText = (): void => {
|
||||
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null
|
||||
if (!el) return
|
||||
el.textContent = ''
|
||||
el.classList.remove('block')
|
||||
el.classList.add('hidden')
|
||||
}
|
||||
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = '';
|
||||
el.classList.remove('block');
|
||||
el.classList.add('hidden');
|
||||
};
|
||||
|
||||
const loadSubtitle = async (url: string): Promise<SubtitleCue[]> => {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return []
|
||||
return parseVtt(await res.text())
|
||||
} catch { return [] }
|
||||
}
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [];
|
||||
return parseVtt(await res.text());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSubtitleOptions = (): void => {
|
||||
const select = state.container.querySelector('[data-subtitle-select]') as HTMLSelectElement | null
|
||||
if (!select) return
|
||||
state.currentSubtitleTracks = subtitlesForMode()
|
||||
select.innerHTML = ''
|
||||
const select = state.container.querySelector(
|
||||
'[data-subtitle-select]'
|
||||
) as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
state.currentSubtitleTracks = subtitlesForMode();
|
||||
select.innerHTML = '';
|
||||
|
||||
const none = document.createElement('option')
|
||||
none.value = 'none'
|
||||
none.textContent = 'Off'
|
||||
select.appendChild(none)
|
||||
select.value = 'none'
|
||||
const none = document.createElement('option');
|
||||
none.value = 'none';
|
||||
none.textContent = 'Off';
|
||||
select.appendChild(none);
|
||||
select.value = 'none';
|
||||
|
||||
state.currentSubtitleTracks.forEach((t, i) => {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = String(i)
|
||||
opt.textContent = t.label
|
||||
select.appendChild(opt)
|
||||
})
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(i);
|
||||
opt.textContent = t.label;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
const wrapper = select.parentElement
|
||||
wrapper?.classList.toggle('hidden', state.currentSubtitleTracks.length === 0)
|
||||
state.activeSubtitles = []
|
||||
hideSubtitleText()
|
||||
}
|
||||
const wrapper = select.parentElement;
|
||||
wrapper?.classList.toggle('hidden', state.currentSubtitleTracks.length === 0);
|
||||
state.activeSubtitles = [];
|
||||
hideSubtitleText();
|
||||
};
|
||||
|
||||
export const updateSubtitleRender = (time: number): void => {
|
||||
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null
|
||||
if (!el) return
|
||||
if (!state.activeSubtitles.length) { hideSubtitleText(); return }
|
||||
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null;
|
||||
if (!el) return;
|
||||
if (!state.activeSubtitles.length) {
|
||||
hideSubtitleText();
|
||||
return;
|
||||
}
|
||||
|
||||
const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end)
|
||||
if (!cue) { hideSubtitleText(); return }
|
||||
const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end);
|
||||
if (!cue) {
|
||||
hideSubtitleText();
|
||||
return;
|
||||
}
|
||||
|
||||
el.textContent = cue.text
|
||||
el.classList.remove('hidden')
|
||||
}
|
||||
el.textContent = cue.text;
|
||||
el.classList.remove('hidden');
|
||||
};
|
||||
|
||||
export const setupSubtitles = (): void => {
|
||||
const select = state.container.querySelector('[data-subtitle-select]') as HTMLSelectElement | null
|
||||
const select = state.container.querySelector(
|
||||
'[data-subtitle-select]'
|
||||
) as HTMLSelectElement | null;
|
||||
select?.addEventListener('change', async () => {
|
||||
if (select.value === 'none') { state.activeSubtitles = []; hideSubtitleText(); return }
|
||||
const track = state.currentSubtitleTracks[Number(select.value)]
|
||||
if (!track) { state.activeSubtitles = []; return }
|
||||
state.activeSubtitles = await loadSubtitle(track.url)
|
||||
})
|
||||
}
|
||||
if (select.value === 'none') {
|
||||
state.activeSubtitles = [];
|
||||
hideSubtitleText();
|
||||
return;
|
||||
}
|
||||
const track = state.currentSubtitleTracks[Number(select.value)];
|
||||
if (!track) {
|
||||
state.activeSubtitles = [];
|
||||
return;
|
||||
}
|
||||
state.activeSubtitles = await loadSubtitle(track.url);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
export const parseVttTime = (raw: string): number => {
|
||||
const parts = raw.trim().split(':')
|
||||
if (parts.length < 2) return 0
|
||||
const secPart = parts.pop()!
|
||||
const minPart = parts.pop()!
|
||||
const hourPart = parts.pop() ?? '0'
|
||||
return (Number(hourPart) * 3600) + (Number(minPart) * 60) + Number(secPart.replace(',', '.'))
|
||||
}
|
||||
const parts = raw.trim().split(':');
|
||||
if (parts.length < 2) return 0;
|
||||
const secPart = parts.pop()!;
|
||||
const minPart = parts.pop()!;
|
||||
const hourPart = parts.pop() ?? '0';
|
||||
return Number(hourPart) * 3600 + Number(minPart) * 60 + Number(secPart.replace(',', '.'));
|
||||
};
|
||||
|
||||
export const parseVttCue = (line: string, lines: string[], i: number) => {
|
||||
if (!line.includes('-->')) return null
|
||||
const [startRaw, endRaw] = line.split('-->')
|
||||
const payload: string[] = []
|
||||
let j = i + 1
|
||||
if (!line.includes('-->')) return null;
|
||||
const [startRaw, endRaw] = line.split('-->');
|
||||
const payload: string[] = [];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() !== '') {
|
||||
payload.push(lines[j]); j++
|
||||
payload.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
const text = payload.join('\n').replace(/<[^>]+>/g, '').trim()
|
||||
if (!text) return null
|
||||
return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text }
|
||||
}
|
||||
const text = payload
|
||||
.join('\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
if (!text) return null;
|
||||
return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text };
|
||||
};
|
||||
|
||||
export const parseVtt = (text: string) => {
|
||||
const lines = text.replace(/\r/g, '').split('\n')
|
||||
const cues = []
|
||||
const lines = text.replace(/\r/g, '').split('\n');
|
||||
const cues = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (i + 1 < lines.length && !line.includes('-->') && lines[i + 1].includes('-->')) {
|
||||
const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1)
|
||||
if (cue) cues.push(cue); i++
|
||||
const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1);
|
||||
if (cue) cues.push(cue);
|
||||
i++;
|
||||
} else if (line.includes('-->')) {
|
||||
const cue = parseVttCue(line, lines, i)
|
||||
if (cue) cues.push(cue)
|
||||
const cue = parseVttCue(line, lines, i);
|
||||
if (cue) cues.push(cue);
|
||||
}
|
||||
}
|
||||
return cues
|
||||
}
|
||||
return cues;
|
||||
};
|
||||
|
||||
@@ -1,97 +1,101 @@
|
||||
import { TimelineBounds } from './types'
|
||||
import { state } from './state'
|
||||
import { TimelineBounds } from './types';
|
||||
import { state } from './state';
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 }
|
||||
let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 };
|
||||
|
||||
export const timelineBounds = (): TimelineBounds => {
|
||||
const duration = Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0
|
||||
let start = 0
|
||||
const duration =
|
||||
Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0;
|
||||
let start = 0;
|
||||
if (state.video.seekable.length > 0) {
|
||||
const seekableStart = state.video.seekable.start(0)
|
||||
if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart
|
||||
const seekableStart = state.video.seekable.start(0);
|
||||
if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart;
|
||||
}
|
||||
if (duration > start) {
|
||||
return { start, end: duration, duration: duration - start }
|
||||
return { start, end: duration, duration: duration - start };
|
||||
}
|
||||
if (state.video.seekable.length > 0) {
|
||||
const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1)
|
||||
const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1);
|
||||
if (Number.isFinite(seekableEnd) && seekableEnd > start) {
|
||||
return { start, end: seekableEnd, duration: seekableEnd - start }
|
||||
return { start, end: seekableEnd, duration: seekableEnd - start };
|
||||
}
|
||||
}
|
||||
return { start: 0, end: duration, duration }
|
||||
}
|
||||
return { start: 0, end: duration, duration };
|
||||
};
|
||||
|
||||
export const invalidateBounds = (): void => {
|
||||
cachedBounds = timelineBounds()
|
||||
}
|
||||
cachedBounds = timelineBounds();
|
||||
};
|
||||
|
||||
export const getBounds = (): TimelineBounds => cachedBounds
|
||||
export const getBounds = (): TimelineBounds => cachedBounds;
|
||||
|
||||
export const displayTimeFromAbsolute = (absoluteTime: number): number => {
|
||||
const b = getBounds()
|
||||
if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0
|
||||
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start
|
||||
}
|
||||
const b = getBounds();
|
||||
if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0;
|
||||
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start;
|
||||
};
|
||||
|
||||
export const absoluteTimeFromDisplay = (displayTime: number): number => {
|
||||
const b = getBounds()
|
||||
if (!Number.isFinite(displayTime) || b.duration <= 0) return 0
|
||||
return b.start + Math.max(0, Math.min(b.duration, displayTime))
|
||||
}
|
||||
const b = getBounds();
|
||||
if (!Number.isFinite(displayTime) || b.duration <= 0) return 0;
|
||||
return b.start + Math.max(0, Math.min(b.duration, displayTime));
|
||||
};
|
||||
|
||||
export const absoluteTimeFromRatio = (ratio: number): number => {
|
||||
const b = getBounds()
|
||||
if (!Number.isFinite(ratio) || b.duration <= 0) return 0
|
||||
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration
|
||||
}
|
||||
const b = getBounds();
|
||||
if (!Number.isFinite(ratio) || b.duration <= 0) return 0;
|
||||
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration;
|
||||
};
|
||||
|
||||
export const getBufferedEnd = (): number => {
|
||||
const currentTime = state.video.currentTime
|
||||
let end = 0
|
||||
const currentTime = state.video.currentTime;
|
||||
let end = 0;
|
||||
for (let i = 0; i < state.video.buffered.length; i++) {
|
||||
if (state.video.buffered.start(i) <= currentTime && state.video.buffered.end(i) >= currentTime) {
|
||||
end = state.video.buffered.end(i)
|
||||
break
|
||||
if (
|
||||
state.video.buffered.start(i) <= currentTime &&
|
||||
state.video.buffered.end(i) >= currentTime
|
||||
) {
|
||||
end = state.video.buffered.end(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end === 0) {
|
||||
for (let i = 0; i < state.video.buffered.length; i++) {
|
||||
if (state.video.buffered.end(i) > currentTime) {
|
||||
end = Math.max(end, state.video.buffered.end(i))
|
||||
end = Math.max(end, state.video.buffered.end(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
return end
|
||||
}
|
||||
return end;
|
||||
};
|
||||
|
||||
export const updateTimeline = (currentTime: number): void => {
|
||||
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state
|
||||
const b = getBounds()
|
||||
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state;
|
||||
const b = getBounds();
|
||||
|
||||
if (b.duration <= 0) {
|
||||
progress.style.width = '0%'
|
||||
buffered.style.width = '0%'
|
||||
scrubber.style.left = '0%'
|
||||
timeDisplay.textContent = '00:00'
|
||||
durationDisplay.textContent = '00:00'
|
||||
return
|
||||
progress.style.width = '0%';
|
||||
buffered.style.width = '0%';
|
||||
scrubber.style.left = '0%';
|
||||
timeDisplay.textContent = '00:00';
|
||||
durationDisplay.textContent = '00:00';
|
||||
return;
|
||||
}
|
||||
|
||||
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100
|
||||
progress.style.width = `${pct}%`
|
||||
scrubber.style.left = `${pct}%`
|
||||
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime))
|
||||
durationDisplay.textContent = formatTime(b.duration)
|
||||
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100;
|
||||
progress.style.width = `${pct}%`;
|
||||
scrubber.style.left = `${pct}%`;
|
||||
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime));
|
||||
durationDisplay.textContent = formatTime(b.duration);
|
||||
|
||||
const bufferedEnd = getBufferedEnd()
|
||||
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100
|
||||
buffered.style.width = `${bufferedPct}%`
|
||||
}
|
||||
const bufferedEnd = getBufferedEnd();
|
||||
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100;
|
||||
buffered.style.width = `${bufferedPct}%`;
|
||||
};
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
export interface ModeSource {
|
||||
token: string
|
||||
subtitles: SubtitleItem[]
|
||||
qualities?: string[]
|
||||
token: string;
|
||||
subtitles: SubtitleItem[];
|
||||
qualities?: string[];
|
||||
}
|
||||
|
||||
export interface SubtitleItem {
|
||||
lang: string
|
||||
token: string
|
||||
lang: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface SkipSegment {
|
||||
type: string
|
||||
start: number
|
||||
end: number
|
||||
type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SubtitleCue {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SubtitleTrack {
|
||||
lang: string
|
||||
label: string
|
||||
url: string
|
||||
lang: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ActiveSegment {
|
||||
type: string
|
||||
start: number
|
||||
end: number
|
||||
type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface TimelineBounds {
|
||||
start: number
|
||||
end: number
|
||||
duration: number
|
||||
start: number;
|
||||
end: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export const q = <T extends Element>(container: HTMLElement, selector: string): T | null =>
|
||||
container.querySelector(selector) as T | null
|
||||
container.querySelector(selector) as T | null;
|
||||
|
||||
export const qs = <T extends Element>(selector: string): T | null =>
|
||||
document.querySelector(selector) as T | null
|
||||
document.querySelector(selector) as T | null;
|
||||
|
||||
export const dataset = (el: HTMLElement, key: string): string =>
|
||||
el.dataset[key] ?? ''
|
||||
export const dataset = (el: HTMLElement, key: string): string => el.dataset[key] ?? '';
|
||||
|
||||
185
static/search.ts
185
static/search.ts
@@ -1,168 +1,173 @@
|
||||
export {}
|
||||
export {};
|
||||
|
||||
type QuickSearchResult = {
|
||||
id?: number
|
||||
image?: string
|
||||
title?: string
|
||||
type?: string
|
||||
}
|
||||
id?: number;
|
||||
image?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
const searchInitializedKey = Symbol('searchInitialized')
|
||||
const globalWindow = window as Window & { [searchInitializedKey]?: boolean }
|
||||
const searchInitializedKey = Symbol('searchInitialized');
|
||||
const globalWindow = window as Window & { [searchInitializedKey]?: boolean };
|
||||
|
||||
let searchTimeout: number | undefined
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement | null
|
||||
const searchDropdown = document.querySelector('[data-search-results-container]') as HTMLElement | null
|
||||
let searchTimeout: number | undefined;
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
|
||||
const searchDropdown = document.querySelector(
|
||||
'[data-search-results-container]'
|
||||
) as HTMLElement | null;
|
||||
|
||||
const isSafeImageUrl = (rawUrl?: string): boolean => {
|
||||
if (!rawUrl || typeof rawUrl !== 'string') {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(rawUrl, window.location.origin)
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:'
|
||||
const parsed = new URL(rawUrl, window.location.origin);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearchResults = (): void => {
|
||||
if (!searchDropdown) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
searchDropdown.replaceChildren()
|
||||
}
|
||||
searchDropdown.replaceChildren();
|
||||
};
|
||||
|
||||
const buildSearchResultItem = (result: QuickSearchResult): HTMLAnchorElement => {
|
||||
const item = document.createElement('a')
|
||||
item.className = 'flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-(--panel-soft) hover:no-underline'
|
||||
item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || '')))
|
||||
const item = document.createElement('a');
|
||||
item.className =
|
||||
'flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-(--panel-soft) hover:no-underline';
|
||||
item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || '')));
|
||||
|
||||
if (isSafeImageUrl(result.image)) {
|
||||
const img = document.createElement('img')
|
||||
img.className = 'aspect-2/3 w-[42px] shrink-0 object-cover bg-(--surface-thumb)'
|
||||
img.setAttribute('src', result.image || '')
|
||||
img.setAttribute('alt', String(result.title || ''))
|
||||
item.appendChild(img)
|
||||
const img = document.createElement('img');
|
||||
img.className = 'aspect-2/3 w-[42px] shrink-0 object-cover bg-(--surface-thumb)';
|
||||
img.setAttribute('src', result.image || '');
|
||||
img.setAttribute('alt', String(result.title || ''));
|
||||
item.appendChild(img);
|
||||
} else {
|
||||
const noImage = document.createElement('div')
|
||||
noImage.className = 'aspect-2/3 w-[42px] shrink-0 bg-(--surface-thumb) text-[0] text-transparent'
|
||||
noImage.textContent = 'no image'
|
||||
item.appendChild(noImage)
|
||||
const noImage = document.createElement('div');
|
||||
noImage.className =
|
||||
'aspect-2/3 w-[42px] shrink-0 bg-(--surface-thumb) text-[0] text-transparent';
|
||||
noImage.textContent = 'no image';
|
||||
item.appendChild(noImage);
|
||||
}
|
||||
|
||||
const info = document.createElement('div')
|
||||
info.className = 'grid min-w-0 gap-px'
|
||||
const info = document.createElement('div');
|
||||
info.className = 'grid min-w-0 gap-px';
|
||||
|
||||
const itemTitle = document.createElement('div')
|
||||
itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-(--text)'
|
||||
itemTitle.textContent = String(result.title || '')
|
||||
info.appendChild(itemTitle)
|
||||
const itemTitle = document.createElement('div');
|
||||
itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-(--text)';
|
||||
itemTitle.textContent = String(result.title || '');
|
||||
info.appendChild(itemTitle);
|
||||
|
||||
const itemType = document.createElement('div')
|
||||
itemType.className = 'text-[0.67rem] text-(--text-faint)'
|
||||
itemType.textContent = String(result.type || '')
|
||||
info.appendChild(itemType)
|
||||
const itemType = document.createElement('div');
|
||||
itemType.className = 'text-[0.67rem] text-(--text-faint)';
|
||||
itemType.textContent = String(result.type || '');
|
||||
info.appendChild(itemType);
|
||||
|
||||
item.appendChild(info)
|
||||
return item
|
||||
}
|
||||
item.appendChild(info);
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderQuickSearchResults = (query: string, results: QuickSearchResult[]): void => {
|
||||
if (!searchDropdown) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
clearSearchResults()
|
||||
return
|
||||
clearSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = document.createElement('div')
|
||||
searchResults.className = 'grid'
|
||||
const searchResults = document.createElement('div');
|
||||
searchResults.className = 'grid';
|
||||
|
||||
const title = document.createElement('div')
|
||||
title.className = 'px-3 py-2 text-[0.68rem] text-(--text-faint)'
|
||||
title.textContent = 'Anime'
|
||||
searchResults.appendChild(title)
|
||||
const title = document.createElement('div');
|
||||
title.className = 'px-3 py-2 text-[0.68rem] text-(--text-faint)';
|
||||
title.textContent = 'Anime';
|
||||
searchResults.appendChild(title);
|
||||
|
||||
results.forEach((result: QuickSearchResult) => {
|
||||
searchResults.appendChild(buildSearchResultItem(result))
|
||||
})
|
||||
searchResults.appendChild(buildSearchResultItem(result));
|
||||
});
|
||||
|
||||
const viewAll = document.createElement('a')
|
||||
viewAll.className = 'bg-(--surface-search-view-all) px-3 py-2 text-center text-[0.8rem] text-(--text-muted) no-underline hover:bg-(--panel-soft) hover:text-(--text) hover:no-underline'
|
||||
viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query))
|
||||
viewAll.textContent = 'View all results for ' + query
|
||||
searchResults.appendChild(viewAll)
|
||||
const viewAll = document.createElement('a');
|
||||
viewAll.className =
|
||||
'bg-(--surface-search-view-all) px-3 py-2 text-center text-[0.8rem] text-(--text-muted) no-underline hover:bg-(--panel-soft) hover:text-(--text) hover:no-underline';
|
||||
viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query));
|
||||
viewAll.textContent = 'View all results for ' + query;
|
||||
searchResults.appendChild(viewAll);
|
||||
|
||||
searchDropdown.replaceChildren(searchResults)
|
||||
}
|
||||
searchDropdown.replaceChildren(searchResults);
|
||||
};
|
||||
|
||||
const fetchAndRenderQuickSearch = (query: string): void => {
|
||||
fetch('/api/search-quick?q=' + encodeURIComponent(query))
|
||||
.then((res: Response) => res.json())
|
||||
.then((results: QuickSearchResult[]) => {
|
||||
renderQuickSearchResults(query, results)
|
||||
renderQuickSearchResults(query, results);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('Search error:', err)
|
||||
})
|
||||
}
|
||||
console.error('Search error:', err);
|
||||
});
|
||||
};
|
||||
|
||||
const onSearchInput = (event: Event): void => {
|
||||
if (searchTimeout) {
|
||||
window.clearTimeout(searchTimeout)
|
||||
window.clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const query = target.value.trim()
|
||||
const query = target.value.trim();
|
||||
if (query.length < 2) {
|
||||
clearSearchResults()
|
||||
return
|
||||
clearSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = window.setTimeout(() => {
|
||||
fetchAndRenderQuickSearch(query)
|
||||
}, 300)
|
||||
}
|
||||
fetchAndRenderQuickSearch(query);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const onSearchBlur = (): void => {
|
||||
window.setTimeout(() => {
|
||||
clearSearchResults()
|
||||
}, 200)
|
||||
}
|
||||
clearSearchResults();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onDocumentClick = (event: MouseEvent): void => {
|
||||
const target = event.target
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.closest('[data-search-root]')) {
|
||||
clearSearchResults()
|
||||
clearSearchResults();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initQuickSearch = (): void => {
|
||||
if (globalWindow[searchInitializedKey]) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
globalWindow[searchInitializedKey] = true
|
||||
globalWindow[searchInitializedKey] = true;
|
||||
|
||||
if (!searchInput || !searchDropdown) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', onSearchInput)
|
||||
searchInput.addEventListener('blur', onSearchBlur)
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
}
|
||||
searchInput.addEventListener('input', onSearchInput);
|
||||
searchInput.addEventListener('blur', onSearchBlur);
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
};
|
||||
|
||||
initQuickSearch()
|
||||
initQuickSearch();
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
const initSortFilter = (): void => {
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null
|
||||
const orderSelect = document.getElementById('order-select') as HTMLSelectElement | null
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
||||
const orderSelect = document.getElementById('order-select') as HTMLSelectElement | null;
|
||||
|
||||
const submitForm = (): void => {
|
||||
const form = document.getElementById('sort-form') as HTMLFormElement | null
|
||||
if (form) form.submit()
|
||||
}
|
||||
const form = document.getElementById('sort-form') as HTMLFormElement | null;
|
||||
if (form) form.submit();
|
||||
};
|
||||
|
||||
sortSelect?.addEventListener('change', () => {
|
||||
const input = document.getElementById('sort-input') as HTMLInputElement | null
|
||||
if (input) input.value = sortSelect.value
|
||||
submitForm()
|
||||
})
|
||||
const input = document.getElementById('sort-input') as HTMLInputElement | null;
|
||||
if (input) input.value = sortSelect.value;
|
||||
submitForm();
|
||||
});
|
||||
|
||||
orderSelect?.addEventListener('change', () => {
|
||||
const input = document.getElementById('order-input') as HTMLInputElement | null
|
||||
if (input) input.value = orderSelect.value
|
||||
submitForm()
|
||||
})
|
||||
}
|
||||
const input = document.getElementById('order-input') as HTMLInputElement | null;
|
||||
if (input) input.value = orderSelect.value;
|
||||
submitForm();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initSortFilter)
|
||||
document.addEventListener('DOMContentLoaded', initSortFilter);
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
type Theme = 'light' | 'dark'
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'theme'
|
||||
const STORAGE_KEY = 'theme';
|
||||
|
||||
const getSavedTheme = (): Theme => {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === 'light' || raw === 'dark') {
|
||||
return raw
|
||||
return raw;
|
||||
}
|
||||
return 'dark'
|
||||
}
|
||||
return 'dark';
|
||||
};
|
||||
|
||||
const applyTheme = (theme: Theme): void => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
};
|
||||
|
||||
const cycleTheme = (): void => {
|
||||
const current = getSavedTheme()
|
||||
const next: Theme = current === 'light' ? 'dark' : 'light'
|
||||
applyTheme(next)
|
||||
}
|
||||
const current = getSavedTheme();
|
||||
const next: Theme = current === 'light' ? 'dark' : 'light';
|
||||
applyTheme(next);
|
||||
};
|
||||
|
||||
const initTheme = (): void => {
|
||||
const saved = getSavedTheme()
|
||||
applyTheme(saved)
|
||||
const saved = getSavedTheme();
|
||||
applyTheme(saved);
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null
|
||||
document.addEventListener('click', e => {
|
||||
const target = e.target as HTMLElement;
|
||||
const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null;
|
||||
if (btn) {
|
||||
cycleTheme()
|
||||
cycleTheme();
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initTheme)
|
||||
document.addEventListener('DOMContentLoaded', initTheme);
|
||||
} else {
|
||||
initTheme()
|
||||
initTheme();
|
||||
}
|
||||
|
||||
@@ -1,85 +1,92 @@
|
||||
export {}
|
||||
export {};
|
||||
|
||||
const jstOffsetMinutes = 9 * 60
|
||||
const jstOffsetMinutes = 9 * 60;
|
||||
|
||||
type ParsedBroadcast = {
|
||||
day: string
|
||||
hour: number
|
||||
minute: number
|
||||
}
|
||||
day: string;
|
||||
hour: number;
|
||||
minute: number;
|
||||
};
|
||||
|
||||
const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = value.trim().match(/^(\d{1,2}):(\d{2})$/)
|
||||
const match = value.trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!match) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const hour = Number.parseInt(match[1], 10)
|
||||
const minute = Number.parseInt(match[2], 10)
|
||||
if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
return null
|
||||
const hour = Number.parseInt(match[1], 10);
|
||||
const minute = Number.parseInt(match[2], 10);
|
||||
if (
|
||||
Number.isNaN(hour) ||
|
||||
Number.isNaN(minute) ||
|
||||
hour < 0 ||
|
||||
hour > 23 ||
|
||||
minute < 0 ||
|
||||
minute > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { hour, minute }
|
||||
}
|
||||
return { hour, minute };
|
||||
};
|
||||
|
||||
const isJstTimezone = (timezone: string | null): boolean => {
|
||||
if (!timezone) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = timezone.trim().toLowerCase()
|
||||
return normalized === 'asia/tokyo' || normalized === 'jst'
|
||||
}
|
||||
const normalized = timezone.trim().toLowerCase();
|
||||
return normalized === 'asia/tokyo' || normalized === 'jst';
|
||||
};
|
||||
|
||||
const parseFromStructuredAttrs = (node: Element): ParsedBroadcast | null => {
|
||||
const day = node.getAttribute('data-broadcast-day')
|
||||
const time = node.getAttribute('data-broadcast-time')
|
||||
const timezone = node.getAttribute('data-broadcast-timezone')
|
||||
const day = node.getAttribute('data-broadcast-day');
|
||||
const time = node.getAttribute('data-broadcast-time');
|
||||
const timezone = node.getAttribute('data-broadcast-timezone');
|
||||
|
||||
if (!day || !time || !isJstTimezone(timezone)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedTime = parseBroadcastTime(time)
|
||||
const parsedTime = parseBroadcastTime(time);
|
||||
if (!parsedTime) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute }
|
||||
}
|
||||
return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute };
|
||||
};
|
||||
|
||||
const parseBroadcast = (text: string | null): ParsedBroadcast | null => {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i)
|
||||
const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i);
|
||||
if (!match) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const day = match[1].trim()
|
||||
const hour = Number.parseInt(match[2], 10)
|
||||
const minute = Number.parseInt(match[3], 10)
|
||||
const day = match[1].trim();
|
||||
const hour = Number.parseInt(match[2], 10);
|
||||
const minute = Number.parseInt(match[3], 10);
|
||||
|
||||
if (Number.isNaN(hour) || Number.isNaN(minute)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return { day, hour, minute }
|
||||
}
|
||||
return { day, hour, minute };
|
||||
};
|
||||
|
||||
const normalizeDay = (day: string): number | null => {
|
||||
const key = day.trim().toLowerCase().replace(/s$/, '')
|
||||
const key = day.trim().toLowerCase().replace(/s$/, '');
|
||||
const days: Record<string, number> = {
|
||||
mon: 1,
|
||||
monday: 1,
|
||||
@@ -98,153 +105,155 @@ const normalizeDay = (day: string): number | null => {
|
||||
saturday: 6,
|
||||
sun: 0,
|
||||
sunday: 0,
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof days[key] !== 'number') {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return days[key]
|
||||
}
|
||||
return days[key];
|
||||
};
|
||||
|
||||
const convertToLocal = (parsed: ParsedBroadcast, localOffsetMinutes: number): string | null => {
|
||||
const sourceMinutes = parsed.hour * 60 + parsed.minute
|
||||
const diff = jstOffsetMinutes - localOffsetMinutes
|
||||
const localTotal = sourceMinutes - diff
|
||||
const sourceMinutes = parsed.hour * 60 + parsed.minute;
|
||||
const diff = jstOffsetMinutes - localOffsetMinutes;
|
||||
const localTotal = sourceMinutes - diff;
|
||||
|
||||
const dayShift = Math.floor(localTotal / 1440)
|
||||
const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440
|
||||
const localHour = Math.floor(normalizedMinutes / 60)
|
||||
const localMinute = normalizedMinutes % 60
|
||||
const dayShift = Math.floor(localTotal / 1440);
|
||||
const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440;
|
||||
const localHour = Math.floor(normalizedMinutes / 60);
|
||||
const localMinute = normalizedMinutes % 60;
|
||||
|
||||
const sourceDayIndex = normalizeDay(parsed.day)
|
||||
const sourceDayIndex = normalizeDay(parsed.day);
|
||||
if (sourceDayIndex === null) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const localDayIndex = ((sourceDayIndex + dayShift) % 7 + 7) % 7
|
||||
const localDay = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][localDayIndex]
|
||||
const localDayIndex = (((sourceDayIndex + dayShift) % 7) + 7) % 7;
|
||||
const localDay = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][
|
||||
localDayIndex
|
||||
];
|
||||
|
||||
const time = `${localHour.toString().padStart(2, '0')}:${localMinute.toString().padStart(2, '0')}`
|
||||
return `${localDay} at ${time} (Local)`
|
||||
}
|
||||
const time = `${localHour.toString().padStart(2, '0')}:${localMinute.toString().padStart(2, '0')}`;
|
||||
return `${localDay} at ${time} (Local)`;
|
||||
};
|
||||
|
||||
const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => {
|
||||
const targetDay = normalizeDay(parsed.day)
|
||||
const targetDay = normalizeDay(parsed.day);
|
||||
if (targetDay === null) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000)
|
||||
const now = new Date();
|
||||
const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000);
|
||||
|
||||
const currentDay = jstNow.getUTCDay()
|
||||
const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes()
|
||||
const targetMinuteOfDay = parsed.hour * 60 + parsed.minute
|
||||
const currentDay = jstNow.getUTCDay();
|
||||
const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes();
|
||||
const targetMinuteOfDay = parsed.hour * 60 + parsed.minute;
|
||||
|
||||
let dayDelta = (targetDay - currentDay + 7) % 7
|
||||
let dayDelta = (targetDay - currentDay + 7) % 7;
|
||||
if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) {
|
||||
dayDelta = 7
|
||||
dayDelta = 7;
|
||||
}
|
||||
|
||||
const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay)
|
||||
return new Date(now.getTime() + minuteDelta * 60 * 1000)
|
||||
}
|
||||
const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay);
|
||||
return new Date(now.getTime() + minuteDelta * 60 * 1000);
|
||||
};
|
||||
|
||||
const formatRelative = (value: number, unit: Intl.RelativeTimeFormatUnit): string => {
|
||||
if (typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function') {
|
||||
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
return formatter.format(value, unit)
|
||||
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||
return formatter.format(value, unit);
|
||||
}
|
||||
|
||||
const suffix = value === 1 ? unit : `${unit}s`
|
||||
return `in ${value} ${suffix}`
|
||||
}
|
||||
const suffix = value === 1 ? unit : `${unit}s`;
|
||||
return `in ${value} ${suffix}`;
|
||||
};
|
||||
|
||||
const relativeText = (target: Date): string => {
|
||||
const diffMs = target.getTime() - Date.now()
|
||||
const diffMs = target.getTime() - Date.now();
|
||||
if (diffMs <= 0) {
|
||||
return 'soon'
|
||||
return 'soon';
|
||||
}
|
||||
|
||||
const minutes = Math.ceil(diffMs / 60000)
|
||||
const minutes = Math.ceil(diffMs / 60000);
|
||||
if (minutes < 60) {
|
||||
return formatRelative(minutes, 'minute')
|
||||
return formatRelative(minutes, 'minute');
|
||||
}
|
||||
|
||||
const hours = Math.ceil(minutes / 60)
|
||||
const hours = Math.ceil(minutes / 60);
|
||||
if (hours < 36) {
|
||||
return formatRelative(hours, 'hour')
|
||||
return formatRelative(hours, 'hour');
|
||||
}
|
||||
|
||||
const days = Math.ceil(hours / 24)
|
||||
return formatRelative(days, 'day')
|
||||
}
|
||||
const days = Math.ceil(hours / 24);
|
||||
return formatRelative(days, 'day');
|
||||
};
|
||||
|
||||
const localDateTimeText = (date: Date): string => {
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
return formatter.format(date)
|
||||
}
|
||||
});
|
||||
return formatter.format(date);
|
||||
};
|
||||
|
||||
const updateNextAiring = (node: Element, parsed: ParsedBroadcast): void => {
|
||||
const card = node.closest('[data-notification-content]')
|
||||
const card = node.closest('[data-notification-content]');
|
||||
if (!card) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const nextNode = card.querySelector('[data-next-airing]')
|
||||
const nextNode = card.querySelector('[data-next-airing]');
|
||||
if (!(nextNode instanceof HTMLElement)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDate = nextAiringUTC(parsed)
|
||||
const nextDate = nextAiringUTC(parsed);
|
||||
if (!nextDate) {
|
||||
nextNode.remove()
|
||||
return
|
||||
nextNode.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})`
|
||||
}
|
||||
nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})`;
|
||||
};
|
||||
|
||||
const updateNode = (node: Element, localOffsetMinutes: number): void => {
|
||||
const card = node.closest('[data-notification-content]')
|
||||
const nextNode = card ? card.querySelector('[data-next-airing]') : null
|
||||
const card = node.closest('[data-notification-content]');
|
||||
const nextNode = card ? card.querySelector('[data-next-airing]') : null;
|
||||
|
||||
const structured = parseFromStructuredAttrs(node)
|
||||
const source = node.getAttribute('data-jst-text')
|
||||
const parsed = structured || parseBroadcast(source)
|
||||
const structured = parseFromStructuredAttrs(node);
|
||||
const source = node.getAttribute('data-jst-text');
|
||||
const parsed = structured || parseBroadcast(source);
|
||||
if (!parsed) {
|
||||
if (nextNode instanceof HTMLElement) {
|
||||
nextNode.remove()
|
||||
nextNode.remove();
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const converted = convertToLocal(parsed, localOffsetMinutes)
|
||||
const converted = convertToLocal(parsed, localOffsetMinutes);
|
||||
if (!converted) {
|
||||
if (nextNode instanceof HTMLElement) {
|
||||
nextNode.remove()
|
||||
nextNode.remove();
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
node.textContent = converted
|
||||
updateNextAiring(node, parsed)
|
||||
}
|
||||
node.textContent = converted;
|
||||
updateNextAiring(node, parsed);
|
||||
};
|
||||
|
||||
const updateAll = (): void => {
|
||||
const localOffsetMinutes = -new Date().getTimezoneOffset()
|
||||
const nodes = document.querySelectorAll('[data-jst-text]')
|
||||
nodes.forEach((node) => updateNode(node, localOffsetMinutes))
|
||||
}
|
||||
const localOffsetMinutes = -new Date().getTimezoneOffset();
|
||||
const nodes = document.querySelectorAll('[data-jst-text]');
|
||||
nodes.forEach(node => updateNode(node, localOffsetMinutes));
|
||||
};
|
||||
|
||||
const initTimezoneConversion = (): void => {
|
||||
document.addEventListener('DOMContentLoaded', updateAll)
|
||||
document.body.addEventListener('htmx:afterSwap', updateAll)
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', updateAll);
|
||||
document.body.addEventListener('htmx:afterSwap', updateAll);
|
||||
};
|
||||
|
||||
initTimezoneConversion()
|
||||
initTimezoneConversion();
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
export {}
|
||||
export {};
|
||||
|
||||
interface ToastOptions {
|
||||
message: string
|
||||
duration?: number
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const toastContainer = (): HTMLElement => {
|
||||
let container = document.getElementById('toast-container')
|
||||
let container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div')
|
||||
container.id = 'toast-container'
|
||||
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2'
|
||||
document.body.appendChild(container)
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
return container
|
||||
}
|
||||
return container;
|
||||
};
|
||||
|
||||
const showToast = ({ message, duration = 3000 }: ToastOptions): void => {
|
||||
const container = toastContainer()
|
||||
const template = document.getElementById('toast-template') as HTMLTemplateElement | null
|
||||
const container = toastContainer();
|
||||
const template = document.getElementById('toast-template') as HTMLTemplateElement | null;
|
||||
|
||||
if (!template) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const toast = template.content.cloneNode(true) as HTMLElement
|
||||
const messageEl = toast.querySelector('.toast-message')
|
||||
const closeBtn = toast.querySelector('.toast-close')
|
||||
const toast = template.content.cloneNode(true) as HTMLElement;
|
||||
const messageEl = toast.querySelector('.toast-message');
|
||||
const closeBtn = toast.querySelector('.toast-close');
|
||||
|
||||
if (messageEl) {
|
||||
messageEl.textContent = message
|
||||
messageEl.textContent = message;
|
||||
}
|
||||
|
||||
closeBtn?.addEventListener('click', () => toast.remove())
|
||||
closeBtn?.addEventListener('click', () => toast.remove());
|
||||
|
||||
container.appendChild(toast)
|
||||
container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.remove('translate-y-2', 'opacity-0')
|
||||
})
|
||||
toast.classList.remove('translate-y-2', 'opacity-0');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('translate-y-2', 'opacity-0')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, duration)
|
||||
}
|
||||
toast.classList.add('translate-y-2', 'opacity-0');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
showToast: typeof showToast
|
||||
showToast: typeof showToast;
|
||||
}
|
||||
}
|
||||
|
||||
window.showToast = showToast
|
||||
window.showToast = showToast;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export const parseClassList = (value: string | null): string[] => {
|
||||
if (!value) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(' ')
|
||||
.map((entry: string): string => entry.trim())
|
||||
.filter((entry: string): boolean => entry.length > 0)
|
||||
}
|
||||
.filter((entry: string): boolean => entry.length > 0);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user