diff --git a/package-lock.json b/package-lock.json
index a2cbcf8..2bbfe63 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.5",
+ "@size-limit/preset-small-lib": "^12.0.1",
"@storybook/addon-essentials": "^8.5.0",
"@storybook/addon-interactions": "^8.5.0",
"@storybook/addon-links": "^8.5.0",
@@ -25,14 +26,17 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
+ "@vitest/coverage-v8": "^4.1.4",
"autoprefixer": "^10.4.20",
"jsdom": "^26.0.0",
"postcss": "^8.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "size-limit": "^12.0.1",
"storybook": "^8.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
@@ -273,13 +277,13 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
- "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.6"
+ "@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -365,9 +369,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
- "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -378,6 +382,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@chromatic-com/storybook": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-3.2.7.tgz",
@@ -1756,37 +1770,585 @@
"strip-json-comments": "~3.1.1"
}
},
- "node_modules/@rushstack/terminal": {
- "version": "0.19.5",
- "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.19.5.tgz",
- "integrity": "sha512-6k5tpdB88G0K7QrH/3yfKO84HK9ggftfUZ51p7fePyCE7+RLLHkWZbID9OFWbXuna+eeCFE7AkKnRMHMxNbz7Q==",
+ "node_modules/@rushstack/terminal": {
+ "version": "0.19.5",
+ "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.19.5.tgz",
+ "integrity": "sha512-6k5tpdB88G0K7QrH/3yfKO84HK9ggftfUZ51p7fePyCE7+RLLHkWZbID9OFWbXuna+eeCFE7AkKnRMHMxNbz7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rushstack/node-core-library": "5.19.1",
+ "@rushstack/problem-matcher": "0.1.1",
+ "supports-color": "~8.1.1"
+ },
+ "peerDependencies": {
+ "@types/node": "*"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rushstack/ts-command-line": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.1.5.tgz",
+ "integrity": "sha512-YmrFTFUdHXblYSa+Xc9OO9FsL/XFcckZy0ycQ6q7VSBsVs5P0uD9vcges5Q9vctGlVdu27w+Ct6IuJ458V0cTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rushstack/terminal": "0.19.5",
+ "@types/argparse": "1.0.38",
+ "argparse": "~1.0.9",
+ "string-argv": "~0.3.1"
+ }
+ },
+ "node_modules/@size-limit/esbuild": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@size-limit/esbuild/-/esbuild-12.0.1.tgz",
+ "integrity": "sha512-Z6km06//90REJ30+WmMWvngG9dZnY52z3bhGxkoOwyXaAwPuQgx6ZdD1edNDABXIZMatbeMejigBPNEl4OaFsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.3",
+ "nanoid": "^5.1.6"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "size-limit": "12.0.1"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/@size-limit/esbuild/node_modules/nanoid": {
+ "version": "5.1.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz",
+ "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ }
+ },
+ "node_modules/@size-limit/file": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-12.0.1.tgz",
+ "integrity": "sha512-Kvbnz46iV7WeHaANf1HmWjXBVMU2KkCU+0xJ78FzIjZwlVKKEqy+QCZprdBMfIWrzrvYeqP4cfuzKG8z6xVivg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "@rushstack/node-core-library": "5.19.1",
- "@rushstack/problem-matcher": "0.1.1",
- "supports-color": "~8.1.1"
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"peerDependencies": {
- "@types/node": "*"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- }
+ "size-limit": "12.0.1"
}
},
- "node_modules/@rushstack/ts-command-line": {
- "version": "5.1.5",
- "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.1.5.tgz",
- "integrity": "sha512-YmrFTFUdHXblYSa+Xc9OO9FsL/XFcckZy0ycQ6q7VSBsVs5P0uD9vcges5Q9vctGlVdu27w+Ct6IuJ458V0cTQ==",
+ "node_modules/@size-limit/preset-small-lib": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@size-limit/preset-small-lib/-/preset-small-lib-12.0.1.tgz",
+ "integrity": "sha512-WqA87RAzGgYOWk0K7WPbgWKlT98eDf5I0DHFD+CNwOck+Cfcchp+rh3QQNTdW5WKDjSZLqGd+rK2ZSca7DPJCg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@rushstack/terminal": "0.19.5",
- "@types/argparse": "1.0.38",
- "argparse": "~1.0.9",
- "string-argv": "~0.3.1"
+ "@size-limit/esbuild": "12.0.1",
+ "@size-limit/file": "12.0.1",
+ "size-limit": "12.0.1"
+ },
+ "peerDependencies": {
+ "size-limit": "12.0.1"
}
},
"node_modules/@standard-schema/spec": {
@@ -2027,6 +2589,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@storybook/addon-interactions/node_modules/@testing-library/user-event": {
+ "version": "14.5.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
+ "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@storybook/addon-interactions/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2564,6 +3140,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@storybook/test/node_modules/@testing-library/user-event": {
+ "version": "14.5.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
+ "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@storybook/test/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2683,9 +3273,9 @@
}
},
"node_modules/@testing-library/user-event": {
- "version": "14.5.2",
- "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
- "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2857,6 +3447,75 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz",
+ "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.4",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.4",
+ "vitest": "4.1.4"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
+ "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
+ "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.4",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/coverage-v8/node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@vitest/expect": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz",
@@ -2913,13 +3572,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz",
- "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
+ "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.0.17",
+ "@vitest/spy": "4.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2928,7 +3587,7 @@
},
"peerDependencies": {
"msw": "^2.4.9",
- "vite": "^6.0.0 || ^7.0.0-0"
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
@@ -2940,9 +3599,9 @@
}
},
"node_modules/@vitest/mocker/node_modules/@vitest/spy": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz",
- "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
+ "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2973,13 +3632,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz",
- "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
+ "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.0.17",
+ "@vitest/utils": "4.1.4",
"pathe": "^2.0.3"
},
"funding": {
@@ -2987,36 +3646,37 @@
}
},
"node_modules/@vitest/runner/node_modules/@vitest/pretty-format": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz",
- "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
+ "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^3.0.3"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner/node_modules/@vitest/utils": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz",
- "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
+ "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.17",
- "tinyrainbow": "^3.0.3"
+ "@vitest/pretty-format": "4.1.4",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner/node_modules/tinyrainbow": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
- "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3024,13 +3684,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz",
- "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
+ "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.17",
+ "@vitest/pretty-format": "4.1.4",
+ "@vitest/utils": "4.1.4",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3039,22 +3700,37 @@
}
},
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz",
- "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
+ "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot/node_modules/@vitest/utils": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
+ "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^3.0.3"
+ "@vitest/pretty-format": "4.1.4",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot/node_modules/tinyrainbow": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
- "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3386,6 +4062,35 @@
"node": ">=4"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
+ "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/autoprefixer": {
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@@ -3545,6 +4250,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/bytes-iec": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz",
+ "integrity": "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -4085,9 +4800,9 @@
}
},
"node_modules/es-module-lexer": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
- "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -4616,6 +5331,13 @@
"node": ">=18"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -4884,6 +5606,58 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -4901,13 +5675,15 @@
}
},
"node_modules/jiti": {
- "version": "1.21.7",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
- "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
+ "peer": true,
"bin": {
- "jiti": "bin/jiti.js"
+ "jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jju": {
@@ -5138,6 +5914,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
+ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/map-or-similar": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz",
@@ -5325,6 +6142,16 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/nanospinner": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/nanospinner/-/nanospinner-1.2.2.tgz",
+ "integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picocolors": "^1.1.1"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -6245,6 +7072,34 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/size-limit": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-12.0.1.tgz",
+ "integrity": "sha512-vuFj+6lDOoBJQu6OLhcMQv7jnbXjuoEn4WsQHlSLOV/8EFfOka/tfjtLQ/rZig5Gagi3R0GnU/0kd4EY/y2etg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes-iec": "^3.1.1",
+ "lilconfig": "^3.1.3",
+ "nanospinner": "^1.2.2",
+ "picocolors": "^1.1.1",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "size-limit": "bin.js"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "jiti": "^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -6280,9 +7135,9 @@
"license": "MIT"
},
"node_modules/std-env": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
- "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
@@ -6563,6 +7418,16 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tailwindcss/node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -6976,31 +7841,31 @@
}
},
"node_modules/vitest": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz",
- "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
+ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.0.17",
- "@vitest/mocker": "4.0.17",
- "@vitest/pretty-format": "4.0.17",
- "@vitest/runner": "4.0.17",
- "@vitest/snapshot": "4.0.17",
- "@vitest/spy": "4.0.17",
- "@vitest/utils": "4.0.17",
- "es-module-lexer": "^1.7.0",
- "expect-type": "^1.2.2",
+ "@vitest/expect": "4.1.4",
+ "@vitest/mocker": "4.1.4",
+ "@vitest/pretty-format": "4.1.4",
+ "@vitest/runner": "4.1.4",
+ "@vitest/snapshot": "4.1.4",
+ "@vitest/spy": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
- "std-env": "^3.10.0",
+ "std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.0.3",
- "vite": "^6.0.0 || ^7.0.0",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -7016,12 +7881,15 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.0.17",
- "@vitest/browser-preview": "4.0.17",
- "@vitest/browser-webdriverio": "4.0.17",
- "@vitest/ui": "4.0.17",
+ "@vitest/browser-playwright": "4.1.4",
+ "@vitest/browser-preview": "4.1.4",
+ "@vitest/browser-webdriverio": "4.1.4",
+ "@vitest/coverage-istanbul": "4.1.4",
+ "@vitest/coverage-v8": "4.1.4",
+ "@vitest/ui": "4.1.4",
"happy-dom": "*",
- "jsdom": "*"
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
@@ -7042,6 +7910,12 @@
"@vitest/browser-webdriverio": {
"optional": true
},
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
"@vitest/ui": {
"optional": true
},
@@ -7050,44 +7924,47 @@
},
"jsdom": {
"optional": true
+ },
+ "vite": {
+ "optional": false
}
}
},
"node_modules/vitest/node_modules/@vitest/expect": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz",
- "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
+ "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.0.17",
- "@vitest/utils": "4.0.17",
- "chai": "^6.2.1",
- "tinyrainbow": "^3.0.3"
+ "@vitest/spy": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest/node_modules/@vitest/pretty-format": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz",
- "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
+ "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^3.0.3"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest/node_modules/@vitest/spy": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz",
- "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
+ "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -7095,14 +7972,15 @@
}
},
"node_modules/vitest/node_modules/@vitest/utils": {
- "version": "4.0.17",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz",
- "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==",
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
+ "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.17",
- "tinyrainbow": "^3.0.3"
+ "@vitest/pretty-format": "4.1.4",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -7119,9 +7997,9 @@
}
},
"node_modules/vitest/node_modules/tinyrainbow": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
- "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index 19c2487..5814b02 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,10 @@
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "size": "size-limit",
+ "size:check": "size-limit --limit",
+ "ci": "npm run typecheck && npm run test -- --run && npm run build:lib"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
@@ -56,6 +59,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.5",
+ "@size-limit/preset-small-lib": "^12.0.1",
"@storybook/addon-essentials": "^8.5.0",
"@storybook/addon-interactions": "^8.5.0",
"@storybook/addon-links": "^8.5.0",
@@ -66,14 +70,17 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
+ "@vitest/coverage-v8": "^4.1.4",
"autoprefixer": "^10.4.20",
"jsdom": "^26.0.0",
"postcss": "^8.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "size-limit": "^12.0.1",
"storybook": "^8.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
@@ -94,5 +101,17 @@
"repository": {
"type": "git",
"url": "https://github.com/sushidev-team/fairu-player.git"
- }
+ },
+ "size-limit": [
+ {
+ "path": "./dist/index.js",
+ "import": "*",
+ "limit": "80 kB",
+ "ignore": ["react", "react-dom"]
+ },
+ {
+ "path": "./dist/player.css",
+ "limit": "15 kB"
+ }
+ ]
}
diff --git a/src/components/ErrorBoundary/PlayerErrorBoundary.stories.tsx b/src/components/ErrorBoundary/PlayerErrorBoundary.stories.tsx
new file mode 100644
index 0000000..8ceeb16
--- /dev/null
+++ b/src/components/ErrorBoundary/PlayerErrorBoundary.stories.tsx
@@ -0,0 +1,100 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { PlayerErrorBoundary } from './PlayerErrorBoundary';
+
+function ErrorThrower(): never {
+ throw new Error('Something went wrong in the player');
+}
+
+function ConditionalErrorThrower({ shouldError }: { shouldError: boolean }) {
+ if (shouldError) {
+ throw new Error('Something went wrong in the player');
+ }
+ return
Player content is working fine
;
+}
+
+const meta: Meta = {
+ title: 'Components/PlayerErrorBoundary',
+ component: PlayerErrorBoundary,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ values: [{ name: 'dark', value: '#121212' }],
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const DefaultFallback: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const InlineFallback: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const CustomFallback: Story = {
+ render: () => (
+ (
+
+
!
+
Custom Error UI
+
{error.message}
+
+ Reset Player
+
+
+ )}
+ >
+
+
+ ),
+};
+
+export const NoError: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const WithClassName: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/src/components/ErrorBoundary/PlayerErrorBoundary.tsx b/src/components/ErrorBoundary/PlayerErrorBoundary.tsx
new file mode 100644
index 0000000..699adb7
--- /dev/null
+++ b/src/components/ErrorBoundary/PlayerErrorBoundary.tsx
@@ -0,0 +1,165 @@
+import { Component, type ReactNode, type ErrorInfo } from 'react';
+import { cn } from '@/utils/cn';
+
+export interface PlayerErrorBoundaryProps {
+ children: ReactNode;
+ /** Custom fallback UI. If not provided, uses default fallback */
+ fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
+ /** Called when an error is caught */
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
+ /** Additional CSS classes for the fallback container */
+ className?: string;
+ /** If true, shows a minimal inline fallback instead of a full block */
+ inline?: boolean;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+/**
+ * Error boundary component for catching render errors in the player.
+ * Provides a graceful fallback UI when an error occurs.
+ */
+export class PlayerErrorBoundary extends Component {
+ constructor(props: PlayerErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
+ this.props.onError?.(error, errorInfo);
+ }
+
+ reset = (): void => {
+ this.setState({ hasError: false, error: null });
+ };
+
+ render(): ReactNode {
+ if (!this.state.hasError || !this.state.error) {
+ return this.props.children;
+ }
+
+ const { fallback, className, inline } = this.props;
+ const error = this.state.error;
+
+ // Custom fallback (ReactNode or render function)
+ if (fallback !== undefined) {
+ if (typeof fallback === 'function') {
+ return fallback(error, this.reset);
+ }
+ return fallback;
+ }
+
+ // Inline variant: compact single-line fallback
+ if (inline) {
+ return (
+
+
+
+
+
+
+
Error
+
+
+
+
+
+
+
+ );
+ }
+
+ // Default block fallback
+ const truncatedMessage =
+ error.message.length > 100
+ ? `${error.message.slice(0, 100)}...`
+ : error.message;
+
+ return (
+
+ {/* Warning triangle icon */}
+
+
+
+
+
+
+
+ Something went wrong
+
+
+ {truncatedMessage && (
+
+ {truncatedMessage}
+
+ )}
+
+
+ Try Again
+
+
+ );
+ }
+}
diff --git a/src/components/ErrorBoundary/index.ts b/src/components/ErrorBoundary/index.ts
new file mode 100644
index 0000000..702e1d7
--- /dev/null
+++ b/src/components/ErrorBoundary/index.ts
@@ -0,0 +1 @@
+export { PlayerErrorBoundary, type PlayerErrorBoundaryProps } from './PlayerErrorBoundary';
diff --git a/src/components/Player/EpisodeView/EpisodeView.stories.tsx b/src/components/Player/EpisodeView/EpisodeView.stories.tsx
index 468509f..5762d60 100644
--- a/src/components/Player/EpisodeView/EpisodeView.stories.tsx
+++ b/src/components/Player/EpisodeView/EpisodeView.stories.tsx
@@ -128,7 +128,7 @@ export const WithAds: Story = {
ads: [
{
id: 'ad-1',
- src: 'https://example.com/ad.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
duration: 10,
skipAfterSeconds: 5,
title: 'Sponsor: Fairu Premium',
diff --git a/src/components/Player/NowPlayingView/NowPlayingView.stories.tsx b/src/components/Player/NowPlayingView/NowPlayingView.stories.tsx
index 8ee7ceb..ac7dcce 100644
--- a/src/components/Player/NowPlayingView/NowPlayingView.stories.tsx
+++ b/src/components/Player/NowPlayingView/NowPlayingView.stories.tsx
@@ -4,7 +4,7 @@ import { PlayerProvider } from '@/context/PlayerContext';
const sampleTrack = {
id: '1',
- src: 'https://example.com/audio.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Awesome Track Title',
artist: 'Amazing Artist',
album: 'Greatest Hits',
diff --git a/src/components/Player/Player.stories.tsx b/src/components/Player/Player.stories.tsx
index c26c135..a3ca588 100644
--- a/src/components/Player/Player.stories.tsx
+++ b/src/components/Player/Player.stories.tsx
@@ -13,7 +13,7 @@ import {
// Sample tracks for stories
const sampleTrack: Track = {
id: '1',
- src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Sample Podcast Episode',
artist: 'Podcast Host',
artwork: 'https://picsum.photos/200',
@@ -29,7 +29,7 @@ const sampleTrack: Track = {
const samplePlaylist: Track[] = [
{
id: '1',
- src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Episode 1: Getting Started',
artist: 'Tech Podcast',
artwork: 'https://picsum.photos/200?random=1',
@@ -37,7 +37,7 @@ const samplePlaylist: Track[] = [
},
{
id: '2',
- src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Episode 2: Advanced Topics',
artist: 'Tech Podcast',
artwork: 'https://picsum.photos/200?random=2',
@@ -45,7 +45,7 @@ const samplePlaylist: Track[] = [
},
{
id: '3',
- src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Episode 3: Best Practices',
artist: 'Tech Podcast',
artwork: 'https://picsum.photos/200?random=3',
@@ -200,7 +200,7 @@ function FairuHostingDemo() {
// For the demo, use a real audio file but show the Fairu pattern
const demoTrack = {
id: exampleUuid,
- src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Podcast Episode (Demo)',
artist: 'Fairu Podcast',
artwork: 'https://picsum.photos/200',
@@ -215,7 +215,7 @@ function FairuHostingDemo() {
Hosting Mode
- Mit fairu.app benötigst du nur die UUID. URLs werden automatisch generiert.
+ With fairu.app you only need the UUID. URLs are generated automatically.
@@ -276,9 +276,9 @@ export const FairuHosting: Story = {
*/
function FairuPlaylistDemo() {
const fairuTracks: FairuTrack[] = [
- { uuid: 'uuid-episode-1', title: 'Episode 1: Einführung', artist: 'Podcast Host' },
+ { uuid: 'uuid-episode-1', title: 'Episode 1: Introduction', artist: 'Podcast Host' },
{ uuid: 'uuid-episode-2', title: 'Episode 2: Deep Dive', artist: 'Podcast Host' },
- { uuid: 'uuid-episode-3', title: 'Episode 3: Fazit', artist: 'Podcast Host' },
+ { uuid: 'uuid-episode-3', title: 'Episode 3: Summary', artist: 'Podcast Host' },
];
// Show generated playlist structure
@@ -286,9 +286,9 @@ function FairuPlaylistDemo() {
// For demo, use real audio files
const demoPlaylist = [
- { id: 'uuid-episode-1', src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', title: 'Episode 1: Einführung', artist: 'Podcast Host', artwork: 'https://picsum.photos/200?random=1' },
- { id: 'uuid-episode-2', src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3', title: 'Episode 2: Deep Dive', artist: 'Podcast Host', artwork: 'https://picsum.photos/200?random=2' },
- { id: 'uuid-episode-3', src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3', title: 'Episode 3: Fazit', artist: 'Podcast Host', artwork: 'https://picsum.photos/200?random=3' },
+ { id: 'uuid-episode-1', src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3', title: 'Episode 1: Introduction', artist: 'Podcast Host', artwork: 'https://picsum.photos/200?random=1' },
+ { id: 'uuid-episode-2', src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3', title: 'Episode 2: Deep Dive', artist: 'Podcast Host', artwork: 'https://picsum.photos/200?random=2' },
+ { id: 'uuid-episode-3', src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3', title: 'Episode 3: Summary', artist: 'Podcast Host', artwork: 'https://picsum.photos/200?random=3' },
];
return (
@@ -380,7 +380,7 @@ function FairuUrlUtilitiesDemo() {
{/* Available functions */}
-
Verfügbare Funktionen:
+
Available Features:
getFairuAudioUrl(uuid)
diff --git a/src/components/Player/Player.tsx b/src/components/Player/Player.tsx
index 1131b1f..08a929a 100644
--- a/src/components/Player/Player.tsx
+++ b/src/components/Player/Player.tsx
@@ -13,6 +13,7 @@ import { ChapterList } from '@/components/chapters/ChapterList';
import { PlaylistView } from '@/components/playlist/PlaylistView';
import { PlaylistControls } from '@/components/playlist/PlaylistControls';
import { AdOverlay } from '@/components/ads/AdOverlay';
+import { PlayerErrorBoundary } from '@/components/ErrorBoundary';
import type { PlayerProps, Chapter } from '@/types/player';
import type { AdState, AdControls } from '@/types/ads';
@@ -68,204 +69,206 @@ export function PlayerInner({
const hasMultipleTracks = playlistState.tracks.length > 1;
return (
-
- {/* Subtle gradient overlay */}
+
+ className={cn(
+ 'fairu-player relative overflow-hidden',
+ // Glassmorphism background
+ 'bg-[var(--fp-glass-bg)] backdrop-blur-[20px]',
+ // Border and radius
+ 'rounded-xl',
+ 'border border-[var(--fp-glass-border)]',
+ // Enhanced shadow
+ 'shadow-[0_8px_32px_rgba(0,0,0,0.4)]',
+ // Padding
+ 'p-5',
+ className
+ )}
+ >
+ {/* Subtle gradient overlay */}
+
- {/* Ad Overlay */}
- {adState && adControls && (
-
- )}
+ {/* Ad Overlay */}
+ {adState && adControls && (
+
+ )}
- {/* Track info */}
- {currentTrack && (
-
- {currentTrack.artwork && (
-
- {/* Artwork glow reflection */}
-
-
-
- )}
-
-
- {currentTrack.title || 'Untitled'}
-
- {currentTrack.artist && (
-
- {currentTrack.artist}
-
- )}
- {chapterState.currentChapter && (
-
- Chapter: {chapterState.currentChapter.title}
-
+ {/* Track info */}
+ {currentTrack && (
+
+ {currentTrack.artwork && (
+
+ {/* Artwork glow reflection */}
+
+
+
)}
+
+
+ {currentTrack.title || 'Untitled'}
+
+ {currentTrack.artist && (
+
+ {currentTrack.artist}
+
+ )}
+ {chapterState.currentChapter && (
+
+ Chapter: {chapterState.currentChapter.title}
+
+ )}
+
-
- )}
-
- {/* Progress bar */}
- {features.progressBar !== false && (
-
- )}
+ )}
- {/* Controls */}
-
-
- {/* Skip backward */}
- {features.skipButtons !== false && (
-
controls.skipBackward()}
+ {/* Progress bar */}
+ {features.progressBar !== false && (
+
+ )}
- {/* Play/Pause */}
-
+ {/* Controls */}
+
+
+ {/* Skip backward */}
+ {features.skipButtons !== false && (
+
controls.skipBackward()}
+ disabled={controlsDisabled}
+ />
+ )}
- {/* Skip forward */}
- {features.skipButtons !== false && (
- controls.skipForward()}
+ {/* Play/Pause */}
+
- )}
-
- {/* Time display */}
- {features.timeDisplay !== false && (
-
- )}
+ {/* Skip forward */}
+ {features.skipButtons !== false && (
+
controls.skipForward()}
+ disabled={controlsDisabled}
+ />
+ )}
+
-
- {/* Playlist controls */}
- {hasMultipleTracks && (
-
0 || playlistState.repeat === 'all'}
- hasNext={playlistState.currentIndex < playlistState.tracks.length - 1 || playlistState.repeat === 'all'}
- shuffle={playlistState.shuffle}
- repeat={playlistState.repeat}
- onPrevious={playlistControls.previous}
- onNext={playlistControls.next}
- onShuffleToggle={playlistControls.toggleShuffle}
- onRepeatChange={playlistControls.setRepeat}
- disabled={controlsDisabled}
+ {/* Time display */}
+ {features.timeDisplay !== false && (
+
)}
- {/* Playback speed */}
- {features.playbackSpeed !== false && (
-
- )}
+
+ {/* Playlist controls */}
+ {hasMultipleTracks && (
+
0 || playlistState.repeat === 'all'}
+ hasNext={playlistState.currentIndex < playlistState.tracks.length - 1 || playlistState.repeat === 'all'}
+ shuffle={playlistState.shuffle}
+ repeat={playlistState.repeat}
+ onPrevious={playlistControls.previous}
+ onNext={playlistControls.next}
+ onShuffleToggle={playlistControls.toggleShuffle}
+ onRepeatChange={playlistControls.setRepeat}
+ disabled={controlsDisabled}
+ />
+ )}
- {/* Volume */}
- {features.volumeControl !== false && (
-
- )}
-
-
+ {/* Playback speed */}
+ {features.playbackSpeed !== false && (
+
+ )}
- {/* Chapter list */}
- {showChapters && features.chapters !== false && chapters.length > 0 && (
-
- controls.seek(chapter.startTime)}
- />
+ {/* Volume */}
+ {features.volumeControl !== false && (
+
+ )}
+
- )}
- {/* Playlist view */}
- {showPlaylist && features.playlistView !== false && hasMultipleTracks && (
-
- )}
+ {/* Chapter list */}
+ {showChapters && features.chapters !== false && chapters.length > 0 && (
+
+ controls.seek(chapter.startTime)}
+ />
+
+ )}
- {/* Error display */}
- {state.error && (
-
- Error: {state.error.message}
-
- )}
-
+ {/* Playlist view */}
+ {showPlaylist && features.playlistView !== false && hasMultipleTracks && (
+
+ )}
+
+ {/* Error display */}
+ {state.error && (
+
+ Error: {state.error.message}
+
+ )}
+
+
);
}
diff --git a/src/components/VideoPlayer/EndScreen/EndScreen.test.tsx b/src/components/VideoPlayer/EndScreen/EndScreen.test.tsx
new file mode 100644
index 0000000..fefbed0
--- /dev/null
+++ b/src/components/VideoPlayer/EndScreen/EndScreen.test.tsx
@@ -0,0 +1,606 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { EndScreen } from './EndScreen';
+import { RecommendedCard } from './RecommendedCard';
+import { AutoPlayCountdown } from './AutoPlayCountdown';
+import type { EndScreenConfig, RecommendedVideo } from '@/types/video';
+
+// ─── Helpers ────────────────────────────────────────────────────────
+
+function createRecommendedVideo(overrides: Partial
= {}): RecommendedVideo {
+ return {
+ id: 'rec-1',
+ title: 'Recommended Video 1',
+ thumbnail: 'https://example.com/thumb1.jpg',
+ duration: 120,
+ channel: 'Test Channel',
+ channelAvatar: 'https://example.com/avatar.jpg',
+ views: '1.2M views',
+ ...overrides,
+ };
+}
+
+function createEndScreenConfig(overrides: Partial = {}): EndScreenConfig {
+ return {
+ enabled: true,
+ recommendations: [
+ createRecommendedVideo({ id: 'rec-1', title: 'Video One' }),
+ createRecommendedVideo({ id: 'rec-2', title: 'Video Two' }),
+ createRecommendedVideo({ id: 'rec-3', title: 'Video Three' }),
+ ],
+ ...overrides,
+ };
+}
+
+// ─── EndScreen ──────────────────────────────────────────────────────
+
+describe('EndScreen', () => {
+ it('renders nothing when not ended and not near end', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders nothing when not enabled', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders nothing when no recommendations', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders when video has ended', () => {
+ render(
+
+ );
+ expect(screen.getByText('Recommended Videos')).toBeInTheDocument();
+ });
+
+ it('renders when near end (within showAt seconds)', () => {
+ render(
+
+ );
+ expect(screen.getByText('Recommended Videos')).toBeInTheDocument();
+ });
+
+ it('renders custom title', () => {
+ render(
+
+ );
+ expect(screen.getByText('More Videos')).toBeInTheDocument();
+ });
+
+ it('renders replay button when showReplay is true and onReplay is provided', () => {
+ const onReplay = vi.fn();
+ render(
+
+ );
+ expect(screen.getByText('Replay')).toBeInTheDocument();
+ });
+
+ it('calls onReplay when replay button is clicked', () => {
+ const onReplay = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Replay'));
+ expect(onReplay).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not render replay button when showReplay is false', () => {
+ render(
+
+ );
+ expect(screen.queryByText('Replay')).not.toBeInTheDocument();
+ });
+
+ it('does not render replay button when onReplay is not provided', () => {
+ render(
+
+ );
+ expect(screen.queryByText('Replay')).not.toBeInTheDocument();
+ });
+
+ it('renders all recommendation cards in grid layout', () => {
+ render(
+
+ );
+ expect(screen.getByText('Video One')).toBeInTheDocument();
+ expect(screen.getByText('Video Two')).toBeInTheDocument();
+ expect(screen.getByText('Video Three')).toBeInTheDocument();
+ });
+
+ it('renders recommendations in carousel layout', () => {
+ render(
+
+ );
+ expect(screen.getByText('Video One')).toBeInTheDocument();
+ expect(screen.getByText('Video Two')).toBeInTheDocument();
+ });
+
+ it('limits displayed videos to columns * 2 in grid', () => {
+ const many = Array.from({ length: 10 }, (_, i) =>
+ createRecommendedVideo({ id: `rec-${i}`, title: `Video ${i}` })
+ );
+ render(
+
+ );
+ // columns=2, max = 4
+ expect(screen.getByText('Video 0')).toBeInTheDocument();
+ expect(screen.getByText('Video 3')).toBeInTheDocument();
+ expect(screen.queryByText('Video 4')).not.toBeInTheDocument();
+ });
+
+ it('applies 2-column grid class', () => {
+ const { container } = render(
+
+ );
+ const grid = container.querySelector('.grid');
+ expect(grid?.className).toContain('grid-cols-2');
+ });
+
+ it('applies 4-column grid class', () => {
+ const { container } = render(
+
+ );
+ const grid = container.querySelector('.grid');
+ expect(grid?.className).toContain('md:grid-cols-4');
+ });
+
+ it('calls onVideoSelect and config.onVideoSelect when a card is clicked', () => {
+ const onVideoSelect = vi.fn();
+ const configOnVideoSelect = vi.fn();
+ const config = createEndScreenConfig({ onVideoSelect: configOnVideoSelect });
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Video One'));
+ expect(onVideoSelect).toHaveBeenCalledWith(config.recommendations[0]);
+ expect(configOnVideoSelect).toHaveBeenCalledWith(config.recommendations[0]);
+ });
+
+ it('renders autoplay countdown when autoPlayNext is true and video ended', () => {
+ render(
+
+ );
+ // "Up next" appears in both badge and countdown area
+ const upNextElements = screen.getAllByText('Up next');
+ expect(upNextElements.length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ });
+
+ it('does not render autoplay countdown when video has not ended', () => {
+ render(
+
+ );
+ // The autoplay countdown should not show (it only appears when isEnded=true).
+ // However, the first card still shows an "Up next" badge.
+ // The countdown-specific text is "Cancel" which should not appear.
+ expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
+ });
+
+ it('hides autoplay countdown after cancel', () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(screen.queryByText('Up next')).not.toBeInTheDocument();
+ });
+
+ it('applies custom className', () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector('.fairu-end-screen')?.className).toContain('my-custom-end');
+ });
+
+ it('marks first card as "up next" when autoPlayNext is enabled', () => {
+ render(
+
+ );
+ // "Up next" appears both as badge on card and in the autoplay countdown
+ const upNextElements = screen.getAllByText('Up next');
+ expect(upNextElements.length).toBeGreaterThanOrEqual(1);
+ });
+});
+
+// ─── RecommendedCard ────────────────────────────────────────────────
+
+describe('RecommendedCard', () => {
+ it('renders video title', () => {
+ const video = createRecommendedVideo({ title: 'My Great Video' });
+ render( );
+ expect(screen.getByText('My Great Video')).toBeInTheDocument();
+ });
+
+ it('renders thumbnail image', () => {
+ const video = createRecommendedVideo({ thumbnail: 'https://example.com/thumb.jpg' });
+ render( );
+ const img = screen.getByAltText('Recommended Video 1');
+ expect(img).toBeInTheDocument();
+ });
+
+ it('renders duration badge', () => {
+ const video = createRecommendedVideo({ duration: 125 });
+ render( );
+ // 125 seconds = 2:05
+ expect(screen.getByText('2:05')).toBeInTheDocument();
+ });
+
+ it('does not render duration badge when duration is undefined', () => {
+ const video = createRecommendedVideo({ duration: undefined });
+ render( );
+ expect(screen.queryByText(/^\d+:\d+$/)).not.toBeInTheDocument();
+ });
+
+ it('renders channel name', () => {
+ const video = createRecommendedVideo({ channel: 'Cool Channel' });
+ render( );
+ expect(screen.getByText('Cool Channel')).toBeInTheDocument();
+ });
+
+ it('renders channel avatar when provided', () => {
+ const video = createRecommendedVideo({
+ channelAvatar: 'https://example.com/avatar.jpg',
+ channel: 'Cool Channel',
+ });
+ render( );
+ const avatarImg = screen.getByAltText('Cool Channel');
+ expect(avatarImg).toBeInTheDocument();
+ expect(avatarImg.getAttribute('src')).toBe('https://example.com/avatar.jpg');
+ });
+
+ it('does not render channel avatar when not provided', () => {
+ const video = createRecommendedVideo({ channelAvatar: undefined, channel: 'Cool Channel' });
+ render( );
+ expect(screen.queryByAltText('Cool Channel')).not.toBeInTheDocument();
+ });
+
+ it('renders views count', () => {
+ const video = createRecommendedVideo({ views: '1.2M views' });
+ render( );
+ expect(screen.getByText('1.2M views')).toBeInTheDocument();
+ });
+
+ it('does not render views when not provided', () => {
+ const video = createRecommendedVideo({ views: undefined });
+ render( );
+ expect(screen.queryByText(/views/)).not.toBeInTheDocument();
+ });
+
+ it('calls onSelect when clicked', () => {
+ const onSelect = vi.fn();
+ const video = createRecommendedVideo();
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ expect(onSelect).toHaveBeenCalledWith(video);
+ });
+
+ it('calls onSelect when Enter key is pressed', () => {
+ const onSelect = vi.fn();
+ const video = createRecommendedVideo();
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ expect(onSelect).toHaveBeenCalledWith(video);
+ });
+
+ it('opens external URL when video has url but no src', () => {
+ const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null);
+ const video = createRecommendedVideo({ url: 'https://example.com/watch', src: undefined });
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ expect(windowOpen).toHaveBeenCalledWith('https://example.com/watch', '_blank');
+ windowOpen.mockRestore();
+ });
+
+ it('does not open external URL when video has src', () => {
+ const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null);
+ const video = createRecommendedVideo({ url: 'https://example.com/watch', src: 'https://example.com/video.mp4' });
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ expect(windowOpen).not.toHaveBeenCalled();
+ windowOpen.mockRestore();
+ });
+
+ it('shows "Up next" badge when isUpNext is true', () => {
+ const video = createRecommendedVideo();
+ render( );
+ expect(screen.getByText('Up next')).toBeInTheDocument();
+ });
+
+ it('does not show "Up next" badge by default', () => {
+ const video = createRecommendedVideo();
+ render( );
+ expect(screen.queryByText('Up next')).not.toBeInTheDocument();
+ });
+
+ it('applies ring styling when isUpNext', () => {
+ const video = createRecommendedVideo();
+ const { container } = render( );
+ const card = container.querySelector('.fairu-recommended-card');
+ expect(card?.className).toContain('ring-2');
+ });
+
+ it('applies custom className', () => {
+ const video = createRecommendedVideo();
+ const { container } = render( );
+ const card = container.querySelector('.fairu-recommended-card');
+ expect(card?.className).toContain('my-card');
+ });
+
+ it('has tabIndex 0 for keyboard accessibility', () => {
+ const video = createRecommendedVideo();
+ render( );
+ expect(screen.getByRole('button')).toHaveAttribute('tabindex', '0');
+ });
+
+ it('renders without channel info', () => {
+ const video = createRecommendedVideo({ channel: undefined, channelAvatar: undefined });
+ render( );
+ expect(screen.getByText(video.title)).toBeInTheDocument();
+ });
+});
+
+// ─── AutoPlayCountdown ─────────────────────────────────────────────
+
+describe('AutoPlayCountdown', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders when active', () => {
+ const video = createRecommendedVideo();
+ render( );
+ expect(screen.getByText('Up next')).toBeInTheDocument();
+ expect(screen.getByText(video.title)).toBeInTheDocument();
+ });
+
+ it('renders nothing when not active', () => {
+ const video = createRecommendedVideo();
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('shows countdown number', () => {
+ const video = createRecommendedVideo();
+ render( );
+ expect(screen.getByText('5')).toBeInTheDocument();
+ });
+
+ it('counts down every second', () => {
+ const video = createRecommendedVideo();
+ render( );
+ expect(screen.getByText('5')).toBeInTheDocument();
+
+ act(() => { vi.advanceTimersByTime(1000); });
+ expect(screen.getByText('4')).toBeInTheDocument();
+
+ act(() => { vi.advanceTimersByTime(1000); });
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+
+ it('calls onComplete when countdown reaches 0', () => {
+ const onComplete = vi.fn();
+ const video = createRecommendedVideo();
+ render( );
+
+ act(() => { vi.advanceTimersByTime(3000); });
+ expect(onComplete).toHaveBeenCalledWith(video);
+ });
+
+ it('shows cancel button when counting down', () => {
+ const video = createRecommendedVideo();
+ render( );
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ });
+
+ it('calls onCancel when cancel is clicked', () => {
+ const onCancel = vi.fn();
+ const video = createRecommendedVideo();
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('hides cancel button after cancelling', () => {
+ const video = createRecommendedVideo();
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
+ });
+
+ it('shows "Autoplay paused" text after cancel', () => {
+ const video = createRecommendedVideo();
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(screen.getByText('Autoplay paused')).toBeInTheDocument();
+ });
+
+ it('shows "Play" button text when paused', () => {
+ const video = createRecommendedVideo();
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(screen.getByText('Play')).toBeInTheDocument();
+ });
+
+ it('shows "Play now" button when actively counting', () => {
+ const video = createRecommendedVideo();
+ render( );
+ expect(screen.getByText('Play now')).toBeInTheDocument();
+ });
+
+ it('calls onComplete when "Play now" is clicked', () => {
+ const onComplete = vi.fn();
+ const video = createRecommendedVideo();
+ render( );
+ fireEvent.click(screen.getByText('Play now'));
+ expect(onComplete).toHaveBeenCalledWith(video);
+ });
+
+ it('stops countdown after cancel', () => {
+ const onComplete = vi.fn();
+ const video = createRecommendedVideo();
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ act(() => { vi.advanceTimersByTime(5000); });
+ expect(onComplete).not.toHaveBeenCalled();
+ });
+
+ it('renders channel name when provided', () => {
+ const video = createRecommendedVideo({ channel: 'My Channel' });
+ render( );
+ expect(screen.getByText('My Channel')).toBeInTheDocument();
+ });
+
+ it('does not render channel name when not provided', () => {
+ const video = createRecommendedVideo({ channel: undefined });
+ render( );
+ expect(screen.queryByText('Test Channel')).not.toBeInTheDocument();
+ });
+
+ it('renders thumbnail image', () => {
+ const video = createRecommendedVideo({ thumbnail: 'https://example.com/thumb.jpg' });
+ render( );
+ const img = screen.getByAltText(video.title);
+ expect(img).toBeInTheDocument();
+ expect(img.getAttribute('src')).toBe('https://example.com/thumb.jpg');
+ });
+
+ it('applies custom className', () => {
+ const video = createRecommendedVideo();
+ const { container } = render(
+
+ );
+ expect(container.querySelector('.fairu-autoplay-countdown')?.className).toContain('my-countdown');
+ });
+
+ it('calls onComplete via play button when paused', () => {
+ const onComplete = vi.fn();
+ const video = createRecommendedVideo();
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ fireEvent.click(screen.getByText('Play'));
+ expect(onComplete).toHaveBeenCalledWith(video);
+ });
+});
diff --git a/src/components/VideoPlayer/GestureOverlay/GestureOverlay.tsx b/src/components/VideoPlayer/GestureOverlay/GestureOverlay.tsx
new file mode 100644
index 0000000..f0cadd8
--- /dev/null
+++ b/src/components/VideoPlayer/GestureOverlay/GestureOverlay.tsx
@@ -0,0 +1,222 @@
+import { useEffect, useState, useCallback } from 'react';
+import { cn } from '@/utils/cn';
+
+export type GestureFeedbackType = 'skip-forward' | 'skip-backward' | 'swipe-up' | 'swipe-down' | 'swipe-left' | 'swipe-right';
+
+export interface GestureFeedback {
+ type: GestureFeedbackType;
+ label?: string;
+}
+
+export interface GestureOverlayProps {
+ /** The current gesture feedback to display, or null for none */
+ feedback: GestureFeedback | null;
+ /** Called when the feedback animation completes */
+ onDismiss?: () => void;
+ /** How long to show the feedback in ms (default: 800) */
+ displayDuration?: number;
+ className?: string;
+}
+
+/**
+ * Visual feedback overlay for touch gestures on the video player.
+ * Shows skip amount, volume indicators, and ripple animations.
+ */
+export function GestureOverlay({
+ feedback,
+ onDismiss,
+ displayDuration = 800,
+ className,
+}: GestureOverlayProps) {
+ const [visible, setVisible] = useState(false);
+ const [currentFeedback, setCurrentFeedback] = useState(null);
+
+ const dismiss = useCallback(() => {
+ setVisible(false);
+ onDismiss?.();
+ }, [onDismiss]);
+
+ useEffect(() => {
+ if (feedback) {
+ setCurrentFeedback(feedback);
+ setVisible(true);
+
+ const timer = setTimeout(() => {
+ dismiss();
+ }, displayDuration);
+
+ return () => clearTimeout(timer);
+ } else {
+ setVisible(false);
+ }
+ }, [feedback, displayDuration, dismiss]);
+
+ if (!visible || !currentFeedback) return null;
+
+ const isSkipForward = currentFeedback.type === 'skip-forward';
+ const isSkipBackward = currentFeedback.type === 'skip-backward';
+ const isSkip = isSkipForward || isSkipBackward;
+ const isSwipeVertical = currentFeedback.type === 'swipe-up' || currentFeedback.type === 'swipe-down';
+ const isSwipeHorizontal = currentFeedback.type === 'swipe-left' || currentFeedback.type === 'swipe-right';
+
+ return (
+
+ {/* Double-tap skip feedback */}
+ {isSkip && (
+
+ {/* Ripple background */}
+
+
+ {/* Skip label and icon */}
+
+ {/* Skip arrows */}
+
+ {isSkipBackward && (
+ <>
+
+
+ >
+ )}
+ {isSkipForward && (
+ <>
+
+
+ >
+ )}
+
+
+ {currentFeedback.label}
+
+
+
+ )}
+
+ {/* Swipe vertical feedback (volume) */}
+ {isSwipeVertical && (
+
+
+
+ {currentFeedback.label && (
+
+ {currentFeedback.label}
+
+ )}
+
+
+ )}
+
+ {/* Swipe horizontal feedback */}
+ {isSwipeHorizontal && (
+
+
+
+ {currentFeedback.label && (
+
+ {currentFeedback.label}
+
+ )}
+
+
+ )}
+
+ {/* Inline keyframes */}
+
+
+ );
+}
+
+/**
+ * Skip arrow icon for double-tap feedback
+ */
+function SkipArrow({ direction }: { direction: 'forward' | 'backward' }) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * Swipe direction indicator icon
+ */
+function SwipeIcon({ direction }: { direction: 'up' | 'down' | 'left' | 'right' }) {
+ const rotation = {
+ up: '-rotate-90',
+ down: 'rotate-90',
+ left: 'rotate-180',
+ right: '',
+ }[direction];
+
+ return (
+
+
+
+ );
+}
+
+export default GestureOverlay;
diff --git a/src/components/VideoPlayer/GestureOverlay/index.ts b/src/components/VideoPlayer/GestureOverlay/index.ts
new file mode 100644
index 0000000..c092476
--- /dev/null
+++ b/src/components/VideoPlayer/GestureOverlay/index.ts
@@ -0,0 +1,2 @@
+export { GestureOverlay, type GestureOverlayProps, type GestureFeedback, type GestureFeedbackType } from './GestureOverlay';
+export { GestureOverlay as default } from './GestureOverlay';
diff --git a/src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx b/src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx
new file mode 100644
index 0000000..cd7b5d2
--- /dev/null
+++ b/src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx
@@ -0,0 +1,421 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { LogoOverlay } from './LogoOverlay';
+import type { LogoConfig, LogoComponentProps } from '@/types/logo';
+
+// ─── Helpers ────────────────────────────────────────────────────────
+
+function createLogoConfig(overrides: Partial = {}): LogoConfig {
+ return {
+ src: 'https://example.com/logo.png',
+ alt: 'Test Logo',
+ ...overrides,
+ };
+}
+
+describe('LogoOverlay', () => {
+ // ── Basic rendering ───────────────────────────────────────────────
+
+ it('renders nothing when neither src nor component is provided', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders logo image when src is provided', () => {
+ render( );
+ const img = screen.getByAltText('Test Logo');
+ expect(img).toBeInTheDocument();
+ expect(img.getAttribute('src')).toBe('https://example.com/logo.png');
+ });
+
+ it('renders image with empty alt text by default', () => {
+ const { container } = render( );
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img?.getAttribute('alt')).toBe('');
+ });
+
+ it('image is not draggable', () => {
+ render( );
+ const img = screen.getByAltText('Test Logo');
+ expect(img.getAttribute('draggable')).toBe('false');
+ });
+
+ // ── Positioning ───────────────────────────────────────────────────
+
+ it('positions at top-left', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.top).toBe('16px');
+ expect(el.style.left).toBe('16px');
+ });
+
+ it('positions at top-right', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.top).toBe('16px');
+ expect(el.style.right).toBe('16px');
+ });
+
+ it('positions at bottom-left', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ // bottom = margin(16) + CONTROLS_HEIGHT(56) + offsetY(0) = 72
+ expect(el.style.bottom).toBe('72px');
+ expect(el.style.left).toBe('16px');
+ });
+
+ it('positions at bottom-right (default)', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.bottom).toBe('72px');
+ expect(el.style.right).toBe('16px');
+ });
+
+ it('applies custom margin', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.top).toBe('24px');
+ expect(el.style.left).toBe('24px');
+ });
+
+ it('applies offsetX and offsetY', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ // top = margin(16) + offsetY(5) = 21
+ expect(el.style.top).toBe('21px');
+ // left = margin(16) + offsetX(10) = 26
+ expect(el.style.left).toBe('26px');
+ });
+
+ // ── Dimensions ────────────────────────────────────────────────────
+
+ it('applies custom width and height to image', () => {
+ render(
+
+ );
+ const img = screen.getByAltText('Test Logo');
+ expect(img.style.width).toBe('200px');
+ expect(img.style.height).toBe('80px');
+ expect(img.style.maxWidth).toBe('200px');
+ expect(img.style.maxHeight).toBe('80px');
+ });
+
+ it('applies default max dimensions when width/height not specified', () => {
+ render( );
+ const img = screen.getByAltText('Test Logo');
+ expect(img.style.width).toBe('auto');
+ expect(img.style.height).toBe('auto');
+ expect(img.style.maxWidth).toBe('120px');
+ expect(img.style.maxHeight).toBe('60px');
+ });
+
+ // ── Opacity ───────────────────────────────────────────────────────
+
+ it('applies default opacity (0.8)', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.opacity).toBe('0.8');
+ });
+
+ it('applies custom opacity', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.opacity).toBe('0.5');
+ });
+
+ // ── Click handler ─────────────────────────────────────────────────
+
+ it('calls onClick when clicked (div wrapper)', () => {
+ const onClick = vi.fn();
+ const { container } = render(
+
+ );
+ fireEvent.click(container.firstChild as HTMLElement);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('applies cursor-pointer class when onClick is provided', () => {
+ const { container } = render(
+
+ );
+ expect((container.firstChild as HTMLElement).className).toContain('cursor-pointer');
+ });
+
+ // ── Link (href) ───────────────────────────────────────────────────
+
+ it('renders as anchor when href is provided', () => {
+ const { container } = render(
+
+ );
+ const link = container.querySelector('a');
+ expect(link).toBeInTheDocument();
+ expect(link?.getAttribute('href')).toBe('https://example.com');
+ });
+
+ it('opens link in new tab by default', () => {
+ const { container } = render(
+
+ );
+ const link = container.querySelector('a');
+ expect(link?.getAttribute('target')).toBe('_blank');
+ expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
+ });
+
+ it('opens link in same tab when target is _self', () => {
+ const { container } = render(
+
+ );
+ const link = container.querySelector('a');
+ expect(link?.getAttribute('target')).toBe('_self');
+ expect(link?.getAttribute('rel')).toBeNull();
+ });
+
+ it('calls onClick and prevents default when both href and onClick are set', () => {
+ const onClick = vi.fn();
+ const { container } = render(
+
+ );
+ const link = container.querySelector('a')!;
+ fireEvent.click(link);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ // ── Animations ────────────────────────────────────────────────────
+
+ it('applies fade animation by default', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.transitionProperty).toBe('opacity, transform');
+ expect(el.style.opacity).toBe('0.8');
+ });
+
+ it('applies none animation with 0ms duration', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.transitionDuration).toBe('0ms');
+ });
+
+ it('applies slide animation with translateX(0) when visible', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.transform).toBe('translateX(0)');
+ });
+
+ it('applies slide animation with translateX(100%) when hidden on right side', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.transform).toBe('translateX(100%)');
+ });
+
+ it('applies slide animation with translateX(-100%) when hidden on left side', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.transform).toBe('translateX(-100%)');
+ });
+
+ it('applies scale animation with scale(1) when visible', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.transform).toBe('scale(1)');
+ });
+
+ it('applies scale animation with scale(0.75) when hidden', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.transform).toBe('scale(0.75)');
+ });
+
+ it('applies custom animation duration', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.transitionDuration).toBe('500ms');
+ });
+
+ it('applies custom animation delay', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.transitionDelay).toBe('200ms');
+ });
+
+ // ── hideWithControls ──────────────────────────────────────────────
+
+ it('sets opacity to 0 when hideWithControls is true and visible is false', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.opacity).toBe('0');
+ });
+
+ it('keeps full opacity when hideWithControls is true and visible is true', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.style.opacity).toBe('0.8');
+ });
+
+ it('is always visible when hideWithControls is false', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ // When hideWithControls is false, isVisible is always true
+ expect(el.style.opacity).toBe('0.8');
+ });
+
+ it('applies pointer-events-none when not visible', () => {
+ const { container } = render(
+
+ );
+ const el = container.firstChild as HTMLElement;
+ expect(el.className).toContain('pointer-events-none');
+ });
+
+ // ── Custom component ──────────────────────────────────────────────
+
+ it('renders custom component instead of image', () => {
+ function CustomLogo(props: LogoComponentProps) {
+ return Custom {props.isPlaying ? 'Playing' : 'Paused'}
;
+ }
+ render(
+
+ );
+ expect(screen.getByTestId('custom-logo')).toBeInTheDocument();
+ expect(screen.getByText('Custom Playing')).toBeInTheDocument();
+ });
+
+ it('passes visibility props to custom component', () => {
+ function CustomLogo(props: LogoComponentProps) {
+ return (
+
+ {props.visible ? 'visible' : 'hidden'}
+ {props.isFullscreen ? ' fullscreen' : ''}
+
+ );
+ }
+ render(
+
+ );
+ expect(screen.getByText('visible fullscreen')).toBeInTheDocument();
+ });
+
+ it('prefers custom component over src', () => {
+ function CustomLogo() {
+ return Custom
;
+ }
+ render(
+
+ );
+ expect(screen.getByTestId('custom-logo')).toBeInTheDocument();
+ expect(screen.queryByAltText('Test Logo')).not.toBeInTheDocument();
+ });
+
+ // ── className ─────────────────────────────────────────────────────
+
+ it('applies custom className prop', () => {
+ const { container } = render(
+
+ );
+ expect((container.firstChild as HTMLElement).className).toContain('my-overlay');
+ });
+
+ it('applies config className', () => {
+ const { container } = render(
+
+ );
+ expect((container.firstChild as HTMLElement).className).toContain('config-class');
+ });
+
+ // ── Absolute positioning class ────────────────────────────────────
+
+ it('has absolute positioning class', () => {
+ const { container } = render(
+
+ );
+ expect((container.firstChild as HTMLElement).className).toContain('absolute');
+ });
+
+ it('has z-index class', () => {
+ const { container } = render(
+
+ );
+ expect((container.firstChild as HTMLElement).className).toContain('z-[15]');
+ });
+});
diff --git a/src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.stories.tsx b/src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.stories.tsx
new file mode 100644
index 0000000..6661b45
--- /dev/null
+++ b/src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.stories.tsx
@@ -0,0 +1,250 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
+import { SubtitleDisplay } from './SubtitleDisplay';
+import { DEFAULT_SUBTITLE_STYLE, SUBTITLE_PRESETS } from '@/types/subtitleStyling';
+import type { SubtitleStyle } from '@/types/subtitleStyling';
+
+/** Convert a SubtitleStyle to React.CSSProperties (mirrors useSubtitleStyling logic) */
+function styleToCss(s: SubtitleStyle): React.CSSProperties {
+ const hexToRgba = (hex: string, opacity: number): string => {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
+ };
+
+ return {
+ fontSize: `${s.fontSize}px`,
+ fontFamily: s.fontFamily,
+ color: s.textColor,
+ backgroundColor: hexToRgba(s.backgroundColor, s.backgroundOpacity),
+ textShadow: s.textShadow,
+ ...(s.position === 'top'
+ ? { top: '10%', bottom: 'auto' }
+ : { bottom: '10%', top: 'auto' }),
+ padding: '4px 8px',
+ borderRadius: '4px',
+ };
+}
+
+const meta: Meta = {
+ title: 'VideoPlayer/SubtitleDisplay',
+ component: SubtitleDisplay,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const OverlayMode: Story = {
+ render: () => (
+
+ Mock Video
+
+
+ ),
+};
+
+export const BelowMode: Story = {
+ render: () => (
+
+ ),
+};
+
+export const NoText: Story = {
+ render: () => (
+
+ Mock Video
+
+
+ ),
+};
+
+export const StyledOverlay: Story = {
+ render: () => (
+
+ Mock Video
+
+
+ ),
+};
+
+export const Interactive: Story = {
+ render: () => {
+ const [text, setText] = useState('Hello, welcome to the show.');
+ const [mode, setMode] = useState<'overlay' | 'below'>('overlay');
+ const [activePreset, setActivePreset] = useState('default');
+ const [subtitleStyle, setSubtitleStyle] = useState(
+ DEFAULT_SUBTITLE_STYLE
+ );
+
+ const handlePreset = (presetName: string) => {
+ const preset = SUBTITLE_PRESETS.find((p) => p.name === presetName);
+ if (preset) {
+ setSubtitleStyle(preset.style);
+ setActivePreset(presetName);
+ }
+ };
+
+ const cssProps = styleToCss(subtitleStyle);
+
+ return (
+
+ {/* Video container */}
+ {mode === 'overlay' ? (
+
+ Mock Video
+
+
+ ) : (
+ <>
+
+ Mock Video
+
+
+ >
+ )}
+
+ {/* Controls */}
+
+ {/* Text input */}
+
+
+ Subtitle text
+
+ setText(e.target.value)}
+ style={{
+ width: '100%',
+ padding: '6px 8px',
+ borderRadius: '4px',
+ border: '1px solid #444',
+ backgroundColor: '#222',
+ color: '#fff',
+ fontSize: '14px',
+ }}
+ />
+
+
+ {/* Mode toggle */}
+
+
+ Mode
+
+
+ {(['overlay', 'below'] as const).map((m) => (
+ setMode(m)}
+ style={{
+ padding: '4px 12px',
+ borderRadius: '4px',
+ border: '1px solid #555',
+ backgroundColor: mode === m ? '#4f46e5' : '#333',
+ color: '#fff',
+ fontSize: '13px',
+ cursor: 'pointer',
+ }}
+ >
+ {m}
+
+ ))}
+
+
+
+ {/* Preset buttons */}
+
+
+ Style presets
+
+
+ {SUBTITLE_PRESETS.map((preset) => (
+ handlePreset(preset.name)}
+ style={{
+ padding: '4px 12px',
+ borderRadius: '4px',
+ border: '1px solid #555',
+ backgroundColor:
+ activePreset === preset.name ? '#4f46e5' : '#333',
+ color: '#fff',
+ fontSize: '13px',
+ cursor: 'pointer',
+ }}
+ >
+ {preset.label}
+
+ ))}
+
+
+
+ {/* Current state */}
+
+
Mode: {mode}
+
Preset: {activePreset}
+
Font size: {subtitleStyle.fontSize}px
+
Text color: {subtitleStyle.textColor}
+
+
+
+ );
+ },
+};
diff --git a/src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.test.tsx b/src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.test.tsx
new file mode 100644
index 0000000..02bdf97
--- /dev/null
+++ b/src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.test.tsx
@@ -0,0 +1,68 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { SubtitleDisplay } from './SubtitleDisplay';
+
+describe('SubtitleDisplay', () => {
+ it('should render nothing when text is null', () => {
+ const { container } = render( );
+ // Should have opacity-0 (hidden)
+ const el = container.firstChild as HTMLElement;
+ expect(el?.className).toContain('opacity-0');
+ });
+
+ it('should render subtitle text in overlay mode', () => {
+ render( );
+ expect(screen.getByText('Hello world')).toBeInTheDocument();
+ });
+
+ it('should render subtitle text in below mode', () => {
+ render( );
+ expect(screen.getByText('Below text')).toBeInTheDocument();
+ });
+
+ it('should apply custom CSS properties', () => {
+ const { container } = render(
+
+ );
+ const span = container.querySelector('span');
+ expect(span?.style.fontSize).toBe('24px');
+ expect(span?.style.color).toBe('rgb(255, 255, 0)');
+ });
+
+ it('should have pointer-events-none in overlay mode', () => {
+ const { container } = render( );
+ const wrapper = container.firstChild as HTMLElement;
+ expect(wrapper?.className).toContain('pointer-events-none');
+ });
+
+ it('should not have pointer-events-none in below mode', () => {
+ const { container } = render( );
+ const wrapper = container.firstChild as HTMLElement;
+ expect(wrapper?.className).not.toContain('pointer-events-none');
+ });
+
+ it('should handle multi-line text', () => {
+ const multiLineText = 'Line 1\nLine 2';
+ const { container } = render( );
+ const span = container.querySelector('span');
+ expect(span?.innerHTML).toContain('Line 1 Line 2');
+ });
+
+ it('should apply additional className', () => {
+ const { container } = render(
+
+ );
+ const wrapper = container.firstChild as HTMLElement;
+ expect(wrapper?.className).toContain('custom-class');
+ });
+
+ it('should have min-height in below mode', () => {
+ const { container } = render( );
+ const wrapper = container.firstChild as HTMLElement;
+ expect(wrapper?.className).toContain('min-h-');
+ });
+});
diff --git a/src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.tsx b/src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.tsx
new file mode 100644
index 0000000..d9b9ccb
--- /dev/null
+++ b/src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.tsx
@@ -0,0 +1,77 @@
+import { cn } from '@/utils/cn';
+
+export type SubtitleDisplayMode = 'overlay' | 'below';
+
+export interface SubtitleDisplayProps {
+ /** The subtitle text to display (null = hidden) */
+ text: string | null;
+ /** Display mode: overlay on video or below the video */
+ mode: SubtitleDisplayMode;
+ /** CSS properties from useSubtitleStyling */
+ style?: React.CSSProperties;
+ /** Additional class name */
+ className?: string;
+}
+
+export function SubtitleDisplay({ text, mode, style: subtitleStyle, className }: SubtitleDisplayProps) {
+ if (mode === 'overlay') {
+ return (
+
+ {text && (
+ ') }}
+ />
+ )}
+
+ );
+ }
+
+ // Below mode
+ return (
+
+ {text && (
+ ') }}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/VideoPlayer/SubtitleDisplay/index.ts b/src/components/VideoPlayer/SubtitleDisplay/index.ts
new file mode 100644
index 0000000..72f0015
--- /dev/null
+++ b/src/components/VideoPlayer/SubtitleDisplay/index.ts
@@ -0,0 +1 @@
+export { SubtitleDisplay, type SubtitleDisplayProps, type SubtitleDisplayMode } from './SubtitleDisplay';
diff --git a/src/components/VideoPlayer/VideoPlayer.stories.tsx b/src/components/VideoPlayer/VideoPlayer.stories.tsx
index e32b068..aaf2324 100644
--- a/src/components/VideoPlayer/VideoPlayer.stories.tsx
+++ b/src/components/VideoPlayer/VideoPlayer.stories.tsx
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { VideoPlayer, type VideoPlayerRef } from './VideoPlayer';
+import { SubtitleDisplay } from './SubtitleDisplay';
import { VideoProvider, useVideoPlayer } from '@/context/VideoContext';
import { VideoAdProvider, useVideoAds } from '@/context/VideoAdContext';
import { createAdEventBus } from '@/utils/AdEventBus';
@@ -23,6 +24,8 @@ import {
getFairuHlsUrl,
type FairuVideoTrack,
} from '@/utils/fairu';
+import { DEFAULT_SUBTITLE_STYLE, SUBTITLE_PRESETS } from '@/types/subtitleStyling';
+import type { SubtitleStyle } from '@/types/subtitleStyling';
const meta: Meta = {
title: 'Components/VideoPlayer',
@@ -48,10 +51,10 @@ type Story = StoryObj;
// Sample video tracks - using free test videos
const sampleVideo: VideoTrack = {
id: '1',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Big Buck Bunny',
artist: 'Blender Foundation',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 596,
};
@@ -69,18 +72,18 @@ const videoPlaylist: VideoTrack[] = [
sampleVideo,
{
id: '2',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Elephants Dream',
artist: 'Blender Foundation',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ElephantsDream.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 653,
},
{
id: '3',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Sintel',
artist: 'Blender Foundation',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/Sintel.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 888,
},
];
@@ -92,7 +95,7 @@ const samplePreRollAd: VideoAdBreak = {
ads: [
{
id: 'ad-1',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
duration: 15,
skipAfterSeconds: 5,
title: 'ForBiggerBlazes - Sample Ad',
@@ -117,7 +120,7 @@ const sampleMidRollAd: VideoAdBreak = {
ads: [
{
id: 'mid-ad-1',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
duration: 15,
skipAfterSeconds: 3,
title: 'Mid-Roll Ad',
@@ -450,7 +453,7 @@ const nonSkippablePreRollAd: VideoAdBreak = {
ads: [
{
id: 'ad-non-skip-1',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
duration: 15,
skipAfterSeconds: null, // null means non-skippable
title: 'Non-Skippable Ad',
@@ -573,12 +576,55 @@ export const WatchProgressTracking: Story = {
};
/**
- * Complete example: Non-skippable ad + Seeking disabled + Watch tracking
+ * Complete example: Non-skippable ad + Seeking disabled + Watch tracking + Subtitles
* This is typical for educational content or compliance videos
*/
function ComplianceVideoDemo() {
const [progress, setProgress] = useState(null);
const [completed, setCompleted] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+
+ // Subtitle state
+ const [subtitleMode, setSubtitleMode] = useState<'overlay' | 'below' | 'off'>('overlay');
+ const [subtitleStyle, setSubtitleStyle] = useState(DEFAULT_SUBTITLE_STYLE);
+
+ // Simulated subtitle cues for compliance video
+ const subtitleCues = useMemo(() => [
+ { start: 0, end: 5, text: 'Welcome to the Compliance Training.' },
+ { start: 5, end: 10, text: 'This video covers workplace safety procedures.' },
+ { start: 10, end: 16, text: 'Please watch the entire video to complete the training.' },
+ { start: 18, end: 23, text: 'Chapter 1: Emergency Exits' },
+ { start: 25, end: 30, text: 'Always be aware of the nearest emergency exit.' },
+ { start: 32, end: 37, text: 'Follow the green signs to find your way out.' },
+ { start: 40, end: 45, text: 'Chapter 2: Fire Safety' },
+ { start: 47, end: 52, text: 'In case of fire, do not use elevators.' },
+ { start: 55, end: 60, text: 'Use the stairs and meet at the assembly point.' },
+ ], []);
+
+ const activeCue = useMemo(() => {
+ if (subtitleMode === 'off') return null;
+ return subtitleCues.find(c => currentTime >= c.start && currentTime < c.end)?.text ?? null;
+ }, [currentTime, subtitleMode, subtitleCues]);
+
+ // Convert subtitle style to CSS
+ const subtitleCss = useMemo((): React.CSSProperties => {
+ const hexToRgba = (hex: string, opacity: number) => {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
+ };
+ return {
+ fontSize: `${subtitleStyle.fontSize}px`,
+ fontFamily: subtitleStyle.fontFamily,
+ color: subtitleStyle.textColor,
+ backgroundColor: hexToRgba(subtitleStyle.backgroundColor, subtitleStyle.backgroundOpacity),
+ textShadow: subtitleStyle.textShadow,
+ ...(subtitleStyle.position === 'top' ? { top: '10%', bottom: 'auto' } : { bottom: '10%', top: 'auto' }),
+ padding: '4px 8px',
+ borderRadius: '4px',
+ };
+ }, [subtitleStyle]);
return (
@@ -586,9 +632,11 @@ function ComplianceVideoDemo() {
⚠️ Compliance Training Video
You must watch this video completely. Seeking is disabled and the ad cannot be skipped.
+ Subtitles are available in overlay or below-video mode.
+ {/* Video Player */}
setCompleted(true)}
onWatchProgressUpdate={setProgress}
+ onTimeUpdate={setCurrentTime}
/>
+ {/* Subtitles (always below the video player) */}
+ {subtitleMode !== 'off' && (
+
+ )}
+
+ {/* Subtitle Controls */}
+
+
+
Subtitles
+
+ {([['below', 'On'], ['off', 'Off']] as const).map(([mode, label]) => (
+ setSubtitleMode(mode)}
+ className={`px-3 py-1 rounded text-xs border transition-colors ${
+ subtitleMode === mode
+ ? 'border-[var(--fp-color-accent)] text-[var(--fp-color-accent)]'
+ : 'border-gray-600 text-gray-400 hover:border-gray-400'
+ }`}
+ >
+ {label}
+
+ ))}
+
+
+
+ {/* Style presets */}
+
+ {SUBTITLE_PRESETS.map(preset => (
+ setSubtitleStyle(preset.style)}
+ className="px-2 py-1 rounded text-xs border border-gray-600 text-gray-400 hover:border-[var(--fp-color-accent)] hover:text-[var(--fp-color-accent)] transition-colors"
+ >
+ {preset.label}
+
+ ))}
+
+
+ {/* Font size */}
+
+ Size: {subtitleStyle.fontSize}px
+ setSubtitleStyle(prev => ({ ...prev, fontSize: Number(e.target.value) }))}
+ className="flex-1 h-1 rounded-full appearance-none bg-gray-700 accent-[var(--fp-color-accent)]"
+ />
+
+
+
{/* Completion Status */}
@@ -632,6 +734,187 @@ export const ComplianceVideoExample: Story = {
render: () =>
,
};
+// ============= Subtitle Demo =============
+
+/**
+ * Interactive subtitle demo with live switching between modes and styles
+ */
+function SubtitleDemo() {
+ const [currentTime, setCurrentTime] = useState(0);
+ const [subtitleMode, setSubtitleMode] = useState<'overlay' | 'below' | 'off'>('below');
+ const [subtitleStyle, setSubtitleStyle] = useState
(DEFAULT_SUBTITLE_STYLE);
+
+ // Subtitle cues that cycle through frequently so you always see something
+ const subtitleCues = useMemo(() => [
+ { start: 0, end: 3, text: 'Welcome! This is a subtitle demo.' },
+ { start: 3, end: 6, text: 'Subtitles change automatically every few seconds.' },
+ { start: 6, end: 9, text: 'You can switch between Overlay, Below and Off modes.' },
+ { start: 9, end: 12, text: 'Try out the different style presets!' },
+ { start: 12, end: 15, text: 'High Contrast makes text easier to read.' },
+ { start: 15, end: 18, text: 'Yellow on Black is a classic subtitle style.' },
+ { start: 18, end: 21, text: 'Transparent removes the background completely.' },
+ { start: 21, end: 24, text: 'Font size can be adjusted with the slider.' },
+ { start: 24, end: 27, text: 'In Below mode, subtitles appear beneath the video.' },
+ { start: 27, end: 30, text: 'In Overlay mode, they are displayed on top of the video.' },
+ ], []);
+
+ // Loop cues every 30 seconds
+ const loopedTime = currentTime % 30;
+ const activeCue = useMemo(() => {
+ if (subtitleMode === 'off') return null;
+ return subtitleCues.find(c => loopedTime >= c.start && loopedTime < c.end)?.text ?? null;
+ }, [loopedTime, subtitleMode, subtitleCues]);
+
+ const subtitleCss = useMemo((): React.CSSProperties => {
+ const hexToRgba = (hex: string, opacity: number) => {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
+ };
+ return {
+ fontSize: `${subtitleStyle.fontSize}px`,
+ fontFamily: subtitleStyle.fontFamily,
+ color: subtitleStyle.textColor,
+ backgroundColor: hexToRgba(subtitleStyle.backgroundColor, subtitleStyle.backgroundOpacity),
+ textShadow: subtitleStyle.textShadow,
+ ...(subtitleStyle.position === 'top' ? { top: '10%', bottom: 'auto' } : { bottom: '10%', top: 'auto' }),
+ padding: '4px 8px',
+ borderRadius: '4px',
+ };
+ }, [subtitleStyle]);
+
+ return (
+
+
+
Subtitle Demo
+
+ Play the video and switch between subtitle modes and styles.
+ Subtitles appear automatically and loop every 30 seconds.
+
+
+
+ {/* Video Player with optional overlay subtitles */}
+
+
+ {subtitleMode === 'overlay' && (
+
+ {activeCue && (
+
+ {activeCue}
+
+ )}
+
+ )}
+
+
+ {/* Below-mode subtitles */}
+ {subtitleMode === 'below' && (
+
+ )}
+
+ {/* Controls */}
+
+ {/* Mode switcher */}
+
+
Display Mode
+
+ {([
+ { key: 'overlay' as const, label: 'Overlay', desc: 'On top of the video' },
+ { key: 'below' as const, label: 'Below', desc: 'Beneath the video' },
+ { key: 'off' as const, label: 'Off', desc: 'Hidden' },
+ ]).map(({ key, label, desc }) => (
+
setSubtitleMode(key)}
+ className={`flex-1 p-2 rounded-lg text-left border transition-all ${
+ subtitleMode === key
+ ? 'border-[var(--fp-color-accent)] bg-[var(--fp-color-accent)]/10'
+ : 'border-gray-600 hover:border-gray-400'
+ }`}
+ >
+
+ {label}
+
+ {desc}
+
+ ))}
+
+
+
+ {/* Style presets */}
+
+
Style Preset
+
+ {SUBTITLE_PRESETS.map(preset => (
+
setSubtitleStyle(preset.style)}
+ className="p-2 rounded-lg border border-gray-600 hover:border-[var(--fp-color-accent)] transition-all text-center"
+ >
+ {preset.label}
+
+ Aa
+
+
+ ))}
+
+
+
+ {/* Font size slider */}
+
+
+ Font Size
+ {subtitleStyle.fontSize}px
+
+
setSubtitleStyle(prev => ({ ...prev, fontSize: Number(e.target.value) }))}
+ className="w-full h-1.5 rounded-full appearance-none bg-gray-700 accent-[var(--fp-color-accent)]"
+ />
+
+
+ {/* Current state info */}
+
+
+ Mode: {subtitleMode}
+ Time: {Math.floor(currentTime)}s
+ Cue: {activeCue ? 'Active' : 'None'}
+
+
+
+
+ );
+}
+
+export const SubtitleDemoExample: Story = {
+ render: () => ,
+};
+
// ============= HLS Streaming Stories =============
// Sample HLS test streams
@@ -927,7 +1210,7 @@ const mixedAdBreak: VideoAdBreak = {
ads: [
{
id: 'video-ad-1',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
duration: 15,
skipAfterSeconds: 5,
title: 'Video Ad',
@@ -974,9 +1257,9 @@ function FairuVideoDemo() {
// For demo, use real video
const demoTrack: VideoTrack = {
id: exampleUuid,
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Big Buck Bunny (Demo)',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
};
return (
@@ -988,7 +1271,7 @@ function FairuVideoDemo() {
Video Hosting
- Video-Hosting mit fairu.app - nur UUID benötigt, unterstützt verschiedene Qualitätsstufen.
+ Video hosting with fairu.app - only UUID needed, supports different quality levels.
@@ -1041,7 +1324,7 @@ export const FairuVideoHosting: Story = {
*/
function FairuVideoPlaylistDemo() {
const fairuTracks: FairuVideoTrack[] = [
- { uuid: 'video-uuid-1', title: 'Kapitel 1: Einführung', version: 'high' },
+ { uuid: 'video-uuid-1', title: 'Chapter 1: Introduction', version: 'high' },
{ uuid: 'video-uuid-2', title: 'Kapitel 2: Grundlagen', version: 'high' },
{ uuid: 'video-uuid-3', title: 'Kapitel 3: Fortgeschritten', version: 'high' },
];
@@ -1053,21 +1336,21 @@ function FairuVideoPlaylistDemo() {
const demoPlaylist: VideoTrack[] = [
{
id: 'video-uuid-1',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
- title: 'Kapitel 1: Einführung',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
+ title: 'Chapter 1: Introduction',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
},
{
id: 'video-uuid-2',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Kapitel 2: Grundlagen',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ElephantsDream.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
},
{
id: 'video-uuid-3',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Kapitel 3: Fortgeschritten',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/Sintel.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
},
];
@@ -1126,7 +1409,7 @@ function FairuHlsDemo() {
Adaptive Streaming
- HLS-Streaming mit automatischer Qualitätsanpassung basierend auf Bandbreite.
+ HLS streaming with automatic quality adjustment based on bandwidth.
@@ -1387,7 +1670,7 @@ function LogoInteractiveDemo() {
Tipp: Aktiviere "Hide with controls" und starte das Video, um die Animationen zu sehen.
- Das Logo wird ein-/ausgeblendet wenn die Controls erscheinen/verschwinden.
+ The logo fades in/out when the controls appear/disappear.
@@ -1540,37 +1823,37 @@ const recommendedVideos: RecommendedVideo[] = [
{
id: 'rec-1',
title: 'Introduction to TypeScript - Complete Guide 2024',
- thumbnail: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg',
+ thumbnail: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 1245,
views: '1.2M views',
channel: 'Code Academy',
channelAvatar: 'https://placehold.co/32x32/2d5a27/ffffff?text=CA',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
},
{
id: 'rec-2',
title: 'React Best Practices You Need to Know',
- thumbnail: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ElephantsDream.jpg',
+ thumbnail: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 845,
views: '856K views',
channel: 'Frontend Masters',
channelAvatar: 'https://placehold.co/32x32/5a272d/ffffff?text=FM',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
},
{
id: 'rec-3',
title: 'Building a Video Player from Scratch',
- thumbnail: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/Sintel.jpg',
+ thumbnail: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 2100,
views: '432K views',
channel: 'Dev Tutorials',
channelAvatar: 'https://placehold.co/32x32/1a1a2e/ffffff?text=DT',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
},
{
id: 'rec-4',
title: 'CSS Grid Layout - Master Guide',
- thumbnail: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg',
+ thumbnail: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 1560,
views: '678K views',
channel: 'CSS Wizards',
@@ -1578,7 +1861,7 @@ const recommendedVideos: RecommendedVideo[] = [
{
id: 'rec-5',
title: 'Node.js Performance Optimization',
- thumbnail: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerEscapes.jpg',
+ thumbnail: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 1890,
views: '234K views',
channel: 'Backend Pro',
@@ -1586,7 +1869,7 @@ const recommendedVideos: RecommendedVideo[] = [
{
id: 'rec-6',
title: 'Database Design Fundamentals',
- thumbnail: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerFun.jpg',
+ thumbnail: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 2400,
views: '567K views',
channel: 'Data School',
@@ -1601,9 +1884,9 @@ export const WithEndScreen: Story = {
args: {
track: {
id: 'short-video',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Short Demo Video',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 15,
},
config: {
@@ -1629,9 +1912,9 @@ export const EndScreenWithAutoPlay: Story = {
args: {
track: {
id: 'short-video-autoplay',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Short Demo Video',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 15,
},
config: {
@@ -1657,9 +1940,9 @@ export const EndScreenCarousel: Story = {
args: {
track: {
id: 'short-video-carousel',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Short Demo Video',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
duration: 15,
},
config: {
@@ -1684,9 +1967,9 @@ function EndScreenInteractiveDemo() {
const [events, setEvents] = useState
([]);
const [currentVideo, setCurrentVideo] = useState({
id: 'demo-video',
- src: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
title: 'Demo Video',
- poster: 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg',
+ poster: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
});
const addEvent = useCallback((event: string) => {
@@ -1725,8 +2008,8 @@ function EndScreenInteractiveDemo() {
End Screen Demo
- Spiele das Video ab und warte bis zum Ende (oder skippe vorwärts). Der End Screen erscheint 5 Sekunden vor Ende.
- Klicke auf ein Video um es abzuspielen, oder nutze den Replay-Button.
+ Play the video and wait until the end (or skip forward). The end screen appears 5 seconds before the end.
+ Click on a video to play it, or use the replay button.
@@ -1902,9 +2185,9 @@ function AllAdFeaturesDemo() {
@@ -2204,7 +2487,7 @@ function MyPlayer() {
const showAd = () => {
playerRef.current?.overlayAdControls.showOverlayAd({
id: 'my-ad',
- imageUrl: 'https://example.com/ad.png',
+ imageUrl: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/cover.jpg?width=1920&format=webp',
displayAt: 0,
clickThroughUrl: 'https://example.com',
});
@@ -2367,9 +2650,9 @@ function EventPipelineDemo() {
diff --git a/src/components/VideoPlayer/VideoPlayer.tsx b/src/components/VideoPlayer/VideoPlayer.tsx
index 901198b..f81fe3f 100644
--- a/src/components/VideoPlayer/VideoPlayer.tsx
+++ b/src/components/VideoPlayer/VideoPlayer.tsx
@@ -11,9 +11,12 @@ import { VideoOverlay } from './VideoOverlay';
import { VideoControls } from './VideoControls';
import { LogoOverlay } from './LogoOverlay';
import { EndScreen } from './EndScreen';
+import { GestureOverlay, type GestureFeedback } from './GestureOverlay';
import { OverlayAd } from '@/components/ads/OverlayAd';
import { InfoCard, InfoCardIcon } from '@/components/ads/InfoCard';
import { useKeyboardControls } from '@/hooks/useKeyboardControls';
+import { useGestures } from '@/hooks/useGestures';
+import { PlayerErrorBoundary } from '@/components/ErrorBoundary';
import type { VideoConfig, VideoPlayerProps, VideoAdConfig, WatchProgress, VideoAdBreak, CustomAdComponentProps, VideoAd, OverlayAd as OverlayAdType, InfoCard as InfoCardType, RecommendedVideo } from '@/types/video';
/**
@@ -119,12 +122,56 @@ function VideoPlayerInner({
}
}, [config.endScreen, onVideoSelect]);
- // Keyboard controls
+ // Keyboard controls (with chapter navigation)
+ const chapters = currentTrack?.chapters;
useKeyboardControls({
controls: isAdPlaying ? undefined : controls,
enabled: !isAdPlaying,
+ chapters,
+ currentTime: state.currentTime,
});
+ // Touch gesture support
+ const [gestureFeedback, setGestureFeedback] = useState(null);
+ const isTouchDevice = typeof window !== 'undefined' && 'ontouchstart' in window;
+ const skipForwardAmount = config.skipForwardSeconds ?? 10;
+ const skipBackwardAmount = config.skipBackwardSeconds ?? 10;
+
+ useGestures({
+ containerRef: containerRef as React.RefObject,
+ enabled: isTouchDevice && !isAdPlaying,
+ onDoubleTapLeft: useCallback(() => {
+ controls.skipBackward(skipBackwardAmount);
+ setGestureFeedback({ type: 'skip-backward', label: `-${skipBackwardAmount}s` });
+ }, [controls, skipBackwardAmount]),
+ onDoubleTapRight: useCallback(() => {
+ controls.skipForward(skipForwardAmount);
+ setGestureFeedback({ type: 'skip-forward', label: `+${skipForwardAmount}s` });
+ }, [controls, skipForwardAmount]),
+ onSwipeUp: useCallback(() => {
+ const newVolume = Math.min(1, state.volume + 0.1);
+ controls.setVolume(newVolume);
+ setGestureFeedback({ type: 'swipe-up', label: `${Math.round(newVolume * 100)}%` });
+ }, [controls, state.volume]),
+ onSwipeDown: useCallback(() => {
+ const newVolume = Math.max(0, state.volume - 0.1);
+ controls.setVolume(newVolume);
+ setGestureFeedback({ type: 'swipe-down', label: `${Math.round(newVolume * 100)}%` });
+ }, [controls, state.volume]),
+ onSwipeLeft: useCallback(() => {
+ controls.skipBackward(skipBackwardAmount);
+ setGestureFeedback({ type: 'swipe-left', label: `-${skipBackwardAmount}s` });
+ }, [controls, skipBackwardAmount]),
+ onSwipeRight: useCallback(() => {
+ controls.skipForward(skipForwardAmount);
+ setGestureFeedback({ type: 'swipe-right', label: `+${skipForwardAmount}s` });
+ }, [controls, skipForwardAmount]),
+ });
+
+ const handleGestureDismiss = useCallback(() => {
+ setGestureFeedback(null);
+ }, []);
+
return (
}
@@ -180,6 +227,14 @@ function VideoPlayerInner({
/>
)}
+ {/* Gesture Overlay (touch feedback) */}
+ {!isAdPlaying && isTouchDevice && (
+
+ )}
+
{/* Ad Overlay */}
{isAdPlaying && adState && adControls && (
@@ -402,32 +457,34 @@ export const VideoPlayer = forwardRef
-
+
-
+ onStart={onStart}
+ onPlay={onPlay}
+ onPause={onPause}
+ onEnded={onEnded}
+ onFinished={onFinished}
+ onTimeUpdate={onTimeUpdate}
+ onWatchProgressUpdate={onWatchProgressUpdate}
+ onTrackChange={onTrackChange}
+ onError={onError}
+ onFullscreenChange={onFullscreenChange}
+ onPictureInPictureChange={handlePictureInPictureChange}
+ onTabVisibilityChange={onTabVisibilityChange}
+ >
+
+
+
);
});
@@ -664,12 +721,56 @@ function VideoPlayerInnerWithAds({
},
};
- // Keyboard controls
+ // Keyboard controls (with chapter navigation)
+ const chaptersWithAds = currentTrack?.chapters;
useKeyboardControls({
controls: isAdPlaying ? undefined : wrappedControls,
enabled: !isAdPlaying,
+ chapters: chaptersWithAds,
+ currentTime: state.currentTime,
+ });
+
+ // Touch gesture support
+ const [gestureFeedbackWithAds, setGestureFeedbackWithAds] = useState(null);
+ const isTouchDeviceWithAds = typeof window !== 'undefined' && 'ontouchstart' in window;
+ const skipForwardAmountWithAds = config.skipForwardSeconds ?? 10;
+ const skipBackwardAmountWithAds = config.skipBackwardSeconds ?? 10;
+
+ useGestures({
+ containerRef: containerRef as React.RefObject,
+ enabled: isTouchDeviceWithAds && !isAdPlaying,
+ onDoubleTapLeft: useCallback(() => {
+ wrappedControls.skipBackward(skipBackwardAmountWithAds);
+ setGestureFeedbackWithAds({ type: 'skip-backward', label: `-${skipBackwardAmountWithAds}s` });
+ }, [wrappedControls, skipBackwardAmountWithAds]),
+ onDoubleTapRight: useCallback(() => {
+ wrappedControls.skipForward(skipForwardAmountWithAds);
+ setGestureFeedbackWithAds({ type: 'skip-forward', label: `+${skipForwardAmountWithAds}s` });
+ }, [wrappedControls, skipForwardAmountWithAds]),
+ onSwipeUp: useCallback(() => {
+ const newVolume = Math.min(1, state.volume + 0.1);
+ wrappedControls.setVolume(newVolume);
+ setGestureFeedbackWithAds({ type: 'swipe-up', label: `${Math.round(newVolume * 100)}%` });
+ }, [wrappedControls, state.volume]),
+ onSwipeDown: useCallback(() => {
+ const newVolume = Math.max(0, state.volume - 0.1);
+ wrappedControls.setVolume(newVolume);
+ setGestureFeedbackWithAds({ type: 'swipe-down', label: `${Math.round(newVolume * 100)}%` });
+ }, [wrappedControls, state.volume]),
+ onSwipeLeft: useCallback(() => {
+ wrappedControls.skipBackward(skipBackwardAmountWithAds);
+ setGestureFeedbackWithAds({ type: 'swipe-left', label: `-${skipBackwardAmountWithAds}s` });
+ }, [wrappedControls, skipBackwardAmountWithAds]),
+ onSwipeRight: useCallback(() => {
+ wrappedControls.skipForward(skipForwardAmountWithAds);
+ setGestureFeedbackWithAds({ type: 'swipe-right', label: `+${skipForwardAmountWithAds}s` });
+ }, [wrappedControls, skipForwardAmountWithAds]),
});
+ const handleGestureDismissWithAds = useCallback(() => {
+ setGestureFeedbackWithAds(null);
+ }, []);
+
return (
}
@@ -725,6 +826,14 @@ function VideoPlayerInnerWithAds({
/>
)}
+ {/* Gesture Overlay (touch feedback) */}
+ {!isAdPlaying && isTouchDeviceWithAds && (
+
+ )}
+
{/* Ad Overlay */}
{isAdPlaying && adState && adControls && (
diff --git a/src/components/VideoPlayer/index.ts b/src/components/VideoPlayer/index.ts
index 9a30aac..47e4879 100644
--- a/src/components/VideoPlayer/index.ts
+++ b/src/components/VideoPlayer/index.ts
@@ -3,6 +3,7 @@ export { VideoPlayer as default } from './VideoPlayer';
export { VideoOverlay, type VideoOverlayProps } from './VideoOverlay';
export { VideoControls, type VideoControlsProps } from './VideoControls';
export { LogoOverlay, type LogoOverlayProps } from './LogoOverlay';
+export { GestureOverlay, type GestureOverlayProps, type GestureFeedback, type GestureFeedbackType } from './GestureOverlay';
export {
EndScreen,
RecommendedCard,
@@ -11,3 +12,4 @@ export {
type RecommendedCardProps,
type AutoPlayCountdownProps,
} from './EndScreen';
+export { SubtitleDisplay, type SubtitleDisplayProps, type SubtitleDisplayMode } from './SubtitleDisplay';
diff --git a/src/components/a11y/ScreenReaderAnnouncer.tsx b/src/components/a11y/ScreenReaderAnnouncer.tsx
new file mode 100644
index 0000000..8b84ff6
--- /dev/null
+++ b/src/components/a11y/ScreenReaderAnnouncer.tsx
@@ -0,0 +1,57 @@
+import { useState, useEffect, useRef } from 'react';
+
+export interface ScreenReaderAnnouncerProps {
+ /** The message to announce. Change this value to trigger an announcement. */
+ message: string;
+ /** Politeness level. Default: 'polite' */
+ politeness?: 'polite' | 'assertive';
+}
+
+/**
+ * Visually hidden component that announces messages to screen readers.
+ * Change the `message` prop to trigger a new announcement.
+ */
+export function ScreenReaderAnnouncer({
+ message,
+ politeness = 'polite',
+}: ScreenReaderAnnouncerProps) {
+ const [announcement, setAnnouncement] = useState('');
+ const timeoutRef = useRef
>();
+
+ useEffect(() => {
+ if (!message) return;
+
+ // Clear previous announcement first to ensure re-announcement of same text
+ setAnnouncement('');
+
+ timeoutRef.current = setTimeout(() => {
+ setAnnouncement(message);
+ }, 50);
+
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, [message]);
+
+ return (
+
+ {announcement}
+
+ );
+}
diff --git a/src/components/a11y/index.ts b/src/components/a11y/index.ts
new file mode 100644
index 0000000..fc12a99
--- /dev/null
+++ b/src/components/a11y/index.ts
@@ -0,0 +1 @@
+export { ScreenReaderAnnouncer, type ScreenReaderAnnouncerProps } from './ScreenReaderAnnouncer';
diff --git a/src/components/ads/AdOverlay/AdOverlay.stories.tsx b/src/components/ads/AdOverlay/AdOverlay.stories.tsx
index d04d555..fb67f6a 100644
--- a/src/components/ads/AdOverlay/AdOverlay.stories.tsx
+++ b/src/components/ads/AdOverlay/AdOverlay.stories.tsx
@@ -30,7 +30,7 @@ type Story = StoryObj;
const sampleAd: Ad = {
id: 'ad-1',
- src: 'https://example.com/ad.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
duration: 15,
skipAfterSeconds: 5,
title: 'Sponsor: Fairu Premium',
@@ -146,7 +146,7 @@ export const Interactive: Story = {
ads: [
{
id: 'ad-1',
- src: 'https://example.com/ad1.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
duration: 10,
skipAfterSeconds: 5,
title: 'Sponsor: Fairu Premium',
@@ -154,7 +154,7 @@ export const Interactive: Story = {
},
{
id: 'ad-2',
- src: 'https://example.com/ad2.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
duration: 8,
skipAfterSeconds: 3,
title: 'Sponsor: Podcast Hosting',
diff --git a/src/components/ads/AdOverlay/AdOverlay.test.tsx b/src/components/ads/AdOverlay/AdOverlay.test.tsx
new file mode 100644
index 0000000..5cbf5eb
--- /dev/null
+++ b/src/components/ads/AdOverlay/AdOverlay.test.tsx
@@ -0,0 +1,208 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { AdOverlay } from './AdOverlay';
+import { createMockAd, createMockAdBreak } from '@/test/helpers';
+import type { AdState, AdControls } from '@/types/ads';
+
+function createMockAdState(overrides: Partial = {}): AdState {
+ return {
+ isPlayingAd: true,
+ currentAd: createMockAd(),
+ currentAdBreak: createMockAdBreak(),
+ adProgress: 5,
+ adDuration: 15,
+ canSkip: false,
+ skipCountdown: 3,
+ adsRemaining: 0,
+ ...overrides,
+ };
+}
+
+function createMockControls(overrides: Partial> = {}) {
+ return {
+ skipAd: vi.fn(),
+ clickThrough: vi.fn(),
+ ...overrides,
+ };
+}
+
+function renderAdOverlay(
+ stateOverrides: Partial = {},
+ controlOverrides: Partial> = {},
+ className?: string
+) {
+ const state = createMockAdState(stateOverrides);
+ const controls = createMockControls(controlOverrides);
+ return render(
+
+ );
+}
+
+describe('AdOverlay', () => {
+ // ── Rendering ──────────────────────────────────────────────────────
+
+ it('renders the ad overlay when isPlayingAd is true', () => {
+ renderAdOverlay();
+ expect(screen.getByRole('dialog', { name: 'Advertisement' })).toBeInTheDocument();
+ });
+
+ it('renders the "Ad" badge', () => {
+ renderAdOverlay();
+ expect(screen.getByText('Ad')).toBeInTheDocument();
+ });
+
+ it('does not render when isPlayingAd is false', () => {
+ renderAdOverlay({ isPlayingAd: false });
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('does not render when currentAd is null', () => {
+ renderAdOverlay({ currentAd: null });
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // ── Ad title ───────────────────────────────────────────────────────
+
+ it('shows ad title when provided', () => {
+ renderAdOverlay({
+ currentAd: createMockAd({ title: 'My Great Ad' }),
+ });
+ expect(screen.getByText('My Great Ad')).toBeInTheDocument();
+ });
+
+ it('does not show title when not provided', () => {
+ renderAdOverlay({
+ currentAd: createMockAd({ title: undefined }),
+ });
+ expect(screen.queryByText('My Great Ad')).not.toBeInTheDocument();
+ });
+
+ // ── Remaining time display ─────────────────────────────────────────
+
+ it('shows remaining time countdown', () => {
+ renderAdOverlay({ adProgress: 5, adDuration: 15 });
+ // remaining = ceil(15 - 5) = 10
+ expect(screen.getByText('10')).toBeInTheDocument();
+ expect(screen.getByText('seconds remaining')).toBeInTheDocument();
+ });
+
+ it('shows 0 remaining time when ad is complete', () => {
+ renderAdOverlay({ adProgress: 15, adDuration: 15 });
+ expect(screen.getByText('0')).toBeInTheDocument();
+ });
+
+ it('calculates remaining correctly for partial progress', () => {
+ renderAdOverlay({ adProgress: 12.3, adDuration: 15 });
+ // remaining = ceil(15 - 12.3) = ceil(2.7) = 3
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+
+ // ── Progress bar ───────────────────────────────────────────────────
+
+ it('renders a progress bar', () => {
+ const { container } = renderAdOverlay({ adProgress: 7.5, adDuration: 15 });
+ const progressBar = container.querySelector('[style*="width"]');
+ expect(progressBar).toBeTruthy();
+ // 7.5 / 15 * 100 = 50%
+ expect(progressBar?.getAttribute('style')).toContain('50%');
+ });
+
+ it('shows 0% progress when adDuration is 0', () => {
+ const { container } = renderAdOverlay({ adProgress: 0, adDuration: 0 });
+ const progressBar = container.querySelector('[style*="width"]');
+ expect(progressBar?.getAttribute('style')).toContain('0%');
+ });
+
+ // ── Ads remaining ──────────────────────────────────────────────────
+
+ it('shows ads remaining count when greater than 0', () => {
+ renderAdOverlay({ adsRemaining: 2 });
+ expect(screen.getByText('3 ads')).toBeInTheDocument(); // adsRemaining + 1
+ });
+
+ it('does not show ads remaining count when 0', () => {
+ renderAdOverlay({ adsRemaining: 0 });
+ expect(screen.queryByText(/ads$/)).not.toBeInTheDocument();
+ });
+
+ // ── Skip button ────────────────────────────────────────────────────
+
+ it('renders AdSkipButton with skip countdown when canSkip is false', () => {
+ renderAdOverlay({ canSkip: false, skipCountdown: 5 });
+ expect(screen.getByText('Skip in 5s')).toBeInTheDocument();
+ });
+
+ it('renders "Skip Ad" button when canSkip is true', () => {
+ renderAdOverlay({ canSkip: true });
+ expect(screen.getByText('Skip Ad')).toBeInTheDocument();
+ });
+
+ it('calls controls.skipAd when skip button is clicked', () => {
+ const skipAd = vi.fn();
+ renderAdOverlay({ canSkip: true }, { skipAd });
+ fireEvent.click(screen.getByText('Skip Ad'));
+ expect(skipAd).toHaveBeenCalledTimes(1);
+ });
+
+ // ── Click through ──────────────────────────────────────────────────
+
+ it('shows "Click to learn more" when ad has clickThroughUrl', () => {
+ renderAdOverlay({
+ currentAd: createMockAd({ clickThroughUrl: 'https://example.com' }),
+ });
+ expect(screen.getByText('Click to learn more')).toBeInTheDocument();
+ });
+
+ it('does not show click prompt when no clickThroughUrl', () => {
+ renderAdOverlay({
+ currentAd: createMockAd({ clickThroughUrl: undefined }),
+ });
+ expect(screen.queryByText('Click to learn more')).not.toBeInTheDocument();
+ });
+
+ it('calls controls.clickThrough when clickable area is clicked', () => {
+ const clickThrough = vi.fn();
+ renderAdOverlay(
+ { currentAd: createMockAd({ clickThroughUrl: 'https://example.com' }) },
+ { clickThrough }
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Click to learn more' }));
+ expect(clickThrough).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles keyboard Enter on clickable area', () => {
+ const clickThrough = vi.fn();
+ renderAdOverlay(
+ { currentAd: createMockAd({ clickThroughUrl: 'https://example.com' }) },
+ { clickThrough }
+ );
+ const clickArea = screen.getByRole('button', { name: 'Click to learn more' });
+ fireEvent.keyDown(clickArea, { key: 'Enter' });
+ expect(clickThrough).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles keyboard Space on clickable area', () => {
+ const clickThrough = vi.fn();
+ renderAdOverlay(
+ { currentAd: createMockAd({ clickThroughUrl: 'https://example.com' }) },
+ { clickThrough }
+ );
+ const clickArea = screen.getByRole('button', { name: 'Click to learn more' });
+ fireEvent.keyDown(clickArea, { key: ' ' });
+ expect(clickThrough).toHaveBeenCalledTimes(1);
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className', () => {
+ renderAdOverlay({}, {}, 'my-ad-overlay');
+ expect(screen.getByRole('dialog').className).toContain('my-ad-overlay');
+ });
+
+ // ── Accessibility ──────────────────────────────────────────────────
+
+ it('has aria-live="polite" for screen readers', () => {
+ renderAdOverlay();
+ expect(screen.getByRole('dialog')).toHaveAttribute('aria-live', 'polite');
+ });
+});
diff --git a/src/components/ads/AdSkipButton/AdSkipButton.test.tsx b/src/components/ads/AdSkipButton/AdSkipButton.test.tsx
new file mode 100644
index 0000000..f535cdf
--- /dev/null
+++ b/src/components/ads/AdSkipButton/AdSkipButton.test.tsx
@@ -0,0 +1,107 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { AdSkipButton } from './AdSkipButton';
+
+describe('AdSkipButton', () => {
+ // ── Countdown display ──────────────────────────────────────────────
+
+ it('shows countdown text when cannot skip', () => {
+ render( );
+ expect(screen.getByText('Skip in 5s')).toBeInTheDocument();
+ });
+
+ it('shows updated countdown value', () => {
+ render( );
+ expect(screen.getByText('Skip in 3s')).toBeInTheDocument();
+ });
+
+ it('shows countdown of 1', () => {
+ render( );
+ expect(screen.getByText('Skip in 1s')).toBeInTheDocument();
+ });
+
+ it('renders countdown as a non-interactive div (not a button)', () => {
+ render( );
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ // ── Skip enabled ───────────────────────────────────────────────────
+
+ it('shows "Skip Ad" button when canSkip is true', () => {
+ render( );
+ expect(screen.getByText('Skip Ad')).toBeInTheDocument();
+ });
+
+ it('renders as a button when canSkip is true', () => {
+ render( );
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('renders a skip icon SVG when canSkip is true', () => {
+ render( );
+ const btn = screen.getByRole('button');
+ const svg = btn.querySelector('svg');
+ expect(svg).toBeTruthy();
+ expect(svg?.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ // ── Click handler ──────────────────────────────────────────────────
+
+ it('calls onClick when skip button is clicked', () => {
+ const onClick = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not crash when onClick is undefined', () => {
+ render( );
+ expect(() => fireEvent.click(screen.getByRole('button'))).not.toThrow();
+ });
+
+ // ── Nothing rendered ───────────────────────────────────────────────
+
+ it('renders nothing when canSkip is false and countdown is 0', () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders nothing when canSkip is false and countdown is negative', () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe('');
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes className to skip button', () => {
+ render( );
+ expect(screen.getByRole('button').className).toContain('my-skip');
+ });
+
+ it('passes className to countdown div', () => {
+ const { container } = render(
+
+ );
+ const div = container.firstElementChild;
+ expect(div?.className).toContain('my-countdown');
+ });
+
+ // ── Button type ────────────────────────────────────────────────────
+
+ it('has type="button" on the skip button', () => {
+ render( );
+ expect(screen.getByRole('button').getAttribute('type')).toBe('button');
+ });
+
+ // ── Styling ────────────────────────────────────────────────────────
+
+ it('applies shadow class to skip button', () => {
+ render( );
+ expect(screen.getByRole('button').className).toContain('shadow-lg');
+ });
+
+ it('applies backdrop-blur to countdown', () => {
+ const { container } = render( );
+ expect(container.firstElementChild?.className).toContain('backdrop-blur-sm');
+ });
+});
diff --git a/src/components/ads/PauseAd/PauseAd.stories.tsx b/src/components/ads/PauseAd/PauseAd.stories.tsx
new file mode 100644
index 0000000..77d2657
--- /dev/null
+++ b/src/components/ads/PauseAd/PauseAd.stories.tsx
@@ -0,0 +1,181 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState, useCallback } from 'react';
+import { PauseAd } from './PauseAd';
+import type { PauseAd as PauseAdType } from '@/types/pauseAd';
+
+const meta: Meta = {
+ title: 'Ads/PauseAd',
+ component: PauseAd,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ values: [{ name: 'dark', value: '#121212' }],
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+ Video Content Area
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+const samplePauseAd: PauseAdType = {
+ id: 'pause-ad-1',
+ imageUrl: 'https://placehold.co/800x400/1a1a2e/00a99d?text=Pause+Ad',
+ clickThroughUrl: 'https://example.com/promo',
+ altText: 'Special promotional offer',
+ title: 'Special Offer',
+ description: 'Get 50% off your first month - Limited time only!',
+ minPauseDuration: 0,
+ trackingUrls: {
+ impression: 'https://example.com/track/impression',
+ click: 'https://example.com/track/click',
+ close: 'https://example.com/track/close',
+ },
+};
+
+export const Default: Story = {
+ args: {
+ ad: samplePauseAd,
+ visible: true,
+ onDismiss: () => console.log('Ad dismissed'),
+ onClick: (ad) => console.log('Ad clicked:', ad.id),
+ },
+};
+
+export const WithoutClickThrough: Story = {
+ args: {
+ ad: {
+ ...samplePauseAd,
+ id: 'pause-ad-no-cta',
+ clickThroughUrl: undefined,
+ },
+ visible: true,
+ onDismiss: () => console.log('Ad dismissed'),
+ onClick: (ad) => console.log('Ad clicked:', ad.id),
+ },
+};
+
+export const Hidden: Story = {
+ args: {
+ ad: samplePauseAd,
+ visible: false,
+ onDismiss: () => console.log('Ad dismissed'),
+ },
+};
+
+export const Interactive: Story = {
+ decorators: [
+ // Override the default decorator to remove the outer container —
+ // the render function provides its own.
+ (Story) => ,
+ ],
+ render: function InteractivePauseAd() {
+ const [isPlaying, setIsPlaying] = useState(true);
+ const [showAd, setShowAd] = useState(false);
+ const [events, setEvents] = useState([]);
+
+ const log = useCallback((message: string) => {
+ setEvents((prev) => [
+ `[${new Date().toLocaleTimeString()}] ${message}`,
+ ...prev.slice(0, 19),
+ ]);
+ }, []);
+
+ const handlePause = useCallback(() => {
+ setIsPlaying(false);
+ log('Video paused');
+ // Show the ad after a brief moment, simulating minPauseDuration
+ const timer = setTimeout(() => {
+ setShowAd(true);
+ log('Pause ad shown');
+ }, 500);
+ return () => clearTimeout(timer);
+ }, [log]);
+
+ const handlePlay = useCallback(() => {
+ setIsPlaying(true);
+ setShowAd(false);
+ log('Video resumed - ad hidden');
+ }, [log]);
+
+ const handleDismiss = useCallback(() => {
+ setShowAd(false);
+ log('onDismiss fired - ad closed by user');
+ }, [log]);
+
+ const handleClick = useCallback(
+ (ad: PauseAdType) => {
+ log(`onClick fired - ad "${ad.id}" clicked`);
+ },
+ [log]
+ );
+
+ const pauseAd: PauseAdType = {
+ id: 'pause-ad-interactive',
+ imageUrl: 'https://placehold.co/800x400/1a1a2e/00a99d?text=Pause+Ad',
+ clickThroughUrl: 'https://example.com/promo',
+ altText: 'Interactive demo ad',
+ title: 'Try Our New Product',
+ description: 'Click to learn more about this limited-time offer.',
+ };
+
+ return (
+
+ {/* Mock video player */}
+
+ {/* Simulated video content */}
+
+
+ {isPlaying ? 'Playing...' : 'Paused'}
+
+
+ {isPlaying ? 'Pause' : 'Play'}
+
+
+
+ {/* Pause ad overlay */}
+
+
+
+ {/* Event log */}
+
+
+ Event Log
+
+ {events.length === 0 ? (
+
+ Click "Pause" to trigger the ad...
+
+ ) : (
+
+ {events.map((event, i) => (
+
+ {event}
+
+ ))}
+
+ )}
+
+
+ );
+ },
+};
diff --git a/src/components/ads/PauseAd/PauseAd.test.tsx b/src/components/ads/PauseAd/PauseAd.test.tsx
new file mode 100644
index 0000000..05a9ffc
--- /dev/null
+++ b/src/components/ads/PauseAd/PauseAd.test.tsx
@@ -0,0 +1,73 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { PauseAd } from './PauseAd';
+import type { PauseAd as PauseAdType } from '@/types/pauseAd';
+
+const mockAd: PauseAdType = {
+ id: 'test-ad',
+ imageUrl: 'https://example.com/ad.jpg',
+ clickThroughUrl: 'https://example.com',
+ title: 'Test Ad Title',
+ description: 'Test description',
+ altText: 'Test alt text',
+};
+
+describe('PauseAd', () => {
+ it('should render nothing when not visible', () => {
+ const { container } = render(
+ {}} />
+ );
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('should render ad when visible', () => {
+ render( {}} />);
+ expect(screen.getByTestId('pause-ad')).toBeInTheDocument();
+ expect(screen.getByAltText('Test alt text')).toBeInTheDocument();
+ });
+
+ it('should display title and description', () => {
+ render( {}} />);
+ expect(screen.getByText('Test Ad Title')).toBeInTheDocument();
+ expect(screen.getByText('Test description')).toBeInTheDocument();
+ });
+
+ it('should show AD badge', () => {
+ render( {}} />);
+ expect(screen.getByText('AD')).toBeInTheDocument();
+ });
+
+ it('should call onDismiss when close button clicked', () => {
+ const onDismiss = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('pause-ad-close'));
+ expect(onDismiss).toHaveBeenCalled();
+ });
+
+ it('should show Learn More button when clickThroughUrl exists', () => {
+ render( {}} />);
+ expect(screen.getByTestId('pause-ad-cta')).toBeInTheDocument();
+ expect(screen.getByText('Learn More')).toBeInTheDocument();
+ });
+
+ it('should not show Learn More when no clickThroughUrl', () => {
+ const adNoUrl = { ...mockAd, clickThroughUrl: undefined };
+ render( {}} />);
+ expect(screen.queryByTestId('pause-ad-cta')).not.toBeInTheDocument();
+ });
+
+ it('should call onClick when ad image clicked', () => {
+ const onClick = vi.fn();
+ render( {}} onClick={onClick} />);
+ fireEvent.click(screen.getByTestId('pause-ad-image'));
+ expect(onClick).toHaveBeenCalledWith(mockAd);
+ });
+
+ it('should open clickthrough URL in new tab', () => {
+ const windowOpen = vi.spyOn(window, 'open');
+ render( {}} />);
+ fireEvent.click(screen.getByTestId('pause-ad-image'));
+ expect(windowOpen).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer');
+ windowOpen.mockRestore();
+ });
+});
diff --git a/src/components/ads/PauseAd/PauseAd.tsx b/src/components/ads/PauseAd/PauseAd.tsx
new file mode 100644
index 0000000..b11ae66
--- /dev/null
+++ b/src/components/ads/PauseAd/PauseAd.tsx
@@ -0,0 +1,123 @@
+import { useCallback } from 'react';
+import { cn } from '@/utils/cn';
+import type { PauseAd as PauseAdType } from '@/types/pauseAd';
+
+export interface PauseAdComponentProps {
+ /** The pause ad data */
+ ad: PauseAdType;
+ /** Whether the ad is visible */
+ visible: boolean;
+ /** Dismiss callback */
+ onDismiss: () => void;
+ /** Click callback */
+ onClick?: (ad: PauseAdType) => void;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+export function PauseAd({
+ ad,
+ visible,
+ onDismiss,
+ onClick,
+ className,
+}: PauseAdComponentProps) {
+ const handleClick = useCallback(() => {
+ if (ad.clickThroughUrl) {
+ window.open(ad.clickThroughUrl, '_blank', 'noopener,noreferrer');
+ }
+ onClick?.(ad);
+ }, [ad, onClick]);
+
+ if (!visible) return null;
+
+ return (
+
+ {/* Ad content */}
+
+ {/* Close button */}
+
+
+
+
+
+
+
+ {/* Ad badge */}
+
+
+ AD
+
+
+
+ {/* Image */}
+
+
+
+
+ {/* Title and description */}
+ {(ad.title || ad.description) && (
+
+ {ad.title && (
+
{ad.title}
+ )}
+ {ad.description && (
+
{ad.description}
+ )}
+
+ )}
+
+ {/* CTA */}
+ {ad.clickThroughUrl && (
+
+
+ Learn More
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ads/PauseAd/index.ts b/src/components/ads/PauseAd/index.ts
new file mode 100644
index 0000000..9a05265
--- /dev/null
+++ b/src/components/ads/PauseAd/index.ts
@@ -0,0 +1 @@
+export { PauseAd, type PauseAdComponentProps } from './PauseAd';
diff --git a/src/components/ads/RewardedAd/RewardedAd.stories.tsx b/src/components/ads/RewardedAd/RewardedAd.stories.tsx
new file mode 100644
index 0000000..8024c8c
--- /dev/null
+++ b/src/components/ads/RewardedAd/RewardedAd.stories.tsx
@@ -0,0 +1,210 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState, useCallback } from 'react';
+import { RewardedAdOverlay } from './RewardedAd';
+import type { RewardedAd as RewardedAdType } from '@/types/rewardedAd';
+
+const mockAd: RewardedAdType = {
+ id: 'rewarded-1',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
+ duration: 30,
+ title: 'Watch to unlock premium episode',
+ rewardDescription: 'Watch this short ad to unlock the full episode',
+ poster: 'https://placehold.co/800x450/1a1a2e/00a99d?text=Rewarded+Ad',
+ clickThroughUrl: 'https://example.com',
+};
+
+const meta: Meta = {
+ title: 'Ads/RewardedAd',
+ component: RewardedAdOverlay,
+ parameters: {
+ layout: 'fullscreen',
+ backgrounds: {
+ default: 'dark',
+ values: [{ name: 'dark', value: '#121212' }],
+ },
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Default: Visible rewarded ad overlay
+// Since the component uses fixed inset-0, it covers the whole viewport.
+// We wrap it in a relative container with defined height so Storybook's
+// canvas area still shows surrounding chrome.
+export const Default: Story = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ args: {
+ ad: mockAd,
+ visible: true,
+ onReward: (ad) => console.log('Reward earned:', ad.id),
+ onClose: (ad, completed) =>
+ console.log('Closed:', ad.id, 'completed:', completed),
+ onClick: (ad) => console.log('Clicked:', ad.id),
+ },
+};
+
+// Hidden: visible=false renders nothing
+export const Hidden: Story = {
+ args: {
+ ad: mockAd,
+ visible: false,
+ onReward: (ad) => console.log('Reward earned:', ad.id),
+ onClose: (ad, completed) =>
+ console.log('Closed:', ad.id, 'completed:', completed),
+ },
+};
+
+// Interactive: Full flow simulation with state management and event logging
+export const Interactive: Story = {
+ render: function InteractiveRewardedAd() {
+ const [visible, setVisible] = useState(false);
+ const [rewarded, setRewarded] = useState(false);
+ const [events, setEvents] = useState([]);
+
+ const log = useCallback((message: string) => {
+ setEvents((prev) => [
+ `[${new Date().toLocaleTimeString()}] ${message}`,
+ ...prev,
+ ]);
+ }, []);
+
+ const handleReward = useCallback(
+ (ad: RewardedAdType) => {
+ log(`onReward: "${ad.title}" (${ad.id})`);
+ setRewarded(true);
+ },
+ [log]
+ );
+
+ const handleClose = useCallback(
+ (ad: RewardedAdType, completed: boolean) => {
+ log(
+ `onClose: "${ad.title}" (${ad.id}) completed=${String(completed)}`
+ );
+ setVisible(false);
+ },
+ [log]
+ );
+
+ const handleClick = useCallback(
+ (ad: RewardedAdType) => {
+ log(`onClick: "${ad.title}" → ${ad.clickThroughUrl ?? 'N/A'}`);
+ },
+ [log]
+ );
+
+ const handleStartAd = useCallback(() => {
+ setRewarded(false);
+ setVisible(true);
+ log('Ad triggered by user');
+ }, [log]);
+
+ // Simulate the video completing (since the video element won't actually
+ // play in Storybook with a fake URL). This manually dispatches the
+ // "ended" event on the video element inside the overlay.
+ const handleSimulateComplete = useCallback(() => {
+ const videoEl = document.querySelector(
+ '[data-testid="rewarded-ad-video"]'
+ ) as HTMLVideoElement | null;
+ if (videoEl) {
+ videoEl.dispatchEvent(new Event('ended'));
+ log('Simulated video completion (dispatched "ended" event)');
+ } else {
+ log('Could not find video element to simulate completion');
+ }
+ }, [log]);
+
+ return (
+
+ {/* Controls */}
+
+
+ Rewarded Ad Demo
+
+
+ {rewarded ? (
+
+
+
+
+
+
+ Premium episode unlocked!
+
+
+
+ Watch again
+
+
+ ) : (
+
+ Watch Ad to Unlock
+
+ )}
+
+
+ {/* Simulate Complete button (always visible when ad is showing) */}
+ {visible && (
+
+ Simulate Complete
+
+ )}
+
+ {/* Event Log */}
+
+
+ Event Log
+
+
+ {events.length === 0 ? (
+
+ No events yet. Click "Watch Ad to Unlock" to start.
+
+ ) : (
+
+ {events.map((event, i) => (
+
+ {event}
+
+ ))}
+
+ )}
+
+
+
+ {/* The overlay */}
+
+
+ );
+ },
+};
diff --git a/src/components/ads/RewardedAd/RewardedAd.test.tsx b/src/components/ads/RewardedAd/RewardedAd.test.tsx
new file mode 100644
index 0000000..33a674b
--- /dev/null
+++ b/src/components/ads/RewardedAd/RewardedAd.test.tsx
@@ -0,0 +1,73 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { RewardedAdOverlay } from './RewardedAd';
+import type { RewardedAd } from '@/types/rewardedAd';
+
+const mockAd: RewardedAd = {
+ id: 'test-rewarded',
+ src: 'https://example.com/ad.mp4',
+ duration: 30,
+ title: 'Watch to unlock',
+ rewardDescription: 'Watch to get premium',
+ poster: 'https://example.com/poster.jpg',
+ clickThroughUrl: 'https://example.com/landing',
+};
+
+describe('RewardedAdOverlay', () => {
+ it('should render nothing when not visible', () => {
+ const { container } = render(
+ {}} onClose={() => {}} />
+ );
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('should render overlay when visible', () => {
+ render(
+ {}} onClose={() => {}} />
+ );
+ expect(screen.getByTestId('rewarded-ad')).toBeInTheDocument();
+ });
+
+ it('should show ad title', () => {
+ render(
+ {}} onClose={() => {}} />
+ );
+ expect(screen.getByText('Watch to unlock')).toBeInTheDocument();
+ });
+
+ it('should show AD badge', () => {
+ render(
+ {}} onClose={() => {}} />
+ );
+ expect(screen.getByText('AD')).toBeInTheDocument();
+ });
+
+ it('should show reward description', () => {
+ render(
+ {}} onClose={() => {}} />
+ );
+ expect(screen.getByTestId('rewarded-ad-description')).toBeInTheDocument();
+ expect(screen.getByText('Watch to get premium')).toBeInTheDocument();
+ });
+
+ it('should show countdown', () => {
+ render(
+ {}} onClose={() => {}} />
+ );
+ expect(screen.getByTestId('rewarded-ad-countdown')).toBeInTheDocument();
+ });
+
+ it('should have a video element', () => {
+ render(
+ {}} onClose={() => {}} />
+ );
+ expect(screen.getByTestId('rewarded-ad-video')).toBeInTheDocument();
+ });
+
+ it('should have a progress bar', () => {
+ render(
+ {}} onClose={() => {}} />
+ );
+ expect(screen.getByTestId('rewarded-ad-progress')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ads/RewardedAd/RewardedAd.tsx b/src/components/ads/RewardedAd/RewardedAd.tsx
new file mode 100644
index 0000000..326c4fb
--- /dev/null
+++ b/src/components/ads/RewardedAd/RewardedAd.tsx
@@ -0,0 +1,190 @@
+import { useRef, useCallback, useState, useEffect } from 'react';
+import { cn } from '@/utils/cn';
+import type { RewardedAd as RewardedAdType } from '@/types/rewardedAd';
+
+export interface RewardedAdOverlayProps {
+ /** The rewarded ad */
+ ad: RewardedAdType;
+ /** Whether the overlay is visible */
+ visible: boolean;
+ /** Called when the ad completes and reward is earned */
+ onReward: (ad: RewardedAdType) => void;
+ /** Called when user closes (completed or not) */
+ onClose: (ad: RewardedAdType, completed: boolean) => void;
+ /** Called on click-through */
+ onClick?: (ad: RewardedAdType) => void;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+export function RewardedAdOverlay({
+ ad,
+ visible,
+ onReward,
+ onClose,
+ onClick,
+ className,
+}: RewardedAdOverlayProps) {
+ const videoRef = useRef(null);
+ const [progress, setProgress] = useState(0);
+ const [duration, setDuration] = useState(ad.duration);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isCompleted, setIsCompleted] = useState(false);
+ const rewardedRef = useRef(false);
+
+ // Reset state when ad changes or becomes visible
+ useEffect(() => {
+ if (visible) {
+ setProgress(0);
+ setDuration(ad.duration);
+ setIsPlaying(false);
+ setIsCompleted(false);
+ rewardedRef.current = false;
+ }
+ }, [visible, ad]);
+
+ // Auto-play when visible
+ useEffect(() => {
+ if (visible && videoRef.current) {
+ videoRef.current.play().catch(() => {
+ // Autoplay blocked - user needs to interact
+ });
+ }
+ }, [visible]);
+
+ const handleTimeUpdate = useCallback(() => {
+ if (videoRef.current) {
+ setProgress(videoRef.current.currentTime);
+ if (videoRef.current.duration && isFinite(videoRef.current.duration)) {
+ setDuration(videoRef.current.duration);
+ }
+ }
+ }, []);
+
+ const handlePlay = useCallback(() => {
+ setIsPlaying(true);
+ }, []);
+
+ const handleEnded = useCallback(() => {
+ setIsPlaying(false);
+ setIsCompleted(true);
+ if (!rewardedRef.current) {
+ rewardedRef.current = true;
+ onReward(ad);
+ }
+ }, [ad, onReward]);
+
+ const handleClick = useCallback(() => {
+ if (ad.clickThroughUrl) {
+ window.open(ad.clickThroughUrl, '_blank', 'noopener,noreferrer');
+ }
+ onClick?.(ad);
+ }, [ad, onClick]);
+
+ const handleClose = useCallback(() => {
+ if (videoRef.current) {
+ videoRef.current.pause();
+ }
+ onClose(ad, isCompleted);
+ }, [ad, isCompleted, onClose]);
+
+ if (!visible) return null;
+
+ const percentage = duration > 0 ? (progress / duration) * 100 : 0;
+ const remainingSeconds = Math.max(0, Math.ceil(duration - progress));
+
+ return (
+
+ {/* Header */}
+
+
+
+ AD
+
+ {ad.title && (
+ {ad.title}
+ )}
+
+
+ {/* Close / remaining time */}
+ {isCompleted ? (
+
+ Close
+
+ ) : (
+
+ {remainingSeconds}s remaining
+
+ )}
+
+
+ {/* Video */}
+
+
+
+ {/* Click area */}
+ {ad.clickThroughUrl && isPlaying && (
+
+ )}
+
+
+ {/* Progress bar */}
+
+
+ {/* Reward description */}
+ {ad.rewardDescription && (
+
+ {ad.rewardDescription}
+
+ )}
+
+ {/* Completed state */}
+ {isCompleted && (
+
+ )}
+
+ );
+}
diff --git a/src/components/ads/RewardedAd/index.ts b/src/components/ads/RewardedAd/index.ts
new file mode 100644
index 0000000..72b1a9b
--- /dev/null
+++ b/src/components/ads/RewardedAd/index.ts
@@ -0,0 +1 @@
+export { RewardedAdOverlay, type RewardedAdOverlayProps } from './RewardedAd';
diff --git a/src/components/ads/index.ts b/src/components/ads/index.ts
index 125fec0..2b076eb 100644
--- a/src/components/ads/index.ts
+++ b/src/components/ads/index.ts
@@ -2,3 +2,5 @@ export { AdOverlay, type AdOverlayProps } from './AdOverlay';
export { AdSkipButton, type AdSkipButtonProps } from './AdSkipButton';
export { OverlayAd, type OverlayAdProps } from './OverlayAd';
export { InfoCard, InfoCardIcon, type InfoCardProps, type InfoCardIconProps } from './InfoCard';
+export { PauseAd, type PauseAdComponentProps } from './PauseAd';
+export { RewardedAdOverlay, type RewardedAdOverlayProps } from './RewardedAd';
diff --git a/src/components/chapters/ChapterList/ChapterList.test.tsx b/src/components/chapters/ChapterList/ChapterList.test.tsx
new file mode 100644
index 0000000..f2a0c97
--- /dev/null
+++ b/src/components/chapters/ChapterList/ChapterList.test.tsx
@@ -0,0 +1,173 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ChapterList } from './ChapterList';
+import { createMockChapters } from '@/test/helpers';
+import type { Chapter } from '@/types/player';
+
+const chapters = createMockChapters() as Chapter[];
+
+function renderChapterList(props: Partial[0]> = {}) {
+ const defaults = {
+ chapters,
+ currentChapterIndex: 0,
+ currentTime: 0,
+ duration: 180,
+ onChapterClick: vi.fn(),
+ };
+ return render( );
+}
+
+describe('ChapterList', () => {
+ // ── Rendering ──────────────────────────────────────────────────────
+
+ it('renders the "Chapters" heading', () => {
+ renderChapterList();
+ expect(screen.getByText('Chapters')).toBeInTheDocument();
+ });
+
+ it('renders all chapter titles', () => {
+ renderChapterList();
+ expect(screen.getByText('Intro')).toBeInTheDocument();
+ expect(screen.getByText('Main Content')).toBeInTheDocument();
+ expect(screen.getByText('Outro')).toBeInTheDocument();
+ });
+
+ it('renders a list with chapter items', () => {
+ renderChapterList();
+ expect(screen.getByRole('list', { name: 'Chapter list' })).toBeInTheDocument();
+ const items = screen.getAllByRole('listitem');
+ expect(items).toHaveLength(3);
+ });
+
+ it('does not render when chapters array is empty', () => {
+ const { container } = renderChapterList({ chapters: [] });
+ expect(container.innerHTML).toBe('');
+ });
+
+ // ── Active chapter highlighting ────────────────────────────────────
+
+ it('marks the active chapter with aria-current', () => {
+ renderChapterList({ currentChapterIndex: 1 });
+ const buttons = screen.getAllByRole('button');
+ expect(buttons[0]).not.toHaveAttribute('aria-current');
+ expect(buttons[1]).toHaveAttribute('aria-current', 'true');
+ expect(buttons[2]).not.toHaveAttribute('aria-current');
+ });
+
+ it('applies active background styling to current chapter', () => {
+ renderChapterList({ currentChapterIndex: 0 });
+ const buttons = screen.getAllByRole('button');
+ expect(buttons[0].className).toContain('bg-[var(--fp-color-surface)]');
+ });
+
+ it('shows active indicator dot for current chapter', () => {
+ renderChapterList({ currentChapterIndex: 1 });
+ const activeButton = screen.getAllByRole('button')[1];
+ const dot = activeButton.querySelector('.rounded-full.bg-\\[var\\(--fp-color-primary\\)\\]');
+ expect(dot).toBeTruthy();
+ });
+
+ it('applies active text color to current chapter title', () => {
+ renderChapterList({ currentChapterIndex: 0 });
+ const introText = screen.getByText('Intro');
+ expect(introText.className).toContain('text-[var(--fp-color-primary)]');
+ expect(introText.className).toContain('font-medium');
+ });
+
+ // ── Click handler ──────────────────────────────────────────────────
+
+ it('calls onChapterClick with chapter and index when clicked', () => {
+ const onChapterClick = vi.fn();
+ renderChapterList({ onChapterClick });
+ fireEvent.click(screen.getByText('Main Content'));
+ expect(onChapterClick).toHaveBeenCalledWith(chapters[1], 1);
+ });
+
+ it('calls onChapterClick for first chapter', () => {
+ const onChapterClick = vi.fn();
+ renderChapterList({ onChapterClick });
+ fireEvent.click(screen.getByText('Intro'));
+ expect(onChapterClick).toHaveBeenCalledWith(chapters[0], 0);
+ });
+
+ it('calls onChapterClick for last chapter', () => {
+ const onChapterClick = vi.fn();
+ renderChapterList({ onChapterClick });
+ fireEvent.click(screen.getByText('Outro'));
+ expect(onChapterClick).toHaveBeenCalledWith(chapters[2], 2);
+ });
+
+ // ── showDuration ───────────────────────────────────────────────────
+
+ it('shows start times when showDuration is true (default)', () => {
+ renderChapterList();
+ expect(screen.getByText('0:00')).toBeInTheDocument();
+ // 0:30 appears for both chapter 1 startTime (30s) and chapter 1 duration (30s)
+ expect(screen.getAllByText('0:30').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('2:00')).toBeInTheDocument();
+ });
+
+ it('shows chapter durations when showDuration is true', () => {
+ renderChapterList();
+ // Chapter 1: 0-30 = 30s, Chapter 2: 30-120 = 90s, Chapter 3: 120-180 = 60s
+ // formatTime(30) = '0:30', formatTime(90) = '1:30', formatTime(60) = '1:00'
+ expect(screen.getAllByText('0:30').length).toBeGreaterThanOrEqual(1); // start time and/or duration
+ expect(screen.getByText('1:30')).toBeInTheDocument();
+ expect(screen.getByText('1:00')).toBeInTheDocument();
+ });
+
+ it('hides durations when showDuration is false', () => {
+ renderChapterList({ showDuration: false });
+ // The formatted times should not be present
+ expect(screen.queryByText('0:00')).not.toBeInTheDocument();
+ expect(screen.queryByText('1:30')).not.toBeInTheDocument();
+ });
+
+ // ── showImage ──────────────────────────────────────────────────────
+
+ it('shows chapter images when showImage is true and images exist', () => {
+ const chaptersWithImages = chapters.map((ch) => ({
+ ...ch,
+ image: `https://example.com/${ch.id}.jpg`,
+ }));
+ const { container } = renderChapterList({ chapters: chaptersWithImages, showImage: true });
+ const images = container.querySelectorAll('img');
+ expect(images.length).toBe(3);
+ });
+
+ it('does not show images when showImage is false', () => {
+ const chaptersWithImages = chapters.map((ch) => ({
+ ...ch,
+ image: `https://example.com/${ch.id}.jpg`,
+ }));
+ const { container } = renderChapterList({ chapters: chaptersWithImages, showImage: false });
+ expect(container.querySelectorAll('img')).toHaveLength(0);
+ });
+
+ it('does not show images when chapters have no image property', () => {
+ const { container } = renderChapterList({ showImage: true });
+ expect(container.querySelectorAll('img')).toHaveLength(0);
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className to the container', () => {
+ const { container } = renderChapterList({ className: 'my-chapters' });
+ expect(container.firstElementChild?.className).toContain('my-chapters');
+ });
+
+ // ── Chapter without endTime ────────────────────────────────────────
+
+ it('calculates duration from next chapter start when endTime is absent', () => {
+ const chaptersNoEnd: Chapter[] = [
+ { id: 'ch-1', title: 'Part 1', startTime: 0 },
+ { id: 'ch-2', title: 'Part 2', startTime: 60 },
+ ];
+ renderChapterList({ chapters: chaptersNoEnd, duration: 120 });
+ // Part 1 duration: 60-0 = 60s -> '1:00', Part 2 duration: 120-60 = 60s -> '1:00'
+ // Part 1: startTime=0 "0:00", duration=60 "1:00"
+ // Part 2: startTime=60 "1:00", duration=60 "1:00"
+ // Total "1:00" appearances = 3 (Part1 dur + Part2 start + Part2 dur)
+ expect(screen.getAllByText('1:00').length).toBe(3);
+ });
+});
diff --git a/src/components/chapters/ChapterMarker/ChapterMarker.test.tsx b/src/components/chapters/ChapterMarker/ChapterMarker.test.tsx
new file mode 100644
index 0000000..504a7ed
--- /dev/null
+++ b/src/components/chapters/ChapterMarker/ChapterMarker.test.tsx
@@ -0,0 +1,125 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ChapterMarker } from './ChapterMarker';
+import type { Chapter } from '@/types/player';
+
+const chapter: Chapter = {
+ id: 'ch-1',
+ title: 'Introduction',
+ startTime: 30,
+};
+
+function renderChapterMarker(props: Partial[0]> = {}) {
+ const defaults = {
+ chapter,
+ duration: 300,
+ isActive: false,
+ onClick: vi.fn(),
+ };
+ return render( );
+}
+
+describe('ChapterMarker', () => {
+ // ── Rendering ──────────────────────────────────────────────────────
+
+ it('renders as a button', () => {
+ renderChapterMarker();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('has type="button"', () => {
+ renderChapterMarker();
+ expect(screen.getByRole('button').getAttribute('type')).toBe('button');
+ });
+
+ it('has an aria-label with the chapter title', () => {
+ renderChapterMarker();
+ expect(screen.getByRole('button', { name: 'Chapter: Introduction' })).toBeInTheDocument();
+ });
+
+ it('has a title attribute with the chapter title', () => {
+ renderChapterMarker();
+ expect(screen.getByRole('button')).toHaveAttribute('title', 'Introduction');
+ });
+
+ // ── Position calculation ───────────────────────────────────────────
+
+ it('calculates left position as percentage of duration', () => {
+ renderChapterMarker({ chapter: { ...chapter, startTime: 60 }, duration: 300 });
+ const btn = screen.getByRole('button');
+ // 60 / 300 * 100 = 20%
+ expect(btn.style.left).toBe('20%');
+ });
+
+ it('calculates 0% position for startTime 0', () => {
+ renderChapterMarker({ chapter: { ...chapter, startTime: 0 }, duration: 300 });
+ expect(screen.getByRole('button').style.left).toBe('0%');
+ });
+
+ it('calculates position for a chapter near the end', () => {
+ renderChapterMarker({ chapter: { ...chapter, startTime: 270 }, duration: 300 });
+ // 270 / 300 * 100 = 90%
+ expect(screen.getByRole('button').style.left).toBe('90%');
+ });
+
+ it('handles zero duration gracefully (0% position)', () => {
+ renderChapterMarker({ duration: 0 });
+ expect(screen.getByRole('button').style.left).toBe('0%');
+ });
+
+ // ── Active state ───────────────────────────────────────────────────
+
+ it('applies active styling when isActive is true', () => {
+ renderChapterMarker({ isActive: true });
+ const btn = screen.getByRole('button');
+ expect(btn.className).toContain('bg-[var(--fp-color-primary)]');
+ expect(btn.className).toContain('z-10');
+ });
+
+ it('applies inactive styling when isActive is false', () => {
+ renderChapterMarker({ isActive: false });
+ const btn = screen.getByRole('button');
+ expect(btn.className).toContain('opacity-40');
+ expect(btn.className).not.toContain('z-10');
+ });
+
+ // ── Click handler ──────────────────────────────────────────────────
+
+ it('calls onClick with the chapter when clicked', () => {
+ const onClick = vi.fn();
+ renderChapterMarker({ onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledWith(chapter);
+ });
+
+ it('calls onClick with a different chapter', () => {
+ const onClick = vi.fn();
+ const otherChapter: Chapter = { id: 'ch-2', title: 'Middle', startTime: 100 };
+ renderChapterMarker({ chapter: otherChapter, onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledWith(otherChapter);
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className', () => {
+ renderChapterMarker({ className: 'my-marker' });
+ expect(screen.getByRole('button').className).toContain('my-marker');
+ });
+
+ // ── Absolute positioning ───────────────────────────────────────────
+
+ it('has absolute positioning class', () => {
+ renderChapterMarker();
+ expect(screen.getByRole('button').className).toContain('absolute');
+ });
+
+ // ── Different chapters ─────────────────────────────────────────────
+
+ it('renders correct label for a different chapter title', () => {
+ renderChapterMarker({
+ chapter: { id: 'ch-x', title: 'The Finale', startTime: 200 },
+ });
+ expect(screen.getByRole('button', { name: 'Chapter: The Finale' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/controls/CastButton/CastButton.test.tsx b/src/components/controls/CastButton/CastButton.test.tsx
new file mode 100644
index 0000000..db07da4
--- /dev/null
+++ b/src/components/controls/CastButton/CastButton.test.tsx
@@ -0,0 +1,146 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { CastButton } from './CastButton';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderCastButton(props: Partial[0]> = {}) {
+ const defaults = {
+ isCasting: false,
+ onClick: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('CastButton', () => {
+ // ── Not casting state ───────────────────────────────────────────────
+
+ it('renders cast label when not casting', () => {
+ renderCastButton({ isCasting: false });
+ expect(screen.getByRole('button', { name: 'Cast' })).toBeInTheDocument();
+ });
+
+ it('shows cast title when not casting', () => {
+ renderCastButton({ isCasting: false });
+ expect(screen.getByRole('button')).toHaveAttribute('title', 'Cast');
+ });
+
+ it('renders the inactive cast SVG icon', () => {
+ renderCastButton({ isCasting: false });
+ const svg = screen.getByRole('button').querySelector('svg');
+ expect(svg).toBeTruthy();
+ expect(svg?.getAttribute('fill')).toBe('currentColor');
+ });
+
+ // ── Casting state ──────────────────────────────────────────────────
+
+ it('renders stop casting label when casting', () => {
+ renderCastButton({ isCasting: true });
+ expect(screen.getByRole('button', { name: 'Stop casting' })).toBeInTheDocument();
+ });
+
+ it('shows stop casting title when casting', () => {
+ renderCastButton({ isCasting: true });
+ expect(screen.getByRole('button')).toHaveAttribute('title', 'Stop casting');
+ });
+
+ it('renders the active cast SVG icon when casting', () => {
+ renderCastButton({ isCasting: true });
+ const svg = screen.getByRole('button').querySelector('svg');
+ expect(svg).toBeTruthy();
+ });
+
+ // ── Icon differences between states ────────────────────────────────
+
+ it('renders different SVG path data for casting vs not casting', () => {
+ const { rerender } = render(
+ ,
+ { wrapper: Wrapper }
+ );
+ const notCastingPath = screen.getByRole('button').querySelector('svg path')?.getAttribute('d');
+
+ rerender( );
+ const castingPath = screen.getByRole('button').querySelector('svg path')?.getAttribute('d');
+
+ expect(notCastingPath).toBeTruthy();
+ expect(castingPath).toBeTruthy();
+ expect(notCastingPath).not.toBe(castingPath);
+ });
+
+ // ── Click handler ──────────────────────────────────────────────────
+
+ it('calls onClick when clicked', () => {
+ const onClick = vi.fn();
+ renderCastButton({ onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onClick when disabled', () => {
+ const onClick = vi.fn();
+ renderCastButton({ disabled: true, onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ // ── Disabled state ─────────────────────────────────────────────────
+
+ it('disables the button when disabled prop is true', () => {
+ renderCastButton({ disabled: true });
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+
+ it('applies disabled opacity styling', () => {
+ renderCastButton({ disabled: true });
+ expect(screen.getByRole('button').className).toContain('opacity-50');
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className', () => {
+ renderCastButton({ className: 'my-cast-class' });
+ expect(screen.getByRole('button').className).toContain('my-cast-class');
+ });
+
+ // ── Aria labels toggle ──────────────────────────────────────────────
+
+ it('toggles aria-label on state change via rerender', () => {
+ const { rerender } = render(
+ ,
+ { wrapper: Wrapper }
+ );
+ expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Cast');
+
+ rerender( );
+ expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Stop casting');
+ });
+
+ // ── Custom labels ──────────────────────────────────────────────────
+
+ it('uses custom start cast label', () => {
+ renderCastButton({
+ isCasting: false,
+ labels: { startCast: 'Streamen', stopCast: 'Streaming beenden' },
+ });
+ expect(screen.getByRole('button', { name: 'Streamen' })).toBeInTheDocument();
+ });
+
+ it('uses custom stop cast label', () => {
+ renderCastButton({
+ isCasting: true,
+ labels: { startCast: 'Streamen', stopCast: 'Streaming beenden' },
+ });
+ expect(screen.getByRole('button', { name: 'Streaming beenden' })).toBeInTheDocument();
+ });
+
+ // ── CSS class ──────────────────────────────────────────────────────
+
+ it('has the fp-cast-button class', () => {
+ renderCastButton();
+ expect(screen.getByRole('button').className).toContain('fp-cast-button');
+ });
+});
diff --git a/src/components/controls/Equalizer/Equalizer.stories.tsx b/src/components/controls/Equalizer/Equalizer.stories.tsx
new file mode 100644
index 0000000..59d1835
--- /dev/null
+++ b/src/components/controls/Equalizer/Equalizer.stories.tsx
@@ -0,0 +1,135 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
+import { Equalizer } from './Equalizer';
+import { DEFAULT_BANDS, EQUALIZER_PRESETS } from '@/types/equalizer';
+import type { EqualizerBand } from '@/types/equalizer';
+
+const meta: Meta = {
+ title: 'Controls/Equalizer',
+ component: Equalizer,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ bands: DEFAULT_BANDS,
+ presets: EQUALIZER_PRESETS,
+ currentPreset: 'flat',
+ enabled: true,
+ onBandChange: () => {},
+ onPresetSelect: () => {},
+ onReset: () => {},
+ onEnabledChange: () => {},
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ bands: DEFAULT_BANDS,
+ presets: EQUALIZER_PRESETS,
+ currentPreset: 'flat',
+ enabled: false,
+ onBandChange: () => {},
+ onPresetSelect: () => {},
+ onReset: () => {},
+ onEnabledChange: () => {},
+ },
+};
+
+export const PodcastPreset: Story = {
+ args: {
+ bands: DEFAULT_BANDS.map((band, i) => ({
+ ...band,
+ gain: [-2, 1, 4, 3, 1][i],
+ })),
+ presets: EQUALIZER_PRESETS,
+ currentPreset: 'podcast',
+ enabled: true,
+ onBandChange: () => {},
+ onPresetSelect: () => {},
+ onReset: () => {},
+ onEnabledChange: () => {},
+ },
+};
+
+export const BassBoost: Story = {
+ args: {
+ bands: DEFAULT_BANDS.map((band, i) => ({
+ ...band,
+ gain: [6, 4, 0, 0, 0][i],
+ })),
+ presets: EQUALIZER_PRESETS,
+ currentPreset: 'bass-boost',
+ enabled: true,
+ onBandChange: () => {},
+ onPresetSelect: () => {},
+ onReset: () => {},
+ onEnabledChange: () => {},
+ },
+};
+
+export const Interactive: Story = {
+ render: () => {
+ const [bands, setBands] = useState(DEFAULT_BANDS);
+ const [enabled, setEnabled] = useState(true);
+ const [currentPreset, setCurrentPreset] = useState('flat');
+
+ const handleBandChange = (index: number, gain: number) => {
+ setBands((prev) =>
+ prev.map((band, i) => (i === index ? { ...band, gain } : band))
+ );
+ setCurrentPreset(null);
+ };
+
+ const handlePresetSelect = (presetName: string) => {
+ const preset = EQUALIZER_PRESETS.find((p) => p.name === presetName);
+ if (preset) {
+ setBands((prev) =>
+ prev.map((band, i) => ({ ...band, gain: preset.bands[i] }))
+ );
+ setCurrentPreset(presetName);
+ }
+ };
+
+ const handleReset = () => {
+ setBands(DEFAULT_BANDS);
+ setCurrentPreset('flat');
+ };
+
+ return (
+
+
+
+
Preset: {currentPreset ?? 'Custom'}
+
Bands: [{bands.map((b) => b.gain).join(', ')}]
+
Enabled: {enabled ? 'Yes' : 'No'}
+
+
+ );
+ },
+};
diff --git a/src/components/controls/Equalizer/Equalizer.tsx b/src/components/controls/Equalizer/Equalizer.tsx
new file mode 100644
index 0000000..2218873
--- /dev/null
+++ b/src/components/controls/Equalizer/Equalizer.tsx
@@ -0,0 +1,145 @@
+import { cn } from '@/utils/cn';
+import type { EqualizerBand, EqualizerPreset } from '@/types/equalizer';
+
+export interface EqualizerProps {
+ /** Current band settings */
+ bands: EqualizerBand[];
+ /** Set gain for a specific band */
+ onBandChange: (index: number, gain: number) => void;
+ /** Apply a preset */
+ onPresetSelect: (presetName: string) => void;
+ /** Reset to flat */
+ onReset: () => void;
+ /** Available presets */
+ presets: EqualizerPreset[];
+ /** Current preset name */
+ currentPreset: string | null;
+ /** Whether the EQ is enabled */
+ enabled: boolean;
+ /** Toggle enabled */
+ onEnabledChange: (enabled: boolean) => void;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+function formatFrequency(hz: number): string {
+ if (hz >= 1000) return `${hz / 1000}k`;
+ return `${hz}`;
+}
+
+export function Equalizer({
+ bands,
+ onBandChange,
+ onPresetSelect,
+ onReset,
+ presets,
+ currentPreset,
+ enabled,
+ onEnabledChange,
+ className,
+}: EqualizerProps) {
+ return (
+
+ {/* Header */}
+
+ Equalizer
+ onEnabledChange(!enabled)}
+ className={cn(
+ 'relative w-9 h-5 rounded-full transition-colors duration-200',
+ enabled ? 'bg-[var(--fp-color-accent)]' : 'bg-[var(--fp-progress-bg)]'
+ )}
+ aria-label={enabled ? 'Disable equalizer' : 'Enable equalizer'}
+ >
+
+
+
+
+ {/* Presets */}
+
+ {presets.map((preset) => (
+ onPresetSelect(preset.name)}
+ disabled={!enabled}
+ className={cn(
+ 'px-2 py-1 rounded text-xs',
+ 'border transition-colors duration-[var(--fp-transition-fast)]',
+ currentPreset === preset.name
+ ? 'border-[var(--fp-color-accent)] text-[var(--fp-color-accent)]'
+ : 'border-[var(--fp-glass-border)] text-[var(--fp-color-text-secondary)]',
+ enabled && 'hover:border-[var(--fp-color-accent)]',
+ !enabled && 'cursor-not-allowed'
+ )}
+ >
+ {preset.label}
+
+ ))}
+
+
+ {/* Band sliders */}
+
+ {bands.map((band, index) => (
+
+
+ {band.gain > 0 ? '+' : ''}{band.gain}
+
+ onBandChange(index, Number(e.target.value))}
+ disabled={!enabled}
+ className={cn(
+ 'h-20 appearance-none cursor-pointer',
+ 'accent-[var(--fp-color-accent)]',
+ !enabled && 'cursor-not-allowed'
+ )}
+ style={{
+ writingMode: 'vertical-lr',
+ direction: 'rtl',
+ width: '20px',
+ }}
+ aria-label={`${formatFrequency(band.frequency)}Hz`}
+ />
+
+ {formatFrequency(band.frequency)}
+
+
+ ))}
+
+
+ {/* Reset */}
+
+ Reset
+
+
+ );
+}
diff --git a/src/components/controls/Equalizer/index.ts b/src/components/controls/Equalizer/index.ts
new file mode 100644
index 0000000..ca22427
--- /dev/null
+++ b/src/components/controls/Equalizer/index.ts
@@ -0,0 +1 @@
+export { Equalizer, type EqualizerProps } from './Equalizer';
diff --git a/src/components/controls/FullscreenButton/FullscreenButton.test.tsx b/src/components/controls/FullscreenButton/FullscreenButton.test.tsx
new file mode 100644
index 0000000..48daa12
--- /dev/null
+++ b/src/components/controls/FullscreenButton/FullscreenButton.test.tsx
@@ -0,0 +1,132 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { FullscreenButton } from './FullscreenButton';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderFullscreenButton(props: Partial[0]> = {}) {
+ const defaults = {
+ isFullscreen: false,
+ onClick: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('FullscreenButton', () => {
+ // ── Enter fullscreen state ──────────────────────────────────────────
+
+ it('renders enter fullscreen label when not fullscreen', () => {
+ renderFullscreenButton({ isFullscreen: false });
+ expect(screen.getByRole('button', { name: 'Enter fullscreen' })).toBeInTheDocument();
+ });
+
+ it('shows enter fullscreen title when not fullscreen', () => {
+ renderFullscreenButton({ isFullscreen: false });
+ expect(screen.getByRole('button')).toHaveAttribute('title', 'Enter fullscreen');
+ });
+
+ it('renders enter fullscreen SVG icon', () => {
+ renderFullscreenButton({ isFullscreen: false });
+ const svg = screen.getByRole('button').querySelector('svg');
+ expect(svg).toBeTruthy();
+ const path = svg?.querySelector('path');
+ expect(path?.getAttribute('d')).toContain('H5');
+ });
+
+ // ── Exit fullscreen state ──────────────────────────────────────────
+
+ it('renders exit fullscreen label when fullscreen', () => {
+ renderFullscreenButton({ isFullscreen: true });
+ expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+ });
+
+ it('shows exit fullscreen title when fullscreen', () => {
+ renderFullscreenButton({ isFullscreen: true });
+ expect(screen.getByRole('button')).toHaveAttribute('title', 'Exit fullscreen');
+ });
+
+ it('renders exit fullscreen SVG icon', () => {
+ renderFullscreenButton({ isFullscreen: true });
+ const svg = screen.getByRole('button').querySelector('svg');
+ expect(svg).toBeTruthy();
+ const path = svg?.querySelector('path');
+ expect(path?.getAttribute('d')).toContain('v3');
+ });
+
+ // ── Click handler ──────────────────────────────────────────────────
+
+ it('calls onClick when clicked', () => {
+ const onClick = vi.fn();
+ renderFullscreenButton({ onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onClick when disabled', () => {
+ const onClick = vi.fn();
+ renderFullscreenButton({ disabled: true, onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ // ── Disabled state ─────────────────────────────────────────────────
+
+ it('disables the button when disabled prop is true', () => {
+ renderFullscreenButton({ disabled: true });
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+
+ it('applies disabled styling', () => {
+ renderFullscreenButton({ disabled: true });
+ expect(screen.getByRole('button').className).toContain('opacity-50');
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className to the button', () => {
+ renderFullscreenButton({ className: 'my-fullscreen' });
+ expect(screen.getByRole('button').className).toContain('my-fullscreen');
+ });
+
+ // ── Aria labels toggle ──────────────────────────────────────────────
+
+ it('toggles aria-label on state change via rerender', () => {
+ const { rerender } = render(
+ ,
+ { wrapper: Wrapper }
+ );
+ expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Enter fullscreen');
+
+ rerender( );
+ expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Exit fullscreen');
+ });
+
+ // ── Custom labels ──────────────────────────────────────────────────
+
+ it('uses custom enter fullscreen label', () => {
+ renderFullscreenButton({
+ isFullscreen: false,
+ labels: { enterFullscreen: 'Vollbild', exitFullscreen: 'Vollbild beenden' },
+ });
+ expect(screen.getByRole('button', { name: 'Vollbild' })).toBeInTheDocument();
+ });
+
+ it('uses custom exit fullscreen label', () => {
+ renderFullscreenButton({
+ isFullscreen: true,
+ labels: { enterFullscreen: 'Vollbild', exitFullscreen: 'Vollbild beenden' },
+ });
+ expect(screen.getByRole('button', { name: 'Vollbild beenden' })).toBeInTheDocument();
+ });
+
+ // ── CSS class ──────────────────────────────────────────────────────
+
+ it('has the fp-fullscreen-button class', () => {
+ renderFullscreenButton();
+ expect(screen.getByRole('button').className).toContain('fp-fullscreen-button');
+ });
+});
diff --git a/src/components/controls/NowPlayingIndicator/NowPlayingIndicator.test.tsx b/src/components/controls/NowPlayingIndicator/NowPlayingIndicator.test.tsx
new file mode 100644
index 0000000..15ab728
--- /dev/null
+++ b/src/components/controls/NowPlayingIndicator/NowPlayingIndicator.test.tsx
@@ -0,0 +1,151 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { NowPlayingIndicator } from './NowPlayingIndicator';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderIndicator(props: Partial[0]> = {}) {
+ const defaults = {
+ isPlaying: false,
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('NowPlayingIndicator', () => {
+ // ── Playing state ───────────────────────────────────────────────────
+
+ it('renders with "Now playing" aria-label when playing', () => {
+ renderIndicator({ isPlaying: true });
+ expect(screen.getByLabelText('Now playing')).toBeInTheDocument();
+ });
+
+ it('renders bars without paused class when playing', () => {
+ renderIndicator({ isPlaying: true });
+ const bars = screen.getByRole('img').querySelectorAll('span');
+ bars.forEach((bar) => {
+ expect(bar.className).toContain('fp-equalizer-bar');
+ expect(bar.className).not.toContain('fp-equalizer-paused');
+ });
+ });
+
+ // ── Paused state ───────────────────────────────────────────────────
+
+ it('renders with "Paused" aria-label when paused', () => {
+ renderIndicator({ isPlaying: false });
+ expect(screen.getByLabelText('Paused')).toBeInTheDocument();
+ });
+
+ it('renders bars with paused class when not playing', () => {
+ renderIndicator({ isPlaying: false });
+ const bars = screen.getByRole('img').querySelectorAll('span');
+ bars.forEach((bar) => {
+ expect(bar.className).toContain('fp-equalizer-paused');
+ });
+ });
+
+ // ── Default bars count ──────────────────────────────────────────────
+
+ it('renders 4 bars by default', () => {
+ renderIndicator();
+ const bars = screen.getByRole('img').querySelectorAll('span');
+ expect(bars.length).toBe(4);
+ });
+
+ // ── Custom bars count ──────────────────────────────────────────────
+
+ it('renders custom number of bars', () => {
+ renderIndicator({ bars: 6 });
+ const barElements = screen.getByRole('img').querySelectorAll('span');
+ expect(barElements.length).toBe(6);
+ });
+
+ it('renders 3 bars when specified', () => {
+ renderIndicator({ bars: 3 });
+ const barElements = screen.getByRole('img').querySelectorAll('span');
+ expect(barElements.length).toBe(3);
+ });
+
+ // ── Size variants ──────────────────────────────────────────────────
+
+ it('applies small size class', () => {
+ renderIndicator({ size: 'sm' });
+ const container = screen.getByRole('img');
+ expect(container.className).toContain('h-3');
+ });
+
+ it('applies medium size class by default', () => {
+ renderIndicator();
+ const container = screen.getByRole('img');
+ expect(container.className).toContain('h-4');
+ });
+
+ it('applies large size class', () => {
+ renderIndicator({ size: 'lg' });
+ const container = screen.getByRole('img');
+ expect(container.className).toContain('h-5');
+ });
+
+ it('applies correct bar width for small size', () => {
+ renderIndicator({ size: 'sm' });
+ const bars = screen.getByRole('img').querySelectorAll('span');
+ bars.forEach((bar) => expect(bar.className).toContain('w-0.5'));
+ });
+
+ it('applies correct bar width for medium size', () => {
+ renderIndicator({ size: 'md' });
+ const bars = screen.getByRole('img').querySelectorAll('span');
+ bars.forEach((bar) => expect(bar.className).toContain('w-1'));
+ });
+
+ it('applies correct bar width for large size', () => {
+ renderIndicator({ size: 'lg' });
+ const bars = screen.getByRole('img').querySelectorAll('span');
+ bars.forEach((bar) => expect(bar.className).toContain('w-1.5'));
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className to the container', () => {
+ renderIndicator({ className: 'my-indicator' });
+ expect(screen.getByRole('img').className).toContain('my-indicator');
+ });
+
+ // ── Role ───────────────────────────────────────────────────────────
+
+ it('has role="img"', () => {
+ renderIndicator();
+ expect(screen.getByRole('img')).toBeInTheDocument();
+ });
+
+ // ── Custom labels ──────────────────────────────────────────────────
+
+ it('uses custom nowPlaying label', () => {
+ renderIndicator({
+ isPlaying: true,
+ labels: { nowPlaying: 'Wird abgespielt', paused: 'Pausiert' },
+ });
+ expect(screen.getByLabelText('Wird abgespielt')).toBeInTheDocument();
+ });
+
+ it('uses custom paused label', () => {
+ renderIndicator({
+ isPlaying: false,
+ labels: { nowPlaying: 'Wird abgespielt', paused: 'Pausiert' },
+ });
+ expect(screen.getByLabelText('Pausiert')).toBeInTheDocument();
+ });
+
+ // ── All bars have accent color ─────────────────────────────────────
+
+ it('applies accent color class to all bars', () => {
+ renderIndicator({ isPlaying: true });
+ const bars = screen.getByRole('img').querySelectorAll('span');
+ bars.forEach((bar) => {
+ expect(bar.className).toContain('bg-[var(--fp-color-accent)]');
+ });
+ });
+});
diff --git a/src/components/controls/PictureInPictureButton/PictureInPictureButton.test.tsx b/src/components/controls/PictureInPictureButton/PictureInPictureButton.test.tsx
new file mode 100644
index 0000000..f1dad98
--- /dev/null
+++ b/src/components/controls/PictureInPictureButton/PictureInPictureButton.test.tsx
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { PictureInPictureButton } from './PictureInPictureButton';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderPiPButton(props: Partial[0]> = {}) {
+ const defaults = {
+ isPictureInPicture: false,
+ onClick: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('PictureInPictureButton', () => {
+ // ── Enter PiP state ─────────────────────────────────────────────────
+
+ it('renders enter PiP label when not in PiP', () => {
+ renderPiPButton({ isPictureInPicture: false });
+ expect(screen.getByRole('button', { name: 'Enter picture-in-picture' })).toBeInTheDocument();
+ });
+
+ it('shows enter PiP title when not in PiP', () => {
+ renderPiPButton({ isPictureInPicture: false });
+ expect(screen.getByRole('button')).toHaveAttribute('title', 'Enter picture-in-picture');
+ });
+
+ it('renders the enter PiP icon with two rects', () => {
+ renderPiPButton({ isPictureInPicture: false });
+ const svg = screen.getByRole('button').querySelector('svg');
+ const rects = svg?.querySelectorAll('rect');
+ expect(rects?.length).toBe(2);
+ });
+
+ // ── Exit PiP state ─────────────────────────────────────────────────
+
+ it('renders exit PiP label when in PiP', () => {
+ renderPiPButton({ isPictureInPicture: true });
+ expect(screen.getByRole('button', { name: 'Exit picture-in-picture' })).toBeInTheDocument();
+ });
+
+ it('shows exit PiP title when in PiP', () => {
+ renderPiPButton({ isPictureInPicture: true });
+ expect(screen.getByRole('button')).toHaveAttribute('title', 'Exit picture-in-picture');
+ });
+
+ it('renders exit PiP icon with one rect and a path', () => {
+ renderPiPButton({ isPictureInPicture: true });
+ const svg = screen.getByRole('button').querySelector('svg');
+ const rects = svg?.querySelectorAll('rect');
+ const paths = svg?.querySelectorAll('path');
+ expect(rects?.length).toBe(1);
+ expect(paths?.length).toBe(1);
+ });
+
+ // ── Click handler ──────────────────────────────────────────────────
+
+ it('calls onClick when clicked', () => {
+ const onClick = vi.fn();
+ renderPiPButton({ onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onClick when disabled', () => {
+ const onClick = vi.fn();
+ renderPiPButton({ disabled: true, onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ // ── Disabled state ─────────────────────────────────────────────────
+
+ it('disables the button when disabled prop is true', () => {
+ renderPiPButton({ disabled: true });
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+
+ it('applies disabled opacity styling', () => {
+ renderPiPButton({ disabled: true });
+ expect(screen.getByRole('button').className).toContain('opacity-50');
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className', () => {
+ renderPiPButton({ className: 'my-pip-class' });
+ expect(screen.getByRole('button').className).toContain('my-pip-class');
+ });
+
+ // ── Aria labels toggle ──────────────────────────────────────────────
+
+ it('toggles aria-label on state change via rerender', () => {
+ const { rerender } = render(
+ ,
+ { wrapper: Wrapper }
+ );
+ expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Enter picture-in-picture');
+
+ rerender( );
+ expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Exit picture-in-picture');
+ });
+
+ // ── Custom labels ──────────────────────────────────────────────────
+
+ it('uses custom enter PiP label', () => {
+ renderPiPButton({
+ isPictureInPicture: false,
+ labels: { enterPictureInPicture: 'Bild-in-Bild starten', exitPictureInPicture: 'Bild-in-Bild beenden' },
+ });
+ expect(screen.getByRole('button', { name: 'Bild-in-Bild starten' })).toBeInTheDocument();
+ });
+
+ it('uses custom exit PiP label', () => {
+ renderPiPButton({
+ isPictureInPicture: true,
+ labels: { enterPictureInPicture: 'Bild-in-Bild starten', exitPictureInPicture: 'Bild-in-Bild beenden' },
+ });
+ expect(screen.getByRole('button', { name: 'Bild-in-Bild beenden' })).toBeInTheDocument();
+ });
+
+ // ── CSS class ──────────────────────────────────────────────────────
+
+ it('has the fp-pip-button class', () => {
+ renderPiPButton();
+ expect(screen.getByRole('button').className).toContain('fp-pip-button');
+ });
+
+ // ── Always renders (no isSupported hide logic in this component) ───
+
+ it('always renders the button', () => {
+ renderPiPButton();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/controls/PlayButton/PlayButton.test.tsx b/src/components/controls/PlayButton/PlayButton.test.tsx
new file mode 100644
index 0000000..d2d96b3
--- /dev/null
+++ b/src/components/controls/PlayButton/PlayButton.test.tsx
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { PlayButton } from './PlayButton';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderPlayButton(props: Partial[0]> = {}) {
+ const defaults = {
+ isPlaying: false,
+ onClick: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('PlayButton', () => {
+ // ── Play state ──────────────────────────────────────────────────────
+
+ it('renders with play aria-label when not playing', () => {
+ renderPlayButton({ isPlaying: false });
+ expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument();
+ });
+
+ it('renders play icon paths when not playing', () => {
+ renderPlayButton({ isPlaying: false });
+ const svgs = screen.getByRole('button').querySelectorAll('svg');
+ // The morphing SVG should be present (not the loading one)
+ expect(svgs.length).toBe(1);
+ const paths = svgs[0].querySelectorAll('path');
+ expect(paths.length).toBe(2);
+ });
+
+ // ── Pause state ─────────────────────────────────────────────────────
+
+ it('renders with pause aria-label when playing', () => {
+ renderPlayButton({ isPlaying: true });
+ expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument();
+ });
+
+ it('shows pause SVG path with full opacity when playing', () => {
+ renderPlayButton({ isPlaying: true });
+ const btn = screen.getByRole('button');
+ const paths = btn.querySelectorAll('svg path');
+ // Second path is the pause icon; when playing it should have opacity-100
+ const pausePath = paths[1];
+ expect(pausePath.getAttribute('class')).toContain('opacity-100');
+ });
+
+ it('shows play SVG path with zero opacity when playing', () => {
+ renderPlayButton({ isPlaying: true });
+ const btn = screen.getByRole('button');
+ const paths = btn.querySelectorAll('svg path');
+ const playPath = paths[0];
+ expect(playPath.getAttribute('class')).toContain('opacity-0');
+ });
+
+ // ── Loading state ───────────────────────────────────────────────────
+
+ it('shows loading spinner when isLoading is true', () => {
+ renderPlayButton({ isLoading: true });
+ const btn = screen.getByRole('button');
+ const svg = btn.querySelector('svg');
+ // Loading spinner has a circle and path, not the morphing play/pause paths
+ expect(svg?.querySelector('circle')).toBeTruthy();
+ expect(svg?.querySelector('path')).toBeTruthy();
+ });
+
+ it('disables the button when isLoading is true', () => {
+ renderPlayButton({ isLoading: true });
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+
+ it('does not call onClick when loading', () => {
+ const onClick = vi.fn();
+ renderPlayButton({ isLoading: true, onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ // ── Click handler ───────────────────────────────────────────────────
+
+ it('calls onClick when clicked', () => {
+ const onClick = vi.fn();
+ renderPlayButton({ onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onClick when disabled', () => {
+ const onClick = vi.fn();
+ renderPlayButton({ disabled: true, onClick });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ // ── Disabled state ──────────────────────────────────────────────────
+
+ it('disables the button when disabled prop is true', () => {
+ renderPlayButton({ disabled: true });
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+
+ it('applies disabled styling', () => {
+ renderPlayButton({ disabled: true });
+ expect(screen.getByRole('button').className).toContain('opacity-50');
+ });
+
+ // ── Custom labels ──────────────────────────────────────────────────
+
+ it('uses custom play label', () => {
+ renderPlayButton({
+ isPlaying: false,
+ labels: { play: 'Abspielen', pause: 'Stopp' },
+ });
+ expect(screen.getByRole('button', { name: 'Abspielen' })).toBeInTheDocument();
+ });
+
+ it('uses custom pause label', () => {
+ renderPlayButton({
+ isPlaying: true,
+ labels: { play: 'Abspielen', pause: 'Stopp' },
+ });
+ expect(screen.getByRole('button', { name: 'Stopp' })).toBeInTheDocument();
+ });
+
+ // ── Size variants ──────────────────────────────────────────────────
+
+ it('applies small size class', () => {
+ renderPlayButton({ size: 'sm' });
+ expect(screen.getByRole('button').className).toContain('w-8');
+ expect(screen.getByRole('button').className).toContain('h-8');
+ });
+
+ it('applies medium size class by default', () => {
+ renderPlayButton();
+ expect(screen.getByRole('button').className).toContain('w-12');
+ expect(screen.getByRole('button').className).toContain('h-12');
+ });
+
+ it('applies large size class', () => {
+ renderPlayButton({ size: 'lg' });
+ expect(screen.getByRole('button').className).toContain('w-16');
+ expect(screen.getByRole('button').className).toContain('h-16');
+ });
+
+ // ── className passthrough ──────────────────────────────────────────
+
+ it('passes custom className to the button', () => {
+ renderPlayButton({ className: 'my-custom-class' });
+ expect(screen.getByRole('button').className).toContain('my-custom-class');
+ });
+
+ // ── Button type ────────────────────────────────────────────────────
+
+ it('renders as a button element with type="button"', () => {
+ renderPlayButton();
+ const btn = screen.getByRole('button');
+ expect(btn.tagName).toBe('BUTTON');
+ expect(btn.getAttribute('type')).toBe('button');
+ });
+
+ // ── Glow effect ────────────────────────────────────────────────────
+
+ it('has a glow effect span element', () => {
+ renderPlayButton();
+ const btn = screen.getByRole('button');
+ const glowSpan = btn.querySelector('span');
+ expect(glowSpan).toBeTruthy();
+ });
+
+ // ── Icon sizing with button size ───────────────────────────────────
+
+ it('applies correct icon size for small button', () => {
+ renderPlayButton({ size: 'sm' });
+ const svg = screen.getByRole('button').querySelector('svg');
+ expect(svg?.getAttribute('class')).toContain('w-4');
+ expect(svg?.getAttribute('class')).toContain('h-4');
+ });
+
+ it('applies correct icon size for large button', () => {
+ renderPlayButton({ size: 'lg' });
+ const svg = screen.getByRole('button').querySelector('svg');
+ expect(svg?.getAttribute('class')).toContain('w-8');
+ expect(svg?.getAttribute('class')).toContain('h-8');
+ });
+});
diff --git a/src/components/controls/PlaybackSpeed/PlaybackSpeed.test.tsx b/src/components/controls/PlaybackSpeed/PlaybackSpeed.test.tsx
new file mode 100644
index 0000000..ec7f70c
--- /dev/null
+++ b/src/components/controls/PlaybackSpeed/PlaybackSpeed.test.tsx
@@ -0,0 +1,163 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { PlaybackSpeed } from './PlaybackSpeed';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderPlaybackSpeed(props: Partial[0]> = {}) {
+ const defaults = {
+ speed: 1,
+ onSpeedChange: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('PlaybackSpeed', () => {
+ // ── Current speed display ───────────────────────────────────────────
+
+ it('renders the current speed as 1x', () => {
+ renderPlaybackSpeed({ speed: 1 });
+ expect(screen.getByText('1x')).toBeInTheDocument();
+ });
+
+ it('renders speed 0.5 as 0.5x', () => {
+ renderPlaybackSpeed({ speed: 0.5 });
+ expect(screen.getByText('0.5x')).toBeInTheDocument();
+ });
+
+ it('renders speed 2 as 2x', () => {
+ renderPlaybackSpeed({ speed: 2 });
+ expect(screen.getByText('2x')).toBeInTheDocument();
+ });
+
+ it('renders speed 1.5 as 1.5x', () => {
+ renderPlaybackSpeed({ speed: 1.5 });
+ expect(screen.getByText('1.5x')).toBeInTheDocument();
+ });
+
+ // ── Dropdown menu ───────────────────────────────────────────────────
+
+ it('does not show dropdown menu initially', () => {
+ renderPlaybackSpeed();
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ it('opens dropdown menu on click', () => {
+ renderPlaybackSpeed();
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ });
+
+ it('shows all default speed options in the menu', () => {
+ renderPlaybackSpeed();
+ fireEvent.click(screen.getByRole('button'));
+ const options = screen.getAllByRole('option');
+ expect(options).toHaveLength(6); // [0.5, 0.75, 1, 1.25, 1.5, 2]
+ });
+
+ it('marks the current speed as selected', () => {
+ renderPlaybackSpeed({ speed: 1.5 });
+ fireEvent.click(screen.getByRole('button'));
+ const selectedOption = screen.getByRole('option', { selected: true });
+ expect(selectedOption).toHaveTextContent('1.5x');
+ });
+
+ it('closes the dropdown after selecting a speed', () => {
+ const onSpeedChange = vi.fn();
+ renderPlaybackSpeed({ speed: 1, onSpeedChange });
+ fireEvent.click(screen.getByRole('button'));
+ const options = screen.getAllByRole('option');
+ // Click the 2x option (last one)
+ fireEvent.click(options[options.length - 1]);
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ // ── Speed change callback ───────────────────────────────────────────
+
+ it('calls onSpeedChange when a speed is selected', () => {
+ const onSpeedChange = vi.fn();
+ renderPlaybackSpeed({ speed: 1, onSpeedChange });
+ fireEvent.click(screen.getByRole('button'));
+ const options = screen.getAllByRole('option');
+ // Click 0.5x (first option)
+ fireEvent.click(options[0]);
+ expect(onSpeedChange).toHaveBeenCalledWith(0.5);
+ });
+
+ it('calls onSpeedChange with 2 when 2x is selected', () => {
+ const onSpeedChange = vi.fn();
+ renderPlaybackSpeed({ speed: 1, onSpeedChange });
+ fireEvent.click(screen.getByRole('button'));
+ const options = screen.getAllByRole('option');
+ fireEvent.click(options[options.length - 1]);
+ expect(onSpeedChange).toHaveBeenCalledWith(2);
+ });
+
+ // ── Custom speeds ──────────────────────────────────────────────────
+
+ it('renders custom speed options', () => {
+ renderPlaybackSpeed({ speeds: [0.25, 0.5, 1, 3] });
+ fireEvent.click(screen.getByRole('button'));
+ const options = screen.getAllByRole('option');
+ expect(options).toHaveLength(4);
+ expect(options[0]).toHaveTextContent('0.25x');
+ expect(options[3]).toHaveTextContent('3x');
+ });
+
+ // ── Disabled state ─────────────────────────────────────────────────
+
+ it('disables the button when disabled is true', () => {
+ renderPlaybackSpeed({ disabled: true });
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+
+ it('applies disabled styling', () => {
+ renderPlaybackSpeed({ disabled: true });
+ expect(screen.getByRole('button').className).toContain('opacity-50');
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes className to the container', () => {
+ const { container } = renderPlaybackSpeed({ className: 'custom-speed' });
+ expect(container.firstElementChild?.className).toContain('custom-speed');
+ });
+
+ // ── Accessibility ──────────────────────────────────────────────────
+
+ it('has aria-label with speed info', () => {
+ renderPlaybackSpeed({ speed: 1.5 });
+ expect(screen.getByRole('button', { name: /playback speed.*1\.5x/i })).toBeInTheDocument();
+ });
+
+ it('has aria-haspopup on the trigger button', () => {
+ renderPlaybackSpeed();
+ expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'listbox');
+ });
+
+ it('has aria-expanded false when menu is closed', () => {
+ renderPlaybackSpeed();
+ expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('has aria-expanded true when menu is open', () => {
+ renderPlaybackSpeed();
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument();
+ });
+
+ // ── Toggle behavior ────────────────────────────────────────────────
+
+ it('toggles dropdown open and closed on repeated clicks', () => {
+ renderPlaybackSpeed();
+ const trigger = screen.getByRole('button');
+ fireEvent.click(trigger);
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ fireEvent.click(trigger);
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/controls/ProgressBar/ProgressBar.stories.tsx b/src/components/controls/ProgressBar/ProgressBar.stories.tsx
index ecb2ac3..4973f21 100644
--- a/src/components/controls/ProgressBar/ProgressBar.stories.tsx
+++ b/src/components/controls/ProgressBar/ProgressBar.stories.tsx
@@ -112,3 +112,108 @@ export const WithMarkersAndChapters: Story = {
markers: sampleMarkers,
},
};
+
+export const WithABLoop: Story = {
+ args: {
+ currentTime: 90,
+ duration: 300,
+ buffered: 200,
+ loopStart: 60,
+ loopEnd: 180,
+ },
+};
+
+export const ABLoopInteractive: Story = {
+ render: () => {
+ const [time, setTime] = useState(90);
+ const [loopStart, setLoopStart] = useState(null);
+ const [loopEnd, setLoopEnd] = useState(null);
+ const duration = 300;
+
+ const formatSeconds = (s: number | null) =>
+ s !== null ? `${Math.floor(s)}s` : '—';
+
+ return (
+
+
+
+ setLoopStart(time)}
+ style={{
+ padding: '4px 12px',
+ borderRadius: '4px',
+ border: '1px solid #666',
+ background: loopStart !== null ? '#2563eb' : '#333',
+ color: '#fff',
+ cursor: 'pointer',
+ fontSize: '13px',
+ }}
+ >
+ Set A
+
+ setLoopEnd(time)}
+ style={{
+ padding: '4px 12px',
+ borderRadius: '4px',
+ border: '1px solid #666',
+ background: loopEnd !== null ? '#2563eb' : '#333',
+ color: '#fff',
+ cursor: 'pointer',
+ fontSize: '13px',
+ }}
+ >
+ Set B
+
+ {
+ setLoopStart(null);
+ setLoopEnd(null);
+ }}
+ style={{
+ padding: '4px 12px',
+ borderRadius: '4px',
+ border: '1px solid #666',
+ background: '#333',
+ color: '#fff',
+ cursor: 'pointer',
+ fontSize: '13px',
+ }}
+ >
+ Clear Loop
+
+
+
+ Current: {Math.floor(time)}s / {duration}s
+ Loop A: {formatSeconds(loopStart)}
+ Loop B: {formatSeconds(loopEnd)}
+
+
+ );
+ },
+};
diff --git a/src/components/controls/ProgressBar/ProgressBar.test.tsx b/src/components/controls/ProgressBar/ProgressBar.test.tsx
new file mode 100644
index 0000000..2b3bb31
--- /dev/null
+++ b/src/components/controls/ProgressBar/ProgressBar.test.tsx
@@ -0,0 +1,384 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ProgressBar } from './ProgressBar';
+import { LabelsProvider } from '@/context/LabelsContext';
+import { createMockChapters, createMockMarkers } from '@/test/helpers';
+import type { ReactNode } from 'react';
+import type { Chapter } from '@/types/player';
+import type { TimelineMarker } from '@/types/markers';
+
+// ─── Helpers ────────────────────────────────────────────────────────
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderProgressBar(
+ props: Partial[0]> = {}
+) {
+ const defaults = {
+ currentTime: 60,
+ duration: 300,
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+// Mock getBoundingClientRect for position calculations
+function mockSliderRect(el: Element) {
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ right: 500,
+ top: 0,
+ bottom: 20,
+ width: 500,
+ height: 20,
+ x: 0,
+ y: 0,
+ toJSON: () => {},
+ });
+}
+
+describe('ProgressBar', () => {
+ // ── Basic rendering ───────────────────────────────────────────────
+
+ it('renders the slider element', () => {
+ renderProgressBar();
+ expect(screen.getByRole('slider')).toBeInTheDocument();
+ });
+
+ it('has correct aria attributes', () => {
+ renderProgressBar({ currentTime: 60, duration: 300 });
+ const slider = screen.getByRole('slider');
+ expect(slider.getAttribute('aria-valuemin')).toBe('0');
+ expect(slider.getAttribute('aria-valuemax')).toBe('300');
+ expect(slider.getAttribute('aria-valuenow')).toBe('60');
+ });
+
+ it('has correct aria-valuetext', () => {
+ renderProgressBar({ currentTime: 65, duration: 300 });
+ const slider = screen.getByRole('slider');
+ // 65 seconds = 1:05, 300 seconds = 5:00
+ expect(slider.getAttribute('aria-valuetext')).toBe('1:05 of 5:00');
+ });
+
+ it('has aria-label from labels', () => {
+ renderProgressBar();
+ expect(screen.getByRole('slider')).toHaveAttribute('aria-label', 'Seek slider');
+ });
+
+ it('is focusable by default', () => {
+ renderProgressBar();
+ expect(screen.getByRole('slider').getAttribute('tabindex')).toBe('0');
+ });
+
+ it('is not focusable when disabled', () => {
+ renderProgressBar({ disabled: true });
+ expect(screen.getByRole('slider').getAttribute('tabindex')).toBe('-1');
+ });
+
+ // ── Progress display ──────────────────────────────────────────────
+
+ it('shows current progress as percentage width', () => {
+ const { container } = renderProgressBar({ currentTime: 150, duration: 300 });
+ // 150/300 = 50%
+ const progressBar = container.querySelectorAll('.rounded-full')[2]; // current progress div
+ expect((progressBar as HTMLElement).style.width).toBe('50%');
+ });
+
+ it('shows 0% progress when duration is 0', () => {
+ renderProgressBar({ currentTime: 0, duration: 0 });
+ // Should not crash and show 0%
+ expect(screen.getByRole('slider')).toBeInTheDocument();
+ });
+
+ // ── Buffered indicator ────────────────────────────────────────────
+
+ it('shows buffered progress', () => {
+ const { container } = renderProgressBar({ buffered: 200, duration: 300 });
+ // 200/300 = ~66.67%
+ const bufferedBar = container.querySelector('.bg-\\[var\\(--fp-progress-buffer\\)\\]');
+ expect(bufferedBar).toBeInTheDocument();
+ expect((bufferedBar as HTMLElement).style.width).toBe('66.66666666666666%');
+ });
+
+ it('shows 0% buffered by default', () => {
+ const { container } = renderProgressBar();
+ const bufferedBar = container.querySelector('.bg-\\[var\\(--fp-progress-buffer\\)\\]');
+ expect((bufferedBar as HTMLElement).style.width).toBe('0%');
+ });
+
+ // ── Seeking via click ─────────────────────────────────────────────
+
+ it('calls onSeek when clicked', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ onSeek });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ // Click at position 250/500 = 50% of 300 = 150
+ fireEvent.mouseDown(slider, { clientX: 250 });
+ expect(onSeek).toHaveBeenCalledWith(150);
+ });
+
+ it('calls onSeekStart when mouse down', () => {
+ const onSeekStart = vi.fn();
+ renderProgressBar({ onSeekStart });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ fireEvent.mouseDown(slider, { clientX: 100 });
+ expect(onSeekStart).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not seek when disabled', () => {
+ const onSeek = vi.fn();
+ const onSeekStart = vi.fn();
+ renderProgressBar({ onSeek, onSeekStart, disabled: true });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ fireEvent.mouseDown(slider, { clientX: 250 });
+ expect(onSeek).not.toHaveBeenCalled();
+ expect(onSeekStart).not.toHaveBeenCalled();
+ });
+
+ // ── Dragging ──────────────────────────────────────────────────────
+
+ it('calls onSeek during drag (mouseMove while dragging)', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ onSeek });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+
+ // Start drag
+ fireEvent.mouseDown(slider, { clientX: 100 });
+ onSeek.mockClear();
+
+ // Move while hovering the slider element
+ fireEvent.mouseMove(slider, { clientX: 200 });
+ expect(onSeek).toHaveBeenCalled();
+ });
+
+ it('calls onSeekEnd on global mouseup after drag', () => {
+ const onSeekEnd = vi.fn();
+ renderProgressBar({ onSeekEnd });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+
+ // Start drag
+ fireEvent.mouseDown(slider, { clientX: 100 });
+ // End drag (global mouseup)
+ fireEvent.mouseUp(document);
+ expect(onSeekEnd).toHaveBeenCalledTimes(1);
+ });
+
+ // ── Touch events ──────────────────────────────────────────────────
+
+ it('calls onSeek on touch start', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ onSeek });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ fireEvent.touchStart(slider, { touches: [{ clientX: 250 }] });
+ expect(onSeek).toHaveBeenCalled();
+ });
+
+ it('does not seek on touch when disabled', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ onSeek, disabled: true });
+ const slider = screen.getByRole('slider');
+ fireEvent.touchStart(slider, { touches: [{ clientX: 250 }] });
+ expect(onSeek).not.toHaveBeenCalled();
+ });
+
+ it('calls onSeekEnd on touch end', () => {
+ const onSeekEnd = vi.fn();
+ renderProgressBar({ onSeekEnd });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ fireEvent.touchStart(slider, { touches: [{ clientX: 100 }] });
+ fireEvent.touchEnd(slider);
+ expect(onSeekEnd).toHaveBeenCalledTimes(1);
+ });
+
+ // ── Keyboard accessibility ────────────────────────────────────────
+
+ it('seeks backward 5 seconds on ArrowLeft', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ currentTime: 60, duration: 300, onSeek });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowLeft' });
+ expect(onSeek).toHaveBeenCalledWith(55);
+ });
+
+ it('seeks forward 5 seconds on ArrowRight', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ currentTime: 60, duration: 300, onSeek });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' });
+ expect(onSeek).toHaveBeenCalledWith(65);
+ });
+
+ it('seeks backward 10 seconds on Shift+ArrowLeft', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ currentTime: 60, duration: 300, onSeek });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowLeft', shiftKey: true });
+ expect(onSeek).toHaveBeenCalledWith(50);
+ });
+
+ it('seeks forward 10 seconds on Shift+ArrowRight', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ currentTime: 60, duration: 300, onSeek });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight', shiftKey: true });
+ expect(onSeek).toHaveBeenCalledWith(70);
+ });
+
+ it('seeks to start on Home key', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ currentTime: 60, duration: 300, onSeek });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'Home' });
+ expect(onSeek).toHaveBeenCalledWith(0);
+ });
+
+ it('seeks to end on End key', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ currentTime: 60, duration: 300, onSeek });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'End' });
+ expect(onSeek).toHaveBeenCalledWith(300);
+ });
+
+ it('does not go below 0 on ArrowLeft', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ currentTime: 2, duration: 300, onSeek });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowLeft' });
+ expect(onSeek).toHaveBeenCalledWith(0);
+ });
+
+ it('does not go above duration on ArrowRight', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ currentTime: 298, duration: 300, onSeek });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' });
+ expect(onSeek).toHaveBeenCalledWith(300);
+ });
+
+ it('ignores keyboard events when disabled', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ onSeek, disabled: true });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowLeft' });
+ expect(onSeek).not.toHaveBeenCalled();
+ });
+
+ it('ignores unrelated keys', () => {
+ const onSeek = vi.fn();
+ renderProgressBar({ onSeek });
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'a' });
+ expect(onSeek).not.toHaveBeenCalled();
+ });
+
+ // ── Chapter markers ───────────────────────────────────────────────
+
+ it('renders chapter markers', () => {
+ const chapters = createMockChapters();
+ const { container } = renderProgressBar({ chapters, duration: 180 });
+ // Chapter markers at startTime 0, 30, 120 => 3 markers
+ const markers = container.querySelectorAll('button[aria-label^="Go to chapter"]');
+ expect(markers.length).toBe(3);
+ });
+
+ it('positions chapter markers correctly', () => {
+ const chapters: Chapter[] = [
+ { id: 'ch-1', title: 'Intro', startTime: 0, endTime: 60 },
+ { id: 'ch-2', title: 'Middle', startTime: 60, endTime: 120 },
+ ];
+ const { container } = renderProgressBar({ chapters, duration: 120 });
+ const markerElements = container.querySelectorAll('button[aria-label^="Go to chapter"]');
+ expect((markerElements[0] as HTMLElement).style.left).toBe('0%');
+ expect((markerElements[1] as HTMLElement).style.left).toBe('50%');
+ });
+
+ // ── Timeline markers ──────────────────────────────────────────────
+
+ it('renders timeline markers', () => {
+ const markers = createMockMarkers();
+ const { container } = renderProgressBar({ markers, duration: 180 });
+ // 3 marker dots
+ const markerDots = container.querySelectorAll('.rounded-full.-translate-y-1\\/2');
+ expect(markerDots.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('applies custom marker color', () => {
+ const markers: TimelineMarker[] = [
+ { id: 'm-1', time: 60, title: 'Marker', color: '#ff0000' },
+ ];
+ const { container } = renderProgressBar({ markers, duration: 300 });
+ const markerDot = container.querySelector('.-translate-x-1\\/2.-translate-y-1\\/2');
+ expect((markerDot as HTMLElement)?.style.backgroundColor).toBe('rgb(255, 0, 0)');
+ });
+
+ // ── Hover / Tooltip ───────────────────────────────────────────────
+
+ it('shows tooltip on hover when showTooltip is true', () => {
+ const { container } = renderProgressBar({ showTooltip: true });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ fireEvent.mouseEnter(slider);
+ fireEvent.mouseMove(slider, { clientX: 250 });
+ // Tooltip should appear with time
+ const tooltip = container.querySelector('.pointer-events-none');
+ expect(tooltip).toBeInTheDocument();
+ });
+
+ it('does not show tooltip when showTooltip is false', () => {
+ const { container } = renderProgressBar({ showTooltip: false });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ fireEvent.mouseEnter(slider);
+ fireEvent.mouseMove(slider, { clientX: 250 });
+ const tooltip = container.querySelector('.pointer-events-none.whitespace-nowrap');
+ expect(tooltip).toBeNull();
+ });
+
+ it('does not show tooltip when disabled', () => {
+ const { container } = renderProgressBar({ disabled: true, showTooltip: true });
+ const slider = screen.getByRole('slider');
+ fireEvent.mouseEnter(slider);
+ fireEvent.mouseMove(slider, { clientX: 250 });
+ const tooltip = container.querySelector('.pointer-events-none.whitespace-nowrap');
+ expect(tooltip).toBeNull();
+ });
+
+ it('hides tooltip on mouse leave', () => {
+ renderProgressBar({ showTooltip: true });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ fireEvent.mouseEnter(slider);
+ fireEvent.mouseMove(slider, { clientX: 250 });
+ fireEvent.mouseLeave(slider);
+ // hoverPosition becomes null
+ const tooltip = screen.getByRole('slider').parentElement?.querySelector('.pointer-events-none.whitespace-nowrap');
+ expect(tooltip).toBeNull();
+ });
+
+ // ── Disabled state ────────────────────────────────────────────────
+
+ it('applies disabled styling', () => {
+ renderProgressBar({ disabled: true });
+ expect(screen.getByRole('slider').className).toContain('cursor-not-allowed');
+ expect(screen.getByRole('slider').className).toContain('opacity-50');
+ });
+
+ it('hides drag handle when disabled', () => {
+ const { container } = renderProgressBar({ disabled: true });
+ const handle = container.querySelector('.shadow-md');
+ expect(handle?.className).toContain('hidden');
+ });
+
+ // ── className passthrough ─────────────────────────────────────────
+
+ it('applies custom className', () => {
+ renderProgressBar({ className: 'my-progress' });
+ expect(screen.getByRole('slider').className).toContain('my-progress');
+ });
+
+ // ── Custom labels ─────────────────────────────────────────────────
+
+ it('uses custom seek slider label', () => {
+ renderProgressBar({ labels: { seekSlider: 'Zeitleiste' } });
+ expect(screen.getByRole('slider', { name: 'Zeitleiste' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/controls/ProgressBar/ProgressBar.tsx b/src/components/controls/ProgressBar/ProgressBar.tsx
index 22114e2..6d269bd 100644
--- a/src/components/controls/ProgressBar/ProgressBar.tsx
+++ b/src/components/controls/ProgressBar/ProgressBar.tsx
@@ -1,4 +1,4 @@
-import React, { useRef, useState, useCallback, useEffect } from 'react';
+import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
import { cn, formatTime } from '@/utils';
import { useLabels } from '@/context/LabelsContext';
import type { Chapter } from '@/types/player';
@@ -9,9 +9,16 @@ export interface ProgressBarProps {
currentTime: number;
duration: number;
buffered?: number;
+ bufferedRanges?: Array<{ start: number; end: number }>;
+ isBuffering?: boolean;
chapters?: Chapter[];
markers?: TimelineMarker[];
showTooltip?: boolean;
+ showChapterSegments?: boolean;
+ /** A-B loop start time in seconds */
+ loopStart?: number | null;
+ /** A-B loop end time in seconds */
+ loopEnd?: number | null;
disabled?: boolean;
onSeek?: (time: number) => void;
onSeekStart?: () => void;
@@ -24,9 +31,14 @@ export function ProgressBar({
currentTime,
duration,
buffered = 0,
+ bufferedRanges,
+ isBuffering = false,
chapters = [],
markers = [],
showTooltip = true,
+ showChapterSegments = true,
+ loopStart = null,
+ loopEnd = null,
disabled = false,
onSeek,
onSeekStart,
@@ -188,6 +200,72 @@ export function ProgressBar({
const hoverMarker = hoverTime > 0 ? getMarkerAtTime(hoverTime) : undefined;
+ // Find the active chapter based on currentTime
+ const activeChapter = useMemo(() => {
+ if (chapters.length === 0) return null;
+ for (let i = chapters.length - 1; i >= 0; i--) {
+ if (currentTime >= chapters[i].startTime) {
+ return chapters[i];
+ }
+ }
+ return null;
+ }, [chapters, currentTime]);
+
+ // Get the chapter index for a given chapter
+ const getChapterIndex = useCallback((chapter: Chapter): number => {
+ return chapters.findIndex((c) => c.id === chapter.id);
+ }, [chapters]);
+
+ // Compute chapter duration
+ const getChapterDuration = useCallback((chapter: Chapter): number => {
+ if (chapter.endTime) {
+ return chapter.endTime - chapter.startTime;
+ }
+ const index = getChapterIndex(chapter);
+ const nextChapter = chapters[index + 1];
+ if (nextChapter) {
+ return nextChapter.startTime - chapter.startTime;
+ }
+ return duration - chapter.startTime;
+ }, [chapters, duration, getChapterIndex]);
+
+ // Handle chapter marker click
+ const handleChapterClick = useCallback((e: React.MouseEvent, chapter: Chapter) => {
+ e.stopPropagation();
+ e.preventDefault();
+ if (!disabled) {
+ onSeek?.(chapter.startTime);
+ }
+ }, [disabled, onSeek]);
+
+ // Compute chapter segments for visualization
+ const chapterSegments = useMemo(() => {
+ if (!showChapterSegments || chapters.length === 0 || duration <= 0) return [];
+ return chapters.map((chapter, index) => {
+ const startPercent = (chapter.startTime / duration) * 100;
+ let endTime: number;
+ if (chapter.endTime) {
+ endTime = chapter.endTime;
+ } else {
+ const nextChapter = chapters[index + 1];
+ endTime = nextChapter ? nextChapter.startTime : duration;
+ }
+ const widthPercent = ((endTime - chapter.startTime) / duration) * 100;
+ return {
+ chapter,
+ startPercent,
+ widthPercent,
+ isEven: index % 2 === 0,
+ };
+ });
+ }, [chapters, duration, showChapterSegments]);
+
+ // Find the hover chapter index for tooltip display
+ const hoverChapterIndex = useMemo(() => {
+ if (!hoverChapter) return -1;
+ return chapters.findIndex((c) => c.id === hoverChapter.id);
+ }, [hoverChapter, chapters]);
+
return (
- {/* Buffered progress */}
-
+ {/* Buffered progress - show individual ranges if available, otherwise fallback to total */}
+ {bufferedRanges && bufferedRanges.length > 0
+ ? bufferedRanges.map((range, i) => {
+ const rangeStart = duration > 0 ? (range.start / duration) * 100 : 0;
+ const rangeWidth = duration > 0 ? ((range.end - range.start) / duration) * 100 : 0;
+ return (
+
+ );
+ })
+ : (
+
+ )
+ }
{/* Current progress */}
+ {/* A-B Loop region */}
+ {loopStart !== null && loopEnd !== null && duration > 0 && (
+
+ )}
+
+ {/* Chapter segment backgrounds */}
+ {chapterSegments.map(({ chapter, startPercent, widthPercent, isEven }) => (
+
+ ))}
+
{/* Chapter markers */}
{chapters.map((chapter) => {
const position = duration > 0 ? (chapter.startTime / duration) * 100 : 0;
+ const isActiveChapter = activeChapter?.id === chapter.id;
return (
-
handleChapterClick(e, chapter)}
+ onMouseDown={(e) => e.stopPropagation()}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.stopPropagation();
+ e.preventDefault();
+ if (!disabled) {
+ onSeek?.(chapter.startTime);
+ }
+ }
+ }}
/>
);
})}
@@ -285,6 +432,7 @@ export function ProgressBar({
'transition-all duration-150',
isActive ? 'opacity-100 scale-100' : 'opacity-0 scale-75',
isDragging && 'scale-125',
+ isBuffering && 'fp-animate-pulse',
disabled && 'hidden'
)}
style={{ left: `${progress}%` }}
@@ -324,8 +472,18 @@ export function ProgressBar({
)}
{!hoverMarker && hoverChapter && (
-
- {hoverChapter.title}
+
+ {hoverChapterIndex >= 0 && (
+
+ Chapter {hoverChapterIndex + 1}
+
+ )}
+
+ {hoverChapter.title}
+
+
+ {formatTime(getChapterDuration(hoverChapter))}
+
)}
diff --git a/src/components/controls/ProgressBar/ThumbnailPreview.tsx b/src/components/controls/ProgressBar/ThumbnailPreview.tsx
new file mode 100644
index 0000000..6f8fe3c
--- /dev/null
+++ b/src/components/controls/ProgressBar/ThumbnailPreview.tsx
@@ -0,0 +1,103 @@
+import { useState, useEffect, useMemo } from 'react';
+import { cn } from '@/utils/cn';
+import { parseVTT, findCueAtTime, generateSpriteCues, type ThumbnailConfig, type ThumbnailCue } from '@/utils/thumbnails';
+
+export interface ThumbnailPreviewProps {
+ /** Time in seconds to show thumbnail for */
+ time: number;
+ /** Thumbnail configuration */
+ config: ThumbnailConfig;
+ /** Whether the preview is visible */
+ visible: boolean;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+export function ThumbnailPreview({
+ time,
+ config,
+ visible,
+ className,
+}: ThumbnailPreviewProps) {
+ const [cues, setCues] = useState
([]);
+ const [loaded, setLoaded] = useState(false);
+
+ // Load and parse VTT file
+ useEffect(() => {
+ if (!config.vttUrl) return;
+
+ let cancelled = false;
+ fetch(config.vttUrl)
+ .then((res) => res.text())
+ .then((text) => {
+ if (!cancelled) {
+ setCues(parseVTT(text));
+ setLoaded(true);
+ }
+ })
+ .catch(() => {
+ // Silently fail - thumbnails are optional
+ });
+
+ return () => { cancelled = true; };
+ }, [config.vttUrl]);
+
+ // Generate sprite cues if no VTT
+ useEffect(() => {
+ if (config.vttUrl) return; // VTT takes priority
+ if (!config.spriteUrl || !config.spriteColumns || !config.spriteRows || !config.thumbWidth || !config.thumbHeight || !config.duration) return;
+
+ const generated = generateSpriteCues({
+ spriteUrl: config.spriteUrl,
+ columns: config.spriteColumns,
+ rows: config.spriteRows,
+ thumbWidth: config.thumbWidth,
+ thumbHeight: config.thumbHeight,
+ interval: config.interval || (config.duration / (config.spriteColumns * config.spriteRows)),
+ duration: config.duration,
+ });
+ setCues(generated);
+ setLoaded(true);
+ }, [config]);
+
+ // Find the current cue
+ const currentCue = useMemo(() => findCueAtTime(cues, time), [cues, time]);
+
+ if (!visible || !loaded || !currentCue) return null;
+
+ const thumbWidth = currentCue.width || config.thumbWidth || 160;
+ const thumbHeight = currentCue.height || config.thumbHeight || 90;
+
+ // If it's a sprite sheet (has x/y coordinates)
+ if (currentCue.x !== undefined && currentCue.y !== undefined) {
+ return (
+
+ );
+ }
+
+ // Individual thumbnail image
+ return (
+
+ );
+}
diff --git a/src/components/controls/ShareButton/ShareButton.stories.tsx b/src/components/controls/ShareButton/ShareButton.stories.tsx
new file mode 100644
index 0000000..eec6f79
--- /dev/null
+++ b/src/components/controls/ShareButton/ShareButton.stories.tsx
@@ -0,0 +1,86 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
+import { ShareButton } from './ShareButton';
+
+const meta: Meta = {
+ title: 'Controls/ShareButton',
+ component: ShareButton,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ values: [{ name: 'dark', value: '#121212' }],
+ },
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ currentTime: 90,
+ getShareUrl: (time?: number) =>
+ `https://fairu.app/episode/123?t=${time ?? 0}`,
+ copyShareUrl: async () => true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ currentTime: 90,
+ getShareUrl: (time?: number) =>
+ `https://fairu.app/episode/123?t=${time ?? 0}`,
+ copyShareUrl: async () => true,
+ disabled: true,
+ },
+};
+
+export const Interactive: Story = {
+ render: function InteractiveShareButton() {
+ const [currentTime, setCurrentTime] = useState(90);
+
+ const getShareUrl = (time?: number) =>
+ `https://fairu.app/episode/123?t=${time ?? 0}`;
+
+ const copyShareUrl = async (time?: number) => {
+ const url = getShareUrl(time);
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
+ try {
+ await navigator.clipboard.writeText(url);
+ return true;
+ } catch {
+ return true;
+ }
+ }
+ return true;
+ };
+
+ return (
+
+
+
+
+ Current Time: {currentTime}s
+ setCurrentTime(Number(e.target.value))}
+ className="w-48"
+ />
+
+
+
+ {getShareUrl(currentTime)}
+
+
+ );
+ },
+};
diff --git a/src/components/controls/ShareButton/ShareButton.tsx b/src/components/controls/ShareButton/ShareButton.tsx
new file mode 100644
index 0000000..fdf24ce
--- /dev/null
+++ b/src/components/controls/ShareButton/ShareButton.tsx
@@ -0,0 +1,87 @@
+import { useState, useCallback } from 'react';
+import { cn } from '@/utils/cn';
+
+export interface ShareButtonProps {
+ /** Current playback time */
+ currentTime: number;
+ /** Function to generate the share URL */
+ getShareUrl: (time?: number) => string;
+ /** Function to copy to clipboard */
+ copyShareUrl: (time?: number) => Promise;
+ /** Additional CSS classes */
+ className?: string;
+ /** Whether the button is disabled */
+ disabled?: boolean;
+}
+
+export function ShareButton({
+ currentTime,
+ getShareUrl: _getShareUrl,
+ copyShareUrl,
+ className,
+ disabled = false,
+}: ShareButtonProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleClick = useCallback(async () => {
+ const success = await copyShareUrl(currentTime);
+ if (success) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ }, [copyShareUrl, currentTime]);
+
+ return (
+
+ {copied ? (
+ // Checkmark icon
+
+
+
+ ) : (
+ // Share icon
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/controls/ShareButton/index.ts b/src/components/controls/ShareButton/index.ts
new file mode 100644
index 0000000..9a272be
--- /dev/null
+++ b/src/components/controls/ShareButton/index.ts
@@ -0,0 +1 @@
+export { ShareButton, type ShareButtonProps } from './ShareButton';
diff --git a/src/components/controls/SkipButtons/SkipButtons.test.tsx b/src/components/controls/SkipButtons/SkipButtons.test.tsx
new file mode 100644
index 0000000..4769a14
--- /dev/null
+++ b/src/components/controls/SkipButtons/SkipButtons.test.tsx
@@ -0,0 +1,160 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { SkipButtons, SkipButton } from './SkipButtons';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderSkipButtons(props: Partial[0]> = {}) {
+ const defaults = {
+ onSkipForward: vi.fn(),
+ onSkipBackward: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('SkipButtons', () => {
+ // ── Rendering both buttons ──────────────────────────────────────────
+
+ it('renders both skip forward and skip backward buttons', () => {
+ renderSkipButtons();
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(2);
+ });
+
+ it('displays default seconds labels (10 and 30)', () => {
+ renderSkipButtons();
+ expect(screen.getByText('10')).toBeInTheDocument();
+ expect(screen.getByText('30')).toBeInTheDocument();
+ });
+
+ it('has correct aria-labels for default seconds', () => {
+ renderSkipButtons();
+ expect(screen.getByRole('button', { name: /skip backward 10 seconds/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /skip forward 30 seconds/i })).toBeInTheDocument();
+ });
+
+ // ── Click handlers ─────────────────────────────────────────────────
+
+ it('calls onSkipBackward when backward button is clicked', () => {
+ const onSkipBackward = vi.fn();
+ renderSkipButtons({ onSkipBackward });
+ fireEvent.click(screen.getByRole('button', { name: /skip backward/i }));
+ expect(onSkipBackward).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onSkipForward when forward button is clicked', () => {
+ const onSkipForward = vi.fn();
+ renderSkipButtons({ onSkipForward });
+ fireEvent.click(screen.getByRole('button', { name: /skip forward/i }));
+ expect(onSkipForward).toHaveBeenCalledTimes(1);
+ });
+
+ // ── Custom seconds ─────────────────────────────────────────────────
+
+ it('displays custom forward seconds', () => {
+ renderSkipButtons({ forwardSeconds: 15 });
+ expect(screen.getByText('15')).toBeInTheDocument();
+ });
+
+ it('displays custom backward seconds', () => {
+ renderSkipButtons({ backwardSeconds: 5 });
+ expect(screen.getByText('5')).toBeInTheDocument();
+ });
+
+ it('updates aria-labels with custom seconds', () => {
+ renderSkipButtons({ forwardSeconds: 15, backwardSeconds: 5 });
+ expect(screen.getByRole('button', { name: /skip backward 5 seconds/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /skip forward 15 seconds/i })).toBeInTheDocument();
+ });
+
+ // ── Disabled state ─────────────────────────────────────────────────
+
+ it('disables both buttons when disabled', () => {
+ renderSkipButtons({ disabled: true });
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((btn) => expect(btn).toBeDisabled());
+ });
+
+ it('does not call handlers when disabled', () => {
+ const onSkipForward = vi.fn();
+ const onSkipBackward = vi.fn();
+ renderSkipButtons({ disabled: true, onSkipForward, onSkipBackward });
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((btn) => fireEvent.click(btn));
+ expect(onSkipForward).not.toHaveBeenCalled();
+ expect(onSkipBackward).not.toHaveBeenCalled();
+ });
+
+ it('applies disabled opacity styling', () => {
+ renderSkipButtons({ disabled: true });
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((btn) => expect(btn.className).toContain('opacity-50'));
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes className to the container div', () => {
+ const { container } = renderSkipButtons({ className: 'skip-custom' });
+ expect(container.firstElementChild?.className).toContain('skip-custom');
+ });
+
+ // ── Button type ────────────────────────────────────────────────────
+
+ it('renders buttons with type="button"', () => {
+ renderSkipButtons();
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((btn) => expect(btn.getAttribute('type')).toBe('button'));
+ });
+});
+
+describe('SkipButton (individual)', () => {
+ function renderSingle(props: Partial[0]> = {}) {
+ const defaults = {
+ direction: 'forward' as const,
+ onClick: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+ }
+
+ it('renders forward button with default 30 seconds', () => {
+ renderSingle({ direction: 'forward' });
+ expect(screen.getByText('30')).toBeInTheDocument();
+ });
+
+ it('renders backward button with default 10 seconds', () => {
+ renderSingle({ direction: 'backward' });
+ expect(screen.getByText('10')).toBeInTheDocument();
+ });
+
+ it('renders forward button with custom seconds', () => {
+ renderSingle({ direction: 'forward', seconds: 15 });
+ expect(screen.getByText('15')).toBeInTheDocument();
+ });
+
+ it('renders backward button with custom seconds', () => {
+ renderSingle({ direction: 'backward', seconds: 5 });
+ expect(screen.getByText('5')).toBeInTheDocument();
+ });
+
+ it('applies small size classes', () => {
+ renderSingle({ size: 'sm' });
+ const btn = screen.getByRole('button');
+ expect(btn.className).toContain('h-9');
+ });
+
+ it('applies medium size classes by default', () => {
+ renderSingle();
+ const btn = screen.getByRole('button');
+ expect(btn.className).toContain('h-12');
+ });
+
+ it('renders SVG icon', () => {
+ renderSingle({ direction: 'forward' });
+ const svg = screen.getByRole('button').querySelector('svg');
+ expect(svg).toBeTruthy();
+ });
+});
diff --git a/src/components/controls/SleepTimer/SleepTimer.tsx b/src/components/controls/SleepTimer/SleepTimer.tsx
new file mode 100644
index 0000000..3c4637a
--- /dev/null
+++ b/src/components/controls/SleepTimer/SleepTimer.tsx
@@ -0,0 +1,180 @@
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { cn } from '@/utils';
+import type { SleepTimerPreset } from '@/types/sleepTimer';
+import { DEFAULT_SLEEP_TIMER_PRESETS } from '@/types/sleepTimer';
+
+export interface SleepTimerProps {
+ /** Whether the timer is currently active */
+ isActive: boolean;
+ /** Remaining time in seconds (displayed as MM:SS when active) */
+ remainingTime: number;
+ /** Preset options to show in the dropdown */
+ presets?: SleepTimerPreset[];
+ /** Whether the control is disabled */
+ disabled?: boolean;
+ /** Called when a preset duration is selected */
+ onStart?: (duration: number | 'endOfTrack') => void;
+ /** Called when the timer is cancelled */
+ onCancel?: () => void;
+ /** Additional CSS class names */
+ className?: string;
+ /** Label for the button (used in aria-label) */
+ label?: string;
+}
+
+/**
+ * Formats seconds into MM:SS display format
+ */
+function formatRemainingTime(seconds: number): string {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
+}
+
+export function SleepTimer({
+ isActive,
+ remainingTime,
+ presets = DEFAULT_SLEEP_TIMER_PRESETS,
+ disabled = false,
+ onStart,
+ onCancel,
+ className,
+ label = 'Sleep timer',
+}: SleepTimerProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const menuRef = useRef(null);
+ const buttonRef = useRef(null);
+
+ // Close menu when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ menuRef.current &&
+ !menuRef.current.contains(e.target as Node) &&
+ buttonRef.current &&
+ !buttonRef.current.contains(e.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const handlePresetSelect = useCallback(
+ (value: number | 'endOfTrack') => {
+ onStart?.(value);
+ setIsOpen(false);
+ },
+ [onStart]
+ );
+
+ const handleCancel = useCallback(() => {
+ onCancel?.();
+ setIsOpen(false);
+ }, [onCancel]);
+
+ const buttonLabel = isActive
+ ? `${label}: ${formatRemainingTime(remainingTime)} remaining`
+ : label;
+
+ return (
+
+
setIsOpen(!isOpen)}
+ disabled={disabled}
+ aria-label={buttonLabel}
+ aria-haspopup="listbox"
+ aria-expanded={isOpen}
+ className={cn(
+ 'fp-button px-2 py-1 text-sm font-medium',
+ 'min-w-[3rem]',
+ isActive && 'text-[var(--fp-color-primary)]',
+ disabled && 'opacity-50 cursor-not-allowed'
+ )}
+ >
+ {isActive ? (
+
+
+
+
+ {formatRemainingTime(remainingTime)}
+
+ ) : (
+
+
+
+ )}
+
+
+ {isOpen && (
+
+ {isActive && (
+
+ Cancel timer
+
+ )}
+ {presets.map((preset) => (
+ handlePresetSelect(preset.value)}
+ className={cn(
+ 'w-full px-3 py-1.5 text-sm text-left',
+ 'hover:bg-[var(--fp-color-surface-hover)]',
+ 'transition-colors'
+ )}
+ >
+ {preset.label}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/controls/SleepTimer/index.ts b/src/components/controls/SleepTimer/index.ts
new file mode 100644
index 0000000..6bd45fa
--- /dev/null
+++ b/src/components/controls/SleepTimer/index.ts
@@ -0,0 +1 @@
+export { SleepTimer, type SleepTimerProps } from './SleepTimer';
diff --git a/src/components/controls/SubtitleSelector/SubtitleSelector.test.tsx b/src/components/controls/SubtitleSelector/SubtitleSelector.test.tsx
new file mode 100644
index 0000000..cb48c86
--- /dev/null
+++ b/src/components/controls/SubtitleSelector/SubtitleSelector.test.tsx
@@ -0,0 +1,252 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { SubtitleSelector } from './SubtitleSelector';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+import type { Subtitle } from '@/types/video';
+
+// ─── Helpers ────────────────────────────────────────────────────────
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function createSubtitles(): Subtitle[] {
+ return [
+ { id: 'en', label: 'English', language: 'en', src: '/subs/en.vtt' },
+ { id: 'de', label: 'Deutsch', language: 'de', src: '/subs/de.vtt' },
+ { id: 'fr', label: 'Fran\u00e7ais', language: 'fr', src: '/subs/fr.vtt' },
+ ];
+}
+
+function renderSubtitleSelector(
+ props: Partial[0]> = {}
+) {
+ const defaults = {
+ currentSubtitle: null as string | null,
+ subtitles: createSubtitles(),
+ onSubtitleChange: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('SubtitleSelector', () => {
+ // ── Basic rendering ───────────────────────────────────────────────
+
+ it('renders the CC toggle button', () => {
+ renderSubtitleSelector();
+ expect(screen.getByRole('button', { name: 'Subtitles' })).toBeInTheDocument();
+ });
+
+ it('renders nothing when subtitles array is empty', () => {
+ const { container } = renderSubtitleSelector({ subtitles: [] });
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('applies custom className', () => {
+ const { container } = renderSubtitleSelector({ className: 'my-selector' });
+ expect(container.querySelector('.fp-subtitle-selector')?.className).toContain('my-selector');
+ });
+
+ // ── Dropdown toggle ───────────────────────────────────────────────
+
+ it('does not show dropdown initially', () => {
+ renderSubtitleSelector();
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ it('shows dropdown when toggle button is clicked', () => {
+ renderSubtitleSelector();
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ });
+
+ it('hides dropdown when toggle button is clicked again', () => {
+ renderSubtitleSelector();
+ const btn = screen.getByRole('button', { name: 'Subtitles' });
+ fireEvent.click(btn);
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ fireEvent.click(btn);
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ it('sets aria-expanded to true when open', () => {
+ renderSubtitleSelector();
+ const btn = screen.getByRole('button', { name: 'Subtitles' });
+ expect(btn.getAttribute('aria-expanded')).toBe('false');
+ fireEvent.click(btn);
+ expect(btn.getAttribute('aria-expanded')).toBe('true');
+ });
+
+ it('has aria-haspopup listbox', () => {
+ renderSubtitleSelector();
+ const btn = screen.getByRole('button', { name: 'Subtitles' });
+ expect(btn.getAttribute('aria-haspopup')).toBe('listbox');
+ });
+
+ // ── Dropdown content ──────────────────────────────────────────────
+
+ it('shows "Off" option in dropdown', () => {
+ renderSubtitleSelector();
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ expect(screen.getByText('Off')).toBeInTheDocument();
+ });
+
+ it('shows all subtitle options in dropdown', () => {
+ renderSubtitleSelector();
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ expect(screen.getByText('English')).toBeInTheDocument();
+ expect(screen.getByText('Deutsch')).toBeInTheDocument();
+ expect(screen.getByText('Fran\u00e7ais')).toBeInTheDocument();
+ });
+
+ it('marks "Off" as selected when currentSubtitle is null', () => {
+ renderSubtitleSelector({ currentSubtitle: null });
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ const offOption = screen.getByRole('option', { name: /Off/ });
+ expect(offOption.getAttribute('aria-selected')).toBe('true');
+ });
+
+ it('marks current subtitle as selected', () => {
+ renderSubtitleSelector({ currentSubtitle: 'de' });
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ const deOption = screen.getByRole('option', { name: /Deutsch/ });
+ expect(deOption.getAttribute('aria-selected')).toBe('true');
+ });
+
+ it('shows checkmark on selected subtitle', () => {
+ renderSubtitleSelector({ currentSubtitle: 'en' });
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ const enOption = screen.getByRole('option', { name: /English/ });
+ expect(enOption.querySelector('svg')).toBeTruthy();
+ });
+
+ it('does not show checkmark on unselected subtitle', () => {
+ renderSubtitleSelector({ currentSubtitle: 'en' });
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ const deOption = screen.getByRole('option', { name: /Deutsch/ });
+ expect(deOption.querySelector('svg')).toBeNull();
+ });
+
+ // ── Selection ─────────────────────────────────────────────────────
+
+ it('calls onSubtitleChange with null when "Off" is selected', () => {
+ const onChange = vi.fn();
+ renderSubtitleSelector({ currentSubtitle: 'en', onSubtitleChange: onChange });
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ fireEvent.click(screen.getByText('Off'));
+ expect(onChange).toHaveBeenCalledWith(null);
+ });
+
+ it('calls onSubtitleChange with subtitle id when subtitle is selected', () => {
+ const onChange = vi.fn();
+ renderSubtitleSelector({ onSubtitleChange: onChange });
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ fireEvent.click(screen.getByText('English'));
+ expect(onChange).toHaveBeenCalledWith('en');
+ });
+
+ it('closes dropdown after selecting a subtitle', () => {
+ renderSubtitleSelector();
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ fireEvent.click(screen.getByText('Deutsch'));
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ it('closes dropdown after selecting "Off"', () => {
+ renderSubtitleSelector({ currentSubtitle: 'en' });
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ fireEvent.click(screen.getByText('Off'));
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ // ── Close behaviors ───────────────────────────────────────────────
+
+ it('closes dropdown when clicking outside', () => {
+ renderSubtitleSelector();
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ fireEvent.mouseDown(document.body);
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ it('closes dropdown when Escape key is pressed', () => {
+ renderSubtitleSelector();
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ // ── Disabled state ────────────────────────────────────────────────
+
+ it('disables the toggle button when disabled', () => {
+ renderSubtitleSelector({ disabled: true });
+ expect(screen.getByRole('button', { name: 'Subtitles' })).toBeDisabled();
+ });
+
+ it('applies disabled styling', () => {
+ renderSubtitleSelector({ disabled: true });
+ expect(screen.getByRole('button', { name: 'Subtitles' }).className).toContain('opacity-50');
+ });
+
+ // ── Current subtitle label display ────────────────────────────────
+
+ it('shows current subtitle label next to CC icon when subtitle is active', () => {
+ renderSubtitleSelector({ currentSubtitle: 'en' });
+ // The label is shown in a span with hidden sm:inline
+ const btn = screen.getByRole('button', { name: 'Subtitles' });
+ expect(btn.textContent).toContain('English');
+ });
+
+ it('does not show subtitle label when currentSubtitle is null', () => {
+ renderSubtitleSelector({ currentSubtitle: null });
+ const btn = screen.getByRole('button', { name: 'Subtitles' });
+ // When null, the subtitle label span is not rendered
+ expect(btn.textContent).not.toContain('English');
+ expect(btn.textContent).not.toContain('Off');
+ });
+
+ // ── Custom labels ─────────────────────────────────────────────────
+
+ it('uses custom labels', () => {
+ renderSubtitleSelector({
+ labels: {
+ subtitles: 'Untertitel',
+ subtitleOptions: 'Untertitel-Optionen',
+ subtitlesOff: 'Aus',
+ },
+ });
+ expect(screen.getByRole('button', { name: 'Untertitel' })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Untertitel' }));
+ expect(screen.getByText('Aus')).toBeInTheDocument();
+ });
+
+ it('uses custom subtitle options aria-label on listbox', () => {
+ renderSubtitleSelector({
+ labels: {
+ subtitles: 'Untertitel',
+ subtitleOptions: 'Untertitel-Optionen',
+ subtitlesOff: 'Aus',
+ },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Untertitel' }));
+ expect(screen.getByRole('listbox', { name: 'Untertitel-Optionen' })).toBeInTheDocument();
+ });
+
+ // ── Dropdown aria ─────────────────────────────────────────────────
+
+ it('dropdown has correct listbox aria-label', () => {
+ renderSubtitleSelector();
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ expect(screen.getByRole('listbox', { name: 'Subtitle options' })).toBeInTheDocument();
+ });
+
+ it('all options have role="option"', () => {
+ renderSubtitleSelector();
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitles' }));
+ // Off + 3 subtitles = 4 options
+ const options = screen.getAllByRole('option');
+ expect(options).toHaveLength(4);
+ });
+});
diff --git a/src/components/controls/SubtitleSettings/SubtitleSettings.stories.tsx b/src/components/controls/SubtitleSettings/SubtitleSettings.stories.tsx
new file mode 100644
index 0000000..1292ced
--- /dev/null
+++ b/src/components/controls/SubtitleSettings/SubtitleSettings.stories.tsx
@@ -0,0 +1,108 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
+import { SubtitleSettings } from './SubtitleSettings';
+import { DEFAULT_SUBTITLE_STYLE, SUBTITLE_PRESETS } from '@/types/subtitleStyling';
+import type { SubtitleStyle } from '@/types/subtitleStyling';
+
+const meta: Meta = {
+ title: 'Controls/SubtitleSettings',
+ component: SubtitleSettings,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ style: { ...DEFAULT_SUBTITLE_STYLE },
+ presets: SUBTITLE_PRESETS,
+ onStyleChange: () => {},
+ onPresetSelect: () => {},
+ onReset: () => {},
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ style: { ...DEFAULT_SUBTITLE_STYLE },
+ presets: SUBTITLE_PRESETS,
+ onStyleChange: () => {},
+ onPresetSelect: () => {},
+ onReset: () => {},
+ disabled: true,
+ },
+};
+
+export const Interactive: Story = {
+ render: () => {
+ const [style, setStyle] = useState({ ...DEFAULT_SUBTITLE_STYLE });
+
+ const handleStyleChange = (updates: Partial) => {
+ setStyle((prev) => ({ ...prev, ...updates }));
+ };
+
+ const handlePresetSelect = (presetName: string) => {
+ const preset = SUBTITLE_PRESETS.find((p) => p.name === presetName);
+ if (preset) {
+ setStyle({ ...preset.style });
+ }
+ };
+
+ const handleReset = () => {
+ setStyle({ ...DEFAULT_SUBTITLE_STYLE });
+ };
+
+ return (
+
+
+
+
+ This is a subtitle preview
+
+
+
+ );
+ },
+};
diff --git a/src/components/controls/SubtitleSettings/SubtitleSettings.tsx b/src/components/controls/SubtitleSettings/SubtitleSettings.tsx
new file mode 100644
index 0000000..14a3a99
--- /dev/null
+++ b/src/components/controls/SubtitleSettings/SubtitleSettings.tsx
@@ -0,0 +1,173 @@
+import { useState, useCallback } from 'react';
+import { cn } from '@/utils/cn';
+import type { SubtitleStyle, SubtitleStylePreset } from '@/types/subtitleStyling';
+
+export interface SubtitleSettingsProps {
+ /** Current subtitle style */
+ style: SubtitleStyle;
+ /** Update style callback */
+ onStyleChange: (updates: Partial) => void;
+ /** Apply a preset callback */
+ onPresetSelect: (presetName: string) => void;
+ /** Reset callback */
+ onReset: () => void;
+ /** Available presets */
+ presets: SubtitleStylePreset[];
+ /** Additional CSS classes */
+ className?: string;
+ /** Whether the settings panel is disabled */
+ disabled?: boolean;
+}
+
+export function SubtitleSettings({
+ style,
+ onStyleChange,
+ onPresetSelect,
+ onReset,
+ presets,
+ className,
+ disabled = false,
+}: SubtitleSettingsProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleToggle = useCallback(() => {
+ if (!disabled) setIsOpen((prev) => !prev);
+ }, [disabled]);
+
+ return (
+
+ {/* Toggle button */}
+
+ {/* CC icon with gear */}
+
+
+
+
+
+
+
+ {/* Settings panel */}
+ {isOpen && (
+
+
Subtitle Style
+
+ {/* Presets */}
+
+
Presets
+
+ {presets.map((preset) => (
+ onPresetSelect(preset.name)}
+ className={cn(
+ 'px-2 py-1 rounded text-xs',
+ 'border border-[var(--fp-glass-border)]',
+ 'hover:border-[var(--fp-color-accent)] hover:text-[var(--fp-color-accent)]',
+ 'transition-colors duration-[var(--fp-transition-fast)]'
+ )}
+ >
+ {preset.label}
+
+ ))}
+
+
+
+ {/* Font size */}
+
+
+ Font Size
+ {style.fontSize}px
+
+
onStyleChange({ fontSize: Number(e.target.value) })}
+ className="w-full h-1 rounded-full appearance-none bg-[var(--fp-progress-bg)] accent-[var(--fp-color-accent)]"
+ />
+
+
+ {/* Background opacity */}
+
+
+ Background
+ {Math.round(style.backgroundOpacity * 100)}%
+
+
onStyleChange({ backgroundOpacity: Number(e.target.value) / 100 })}
+ className="w-full h-1 rounded-full appearance-none bg-[var(--fp-progress-bg)] accent-[var(--fp-color-accent)]"
+ />
+
+
+ {/* Position toggle */}
+
+
Position
+
+ {(['bottom', 'top'] as const).map((pos) => (
+ onStyleChange({ position: pos })}
+ className={cn(
+ 'flex-1 px-2 py-1 rounded text-xs capitalize',
+ 'border transition-colors duration-[var(--fp-transition-fast)]',
+ style.position === pos
+ ? 'border-[var(--fp-color-accent)] text-[var(--fp-color-accent)]'
+ : 'border-[var(--fp-glass-border)] text-[var(--fp-color-text-secondary)]',
+ 'hover:border-[var(--fp-color-accent)]'
+ )}
+ >
+ {pos}
+
+ ))}
+
+
+
+ {/* Reset button */}
+
+ Reset to Default
+
+
+ )}
+
+ );
+}
diff --git a/src/components/controls/SubtitleSettings/index.ts b/src/components/controls/SubtitleSettings/index.ts
new file mode 100644
index 0000000..b3ad49f
--- /dev/null
+++ b/src/components/controls/SubtitleSettings/index.ts
@@ -0,0 +1 @@
+export { SubtitleSettings, type SubtitleSettingsProps } from './SubtitleSettings';
diff --git a/src/components/controls/TimeDisplay/TimeDisplay.test.tsx b/src/components/controls/TimeDisplay/TimeDisplay.test.tsx
new file mode 100644
index 0000000..e9de09d
--- /dev/null
+++ b/src/components/controls/TimeDisplay/TimeDisplay.test.tsx
@@ -0,0 +1,123 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { TimeDisplay } from './TimeDisplay';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderTimeDisplay(props: Partial[0]> = {}) {
+ const defaults = {
+ currentTime: 0,
+ duration: 0,
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('TimeDisplay', () => {
+ // ── Basic rendering ─────────────────────────────────────────────────
+
+ it('renders current time and duration', () => {
+ renderTimeDisplay({ currentTime: 65, duration: 180 });
+ expect(screen.getByText('1:05')).toBeInTheDocument();
+ expect(screen.getByText('3:00')).toBeInTheDocument();
+ });
+
+ it('renders the time separator', () => {
+ renderTimeDisplay({ currentTime: 10, duration: 60 });
+ expect(screen.getByText('/')).toBeInTheDocument();
+ });
+
+ it('displays formatted time in MM:SS format', () => {
+ renderTimeDisplay({ currentTime: 125, duration: 300 });
+ expect(screen.getByText('2:05')).toBeInTheDocument();
+ expect(screen.getByText('5:00')).toBeInTheDocument();
+ });
+
+ it('displays HH:MM:SS format for long durations', () => {
+ renderTimeDisplay({ currentTime: 3661, duration: 7200 });
+ expect(screen.getByText('1:01:01')).toBeInTheDocument();
+ expect(screen.getByText('2:00:00')).toBeInTheDocument();
+ });
+
+ // ── Remaining mode ──────────────────────────────────────────────────
+
+ it('shows remaining time when showRemaining is true', () => {
+ renderTimeDisplay({ currentTime: 60, duration: 180, showRemaining: true });
+ expect(screen.getByText('1:00')).toBeInTheDocument();
+ // Remaining: 180 - 60 = 120 -> -2:00
+ expect(screen.getByText('-2:00')).toBeInTheDocument();
+ });
+
+ it('shows total duration when showRemaining is false', () => {
+ renderTimeDisplay({ currentTime: 60, duration: 180, showRemaining: false });
+ expect(screen.getByText('3:00')).toBeInTheDocument();
+ expect(screen.queryByText('-2:00')).not.toBeInTheDocument();
+ });
+
+ it('shows zero remaining at the end of playback', () => {
+ renderTimeDisplay({ currentTime: 180, duration: 180, showRemaining: true });
+ expect(screen.getByText('-0:00')).toBeInTheDocument();
+ });
+
+ // ── Zero times ──────────────────────────────────────────────────────
+
+ it('renders 0:00 for zero currentTime and duration', () => {
+ renderTimeDisplay({ currentTime: 0, duration: 0 });
+ const zeros = screen.getAllByText('0:00');
+ expect(zeros.length).toBe(2);
+ });
+
+ it('renders 0:00 for zero currentTime', () => {
+ renderTimeDisplay({ currentTime: 0, duration: 120 });
+ expect(screen.getByText('0:00')).toBeInTheDocument();
+ expect(screen.getByText('2:00')).toBeInTheDocument();
+ });
+
+ // ── className ───────────────────────────────────────────────────────
+
+ it('applies custom className', () => {
+ const { container } = renderTimeDisplay({ className: 'my-time-class' });
+ expect(container.firstElementChild?.className).toContain('my-time-class');
+ });
+
+ // ── Accessibility ───────────────────────────────────────────────────
+
+ it('has an aria-label with time information', () => {
+ renderTimeDisplay({ currentTime: 65, duration: 180 });
+ const el = screen.getByLabelText('1:05 of 3:00');
+ expect(el).toBeInTheDocument();
+ });
+
+ it('updates aria-label when time changes', () => {
+ const { rerender } = render(
+ ,
+ { wrapper: Wrapper }
+ );
+ expect(screen.getByLabelText('0:10 of 1:00')).toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByLabelText('0:30 of 1:00')).toBeInTheDocument();
+ });
+
+ // ── Custom labels ──────────────────────────────────────────────────
+
+ it('uses custom separator label', () => {
+ renderTimeDisplay({
+ currentTime: 10,
+ duration: 60,
+ labels: { timeSeparator: '|' },
+ });
+ expect(screen.getByText('|')).toBeInTheDocument();
+ expect(screen.queryByText('/')).not.toBeInTheDocument();
+ });
+
+ // ── Tabular nums ───────────────────────────────────────────────────
+
+ it('applies tabular-nums class for monospaced digits', () => {
+ const { container } = renderTimeDisplay({ currentTime: 10, duration: 60 });
+ expect(container.firstElementChild?.className).toContain('tabular-nums');
+ });
+});
diff --git a/src/components/controls/VolumeControl/VolumeControl.test.tsx b/src/components/controls/VolumeControl/VolumeControl.test.tsx
new file mode 100644
index 0000000..39aef91
--- /dev/null
+++ b/src/components/controls/VolumeControl/VolumeControl.test.tsx
@@ -0,0 +1,426 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { VolumeControl } from './VolumeControl';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+// ─── Helpers ────────────────────────────────────────────────────────
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function renderVolumeControl(
+ props: Partial[0]> = {}
+) {
+ const defaults = {
+ volume: 0.75,
+ muted: false,
+ onVolumeChange: vi.fn(),
+ onMuteToggle: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+function mockSliderRect(el: Element) {
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ right: 96,
+ top: 0,
+ bottom: 24,
+ width: 96,
+ height: 24,
+ x: 0,
+ y: 0,
+ toJSON: () => {},
+ });
+}
+
+function mockVerticalSliderRect(el: Element) {
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ right: 24,
+ top: 0,
+ bottom: 96,
+ width: 24,
+ height: 96,
+ x: 0,
+ y: 0,
+ toJSON: () => {},
+ });
+}
+
+describe('VolumeControl', () => {
+ // ── Mute button ───────────────────────────────────────────────────
+
+ it('renders mute button with correct label when not muted', () => {
+ renderVolumeControl({ muted: false });
+ expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument();
+ });
+
+ it('renders unmute button with correct label when muted', () => {
+ renderVolumeControl({ muted: true });
+ expect(screen.getByRole('button', { name: 'Unmute' })).toBeInTheDocument();
+ });
+
+ it('calls onMuteToggle when mute button is clicked', () => {
+ const onMuteToggle = vi.fn();
+ renderVolumeControl({ onMuteToggle });
+ fireEvent.click(screen.getByRole('button', { name: 'Mute' }));
+ expect(onMuteToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables mute button when disabled', () => {
+ renderVolumeControl({ disabled: true });
+ expect(screen.getByRole('button', { name: 'Mute' })).toBeDisabled();
+ });
+
+ it('applies disabled styling to mute button', () => {
+ renderVolumeControl({ disabled: true });
+ expect(screen.getByRole('button', { name: 'Mute' }).className).toContain('opacity-50');
+ });
+
+ // ── Volume icon states ────────────────────────────────────────────
+
+ it('shows muted icon (X lines) when volume is 0', () => {
+ renderVolumeControl({ volume: 0, muted: false });
+ const btn = screen.getByRole('button', { name: 'Mute' });
+ const svg = btn.querySelector('svg')!;
+ // Muted icon has line elements
+ const lines = svg.querySelectorAll('line');
+ expect(lines.length).toBe(2);
+ });
+
+ it('shows muted icon when muted is true regardless of volume', () => {
+ renderVolumeControl({ volume: 0.8, muted: true });
+ const btn = screen.getByRole('button', { name: 'Unmute' });
+ const svg = btn.querySelector('svg')!;
+ // effectiveVolume = 0 when muted
+ const lines = svg.querySelectorAll('line');
+ expect(lines.length).toBe(2);
+ });
+
+ it('shows low volume icon (one wave) for volume < 0.5', () => {
+ renderVolumeControl({ volume: 0.3, muted: false });
+ const btn = screen.getByRole('button', { name: 'Mute' });
+ const svg = btn.querySelector('svg')!;
+ const paths = svg.querySelectorAll('g[fill="currentColor"] > path');
+ // First wave visible (opacity 1), second hidden (opacity 0)
+ expect(paths[0]?.getAttribute('style')).toContain('opacity: 1');
+ expect(paths[1]?.getAttribute('style')).toContain('opacity: 0');
+ });
+
+ it('shows full volume icon (two waves) for volume >= 0.5', () => {
+ renderVolumeControl({ volume: 0.75, muted: false });
+ const btn = screen.getByRole('button', { name: 'Mute' });
+ const svg = btn.querySelector('svg')!;
+ const paths = svg.querySelectorAll('g[fill="currentColor"] > path');
+ // Both waves visible
+ expect(paths[0]?.getAttribute('style')).toContain('opacity: 1');
+ expect(paths[1]?.getAttribute('style')).toContain('opacity: 1');
+ });
+
+ // ── Horizontal layout ─────────────────────────────────────────────
+
+ it('renders inline slider in horizontal mode', () => {
+ renderVolumeControl({ orientation: 'horizontal' });
+ const slider = screen.getByRole('slider');
+ expect(slider.getAttribute('aria-orientation')).toBe('horizontal');
+ });
+
+ it('shows correct volume percentage as aria value (horizontal)', () => {
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.75 });
+ const slider = screen.getByRole('slider');
+ expect(slider.getAttribute('aria-valuenow')).toBe('75');
+ expect(slider.getAttribute('aria-valuetext')).toBe('75%');
+ });
+
+ it('shows 0% when muted (horizontal)', () => {
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.75, muted: true });
+ const slider = screen.getByRole('slider');
+ expect(slider.getAttribute('aria-valuenow')).toBe('0');
+ });
+
+ it('has correct aria-valuemin and aria-valuemax (horizontal)', () => {
+ renderVolumeControl({ orientation: 'horizontal' });
+ const slider = screen.getByRole('slider');
+ expect(slider.getAttribute('aria-valuemin')).toBe('0');
+ expect(slider.getAttribute('aria-valuemax')).toBe('100');
+ });
+
+ it('has correct aria-label (horizontal)', () => {
+ renderVolumeControl({ orientation: 'horizontal' });
+ expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument();
+ });
+
+ it('calls onVolumeChange on slider click (horizontal)', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', onVolumeChange });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ fireEvent.mouseDown(slider, { clientX: 48, clientY: 12 });
+ // 48/96 = 0.5
+ expect(onVolumeChange).toHaveBeenCalledWith(0.5);
+ });
+
+ it('does not change volume on slider click when disabled (horizontal)', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', onVolumeChange, disabled: true });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+ fireEvent.mouseDown(slider, { clientX: 48, clientY: 12 });
+ expect(onVolumeChange).not.toHaveBeenCalled();
+ });
+
+ it('calls onVolumeChange on drag (horizontal)', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', onVolumeChange });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+
+ fireEvent.mouseDown(slider, { clientX: 48, clientY: 12 });
+ onVolumeChange.mockClear();
+
+ // Global mousemove
+ fireEvent.mouseMove(document, { clientX: 72, clientY: 12 });
+ expect(onVolumeChange).toHaveBeenCalled();
+ });
+
+ it('stops dragging on global mouseup (horizontal)', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', onVolumeChange });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+
+ fireEvent.mouseDown(slider, { clientX: 48, clientY: 12 });
+ fireEvent.mouseUp(document);
+ onVolumeChange.mockClear();
+
+ // After mouseup, mousemove should not call onVolumeChange
+ fireEvent.mouseMove(document, { clientX: 72, clientY: 12 });
+ expect(onVolumeChange).not.toHaveBeenCalled();
+ });
+
+ it('clamps volume to 0-1 range on slider click (horizontal)', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', onVolumeChange });
+ const slider = screen.getByRole('slider');
+ mockSliderRect(slider);
+
+ // Click beyond right edge
+ fireEvent.mouseDown(slider, { clientX: 200, clientY: 12 });
+ expect(onVolumeChange).toHaveBeenCalledWith(1);
+
+ onVolumeChange.mockClear();
+ // Click before left edge
+ fireEvent.mouseDown(slider, { clientX: -50, clientY: 12 });
+ expect(onVolumeChange).toHaveBeenCalledWith(0);
+ });
+
+ // ── Vertical layout ───────────────────────────────────────────────
+
+ it('renders vertical slider overlay on hover (vertical)', () => {
+ renderVolumeControl({ orientation: 'vertical' });
+ const container = screen.getByRole('button', { name: 'Mute' }).parentElement!;
+ fireEvent.mouseEnter(container);
+ const slider = screen.getByRole('slider');
+ expect(slider.getAttribute('aria-orientation')).toBe('vertical');
+ });
+
+ it('shows volume percentage text in vertical overlay', () => {
+ renderVolumeControl({ orientation: 'vertical', volume: 0.75 });
+ const container = screen.getByRole('button', { name: 'Mute' }).parentElement!;
+ fireEvent.mouseEnter(container);
+ expect(screen.getByText('75%')).toBeInTheDocument();
+ });
+
+ it('shows 0% when muted in vertical overlay', () => {
+ renderVolumeControl({ orientation: 'vertical', volume: 0.75, muted: true });
+ const container = screen.getByRole('button', { name: 'Unmute' }).parentElement!;
+ fireEvent.mouseEnter(container);
+ expect(screen.getByText('0%')).toBeInTheDocument();
+ });
+
+ it('calls onVolumeChange on slider click (vertical)', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'vertical', onVolumeChange });
+ const container = screen.getByRole('button', { name: 'Mute' }).parentElement!;
+ fireEvent.mouseEnter(container);
+
+ const slider = screen.getByRole('slider');
+ mockVerticalSliderRect(slider);
+ // Click at vertical position: 1 - (48/96) = 0.5
+ fireEvent.mouseDown(slider, { clientX: 12, clientY: 48 });
+ expect(onVolumeChange).toHaveBeenCalledWith(0.5);
+ });
+
+ it('hides vertical slider on mouse leave (when not dragging)', () => {
+ renderVolumeControl({ orientation: 'vertical' });
+ const container = screen.getByRole('button', { name: 'Mute' }).parentElement!;
+ fireEvent.mouseEnter(container);
+ // Slider should be visible (opacity-100)
+ const overlay = container.querySelector('.absolute.bottom-full');
+ expect(overlay?.className).toContain('opacity-100');
+
+ fireEvent.mouseLeave(container);
+ expect(overlay?.className).toContain('opacity-0');
+ });
+
+ // ── Keyboard interaction ──────────────────────────────────────────
+
+ it('increases volume on ArrowUp key (horizontal)', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange });
+ const slider = screen.getByRole('slider');
+ fireEvent.keyDown(slider, { key: 'ArrowUp' });
+ // step = 0.05, so 0.5 + 0.05 = 0.55
+ expect(onVolumeChange).toHaveBeenCalledWith(0.55);
+ });
+
+ it('increases volume on ArrowRight key', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange });
+ const slider = screen.getByRole('slider');
+ fireEvent.keyDown(slider, { key: 'ArrowRight' });
+ expect(onVolumeChange).toHaveBeenCalledWith(0.55);
+ });
+
+ it('decreases volume on ArrowDown key', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange });
+ const slider = screen.getByRole('slider');
+ fireEvent.keyDown(slider, { key: 'ArrowDown' });
+ expect(onVolumeChange).toHaveBeenCalledWith(0.45);
+ });
+
+ it('decreases volume on ArrowLeft key', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange });
+ const slider = screen.getByRole('slider');
+ fireEvent.keyDown(slider, { key: 'ArrowLeft' });
+ expect(onVolumeChange).toHaveBeenCalledWith(0.45);
+ });
+
+ it('uses large step (0.1) with Shift key', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange });
+ const slider = screen.getByRole('slider');
+ fireEvent.keyDown(slider, { key: 'ArrowUp', shiftKey: true });
+ expect(onVolumeChange).toHaveBeenCalledWith(0.6);
+ });
+
+ it('clamps volume to max 1 on keyboard', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.98, onVolumeChange });
+ const slider = screen.getByRole('slider');
+ fireEvent.keyDown(slider, { key: 'ArrowUp' });
+ expect(onVolumeChange).toHaveBeenCalledWith(1);
+ });
+
+ it('clamps volume to min 0 on keyboard', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.02, onVolumeChange });
+ const slider = screen.getByRole('slider');
+ fireEvent.keyDown(slider, { key: 'ArrowDown' });
+ expect(onVolumeChange).toHaveBeenCalledWith(0);
+ });
+
+ it('ignores keyboard when disabled', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange, disabled: true });
+ const slider = screen.getByRole('slider');
+ fireEvent.keyDown(slider, { key: 'ArrowUp' });
+ expect(onVolumeChange).not.toHaveBeenCalled();
+ });
+
+ it('ignores unrelated keys', () => {
+ const onVolumeChange = vi.fn();
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange });
+ const slider = screen.getByRole('slider');
+ fireEvent.keyDown(slider, { key: 'a' });
+ expect(onVolumeChange).not.toHaveBeenCalled();
+ });
+
+ // ── className passthrough ─────────────────────────────────────────
+
+ it('applies custom className (horizontal)', () => {
+ const { container } = renderVolumeControl({ orientation: 'horizontal', className: 'my-volume' });
+ expect(container.firstChild).toHaveClass('my-volume');
+ });
+
+ it('applies custom className (vertical)', () => {
+ const { container } = renderVolumeControl({ orientation: 'vertical', className: 'my-volume' });
+ expect(container.firstChild).toHaveClass('my-volume');
+ });
+
+ // ── Custom labels ─────────────────────────────────────────────────
+
+ it('uses custom mute label', () => {
+ renderVolumeControl({
+ muted: false,
+ labels: { mute: 'Stummschalten', unmute: 'Laut', volume: 'Lautst\u00e4rke' },
+ });
+ expect(screen.getByRole('button', { name: 'Stummschalten' })).toBeInTheDocument();
+ });
+
+ it('uses custom unmute label', () => {
+ renderVolumeControl({
+ muted: true,
+ labels: { mute: 'Stummschalten', unmute: 'Laut', volume: 'Lautst\u00e4rke' },
+ });
+ expect(screen.getByRole('button', { name: 'Laut' })).toBeInTheDocument();
+ });
+
+ it('uses custom volume aria-label on slider', () => {
+ renderVolumeControl({
+ orientation: 'horizontal',
+ labels: { mute: 'Stummschalten', unmute: 'Laut', volume: 'Lautst\u00e4rke' },
+ });
+ expect(screen.getByRole('slider', { name: 'Lautst\u00e4rke' })).toBeInTheDocument();
+ });
+
+ // ── Close on outside click ────────────────────────────────────────
+
+ it('closes vertical slider on outside click', () => {
+ renderVolumeControl({ orientation: 'vertical' });
+ const container = screen.getByRole('button', { name: 'Mute' }).parentElement!;
+ fireEvent.mouseEnter(container);
+ const overlay = container.querySelector('.absolute.bottom-full');
+ expect(overlay?.className).toContain('opacity-100');
+
+ // Click outside
+ fireEvent.mouseDown(document.body);
+ expect(overlay?.className).toContain('opacity-0');
+ });
+
+ // ── Slider not focusable when disabled ────────────────────────────
+
+ it('slider has tabIndex -1 when disabled (horizontal)', () => {
+ renderVolumeControl({ orientation: 'horizontal', disabled: true });
+ const slider = screen.getByRole('slider');
+ expect(slider.getAttribute('tabindex')).toBe('-1');
+ });
+
+ it('slider has tabIndex 0 when enabled (horizontal)', () => {
+ renderVolumeControl({ orientation: 'horizontal', disabled: false });
+ const slider = screen.getByRole('slider');
+ expect(slider.getAttribute('tabindex')).toBe('0');
+ });
+
+ // ── Volume fill width ─────────────────────────────────────────────
+
+ it('shows correct volume fill width (horizontal)', () => {
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.6, muted: false });
+ const slider = screen.getByRole('slider');
+ // Volume fill is inside the slider
+ const fill = slider.querySelector('.absolute.left-0.top-0.bottom-0');
+ expect((fill as HTMLElement)?.style.width).toBe('60%');
+ });
+
+ it('shows 0% fill when muted (horizontal)', () => {
+ renderVolumeControl({ orientation: 'horizontal', volume: 0.6, muted: true });
+ const slider = screen.getByRole('slider');
+ const fill = slider.querySelector('.absolute.left-0.top-0.bottom-0');
+ expect((fill as HTMLElement)?.style.width).toBe('0%');
+ });
+});
diff --git a/src/components/controls/index.ts b/src/components/controls/index.ts
index ea1f94a..3237e9a 100644
--- a/src/components/controls/index.ts
+++ b/src/components/controls/index.ts
@@ -10,3 +10,7 @@ export { QualitySelector, type QualitySelectorProps } from './QualitySelector';
export { SubtitleSelector, type SubtitleSelectorProps } from './SubtitleSelector';
export { PictureInPictureButton, type PictureInPictureButtonProps } from './PictureInPictureButton';
export { CastButton, type CastButtonProps } from './CastButton';
+export { SleepTimer, type SleepTimerProps } from './SleepTimer';
+export { ShareButton, type ShareButtonProps } from './ShareButton';
+export { SubtitleSettings, type SubtitleSettingsProps } from './SubtitleSettings';
+export { Equalizer, type EqualizerProps } from './Equalizer';
diff --git a/src/components/markers/MarkerList/MarkerList.test.tsx b/src/components/markers/MarkerList/MarkerList.test.tsx
new file mode 100644
index 0000000..e38ac24
--- /dev/null
+++ b/src/components/markers/MarkerList/MarkerList.test.tsx
@@ -0,0 +1,175 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MarkerList } from './MarkerList';
+import type { TimelineMarker } from '@/types/markers';
+
+const markers: TimelineMarker[] = [
+ { id: 'm-1', time: 15, title: 'Highlight 1' },
+ { id: 'm-2', time: 60, title: 'Highlight 2' },
+ { id: 'm-3', time: 120, title: 'Highlight 3' },
+];
+
+function renderMarkerList(props: Partial[0]> = {}) {
+ const defaults = {
+ markers,
+ currentTime: 0,
+ duration: 180,
+ onMarkerClick: vi.fn(),
+ };
+ return render( );
+}
+
+describe('MarkerList', () => {
+ // ── Rendering ──────────────────────────────────────────────────────
+
+ it('renders the "Markers" heading', () => {
+ renderMarkerList();
+ expect(screen.getByText('Markers')).toBeInTheDocument();
+ });
+
+ it('renders all marker titles', () => {
+ renderMarkerList();
+ expect(screen.getByText('Highlight 1')).toBeInTheDocument();
+ expect(screen.getByText('Highlight 2')).toBeInTheDocument();
+ expect(screen.getByText('Highlight 3')).toBeInTheDocument();
+ });
+
+ it('renders a list with marker items', () => {
+ renderMarkerList();
+ expect(screen.getByRole('list', { name: 'Marker list' })).toBeInTheDocument();
+ const items = screen.getAllByRole('listitem');
+ expect(items).toHaveLength(3);
+ });
+
+ it('does not render when markers array is empty', () => {
+ const { container } = renderMarkerList({ markers: [] });
+ expect(container.innerHTML).toBe('');
+ });
+
+ // ── Marker times ───────────────────────────────────────────────────
+
+ it('shows formatted time for each marker', () => {
+ renderMarkerList();
+ expect(screen.getByText('0:15')).toBeInTheDocument();
+ expect(screen.getByText('1:00')).toBeInTheDocument();
+ expect(screen.getByText('2:00')).toBeInTheDocument();
+ });
+
+ // ── Active marker ──────────────────────────────────────────────────
+
+ it('marks the active marker with aria-current', () => {
+ renderMarkerList({ activeMarkerIndex: 1 });
+ const buttons = screen.getAllByRole('button');
+ expect(buttons[0]).not.toHaveAttribute('aria-current');
+ expect(buttons[1]).toHaveAttribute('aria-current', 'true');
+ expect(buttons[2]).not.toHaveAttribute('aria-current');
+ });
+
+ it('applies active background to current marker', () => {
+ renderMarkerList({ activeMarkerIndex: 0 });
+ const buttons = screen.getAllByRole('button');
+ expect(buttons[0].className).toContain('bg-[var(--fp-color-surface)]');
+ });
+
+ it('shows active indicator dot for active marker', () => {
+ renderMarkerList({ activeMarkerIndex: 1 });
+ const activeButton = screen.getAllByRole('button')[1];
+ const dot = activeButton.querySelector('.rounded-full');
+ expect(dot).toBeTruthy();
+ });
+
+ it('applies accent text color to active marker title', () => {
+ renderMarkerList({ activeMarkerIndex: 0 });
+ const title = screen.getByText('Highlight 1');
+ expect(title.className).toContain('text-[var(--fp-color-accent)]');
+ expect(title.className).toContain('font-medium');
+ });
+
+ it('does not highlight any marker when activeMarkerIndex is -1', () => {
+ renderMarkerList({ activeMarkerIndex: -1 });
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((btn) => {
+ expect(btn).not.toHaveAttribute('aria-current');
+ });
+ });
+
+ // ── Click handler ──────────────────────────────────────────────────
+
+ it('calls onMarkerClick with marker and index when clicked', () => {
+ const onMarkerClick = vi.fn();
+ renderMarkerList({ onMarkerClick });
+ fireEvent.click(screen.getByText('Highlight 2'));
+ expect(onMarkerClick).toHaveBeenCalledWith(markers[1], 1);
+ });
+
+ it('calls onMarkerClick for first marker', () => {
+ const onMarkerClick = vi.fn();
+ renderMarkerList({ onMarkerClick });
+ fireEvent.click(screen.getByText('Highlight 1'));
+ expect(onMarkerClick).toHaveBeenCalledWith(markers[0], 0);
+ });
+
+ it('calls onMarkerClick for last marker', () => {
+ const onMarkerClick = vi.fn();
+ renderMarkerList({ onMarkerClick });
+ fireEvent.click(screen.getByText('Highlight 3'));
+ expect(onMarkerClick).toHaveBeenCalledWith(markers[2], 2);
+ });
+
+ // ── Preview images ─────────────────────────────────────────────────
+
+ it('shows preview images when showPreviewImage is true and images exist', () => {
+ const markersWithImages: TimelineMarker[] = markers.map((m) => ({
+ ...m,
+ previewImage: `https://example.com/${m.id}.jpg`,
+ }));
+ const { container } = renderMarkerList({ markers: markersWithImages, showPreviewImage: true });
+ const images = container.querySelectorAll('img');
+ expect(images.length).toBe(3);
+ });
+
+ it('hides preview images when showPreviewImage is false', () => {
+ const markersWithImages: TimelineMarker[] = markers.map((m) => ({
+ ...m,
+ previewImage: `https://example.com/${m.id}.jpg`,
+ }));
+ const { container } = renderMarkerList({ markers: markersWithImages, showPreviewImage: false });
+ expect(container.querySelectorAll('img')).toHaveLength(0);
+ });
+
+ it('does not show images when markers have no previewImage', () => {
+ const { container } = renderMarkerList({ showPreviewImage: true });
+ expect(container.querySelectorAll('img')).toHaveLength(0);
+ });
+
+ // ── Markers without titles ─────────────────────────────────────────
+
+ it('handles markers without title', () => {
+ const noTitleMarkers: TimelineMarker[] = [
+ { id: 'm-1', time: 10 },
+ { id: 'm-2', time: 50, title: 'Has Title' },
+ ];
+ renderMarkerList({ markers: noTitleMarkers });
+ expect(screen.getByText('Has Title')).toBeInTheDocument();
+ expect(screen.getByText('0:10')).toBeInTheDocument();
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className to the container', () => {
+ const { container } = renderMarkerList({ className: 'my-markers' });
+ expect(container.firstElementChild?.className).toContain('my-markers');
+ });
+
+ // ── Marker with custom color ───────────────────────────────────────
+
+ it('applies custom color to active marker dot', () => {
+ const colorMarkers: TimelineMarker[] = [
+ { id: 'm-1', time: 10, title: 'Red', color: '#ff0000' },
+ ];
+ renderMarkerList({ markers: colorMarkers, activeMarkerIndex: 0 });
+ const dot = screen.getByRole('button').querySelector('.rounded-full');
+ expect(dot).toBeTruthy();
+ expect((dot as HTMLElement).style.backgroundColor).toBe('rgb(255, 0, 0)');
+ });
+});
diff --git a/src/components/playlist/PlaylistView/PlaylistView.stories.tsx b/src/components/playlist/PlaylistView/PlaylistView.stories.tsx
index bc3f060..74df5c8 100644
--- a/src/components/playlist/PlaylistView/PlaylistView.stories.tsx
+++ b/src/components/playlist/PlaylistView/PlaylistView.stories.tsx
@@ -6,7 +6,7 @@ import type { Track } from '@/types/player';
const sampleTracks: Track[] = [
{
id: '1',
- src: 'https://example.com/track1.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Episode 1: Getting Started with React',
artist: 'Tech Talks Podcast',
artwork: 'https://picsum.photos/100?random=1',
@@ -14,7 +14,7 @@ const sampleTracks: Track[] = [
},
{
id: '2',
- src: 'https://example.com/track2.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Episode 2: State Management Deep Dive',
artist: 'Tech Talks Podcast',
artwork: 'https://picsum.photos/100?random=2',
@@ -22,7 +22,7 @@ const sampleTracks: Track[] = [
},
{
id: '3',
- src: 'https://example.com/track3.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Episode 3: Building Custom Hooks',
artist: 'Tech Talks Podcast',
artwork: 'https://picsum.photos/100?random=3',
@@ -30,7 +30,7 @@ const sampleTracks: Track[] = [
},
{
id: '4',
- src: 'https://example.com/track4.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Episode 4: Performance Optimization',
artist: 'Tech Talks Podcast',
artwork: 'https://picsum.photos/100?random=4',
@@ -38,7 +38,7 @@ const sampleTracks: Track[] = [
},
{
id: '5',
- src: 'https://example.com/track5.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Episode 5: Testing Best Practices',
artist: 'Tech Talks Podcast',
artwork: 'https://picsum.photos/100?random=5',
diff --git a/src/components/playlist/PlaylistView/PlaylistView.test.tsx b/src/components/playlist/PlaylistView/PlaylistView.test.tsx
new file mode 100644
index 0000000..861b11a
--- /dev/null
+++ b/src/components/playlist/PlaylistView/PlaylistView.test.tsx
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { PlaylistView } from './PlaylistView';
+import { createMockPlaylist } from '@/test/helpers';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+const tracks = createMockPlaylist(3);
+
+function renderPlaylistView(props: Partial[0]> = {}) {
+ const defaults = {
+ tracks,
+ currentIndex: 0,
+ onTrackClick: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('PlaylistView', () => {
+ // ── Rendering ──────────────────────────────────────────────────────
+
+ it('renders the "Playlist" heading', () => {
+ renderPlaylistView();
+ expect(screen.getByText('Playlist')).toBeInTheDocument();
+ });
+
+ it('renders the track count', () => {
+ renderPlaylistView();
+ expect(screen.getByText('3 tracks')).toBeInTheDocument();
+ });
+
+ it('renders singular "track" for single track', () => {
+ renderPlaylistView({ tracks: [tracks[0]] });
+ expect(screen.getByText('1 track')).toBeInTheDocument();
+ });
+
+ it('renders all track titles', () => {
+ renderPlaylistView();
+ expect(screen.getByText('Track 1')).toBeInTheDocument();
+ expect(screen.getByText('Track 2')).toBeInTheDocument();
+ expect(screen.getByText('Track 3')).toBeInTheDocument();
+ });
+
+ it('renders a list with role and label', () => {
+ renderPlaylistView();
+ expect(screen.getByRole('list', { name: 'Playlist' })).toBeInTheDocument();
+ const items = screen.getAllByRole('listitem');
+ expect(items).toHaveLength(3);
+ });
+
+ it('does not render when tracks array is empty', () => {
+ const { container } = renderPlaylistView({ tracks: [] });
+ expect(container.innerHTML).toBe('');
+ });
+
+ // ── Current track highlighting ─────────────────────────────────────
+
+ it('marks the current track as active', () => {
+ renderPlaylistView({ currentIndex: 1 });
+ const buttons = screen.getAllByRole('button');
+ expect(buttons[0]).not.toHaveAttribute('aria-current');
+ expect(buttons[1]).toHaveAttribute('aria-current', 'true');
+ expect(buttons[2]).not.toHaveAttribute('aria-current');
+ });
+
+ it('highlights the first track by default', () => {
+ renderPlaylistView({ currentIndex: 0 });
+ const buttons = screen.getAllByRole('button');
+ expect(buttons[0]).toHaveAttribute('aria-current', 'true');
+ });
+
+ // ── Click handler ──────────────────────────────────────────────────
+
+ it('calls onTrackClick with track and index when clicked', () => {
+ const onTrackClick = vi.fn();
+ renderPlaylistView({ onTrackClick });
+ fireEvent.click(screen.getByText('Track 2'));
+ expect(onTrackClick).toHaveBeenCalledWith(tracks[1], 1);
+ });
+
+ it('calls onTrackClick for the first track', () => {
+ const onTrackClick = vi.fn();
+ renderPlaylistView({ onTrackClick });
+ fireEvent.click(screen.getByText('Track 1'));
+ expect(onTrackClick).toHaveBeenCalledWith(tracks[0], 0);
+ });
+
+ it('calls onTrackClick for the last track', () => {
+ const onTrackClick = vi.fn();
+ renderPlaylistView({ onTrackClick });
+ fireEvent.click(screen.getByText('Track 3'));
+ expect(onTrackClick).toHaveBeenCalledWith(tracks[2], 2);
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className to the container', () => {
+ const { container } = renderPlaylistView({ className: 'my-playlist' });
+ expect(container.firstElementChild?.className).toContain('my-playlist');
+ });
+
+ // ── maxHeight ──────────────────────────────────────────────────────
+
+ it('applies default maxHeight of 300px to the list', () => {
+ renderPlaylistView();
+ const list = screen.getByRole('list');
+ expect(list.style.maxHeight).toBe('300px');
+ });
+
+ it('applies custom maxHeight', () => {
+ renderPlaylistView({ maxHeight: '500px' });
+ const list = screen.getByRole('list');
+ expect(list.style.maxHeight).toBe('500px');
+ });
+
+ // ── Track info ─────────────────────────────────────────────────────
+
+ it('renders track artist names', () => {
+ renderPlaylistView();
+ expect(screen.getByText('Artist 1')).toBeInTheDocument();
+ expect(screen.getByText('Artist 2')).toBeInTheDocument();
+ expect(screen.getByText('Artist 3')).toBeInTheDocument();
+ });
+
+ // ── isPlaying ──────────────────────────────────────────────────────
+
+ it('renders with isPlaying false by default', () => {
+ renderPlaylistView({ currentIndex: 0 });
+ // When not playing, should show track number instead of NowPlayingIndicator
+ expect(screen.getByText('1')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/playlist/TrackItem/TrackItem.test.tsx b/src/components/playlist/TrackItem/TrackItem.test.tsx
new file mode 100644
index 0000000..08fd14a
--- /dev/null
+++ b/src/components/playlist/TrackItem/TrackItem.test.tsx
@@ -0,0 +1,158 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { TrackItem } from './TrackItem';
+import { createMockTrack } from '@/test/helpers';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+const track = createMockTrack();
+
+function renderTrackItem(props: Partial[0]> = {}) {
+ const defaults = {
+ track,
+ index: 0,
+ onClick: vi.fn(),
+ };
+ return render( , { wrapper: Wrapper });
+}
+
+describe('TrackItem', () => {
+ // ── Rendering track info ───────────────────────────────────────────
+
+ it('renders the track title', () => {
+ renderTrackItem();
+ expect(screen.getByText('Test Track')).toBeInTheDocument();
+ });
+
+ it('renders the track artist', () => {
+ renderTrackItem();
+ expect(screen.getByText('Test Artist')).toBeInTheDocument();
+ });
+
+ it('renders "Untitled" when track has no title', () => {
+ renderTrackItem({ track: createMockTrack({ title: undefined }) });
+ expect(screen.getByText('Untitled')).toBeInTheDocument();
+ });
+
+ it('does not render artist when not provided', () => {
+ renderTrackItem({ track: createMockTrack({ artist: undefined }) });
+ expect(screen.queryByText('Test Artist')).not.toBeInTheDocument();
+ });
+
+ // ── Track number ───────────────────────────────────────────────────
+
+ it('shows 1-based track number when not active', () => {
+ renderTrackItem({ index: 0 });
+ expect(screen.getByText('1')).toBeInTheDocument();
+ });
+
+ it('shows correct track number for different indices', () => {
+ renderTrackItem({ index: 4 });
+ expect(screen.getByText('5')).toBeInTheDocument();
+ });
+
+ it('shows track number when active but not playing', () => {
+ renderTrackItem({ isActive: true, isPlaying: false, index: 2 });
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+
+ // ── Now playing indicator ──────────────────────────────────────────
+
+ it('shows NowPlayingIndicator when active and playing', () => {
+ renderTrackItem({ isActive: true, isPlaying: true });
+ // The NowPlayingIndicator renders with role="img"
+ expect(screen.getByRole('img')).toBeInTheDocument();
+ });
+
+ it('does not show NowPlayingIndicator when not active', () => {
+ renderTrackItem({ isActive: false, isPlaying: false });
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ });
+
+ // ── Artwork ────────────────────────────────────────────────────────
+
+ it('renders artwork image when provided', () => {
+ const { container } = renderTrackItem();
+ const img = container.querySelector('img') as HTMLImageElement | null;
+ expect(img).toBeTruthy();
+ expect(img?.src).toBe('https://example.com/cover.jpg');
+ });
+
+ it('does not render artwork when not provided', () => {
+ const { container } = renderTrackItem({ track: createMockTrack({ artwork: undefined }) });
+ expect(container.querySelector('img')).toBeNull();
+ });
+
+ // ── Duration ───────────────────────────────────────────────────────
+
+ it('renders formatted duration', () => {
+ renderTrackItem({ track: createMockTrack({ duration: 180 }) });
+ expect(screen.getByText('3:00')).toBeInTheDocument();
+ });
+
+ it('does not render duration when not provided', () => {
+ renderTrackItem({ track: createMockTrack({ duration: undefined }) });
+ expect(screen.queryByText('3:00')).not.toBeInTheDocument();
+ });
+
+ // ── Active state ───────────────────────────────────────────────────
+
+ it('marks active track with aria-current', () => {
+ renderTrackItem({ isActive: true });
+ expect(screen.getByRole('button')).toHaveAttribute('aria-current', 'true');
+ });
+
+ it('does not mark inactive track with aria-current', () => {
+ renderTrackItem({ isActive: false });
+ expect(screen.getByRole('button')).not.toHaveAttribute('aria-current');
+ });
+
+ it('applies active background styling', () => {
+ renderTrackItem({ isActive: true });
+ expect(screen.getByRole('button').className).toContain('bg-[var(--fp-color-surface)]');
+ });
+
+ it('applies active text color to title', () => {
+ renderTrackItem({ isActive: true });
+ const title = screen.getByText('Test Track');
+ expect(title.className).toContain('text-[var(--fp-color-primary)]');
+ expect(title.className).toContain('font-medium');
+ });
+
+ // ── Click handler ──────────────────────────────────────────────────
+
+ it('calls onClick with track and index when clicked', () => {
+ const onClick = vi.fn();
+ renderTrackItem({ onClick, index: 2 });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledWith(track, 2);
+ });
+
+ it('calls onClick with a different track', () => {
+ const onClick = vi.fn();
+ const otherTrack = createMockTrack({ id: 'other', title: 'Other Track' });
+ renderTrackItem({ track: otherTrack, onClick, index: 0 });
+ fireEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledWith(otherTrack, 0);
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className to the button', () => {
+ renderTrackItem({ className: 'my-track-item' });
+ expect(screen.getByRole('button').className).toContain('my-track-item');
+ });
+
+ // ── Button type ────────────────────────────────────────────────────
+
+ it('renders as a button with type="button"', () => {
+ renderTrackItem();
+ const btn = screen.getByRole('button');
+ expect(btn.tagName).toBe('BUTTON');
+ expect(btn.getAttribute('type')).toBe('button');
+ });
+});
diff --git a/src/components/podcast/PodcastPage.stories.tsx b/src/components/podcast/PodcastPage.stories.tsx
index f97a0c3..8444758 100644
--- a/src/components/podcast/PodcastPage.stories.tsx
+++ b/src/components/podcast/PodcastPage.stories.tsx
@@ -29,7 +29,7 @@ const generateEpisodes = (count: number): Episode[] => {
episodes.push({
id: `episode-${i + 1}`,
- src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
+ src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: `Episode ${count - i}: ${getRandomTitle()}`,
artist: samplePodcast.author,
artwork: `https://picsum.photos/seed/ep${i}/200/200`,
diff --git a/src/components/stats/Rating.stories.tsx b/src/components/stats/Rating.stories.tsx
index e26411e..9b4b3d9 100644
--- a/src/components/stats/Rating.stories.tsx
+++ b/src/components/stats/Rating.stories.tsx
@@ -192,7 +192,7 @@ function InteractiveDemo() {
/>
- Deine Bewertung: {state.userRating ?? 'Keine'}
+ Your rating: {state.userRating ?? 'None'}
@@ -202,7 +202,7 @@ function InteractiveDemo() {
Event Log:
{events.length === 0 ? (
-
Klicke auf die Bewertungen...
+
Click on the ratings...
) : (
events.map((event, i) =>
{event}
)
)}
diff --git a/src/components/stats/Rating.test.tsx b/src/components/stats/Rating.test.tsx
new file mode 100644
index 0000000..7a56cb0
--- /dev/null
+++ b/src/components/stats/Rating.test.tsx
@@ -0,0 +1,278 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { Rating } from './Rating';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { ReactNode } from 'react';
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return
{children} ;
+}
+
+function renderRating(props: Partial
[0]> = {}) {
+ return render( , { wrapper: Wrapper });
+}
+
+describe('Rating', () => {
+ // ── Default rendering ──────────────────────────────────────────────
+
+ it('renders thumbs up and thumbs down buttons', () => {
+ renderRating();
+ expect(screen.getByRole('button', { name: 'Like' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Dislike' })).toBeInTheDocument();
+ });
+
+ it('renders with no user rating by default', () => {
+ renderRating();
+ // Both buttons should show unfilled state (stroke only)
+ const upBtn = screen.getByRole('button', { name: 'Like' });
+ const downBtn = screen.getByRole('button', { name: 'Dislike' });
+ expect(upBtn.className).toContain('text-gray-400');
+ expect(downBtn.className).toContain('text-gray-400');
+ });
+
+ it('shows count of 0 by default', () => {
+ renderRating();
+ const zeros = screen.getAllByText('0');
+ expect(zeros.length).toBe(2); // up count and down count
+ });
+
+ // ── Click up ───────────────────────────────────────────────────────
+
+ it('calls onRateUp when thumbs up is clicked', () => {
+ const onRateUp = vi.fn();
+ renderRating({ onRateUp });
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ expect(onRateUp).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onRatingChange with "up" when thumbs up is clicked', () => {
+ const onRatingChange = vi.fn();
+ renderRating({ onRatingChange });
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ expect(onRatingChange).toHaveBeenCalledWith('up');
+ });
+
+ it('increments up count when thumbs up is clicked', () => {
+ renderRating({ initialState: { upCount: 10, downCount: 5 } });
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ expect(screen.getByText('11')).toBeInTheDocument();
+ });
+
+ // ── Click down ─────────────────────────────────────────────────────
+
+ it('calls onRateDown when thumbs down is clicked', () => {
+ const onRateDown = vi.fn();
+ renderRating({ onRateDown });
+ fireEvent.click(screen.getByRole('button', { name: 'Dislike' }));
+ expect(onRateDown).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onRatingChange with "down" when thumbs down is clicked', () => {
+ const onRatingChange = vi.fn();
+ renderRating({ onRatingChange });
+ fireEvent.click(screen.getByRole('button', { name: 'Dislike' }));
+ expect(onRatingChange).toHaveBeenCalledWith('down');
+ });
+
+ it('increments down count when thumbs down is clicked', () => {
+ renderRating({ initialState: { upCount: 10, downCount: 5 } });
+ fireEvent.click(screen.getByRole('button', { name: 'Dislike' }));
+ expect(screen.getByText('6')).toBeInTheDocument();
+ });
+
+ // ── Toggle rating ──────────────────────────────────────────────────
+
+ it('removes rating when same button is clicked twice', () => {
+ const onRatingChange = vi.fn();
+ renderRating({ onRatingChange });
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ expect(onRatingChange).toHaveBeenCalledWith('up');
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ expect(onRatingChange).toHaveBeenCalledWith(null);
+ });
+
+ it('calls onRateRemove when removing a rating', () => {
+ const onRateRemove = vi.fn();
+ renderRating({ onRateRemove, initialState: { userRating: 'up', upCount: 1 } });
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ expect(onRateRemove).toHaveBeenCalledTimes(1);
+ });
+
+ it('switches from up to down correctly', () => {
+ const onRatingChange = vi.fn();
+ renderRating({ onRatingChange, initialState: { upCount: 5, downCount: 3 } });
+ // Click up
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ expect(onRatingChange).toHaveBeenLastCalledWith('up');
+ // Click down (switches from up to down)
+ fireEvent.click(screen.getByRole('button', { name: 'Dislike' }));
+ expect(onRatingChange).toHaveBeenLastCalledWith('down');
+ });
+
+ // ── Initial state ──────────────────────────────────────────────────
+
+ it('renders with initial upCount and downCount', () => {
+ renderRating({ initialState: { upCount: 42, downCount: 8 } });
+ expect(screen.getByText('42')).toBeInTheDocument();
+ expect(screen.getByText('8')).toBeInTheDocument();
+ });
+
+ it('renders with initial userRating of up', () => {
+ renderRating({ initialState: { userRating: 'up', upCount: 10 } });
+ const upBtn = screen.getByRole('button', { name: 'Like' });
+ expect(upBtn.className).toContain('text-green-500');
+ });
+
+ it('renders with initial userRating of down', () => {
+ renderRating({ initialState: { userRating: 'down', downCount: 5 } });
+ const downBtn = screen.getByRole('button', { name: 'Dislike' });
+ expect(downBtn.className).toContain('text-red-500');
+ });
+
+ // ── Disabled state ─────────────────────────────────────────────────
+
+ it('disables both buttons when disabled', () => {
+ renderRating({ disabled: true });
+ expect(screen.getByRole('button', { name: 'Like' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Dislike' })).toBeDisabled();
+ });
+
+ it('applies disabled styling', () => {
+ renderRating({ disabled: true });
+ const upBtn = screen.getByRole('button', { name: 'Like' });
+ expect(upBtn.className).toContain('opacity-50');
+ });
+
+ it('does not call callbacks when disabled', () => {
+ const onRateUp = vi.fn();
+ const onRateDown = vi.fn();
+ renderRating({ disabled: true, onRateUp, onRateDown });
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Dislike' }));
+ expect(onRateUp).not.toHaveBeenCalled();
+ expect(onRateDown).not.toHaveBeenCalled();
+ });
+
+ // ── showCounts ─────────────────────────────────────────────────────
+
+ it('hides counts when showCounts is false', () => {
+ renderRating({
+ showCounts: false,
+ initialState: { upCount: 42, downCount: 8 },
+ });
+ expect(screen.queryByText('42')).not.toBeInTheDocument();
+ expect(screen.queryByText('8')).not.toBeInTheDocument();
+ });
+
+ it('shows counts when showCounts is true (default)', () => {
+ renderRating({ initialState: { upCount: 42, downCount: 8 } });
+ expect(screen.getByText('42')).toBeInTheDocument();
+ expect(screen.getByText('8')).toBeInTheDocument();
+ });
+
+ // ── showPercentage ─────────────────────────────────────────────────
+
+ it('shows percentage instead of count when showPercentage is true', () => {
+ renderRating({
+ showPercentage: true,
+ initialState: { upCount: 75, downCount: 25 },
+ });
+ expect(screen.getByText('75%')).toBeInTheDocument();
+ });
+
+ it('shows raw count when total is 0 and showPercentage is true (no percentage when no votes)', () => {
+ renderRating({
+ showPercentage: true,
+ initialState: { upCount: 0, downCount: 0 },
+ });
+ // When total is 0, falls back to showing raw upCount
+ expect(screen.getByText('0')).toBeInTheDocument();
+ });
+
+ it('does not show down count when showPercentage is true', () => {
+ renderRating({
+ showPercentage: true,
+ initialState: { upCount: 75, downCount: 25 },
+ });
+ // Down count should not display as a number
+ expect(screen.queryByText('25')).not.toBeInTheDocument();
+ });
+
+ // ── Size variants ──────────────────────────────────────────────────
+
+ it('applies small size class', () => {
+ const { container } = renderRating({ size: 'sm' });
+ expect(container.firstElementChild?.className).toContain('text-xs');
+ });
+
+ it('applies medium size class by default', () => {
+ const { container } = renderRating();
+ expect(container.firstElementChild?.className).toContain('text-sm');
+ });
+
+ it('applies large size class', () => {
+ const { container } = renderRating({ size: 'lg' });
+ expect(container.firstElementChild?.className).toContain('text-base');
+ });
+
+ it('applies correct icon size for small', () => {
+ renderRating({ size: 'sm' });
+ const svgs = screen.getByRole('button', { name: 'Like' }).querySelectorAll('svg');
+ expect(svgs[0].getAttribute('class')).toContain('w-4');
+ });
+
+ it('applies correct icon size for large', () => {
+ renderRating({ size: 'lg' });
+ const svgs = screen.getByRole('button', { name: 'Like' }).querySelectorAll('svg');
+ expect(svgs[0].getAttribute('class')).toContain('w-6');
+ });
+
+ // ── Custom labels ──────────────────────────────────────────────────
+
+ it('uses custom rateUp label', () => {
+ renderRating({ labels: { rateUp: 'Daumen hoch' } });
+ expect(screen.getByRole('button', { name: 'Daumen hoch' })).toBeInTheDocument();
+ });
+
+ it('uses custom rateDown label', () => {
+ renderRating({ labels: { rateDown: 'Daumen runter' } });
+ expect(screen.getByRole('button', { name: 'Daumen runter' })).toBeInTheDocument();
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className', () => {
+ const { container } = renderRating({ className: 'my-rating' });
+ expect(container.firstElementChild?.className).toContain('my-rating');
+ });
+
+ // ── Controlled state ───────────────────────────────────────────────
+
+ it('uses controlled state when provided', () => {
+ renderRating({
+ state: { userRating: 'up', upCount: 100, downCount: 50 },
+ });
+ expect(screen.getByText('100')).toBeInTheDocument();
+ expect(screen.getByText('50')).toBeInTheDocument();
+ const upBtn = screen.getByRole('button', { name: 'Like' });
+ expect(upBtn.className).toContain('text-green-500');
+ });
+
+ // ── canRate ────────────────────────────────────────────────────────
+
+ it('does not allow rating when canRate is false', () => {
+ const onRateUp = vi.fn();
+ renderRating({ initialState: { canRate: false }, onRateUp });
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ expect(onRateUp).not.toHaveBeenCalled();
+ });
+
+ // ── enabled ────────────────────────────────────────────────────────
+
+ it('does not allow rating when enabled is false', () => {
+ const onRateUp = vi.fn();
+ renderRating({ initialState: { enabled: false }, onRateUp });
+ fireEvent.click(screen.getByRole('button', { name: 'Like' }));
+ expect(onRateUp).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/stats/Stats.stories.tsx b/src/components/stats/Stats.stories.tsx
index d1e9541..715c3ae 100644
--- a/src/components/stats/Stats.stories.tsx
+++ b/src/components/stats/Stats.stories.tsx
@@ -247,7 +247,7 @@ function CombinedDemo() {
diff --git a/src/components/stats/Stats.test.tsx b/src/components/stats/Stats.test.tsx
new file mode 100644
index 0000000..8e9be7d
--- /dev/null
+++ b/src/components/stats/Stats.test.tsx
@@ -0,0 +1,241 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { Stats, StatIcons } from './Stats';
+import type { StatItem } from '@/types/stats';
+
+const items: StatItem[] = [
+ { id: 'plays', label: 'Plays', value: 1234 },
+ { id: 'likes', label: 'Likes', value: 567 },
+ { id: 'comments', label: 'Comments', value: 89 },
+];
+
+function renderStats(props: Partial
[0]> = {}) {
+ const defaults = {
+ items,
+ };
+ return render( );
+}
+
+describe('Stats', () => {
+ // ── Rendering items ────────────────────────────────────────────────
+
+ it('renders all stat labels', () => {
+ renderStats();
+ expect(screen.getByText('Plays:')).toBeInTheDocument();
+ expect(screen.getByText('Likes:')).toBeInTheDocument();
+ expect(screen.getByText('Comments:')).toBeInTheDocument();
+ });
+
+ it('renders all stat values', () => {
+ renderStats();
+ expect(screen.getByText('1234')).toBeInTheDocument();
+ expect(screen.getByText('567')).toBeInTheDocument();
+ expect(screen.getByText('89')).toBeInTheDocument();
+ });
+
+ it('renders string values', () => {
+ const stringItems: StatItem[] = [
+ { id: 'date', label: 'Published', value: 'Jan 1, 2024' },
+ ];
+ renderStats({ items: stringItems });
+ expect(screen.getByText('Jan 1, 2024')).toBeInTheDocument();
+ });
+
+ it('does not render when all items are empty', () => {
+ const { container } = renderStats({ items: [] });
+ expect(container.innerHTML).toBe('');
+ });
+
+ // ── Hidden items ───────────────────────────────────────────────────
+
+ it('does not render hidden items', () => {
+ const itemsWithHidden: StatItem[] = [
+ { id: 'visible', label: 'Visible', value: 100 },
+ { id: 'hidden', label: 'Hidden', value: 200, hidden: true },
+ ];
+ renderStats({ items: itemsWithHidden });
+ expect(screen.getByText('Visible:')).toBeInTheDocument();
+ expect(screen.queryByText('Hidden:')).not.toBeInTheDocument();
+ });
+
+ it('renders nothing when all items are hidden', () => {
+ const allHidden: StatItem[] = [
+ { id: 'a', label: 'A', value: 1, hidden: true },
+ { id: 'b', label: 'B', value: 2, hidden: true },
+ ];
+ const { container } = renderStats({ items: allHidden });
+ expect(container.innerHTML).toBe('');
+ });
+
+ // ── Ordering ───────────────────────────────────────────────────────
+
+ it('sorts items by order property', () => {
+ const ordered: StatItem[] = [
+ { id: 'c', label: 'Third', value: 3, order: 3 },
+ { id: 'a', label: 'First', value: 1, order: 1 },
+ { id: 'b', label: 'Second', value: 2, order: 2 },
+ ];
+ const { container } = renderStats({ items: ordered });
+ const texts = container.querySelectorAll('.text-gray-400');
+ expect(texts[0]).toHaveTextContent('First:');
+ expect(texts[1]).toHaveTextContent('Second:');
+ expect(texts[2]).toHaveTextContent('Third:');
+ });
+
+ // ── Layout options ─────────────────────────────────────────────────
+
+ it('applies horizontal layout by default', () => {
+ const { container } = renderStats();
+ expect(container.firstElementChild?.className).toContain('flex');
+ expect(container.firstElementChild?.className).toContain('flex-wrap');
+ });
+
+ it('applies vertical layout', () => {
+ const { container } = renderStats({ layout: 'vertical' });
+ expect(container.firstElementChild?.className).toContain('flex-col');
+ });
+
+ it('applies grid layout', () => {
+ const { container } = renderStats({ layout: 'grid' });
+ expect(container.firstElementChild?.className).toContain('grid');
+ });
+
+ it('applies grid template columns based on columns prop', () => {
+ const { container } = renderStats({ layout: 'grid', columns: 3 });
+ expect(container.firstElementChild?.getAttribute('style')).toContain('repeat(3');
+ });
+
+ // ── Dividers ───────────────────────────────────────────────────────
+
+ it('shows dividers when showDividers is true and layout is horizontal', () => {
+ const { container } = renderStats({ showDividers: true, layout: 'horizontal' });
+ const dividerElements = container.querySelectorAll('.border-l');
+ // First item has no divider, subsequent items do
+ expect(dividerElements.length).toBe(2);
+ });
+
+ it('does not show dividers when showDividers is false', () => {
+ const { container } = renderStats({ showDividers: false });
+ const dividerElements = container.querySelectorAll('.border-l');
+ expect(dividerElements.length).toBe(0);
+ });
+
+ // ── Compact mode ───────────────────────────────────────────────────
+
+ it('applies compact text size', () => {
+ const { container } = renderStats({ compact: true });
+ const statItems = container.querySelectorAll('.text-xs');
+ expect(statItems.length).toBeGreaterThan(0);
+ });
+
+ it('applies normal text size when not compact', () => {
+ const { container } = renderStats({ compact: false });
+ const statItems = container.querySelectorAll('.text-sm');
+ expect(statItems.length).toBeGreaterThan(0);
+ });
+
+ // ── Icons ──────────────────────────────────────────────────────────
+
+ it('renders icons when provided', () => {
+ const itemsWithIcons: StatItem[] = [
+ { id: 'plays', label: 'Plays', value: 100, icon: StatIcons.plays },
+ ];
+ renderStats({ items: itemsWithIcons });
+ const svg = screen.getByText('Plays:').parentElement?.querySelector('svg');
+ expect(svg).toBeTruthy();
+ });
+
+ it('does not render icon container when no icon provided', () => {
+ const noIconItems: StatItem[] = [
+ { id: 'plays', label: 'Plays', value: 100 },
+ ];
+ renderStats({ items: noIconItems });
+ const itemEl = screen.getByText('Plays:').parentElement;
+ expect(itemEl?.querySelector('svg')).toBeNull();
+ });
+
+ // ── Predefined icons exist ─────────────────────────────────────────
+
+ it('exports plays icon', () => {
+ expect(StatIcons.plays).toBeTruthy();
+ });
+
+ it('exports views icon', () => {
+ expect(StatIcons.views).toBeTruthy();
+ });
+
+ it('exports likes icon', () => {
+ expect(StatIcons.likes).toBeTruthy();
+ });
+
+ it('exports comments icon', () => {
+ expect(StatIcons.comments).toBeTruthy();
+ });
+
+ it('exports shares icon', () => {
+ expect(StatIcons.shares).toBeTruthy();
+ });
+
+ it('exports downloads icon', () => {
+ expect(StatIcons.downloads).toBeTruthy();
+ });
+
+ it('exports duration icon', () => {
+ expect(StatIcons.duration).toBeTruthy();
+ });
+
+ it('exports calendar icon', () => {
+ expect(StatIcons.calendar).toBeTruthy();
+ });
+
+ it('exports episodes icon', () => {
+ expect(StatIcons.episodes).toBeTruthy();
+ });
+
+ it('exports subscribers icon', () => {
+ expect(StatIcons.subscribers).toBeTruthy();
+ });
+
+ // ── Clickable items ────────────────────────────────────────────────
+
+ it('calls onClick when a clickable item is clicked', () => {
+ const onClick = vi.fn();
+ const clickableItems: StatItem[] = [
+ { id: 'stat', label: 'Stat', value: 42, onClick },
+ ];
+ renderStats({ items: clickableItems });
+ fireEvent.click(screen.getByText('42'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ // ── Links ──────────────────────────────────────────────────────────
+
+ it('renders item as a link when href is provided', () => {
+ const linkItems: StatItem[] = [
+ { id: 'link', label: 'Link', value: 'Click me', href: 'https://example.com' },
+ ];
+ renderStats({ items: linkItems });
+ const link = screen.getByRole('link');
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ // ── Tooltip ────────────────────────────────────────────────────────
+
+ it('renders tooltip as title attribute', () => {
+ const tooltipItems: StatItem[] = [
+ { id: 'tip', label: 'Tip', value: 99, tooltip: 'Total plays' },
+ ];
+ renderStats({ items: tooltipItems });
+ const itemEl = screen.getByText('99').closest('[title]');
+ expect(itemEl).toHaveAttribute('title', 'Total plays');
+ });
+
+ // ── className ──────────────────────────────────────────────────────
+
+ it('passes custom className to the container', () => {
+ const { container } = renderStats({ className: 'my-stats' });
+ expect(container.firstElementChild?.className).toContain('my-stats');
+ });
+});
diff --git a/src/context/AdContext.test.tsx b/src/context/AdContext.test.tsx
new file mode 100644
index 0000000..0d7bb7a
--- /dev/null
+++ b/src/context/AdContext.test.tsx
@@ -0,0 +1,1446 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act, render } from '@testing-library/react';
+import { AdProvider, useAds } from './AdContext';
+import { createMockAd, createMockAdBreak } from '@/test/helpers';
+import type { AdConfig, AdBreak } from '@/types/ads';
+
+// Mock fetch
+const mockFetch = vi.fn(() => Promise.resolve(new Response()));
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
+ mockFetch.mockClear();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+const createWrapper = (config: Partial = {}) => {
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe('AdContext', () => {
+ describe('useAds hook', () => {
+ it('throws error when used outside provider', () => {
+ expect(() => {
+ renderHook(() => useAds());
+ }).toThrow('useAds must be used within an AdProvider');
+ });
+
+ it('returns context inside provider', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBeDefined();
+ expect(result.current.state).toBeDefined();
+ expect(result.current.controls).toBeDefined();
+ expect(result.current.config).toBeDefined();
+ });
+ });
+
+ describe('Initial state', () => {
+ it('is not playing ad initially', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ });
+
+ it('has no current ad initially', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.currentAd).toBeNull();
+ });
+
+ it('has no current ad break initially', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.currentAdBreak).toBeNull();
+ });
+
+ it('has zero ad progress', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.adProgress).toBe(0);
+ });
+
+ it('has zero ad duration', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.adDuration).toBe(0);
+ });
+
+ it('cannot skip initially', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.canSkip).toBe(false);
+ });
+
+ it('has zero skip countdown', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.skipCountdown).toBe(0);
+ });
+
+ it('has zero ads remaining', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.adsRemaining).toBe(0);
+ });
+ });
+
+ describe('Config defaults', () => {
+ it('defaults enabled to false', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.enabled).toBe(false);
+ });
+
+ it('defaults adBreaks to empty', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.adBreaks).toEqual([]);
+ });
+
+ it('defaults skipAllowed to true', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.skipAllowed).toBe(true);
+ });
+
+ it('defaults defaultSkipAfter to 5', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.defaultSkipAfter).toBe(5);
+ });
+
+ it('merges user config with defaults', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({
+ enabled: true,
+ defaultSkipAfter: 10,
+ }),
+ });
+
+ expect(result.current.config.enabled).toBe(true);
+ expect(result.current.config.defaultSkipAfter).toBe(10);
+ expect(result.current.config.skipAllowed).toBe(true);
+ });
+ });
+
+ describe('Controls', () => {
+ it('has all ad controls', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.controls.skipAd).toBeDefined();
+ expect(result.current.controls.clickThrough).toBeDefined();
+ expect(result.current.controls.startAdBreak).toBeDefined();
+ expect(result.current.controls.stopAds).toBeDefined();
+ });
+ });
+
+ describe('startAdBreak', () => {
+ it('starts playing the first ad in the break', () => {
+ const onAdStart = vi.fn();
+ const ad = createMockAd();
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAdStart }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(true);
+ expect(result.current.state.currentAd?.id).toBe(ad.id);
+ expect(result.current.state.currentAdBreak?.id).toBe(adBreak.id);
+ expect(result.current.state.adDuration).toBe(ad.duration);
+ expect(onAdStart).toHaveBeenCalledWith(ad, adBreak);
+ });
+
+ it('ignores empty ad breaks', () => {
+ const onAdStart = vi.fn();
+ const adBreak = createMockAdBreak({ ads: [] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAdStart }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ expect(onAdStart).not.toHaveBeenCalled();
+ });
+
+ it('sets ads remaining correctly', () => {
+ const ads = [
+ createMockAd({ id: 'ad-1' }),
+ createMockAd({ id: 'ad-2' }),
+ createMockAd({ id: 'ad-3' }),
+ ];
+ const adBreak = createMockAdBreak({ ads });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.adsRemaining).toBe(2);
+ });
+
+ it('resets ad progress to zero', () => {
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(createMockAdBreak());
+ });
+
+ expect(result.current.state.adProgress).toBe(0);
+ });
+
+ it('fires impression tracking', () => {
+ const ad = createMockAd({
+ trackingUrls: {
+ impression: 'https://example.com/impression',
+ start: 'https://example.com/start',
+ },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/impression', expect.objectContaining({
+ method: 'GET',
+ mode: 'no-cors',
+ }));
+ });
+
+ it('fires start tracking', () => {
+ const ad = createMockAd({
+ trackingUrls: {
+ start: 'https://example.com/start',
+ },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/start', expect.objectContaining({
+ method: 'GET',
+ }));
+ });
+ });
+
+ describe('Skip countdown', () => {
+ it('sets skipCountdown from ad skipAfterSeconds', () => {
+ const ad = createMockAd({ skipAfterSeconds: 5 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(5);
+ expect(result.current.state.canSkip).toBe(false);
+ });
+
+ it('decrements countdown every second', () => {
+ const ad = createMockAd({ skipAfterSeconds: 3 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(2);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(1);
+ });
+
+ it('enables skip when countdown reaches zero', () => {
+ const ad = createMockAd({ skipAfterSeconds: 2 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(result.current.state.canSkip).toBe(true);
+ expect(result.current.state.skipCountdown).toBe(0);
+ });
+
+ it('immediately allows skip when skipAfterSeconds is 0', () => {
+ const ad = createMockAd({ skipAfterSeconds: 0 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.canSkip).toBe(true);
+ expect(result.current.state.skipCountdown).toBe(0);
+ });
+
+ it('uses defaultSkipAfter from config when ad has no skipAfterSeconds', () => {
+ const ad = createMockAd({ skipAfterSeconds: undefined });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, defaultSkipAfter: 7 }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(7);
+ });
+
+ it('falls back to defaultSkipAfter when skipAfterSeconds is null', () => {
+ // In AdContext, null is treated as nullish and falls through to defaultSkipAfter
+ const ad = createMockAd({ skipAfterSeconds: null });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, defaultSkipAfter: 5 }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.canSkip).toBe(false);
+ expect(result.current.state.skipCountdown).toBe(5);
+
+ // After countdown, skip is allowed
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(result.current.state.canSkip).toBe(true);
+ });
+ });
+
+ describe('skipAd', () => {
+ it('skips the current ad when allowed', () => {
+ const onAdSkip = vi.fn();
+ const ad = createMockAd({ skipAfterSeconds: 0 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAdSkip }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAdSkip).toHaveBeenCalledWith(ad, adBreak);
+ });
+
+ it('does not skip when canSkip is false', () => {
+ const onAdSkip = vi.fn();
+ const ad = createMockAd({ skipAfterSeconds: 10 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAdSkip }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAdSkip).not.toHaveBeenCalled();
+ expect(result.current.state.isPlayingAd).toBe(true);
+ });
+
+ it('fires skip tracking URL', () => {
+ const ad = createMockAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { skip: 'https://example.com/skip' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ mockFetch.mockClear();
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/skip', expect.objectContaining({
+ method: 'GET',
+ }));
+ });
+
+ it('advances to next ad in break when skipping', () => {
+ const ad1 = createMockAd({ id: 'ad-1', skipAfterSeconds: 0 });
+ const ad2 = createMockAd({ id: 'ad-2', skipAfterSeconds: 0 });
+ const adBreak = createMockAdBreak({ ads: [ad1, ad2] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-1');
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-2');
+ expect(result.current.state.isPlayingAd).toBe(true);
+ });
+
+ it('ends ad break when skipping last ad', () => {
+ const onAllAdsComplete = vi.fn();
+ const ad = createMockAd({ skipAfterSeconds: 0 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAllAdsComplete }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ expect(result.current.state.currentAd).toBeNull();
+ expect(onAllAdsComplete).toHaveBeenCalledWith(adBreak);
+ });
+
+ it('does nothing when no ad is playing', () => {
+ const onAdSkip = vi.fn();
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAdSkip }),
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAdSkip).not.toHaveBeenCalled();
+ });
+
+ it('clears skip timer when skipping after countdown completes', () => {
+ const onAdSkip = vi.fn();
+ const ad = createMockAd({ skipAfterSeconds: 2 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAdSkip }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ // Wait for countdown to finish (timer clears itself but ref still holds the ID)
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(result.current.state.canSkip).toBe(true);
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAdSkip).toHaveBeenCalledWith(ad, adBreak);
+ });
+ });
+
+ describe('clickThrough', () => {
+ it('opens click-through URL in new tab', () => {
+ const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+ const ad = createMockAd({ clickThroughUrl: 'https://advertiser.com' });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(windowOpenSpy).toHaveBeenCalledWith('https://advertiser.com', '_blank');
+ windowOpenSpy.mockRestore();
+ });
+
+ it('fires click tracking URL', () => {
+ const ad = createMockAd({
+ trackingUrls: { click: 'https://example.com/click' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ mockFetch.mockClear();
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/click', expect.objectContaining({
+ method: 'GET',
+ }));
+ });
+
+ it('calls onAdClick callback', () => {
+ const onAdClick = vi.fn();
+ const ad = createMockAd();
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAdClick }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(onAdClick).toHaveBeenCalledWith(ad, adBreak);
+ });
+
+ it('does nothing when no ad is playing', () => {
+ const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(windowOpenSpy).not.toHaveBeenCalled();
+ windowOpenSpy.mockRestore();
+ });
+
+ it('handles ad without clickThroughUrl', () => {
+ const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+ const ad = createMockAd({ clickThroughUrl: undefined });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(windowOpenSpy).not.toHaveBeenCalled();
+ windowOpenSpy.mockRestore();
+ });
+ });
+
+ describe('stopAds', () => {
+ it('resets all ad state', () => {
+ const ad = createMockAd();
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(true);
+
+ act(() => {
+ result.current.controls.stopAds();
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ expect(result.current.state.currentAd).toBeNull();
+ expect(result.current.state.currentAdBreak).toBeNull();
+ expect(result.current.state.adProgress).toBe(0);
+ expect(result.current.state.adDuration).toBe(0);
+ expect(result.current.state.canSkip).toBe(false);
+ expect(result.current.state.skipCountdown).toBe(0);
+ expect(result.current.state.adsRemaining).toBe(0);
+ });
+
+ it('clears skip countdown timer', () => {
+ const ad = createMockAd({ skipAfterSeconds: 10 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.stopAds();
+ });
+
+ // Advance time - countdown should not continue
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(0);
+ });
+ });
+
+ describe('Multiple ads in break', () => {
+ it('starts with first ad', () => {
+ const ads = [
+ createMockAd({ id: 'ad-1', duration: 10 }),
+ createMockAd({ id: 'ad-2', duration: 20 }),
+ createMockAd({ id: 'ad-3', duration: 15 }),
+ ];
+ const adBreak = createMockAdBreak({ ads });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-1');
+ expect(result.current.state.adsRemaining).toBe(2);
+ });
+
+ it('tracks remaining ads count after skip', () => {
+ const ads = [
+ createMockAd({ id: 'ad-1', skipAfterSeconds: 0 }),
+ createMockAd({ id: 'ad-2', skipAfterSeconds: 0 }),
+ createMockAd({ id: 'ad-3', skipAfterSeconds: 0 }),
+ ];
+ const adBreak = createMockAdBreak({ ads });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.adsRemaining).toBe(2);
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-2');
+ expect(result.current.state.adsRemaining).toBe(1);
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-3');
+ expect(result.current.state.adsRemaining).toBe(0);
+ });
+ });
+
+ describe('Ad lifecycle callbacks', () => {
+ it('calls onAdStart when ad begins', () => {
+ const onAdStart = vi.fn();
+ const ad = createMockAd();
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAdStart }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(onAdStart).toHaveBeenCalledWith(ad, adBreak);
+ });
+
+ it('calls onAllAdsComplete when all ads in break finish', () => {
+ const onAllAdsComplete = vi.fn();
+ const ad = createMockAd({ skipAfterSeconds: 0 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true, onAllAdsComplete }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAllAdsComplete).toHaveBeenCalledWith(adBreak);
+ });
+ });
+
+ describe('Tracking URLs', () => {
+ it('does not call fetch when no tracking URLs are configured', () => {
+ const ad = createMockAd({ trackingUrls: undefined });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ mockFetch.mockClear();
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it('handles fetch errors gracefully', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ const ad = createMockAd({
+ trackingUrls: { impression: 'https://example.com/impression' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ // Should not throw
+ expect(result.current.state.isPlayingAd).toBe(true);
+ consoleSpy.mockRestore();
+ });
+ });
+
+ // =============================================================
+ // Audio element event handler tests
+ // =============================================================
+
+ describe('Audio element event handlers', () => {
+ /**
+ * Helper: renders the AdProvider, starts an ad break, and returns
+ * the internal element plus the hook result so we can
+ * dispatch native events and assert state/callback changes.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ function setupWithAudio(config: Partial, _adBreak: AdBreak) {
+ let audioElement: HTMLAudioElement | null = null;
+
+ // Consumer component that captures the audio element from the DOM
+ function AudioCapture() {
+ const ctx = useAds();
+ void ctx;
+ return null;
+ }
+
+ const { container, rerender: _rerender } = render(
+
+
+
+ );
+
+ // The AdProvider renders an element internally
+ audioElement = container.querySelector('audio');
+
+ // Override play so jsdom does not throw
+ if (audioElement) {
+ audioElement.play = vi.fn(() => Promise.resolve());
+ }
+
+ return { audioElement: audioElement!, container };
+ }
+ void setupWithAudio;
+
+ /**
+ * Helper that renders the provider, starts an ad break via the hook,
+ * and returns both the hook result and the audio element.
+ */
+ function renderAndStartAd(config: Partial, adBreak: AdBreak) {
+ let hookResult: ReturnType | null = null;
+
+ function Consumer() {
+ hookResult = useAds();
+ return null;
+ }
+
+ const { container } = render(
+
+
+
+ );
+
+ const audioElement = container.querySelector('audio')!;
+ audioElement.play = vi.fn(() => Promise.resolve());
+
+ // Start the ad break
+ act(() => {
+ hookResult!.controls.startAdBreak(adBreak);
+ });
+
+ return { hookResult: hookResult!, audioElement, container };
+ }
+
+ describe('timeupdate handler', () => {
+ it('fires onAdProgress callback with progress info', () => {
+ const onAdProgress = vi.fn();
+ const ad = createMockAd({
+ duration: 30,
+ skipAfterSeconds: 0,
+ trackingUrls: {},
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { hookResult: _hookResult, audioElement } = renderAndStartAd(
+ { enabled: true, onAdProgress },
+ adBreak
+ );
+
+ // Simulate time update at 15s of 30s (50%)
+ Object.defineProperty(audioElement, 'currentTime', { writable: true, value: 15 });
+ Object.defineProperty(audioElement, 'duration', { writable: true, value: 30 });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(onAdProgress).toHaveBeenCalledWith(
+ expect.objectContaining({
+ currentTime: 15,
+ duration: 30,
+ percentage: 50,
+ remainingTime: 15,
+ }),
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ });
+
+ it('fires firstQuartile callback at 25%', () => {
+ const onFirstQuartile = vi.fn();
+ const ad = createMockAd({
+ duration: 40,
+ skipAfterSeconds: 0,
+ trackingUrls: { firstQuartile: 'https://example.com/q1' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true, onFirstQuartile },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ Object.defineProperty(audioElement, 'currentTime', { writable: true, value: 10 });
+ Object.defineProperty(audioElement, 'duration', { writable: true, value: 40 });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(onFirstQuartile).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/q1',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('fires midpoint callback at 50%', () => {
+ const onMidpoint = vi.fn();
+ const ad = createMockAd({
+ duration: 40,
+ skipAfterSeconds: 0,
+ trackingUrls: { midpoint: 'https://example.com/mid' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true, onMidpoint },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ Object.defineProperty(audioElement, 'currentTime', { writable: true, value: 20 });
+ Object.defineProperty(audioElement, 'duration', { writable: true, value: 40 });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(onMidpoint).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/mid',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('fires thirdQuartile callback at 75%', () => {
+ const onThirdQuartile = vi.fn();
+ const ad = createMockAd({
+ duration: 40,
+ skipAfterSeconds: 0,
+ trackingUrls: { thirdQuartile: 'https://example.com/q3' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true, onThirdQuartile },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ Object.defineProperty(audioElement, 'currentTime', { writable: true, value: 30 });
+ Object.defineProperty(audioElement, 'duration', { writable: true, value: 40 });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(onThirdQuartile).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/q3',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('fires each quartile only once even with multiple timeupdates', () => {
+ const onFirstQuartile = vi.fn();
+ const ad = createMockAd({
+ duration: 40,
+ skipAfterSeconds: 0,
+ trackingUrls: { firstQuartile: 'https://example.com/q1' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true, onFirstQuartile },
+ adBreak
+ );
+
+ Object.defineProperty(audioElement, 'currentTime', { writable: true, value: 10 });
+ Object.defineProperty(audioElement, 'duration', { writable: true, value: 40 });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ // Should only fire once
+ expect(onFirstQuartile).toHaveBeenCalledTimes(1);
+ });
+
+ it('tracks custom progress offsets', () => {
+ const ad = createMockAd({
+ duration: 30,
+ skipAfterSeconds: 0,
+ trackingUrls: {
+ progress: [
+ { offset: 5, url: 'https://example.com/progress-5' },
+ { offset: 10, url: 'https://example.com/progress-10' },
+ ],
+ },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ // First update at 5s - should fire first progress offset
+ Object.defineProperty(audioElement, 'currentTime', { writable: true, value: 5 });
+ Object.defineProperty(audioElement, 'duration', { writable: true, value: 30 });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/progress-5',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+
+ mockFetch.mockClear();
+
+ // Second update at 10s - should fire second progress offset
+ Object.defineProperty(audioElement, 'currentTime', { writable: true, value: 10 });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/progress-10',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('does not re-fire already tracked progress offsets', () => {
+ const ad = createMockAd({
+ duration: 30,
+ skipAfterSeconds: 0,
+ trackingUrls: {
+ progress: [
+ { offset: 5, url: 'https://example.com/progress-5' },
+ ],
+ },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ Object.defineProperty(audioElement, 'currentTime', { writable: true, value: 5 });
+ Object.defineProperty(audioElement, 'duration', { writable: true, value: 30 });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ // Fire again at same offset - should not fetch again
+ mockFetch.mockClear();
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(mockFetch).not.toHaveBeenCalledWith(
+ 'https://example.com/progress-5',
+ expect.anything()
+ );
+ });
+ });
+
+ describe('ended handler', () => {
+ it('clears skip timer when ad ends before countdown finishes', () => {
+ const onAdComplete = vi.fn();
+ const ad = createMockAd({
+ skipAfterSeconds: 10, // long countdown, ad will end before it finishes
+ trackingUrls: { complete: 'https://example.com/complete' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ let hookRef: ReturnType | null = null;
+ function Consumer() {
+ hookRef = useAds();
+ return null;
+ }
+
+ const { container } = render(
+
+
+
+ );
+
+ const audioElement = container.querySelector('audio')!;
+ audioElement.play = vi.fn(() => Promise.resolve());
+
+ act(() => {
+ hookRef!.controls.startAdBreak(adBreak);
+ });
+
+ // Skip timer is running (countdown from 10)
+ act(() => {
+ vi.advanceTimersByTime(2000); // countdown at 8
+ });
+ expect(hookRef!.state.skipCountdown).toBe(8);
+
+ // Ad ends while skip timer is still running
+ act(() => {
+ audioElement.dispatchEvent(new Event('ended'));
+ });
+
+ expect(onAdComplete).toHaveBeenCalled();
+ expect(hookRef!.state.isPlayingAd).toBe(false);
+
+ // Skip timer should not continue counting after ended
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(hookRef!.state.skipCountdown).toBe(0);
+ });
+
+ it('fires complete tracking and onAdComplete callback', () => {
+ const onAdComplete = vi.fn();
+ const ad = createMockAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { complete: 'https://example.com/complete' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true, onAdComplete },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('ended'));
+ });
+
+ expect(onAdComplete).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/complete',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('advances to next ad in break when current ad ends', () => {
+ const onAdStart = vi.fn();
+ const ad1 = createMockAd({ id: 'ad-1', skipAfterSeconds: 0 });
+ const ad2 = createMockAd({ id: 'ad-2', skipAfterSeconds: 0 });
+ const adBreak = createMockAdBreak({ ads: [ad1, ad2] });
+
+ let hookRef: ReturnType | null = null;
+ function Consumer() {
+ hookRef = useAds();
+ return null;
+ }
+
+ const { container } = render(
+
+
+
+ );
+
+ const audioElement = container.querySelector('audio')!;
+ audioElement.play = vi.fn(() => Promise.resolve());
+
+ act(() => {
+ hookRef!.controls.startAdBreak(adBreak);
+ });
+
+ expect(hookRef!.state.currentAd?.id).toBe('ad-1');
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('ended'));
+ });
+
+ expect(hookRef!.state.currentAd?.id).toBe('ad-2');
+ expect(hookRef!.state.isPlayingAd).toBe(true);
+ });
+
+ it('ends ad break and calls onAllAdsComplete when last ad ends', () => {
+ const onAllAdsComplete = vi.fn();
+ const ad = createMockAd({ skipAfterSeconds: 0 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ let hookRef: ReturnType | null = null;
+ function Consumer() {
+ hookRef = useAds();
+ return null;
+ }
+
+ const { container } = render(
+
+
+
+ );
+
+ const audioElement = container.querySelector('audio')!;
+ audioElement.play = vi.fn(() => Promise.resolve());
+
+ act(() => {
+ hookRef!.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('ended'));
+ });
+
+ expect(hookRef!.state.isPlayingAd).toBe(false);
+ expect(hookRef!.state.currentAd).toBeNull();
+ expect(onAllAdsComplete).toHaveBeenCalledWith(adBreak);
+ });
+ });
+
+ describe('error handler', () => {
+ it('fires error tracking and onAdError callback', () => {
+ const onAdError = vi.fn();
+ const ad = createMockAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { error: 'https://example.com/error' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true, onAdError },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('error'));
+ });
+
+ expect(onAdError).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/error',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('resets state after error', () => {
+ const ad = createMockAd({ skipAfterSeconds: 0 });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ let hookRef: ReturnType | null = null;
+ function Consumer() {
+ hookRef = useAds();
+ return null;
+ }
+
+ const { container } = render(
+
+
+
+ );
+
+ const audioElement = container.querySelector('audio')!;
+ audioElement.play = vi.fn(() => Promise.resolve());
+
+ act(() => {
+ hookRef!.controls.startAdBreak(adBreak);
+ });
+
+ expect(hookRef!.state.isPlayingAd).toBe(true);
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('error'));
+ });
+
+ expect(hookRef!.state.isPlayingAd).toBe(false);
+ expect(hookRef!.state.currentAd).toBeNull();
+ });
+ });
+
+ describe('pause handler', () => {
+ it('fires pause tracking and onAdPause callback', () => {
+ const onAdPause = vi.fn();
+ const ad = createMockAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { pause: 'https://example.com/pause' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true, onAdPause },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('pause'));
+ });
+
+ expect(onAdPause).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/pause',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+ });
+
+ describe('resume (play) handler', () => {
+ it('fires resume tracking and onAdResume callback', () => {
+ const onAdResume = vi.fn();
+ const ad = createMockAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { resume: 'https://example.com/resume' },
+ });
+ const adBreak = createMockAdBreak({ ads: [ad] });
+
+ const { audioElement } = renderAndStartAd(
+ { enabled: true, onAdResume },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ act(() => {
+ audioElement.dispatchEvent(new Event('play'));
+ });
+
+ expect(onAdResume).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/resume',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+ });
+ });
+});
diff --git a/src/context/FairuProvider.tsx b/src/context/FairuProvider.tsx
new file mode 100644
index 0000000..a98594e
--- /dev/null
+++ b/src/context/FairuProvider.tsx
@@ -0,0 +1,79 @@
+import React, { useMemo } from 'react';
+import { PlayerProvider } from './PlayerContext';
+import { TrackingProvider } from './TrackingContext';
+import { AdProvider } from './AdContext';
+import type { PlayerConfig, Track } from '@/types/player';
+import type { TrackingConfig } from '@/types/tracking';
+import type { AdConfig } from '@/types/ads';
+import type { PartialLabels } from '@/types/labels';
+
+export interface FairuProviderProps {
+ children: React.ReactNode;
+
+ /** Player configuration */
+ config?: PlayerConfig;
+
+ /** Tracking/analytics configuration. Disabled by default (GDPR). */
+ tracking?: Partial;
+
+ /** Ad configuration */
+ ads?: Partial;
+
+ /** Label overrides for i18n */
+ labels?: PartialLabels;
+
+ // Event callbacks (forwarded to PlayerProvider)
+ onPlay?: () => void;
+ onPause?: () => void;
+ onEnded?: () => void;
+ onTimeUpdate?: (time: number) => void;
+ onTrackChange?: (track: Track, index: number) => void;
+ onError?: (error: Error) => void;
+}
+
+export function FairuProvider({
+ children,
+ config,
+ tracking,
+ ads,
+ labels,
+ onPlay,
+ onPause,
+ onEnded,
+ onTimeUpdate,
+ onTrackChange,
+ onError,
+}: FairuProviderProps) {
+ // Merge labels into player config
+ const playerConfig = useMemo(() => ({
+ ...config,
+ labels: { ...config?.labels, ...labels },
+ }), [config, labels]);
+
+ // Build the provider tree - innermost is PlayerProvider
+ let content = (
+
+ {children}
+
+ );
+
+ // Optionally wrap with AdProvider
+ if (ads) {
+ content = {content} ;
+ }
+
+ // Optionally wrap with TrackingProvider
+ if (tracking) {
+ content = {content} ;
+ }
+
+ return content;
+}
diff --git a/src/context/LabelsContext.test.tsx b/src/context/LabelsContext.test.tsx
new file mode 100644
index 0000000..663e782
--- /dev/null
+++ b/src/context/LabelsContext.test.tsx
@@ -0,0 +1,211 @@
+import { describe, it, expect } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { LabelsProvider, useLabels } from './LabelsContext';
+import { defaultLabels } from '@/types/labels';
+
+const createWrapper = (labels?: Record) => {
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe('LabelsContext', () => {
+ describe('useLabels hook', () => {
+ it('returns default labels when no provider wraps it', () => {
+ const { result } = renderHook(() => useLabels());
+ expect(result.current).toEqual(defaultLabels);
+ });
+
+ it('returns default labels when provider has no custom labels', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current).toEqual(defaultLabels);
+ });
+
+ it('returns default play label', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.play).toBe('Play');
+ });
+
+ it('returns default pause label', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.pause).toBe('Pause');
+ });
+
+ it('returns default mute label', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.mute).toBe('Mute');
+ });
+
+ it('returns default unmute label', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.unmute).toBe('Unmute');
+ });
+
+ it('returns default volume label', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.volume).toBe('Volume');
+ });
+
+ it('returns default skipForward label with template', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.skipForward).toBe('Skip forward {seconds} seconds');
+ });
+
+ it('returns default skipBackward label with template', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.skipBackward).toBe('Skip backward {seconds} seconds');
+ });
+
+ it('returns default ad-related labels', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.ad).toBe('AD');
+ expect(result.current.skipAd).toBe('Skip Ad');
+ expect(result.current.skipIn).toBe('Skip in {seconds}s');
+ expect(result.current.learnMore).toBe('Learn more about this ad');
+ });
+
+ it('returns default fullscreen labels', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.enterFullscreen).toBe('Enter fullscreen');
+ expect(result.current.exitFullscreen).toBe('Exit fullscreen');
+ });
+
+ it('returns default picture-in-picture labels', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.enterPictureInPicture).toBe('Enter picture-in-picture');
+ expect(result.current.exitPictureInPicture).toBe('Exit picture-in-picture');
+ });
+
+ it('returns default cast labels', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.startCast).toBe('Cast');
+ expect(result.current.stopCast).toBe('Stop casting');
+ });
+ });
+
+ describe('Custom label overrides', () => {
+ it('overrides a single label', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper({ play: 'Abspielen' }),
+ });
+ expect(result.current.play).toBe('Abspielen');
+ });
+
+ it('overrides multiple labels', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper({
+ play: 'Abspielen',
+ pause: 'Anhalten',
+ mute: 'Stumm',
+ }),
+ });
+ expect(result.current.play).toBe('Abspielen');
+ expect(result.current.pause).toBe('Anhalten');
+ expect(result.current.mute).toBe('Stumm');
+ });
+
+ it('preserves non-overridden defaults when overriding some labels', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper({ play: 'Reproducir' }),
+ });
+ expect(result.current.play).toBe('Reproducir');
+ expect(result.current.pause).toBe('Pause');
+ expect(result.current.mute).toBe('Mute');
+ expect(result.current.volume).toBe('Volume');
+ expect(result.current.enterFullscreen).toBe('Enter fullscreen');
+ });
+
+ it('overrides ad labels while keeping other defaults', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper({
+ skipAd: 'Werbung uberspringen',
+ learnMore: 'Mehr erfahren',
+ }),
+ });
+ expect(result.current.skipAd).toBe('Werbung uberspringen');
+ expect(result.current.learnMore).toBe('Mehr erfahren');
+ expect(result.current.play).toBe('Play');
+ });
+
+ it('overrides template labels', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper({
+ skipForward: '{seconds}秒スキップ',
+ skipIn: '{seconds}秒後にスキップ',
+ }),
+ });
+ expect(result.current.skipForward).toBe('{seconds}秒スキップ');
+ expect(result.current.skipIn).toBe('{seconds}秒後にスキップ');
+ });
+
+ it('handles empty string override', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper({ timeSeparator: '' }),
+ });
+ expect(result.current.timeSeparator).toBe('');
+ });
+ });
+
+ describe('Provider rendering', () => {
+ it('renders children', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current).toBeDefined();
+ });
+
+ it('provides labels to nested components', () => {
+ const InnerWrapper = ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+ );
+
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: InnerWrapper,
+ });
+
+ // Inner provider overrides only pause, other defaults apply
+ expect(result.current.pause).toBe('InnerPause');
+ // Inner provider does not see outer's play override (it merges with defaults)
+ expect(result.current.play).toBe('Play');
+ });
+
+ it('returns all expected label keys', () => {
+ const { result } = renderHook(() => useLabels(), {
+ wrapper: createWrapper(),
+ });
+
+ const expectedKeys = Object.keys(defaultLabels);
+ const actualKeys = Object.keys(result.current);
+ expect(actualKeys).toEqual(expect.arrayContaining(expectedKeys));
+ expect(actualKeys.length).toBe(expectedKeys.length);
+ });
+ });
+});
diff --git a/src/context/PlayerContext.test.tsx b/src/context/PlayerContext.test.tsx
new file mode 100644
index 0000000..8d26ddf
--- /dev/null
+++ b/src/context/PlayerContext.test.tsx
@@ -0,0 +1,511 @@
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
+import { PlayerProvider } from './PlayerContext';
+import { usePlayer } from '@/hooks/usePlayer';
+import {
+ createMockTrack,
+ createMockPlaylist,
+ createMockPlayerConfig,
+} from '@/test/helpers';
+
+const createWrapper = (config = {}) => {
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe('PlayerContext', () => {
+ describe('Provider rendering', () => {
+ it('renders children', () => {
+ render(
+
+ Hello
+
+ );
+ expect(screen.getByTestId('child')).toBeInTheDocument();
+ });
+
+ it('renders multiple children', () => {
+ render(
+
+ First
+ Second
+
+ );
+ expect(screen.getByTestId('child-1')).toBeInTheDocument();
+ expect(screen.getByTestId('child-2')).toBeInTheDocument();
+ });
+ });
+
+ describe('usePlayer hook', () => {
+ it('throws error when used outside provider', () => {
+ expect(() => {
+ renderHook(() => usePlayer());
+ }).toThrow('usePlayer must be used within a PlayerProvider');
+ });
+
+ it('returns context inside provider', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBeDefined();
+ expect(result.current.state).toBeDefined();
+ expect(result.current.controls).toBeDefined();
+ expect(result.current.playlistState).toBeDefined();
+ expect(result.current.playlistControls).toBeDefined();
+ expect(result.current.config).toBeDefined();
+ expect(result.current.audioRef).toBeDefined();
+ });
+ });
+
+ describe('Initial state', () => {
+ it('has correct default playing state', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isPlaying).toBe(false);
+ expect(result.current.state.isPaused).toBe(true);
+ expect(result.current.state.isEnded).toBe(false);
+ });
+
+ it('has correct default time state', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.currentTime).toBe(0);
+ expect(result.current.state.duration).toBe(0);
+ expect(result.current.state.buffered).toBe(0);
+ });
+
+ it('has correct default volume state', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.volume).toBe(1);
+ expect(result.current.state.isMuted).toBe(false);
+ });
+
+ it('has correct default playback rate', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.playbackRate).toBe(1);
+ });
+
+ it('has no error initially', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.error).toBeNull();
+ });
+ });
+
+ describe('Config defaults', () => {
+ it('has default features enabled', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.features?.chapters).toBe(true);
+ expect(result.current.config.features?.volumeControl).toBe(true);
+ expect(result.current.config.features?.playbackSpeed).toBe(true);
+ expect(result.current.config.features?.skipButtons).toBe(true);
+ expect(result.current.config.features?.progressBar).toBe(true);
+ expect(result.current.config.features?.timeDisplay).toBe(true);
+ expect(result.current.config.features?.playlistView).toBe(true);
+ });
+
+ it('has correct default skip seconds', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.skipForwardSeconds).toBe(30);
+ expect(result.current.config.skipBackwardSeconds).toBe(10);
+ });
+
+ it('has default playback speeds', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.playbackSpeeds).toEqual([0.5, 0.75, 1, 1.25, 1.5, 2]);
+ });
+
+ it('defaults autoPlay to false', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.autoPlay).toBe(false);
+ });
+
+ it('defaults autoPlayNext to true', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.autoPlayNext).toBe(true);
+ });
+
+ it('defaults shuffle to false', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.shuffle).toBe(false);
+ });
+
+ it('defaults repeat to none', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.repeat).toBe('none');
+ });
+ });
+
+ describe('Config overrides', () => {
+ it('overrides volume', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ volume: 0.5 }),
+ });
+
+ expect(result.current.config.volume).toBe(0.5);
+ });
+
+ it('overrides muted', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ muted: true }),
+ });
+
+ expect(result.current.config.muted).toBe(true);
+ });
+
+ it('overrides skip seconds', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({
+ skipForwardSeconds: 15,
+ skipBackwardSeconds: 5,
+ }),
+ });
+
+ expect(result.current.config.skipForwardSeconds).toBe(15);
+ expect(result.current.config.skipBackwardSeconds).toBe(5);
+ });
+
+ it('overrides specific features while keeping defaults', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({
+ features: { chapters: false },
+ }),
+ });
+
+ expect(result.current.config.features?.chapters).toBe(false);
+ expect(result.current.config.features?.volumeControl).toBe(true);
+ expect(result.current.config.features?.progressBar).toBe(true);
+ });
+
+ it('overrides repeat mode', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ repeat: 'all' }),
+ });
+
+ expect(result.current.config.repeat).toBe('all');
+ });
+
+ it('overrides shuffle', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ shuffle: true }),
+ });
+
+ expect(result.current.config.shuffle).toBe(true);
+ });
+
+ it('accepts custom playback speeds', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playbackSpeeds: [0.25, 0.5, 1, 2, 3] }),
+ });
+
+ expect(result.current.config.playbackSpeeds).toEqual([0.25, 0.5, 1, 2, 3]);
+ });
+
+ it('accepts mock player config', () => {
+ const config = createMockPlayerConfig({ volume: 0.75, muted: true });
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(config),
+ });
+
+ expect(result.current.config.volume).toBe(0.75);
+ expect(result.current.config.muted).toBe(true);
+ });
+ });
+
+ describe('Track and playlist', () => {
+ it('sets current track from config track', () => {
+ const track = createMockTrack();
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ track }),
+ });
+
+ expect(result.current.playlistState.currentTrack).toEqual(track);
+ });
+
+ it('sets playlist tracks from config playlist', () => {
+ const playlist = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist }),
+ });
+
+ expect(result.current.playlistState.tracks).toHaveLength(3);
+ expect(result.current.playlistState.currentIndex).toBe(0);
+ expect(result.current.playlistState.currentTrack).toEqual(playlist[0]);
+ });
+
+ it('has empty tracks when no track or playlist provided', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.playlistState.tracks).toHaveLength(0);
+ expect(result.current.playlistState.currentTrack).toBeNull();
+ });
+
+ it('prefers playlist over single track', () => {
+ const track = createMockTrack({ id: 'single' });
+ const playlist = createMockPlaylist(2);
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ track, playlist }),
+ });
+
+ expect(result.current.playlistState.tracks).toHaveLength(2);
+ expect(result.current.playlistState.currentTrack?.id).toBe(playlist[0].id);
+ });
+
+ it('wraps single track in array for playlist state', () => {
+ const track = createMockTrack();
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ track }),
+ });
+
+ expect(result.current.playlistState.tracks).toHaveLength(1);
+ expect(result.current.playlistState.tracks[0]).toEqual(track);
+ });
+ });
+
+ describe('Controls', () => {
+ it('has all player controls', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.controls.play).toBeDefined();
+ expect(result.current.controls.pause).toBeDefined();
+ expect(result.current.controls.toggle).toBeDefined();
+ expect(result.current.controls.stop).toBeDefined();
+ expect(result.current.controls.seek).toBeDefined();
+ expect(result.current.controls.seekTo).toBeDefined();
+ expect(result.current.controls.skipForward).toBeDefined();
+ expect(result.current.controls.skipBackward).toBeDefined();
+ expect(result.current.controls.setVolume).toBeDefined();
+ expect(result.current.controls.toggleMute).toBeDefined();
+ expect(result.current.controls.setPlaybackRate).toBeDefined();
+ });
+
+ it('has all playlist controls', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.playlistControls.next).toBeDefined();
+ expect(result.current.playlistControls.previous).toBeDefined();
+ expect(result.current.playlistControls.goToTrack).toBeDefined();
+ expect(result.current.playlistControls.setRepeat).toBeDefined();
+ expect(result.current.playlistControls.toggleShuffle).toBeDefined();
+ expect(result.current.playlistControls.addToQueue).toBeDefined();
+ expect(result.current.playlistControls.removeFromQueue).toBeDefined();
+ expect(result.current.playlistControls.clearQueue).toBeDefined();
+ });
+ });
+
+ describe('Playlist controls integration', () => {
+ it('goes to next track', () => {
+ const playlist = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist }),
+ });
+
+ expect(result.current.playlistState.currentIndex).toBe(0);
+
+ act(() => {
+ result.current.playlistControls.next();
+ });
+
+ expect(result.current.playlistState.currentIndex).toBe(1);
+ expect(result.current.playlistState.currentTrack?.id).toBe(playlist[1].id);
+ });
+
+ it('goes to previous track', () => {
+ const playlist = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist }),
+ });
+
+ act(() => {
+ result.current.playlistControls.next();
+ });
+
+ act(() => {
+ result.current.playlistControls.previous();
+ });
+
+ expect(result.current.playlistState.currentIndex).toBe(0);
+ });
+
+ it('goes to specific track by index', () => {
+ const playlist = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist }),
+ });
+
+ act(() => {
+ result.current.playlistControls.goToTrack(2);
+ });
+
+ expect(result.current.playlistState.currentIndex).toBe(2);
+ expect(result.current.playlistState.currentTrack?.id).toBe(playlist[2].id);
+ });
+
+ it('toggles shuffle', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist: createMockPlaylist() }),
+ });
+
+ expect(result.current.playlistState.shuffle).toBe(false);
+
+ act(() => {
+ result.current.playlistControls.toggleShuffle();
+ });
+
+ expect(result.current.playlistState.shuffle).toBe(true);
+ });
+
+ it('sets repeat mode', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist: createMockPlaylist() }),
+ });
+
+ expect(result.current.playlistState.repeat).toBe('none');
+
+ act(() => {
+ result.current.playlistControls.setRepeat('all');
+ });
+
+ expect(result.current.playlistState.repeat).toBe('all');
+ });
+
+ it('adds track to queue', () => {
+ const track = createMockTrack({ id: 'queued-track' });
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist: createMockPlaylist() }),
+ });
+
+ expect(result.current.playlistState.queue).toHaveLength(0);
+
+ act(() => {
+ result.current.playlistControls.addToQueue(track);
+ });
+
+ expect(result.current.playlistState.queue).toHaveLength(1);
+ expect(result.current.playlistState.queue[0].id).toBe('queued-track');
+ });
+
+ it('removes track from queue', () => {
+ const track = createMockTrack({ id: 'queued-track' });
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist: createMockPlaylist() }),
+ });
+
+ act(() => {
+ result.current.playlistControls.addToQueue(track);
+ });
+
+ act(() => {
+ result.current.playlistControls.removeFromQueue(0);
+ });
+
+ expect(result.current.playlistState.queue).toHaveLength(0);
+ });
+
+ it('clears queue', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist: createMockPlaylist() }),
+ });
+
+ act(() => {
+ result.current.playlistControls.addToQueue(createMockTrack({ id: 'q1' }));
+ result.current.playlistControls.addToQueue(createMockTrack({ id: 'q2' }));
+ });
+
+ expect(result.current.playlistState.queue).toHaveLength(2);
+
+ act(() => {
+ result.current.playlistControls.clearQueue();
+ });
+
+ expect(result.current.playlistState.queue).toHaveLength(0);
+ });
+ });
+
+ describe('Callbacks', () => {
+ it('calls onTrackChange when track changes', () => {
+ const onTrackChange = vi.fn();
+ const playlist = createMockPlaylist(3);
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => usePlayer(), { wrapper });
+
+ act(() => {
+ result.current.playlistControls.next();
+ });
+
+ expect(onTrackChange).toHaveBeenCalledWith(playlist[1], 1);
+ });
+ });
+
+ describe('Audio ref', () => {
+ it('provides audioRef', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ track: createMockTrack() }),
+ });
+
+ expect(result.current.audioRef).toBeDefined();
+ });
+ });
+
+ describe('Labels integration', () => {
+ it('passes labels from config to LabelsProvider', () => {
+ // PlayerProvider wraps children in LabelsProvider with config.labels
+ // We can verify this by checking the provider renders without error
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({
+ labels: { play: 'Abspielen' },
+ }),
+ });
+
+ expect(result.current).toBeDefined();
+ });
+ });
+});
diff --git a/src/context/TrackingContext.test.tsx b/src/context/TrackingContext.test.tsx
new file mode 100644
index 0000000..c58f702
--- /dev/null
+++ b/src/context/TrackingContext.test.tsx
@@ -0,0 +1,703 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { TrackingProvider, useTracking } from './TrackingContext';
+import { createMockTrackingConfig } from '@/test/helpers';
+import type { TrackingConfig, TrackingEvent } from '@/types/tracking';
+
+// Mock fetch
+const mockFetch = vi.fn(() => Promise.resolve(new Response()));
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+const createWrapper = (config: Partial = {}) => {
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+const createEvent = (overrides: Partial = {}): TrackingEvent => ({
+ type: 'play',
+ timestamp: Date.now(),
+ data: {
+ currentTime: 10,
+ duration: 300,
+ },
+ ...overrides,
+});
+
+describe('TrackingContext', () => {
+ describe('useTracking hook', () => {
+ it('throws error when used outside provider', () => {
+ expect(() => {
+ renderHook(() => useTracking());
+ }).toThrow('useTracking must be used within a TrackingProvider');
+ });
+
+ it('returns context when used inside provider', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBeDefined();
+ expect(result.current.config).toBeDefined();
+ expect(result.current.track).toBeDefined();
+ expect(result.current.setEnabled).toBeDefined();
+ expect(result.current.setSessionId).toBeDefined();
+ expect(result.current.flush).toBeDefined();
+ });
+ });
+
+ describe('Default state', () => {
+ it('defaults to disabled', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.enabled).toBe(false);
+ });
+
+ it('has default event types enabled', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.events?.play).toBe(true);
+ expect(result.current.config.events?.pause).toBe(true);
+ expect(result.current.config.events?.seek).toBe(true);
+ expect(result.current.config.events?.complete).toBe(true);
+ expect(result.current.config.events?.progress).toBe(true);
+ expect(result.current.config.events?.error).toBe(true);
+ });
+
+ it('has default progress intervals', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.progressIntervals).toEqual([25, 50, 75, 100]);
+ });
+
+ it('defaults batchEvents to false', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.batchEvents).toBe(false);
+ });
+ });
+
+ describe('setEnabled', () => {
+ it('enables tracking', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.enabled).toBe(false);
+
+ act(() => {
+ result.current.setEnabled(true);
+ });
+
+ expect(result.current.config.enabled).toBe(true);
+ });
+
+ it('disables tracking', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ expect(result.current.config.enabled).toBe(true);
+
+ act(() => {
+ result.current.setEnabled(false);
+ });
+
+ expect(result.current.config.enabled).toBe(false);
+ });
+
+ it('does not track events when disabled', () => {
+ const onTrack = vi.fn();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({ enabled: false, onTrack }),
+ });
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ expect(onTrack).not.toHaveBeenCalled();
+ });
+
+ it('tracks events after being enabled', () => {
+ const onTrack = vi.fn();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({ onTrack }),
+ });
+
+ act(() => {
+ result.current.setEnabled(true);
+ });
+
+ // Enabling creates a new TrackingService which sends session_start via onTrack
+ const callsAfterEnable = onTrack.mock.calls.length;
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ expect(onTrack).toHaveBeenCalledTimes(callsAfterEnable + 1);
+ });
+ });
+
+ describe('track()', () => {
+ it('calls onTrack callback when enabled', () => {
+ const onTrack = vi.fn();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({ enabled: true, onTrack }),
+ });
+
+ const event = createEvent();
+ act(() => {
+ result.current.track(event);
+ });
+
+ // onTrack is called for session_start and then for the play event
+ expect(onTrack).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'play' }),
+ );
+ });
+
+ it('sends event to endpoint immediately when batchEvents is false', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ endpoint: 'https://example.com/track',
+ }),
+ });
+
+ const event = createEvent();
+ act(() => {
+ result.current.track(event);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/track', expect.objectContaining({
+ method: 'POST',
+ headers: expect.objectContaining({
+ 'Content-Type': 'application/json',
+ }),
+ }));
+ });
+
+ it('does not send when disabled', () => {
+ mockFetch.mockClear();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: false,
+ endpoint: 'https://example.com/track',
+ }),
+ });
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it('does not send when no endpoint configured', () => {
+ mockFetch.mockClear();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it('sends custom headers with request', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ endpoint: 'https://example.com/track',
+ headers: { 'X-API-Key': 'test-key' },
+ }),
+ });
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/track', expect.objectContaining({
+ headers: expect.objectContaining({
+ 'Content-Type': 'application/json',
+ 'X-API-Key': 'test-key',
+ }),
+ }));
+ });
+ });
+
+ describe('Event type filtering', () => {
+ it('filters out disabled event types', () => {
+ const onTrack = vi.fn();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ onTrack,
+ events: { play: false },
+ }),
+ });
+
+ // session_start fires onTrack at construction
+ const callsAfterConstruct = onTrack.mock.calls.length;
+
+ act(() => {
+ result.current.track(createEvent({ type: 'play' }));
+ });
+
+ // play is filtered out, so no additional call
+ expect(onTrack).toHaveBeenCalledTimes(callsAfterConstruct);
+ });
+
+ it('allows enabled event types', () => {
+ const onTrack = vi.fn();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ onTrack,
+ events: { play: true },
+ }),
+ });
+
+ const callsAfterConstruct = onTrack.mock.calls.length;
+
+ act(() => {
+ result.current.track(createEvent({ type: 'play' }));
+ });
+
+ expect(onTrack).toHaveBeenCalledTimes(callsAfterConstruct + 1);
+ });
+
+ it('filters pause events when pause is disabled', () => {
+ const onTrack = vi.fn();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ onTrack,
+ events: { pause: false, play: true },
+ }),
+ });
+
+ // session_start fires onTrack at construction
+ const callsAfterConstruct = onTrack.mock.calls.length;
+
+ act(() => {
+ result.current.track(createEvent({ type: 'pause' }));
+ });
+
+ // pause is filtered, no additional call
+ expect(onTrack).toHaveBeenCalledTimes(callsAfterConstruct);
+
+ act(() => {
+ result.current.track(createEvent({ type: 'play' }));
+ });
+
+ expect(onTrack).toHaveBeenCalledTimes(callsAfterConstruct + 1);
+ });
+
+ it('allows seek events when seek is enabled', () => {
+ const onTrack = vi.fn();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ onTrack,
+ events: { seek: true },
+ }),
+ });
+
+ const callsAfterConstruct = onTrack.mock.calls.length;
+
+ act(() => {
+ result.current.track(createEvent({ type: 'seek' }));
+ });
+
+ expect(onTrack).toHaveBeenCalledTimes(callsAfterConstruct + 1);
+ });
+
+ it('filters error events when error is disabled', () => {
+ const onTrack = vi.fn();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ onTrack,
+ events: { error: false },
+ }),
+ });
+
+ // session_start fires onTrack at construction
+ const callsAfterConstruct = onTrack.mock.calls.length;
+
+ act(() => {
+ result.current.track(createEvent({ type: 'error' }));
+ });
+
+ // error is filtered, no additional call
+ expect(onTrack).toHaveBeenCalledTimes(callsAfterConstruct);
+ });
+ });
+
+ describe('transformEvent', () => {
+ it('transforms event before tracking', () => {
+ const onTrack = vi.fn();
+ const transformEvent = vi.fn((event: TrackingEvent) => ({
+ ...event,
+ data: { ...event.data, metadata: { transformed: true } },
+ }));
+
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ onTrack,
+ transformEvent,
+ }),
+ });
+
+ const event = createEvent();
+ act(() => {
+ result.current.track(event);
+ });
+
+ // transformEvent is called for both session_start and play
+ expect(transformEvent).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'play' }),
+ );
+ expect(onTrack).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({
+ metadata: { transformed: true },
+ }),
+ }));
+ });
+
+ it('filters out event when transformEvent returns null', () => {
+ const onTrack = vi.fn();
+ const transformEvent = vi.fn(() => null);
+
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ onTrack,
+ transformEvent,
+ }),
+ });
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ expect(transformEvent).toHaveBeenCalled();
+ expect(onTrack).not.toHaveBeenCalled();
+ });
+
+ it('calls transformEvent with the original event', () => {
+ const transformEvent = vi.fn((event: TrackingEvent) => event);
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ transformEvent,
+ }),
+ });
+
+ const event = createEvent({ type: 'pause' });
+ act(() => {
+ result.current.track(event);
+ });
+
+ // transformEvent is called for session_start and then for the pause event
+ expect(transformEvent).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'pause' }),
+ );
+ });
+ });
+
+ describe('Batch mode', () => {
+ it('queues events when batchEvents is true', () => {
+ mockFetch.mockClear();
+ const onTrack = vi.fn();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ batchEvents: true,
+ batchSize: 10,
+ batchInterval: 60000, // long interval so it does not auto-flush
+ endpoint: 'https://example.com/track',
+ onTrack,
+ }),
+ });
+
+ // session_start fires onTrack at construction
+ const callsAfterConstruct = onTrack.mock.calls.length;
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ // onTrack is still called immediately per event
+ expect(onTrack).toHaveBeenCalledTimes(callsAfterConstruct + 1);
+
+ // But fetch is not called yet (batched)
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it('flushes when batch size is reached', () => {
+ mockFetch.mockClear();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ batchEvents: true,
+ batchSize: 3,
+ batchInterval: 60000,
+ endpoint: 'https://example.com/track',
+ }),
+ });
+
+ act(() => {
+ result.current.track(createEvent({ type: 'play' }));
+ result.current.track(createEvent({ type: 'pause' }));
+ result.current.track(createEvent({ type: 'seek' }));
+ });
+
+ expect(mockFetch).toHaveBeenCalled();
+ });
+
+ it('flushes at batch interval', async () => {
+ mockFetch.mockClear();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ batchEvents: true,
+ batchInterval: 5000,
+ endpoint: 'https://example.com/track',
+ }),
+ });
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(mockFetch).toHaveBeenCalled();
+ });
+
+ it('does not flush when queue is empty', async () => {
+ mockFetch.mockClear();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ batchEvents: true,
+ batchInterval: 60000,
+ endpoint: 'https://example.com/track',
+ }),
+ });
+
+ // First flush sends the queued session_start
+ await act(async () => {
+ await result.current.flush();
+ });
+
+ mockFetch.mockClear();
+
+ // Second flush: queue is now truly empty
+ await act(async () => {
+ await result.current.flush();
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('flush()', () => {
+ it('sends all queued events', async () => {
+ mockFetch.mockClear();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ batchEvents: true,
+ batchSize: 100,
+ batchInterval: 60000,
+ endpoint: 'https://example.com/track',
+ }),
+ });
+
+ act(() => {
+ result.current.track(createEvent({ type: 'play' }));
+ result.current.track(createEvent({ type: 'pause' }));
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+
+ await act(async () => {
+ await result.current.flush();
+ });
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const body = JSON.parse((mockFetch.mock.calls[0] as any[])[1]?.body as string);
+ // session_start + play + pause = 3
+ expect(body.events).toHaveLength(3);
+ });
+
+ it('clears the queue after flush', async () => {
+ mockFetch.mockClear();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ batchEvents: true,
+ batchSize: 100,
+ batchInterval: 60000,
+ endpoint: 'https://example.com/track',
+ }),
+ });
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ await act(async () => {
+ await result.current.flush();
+ });
+
+ mockFetch.mockClear();
+
+ await act(async () => {
+ await result.current.flush();
+ });
+
+ // No more events to send
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('setSessionId', () => {
+ it('sets session ID', () => {
+ mockFetch.mockClear();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ endpoint: 'https://example.com/track',
+ }),
+ });
+
+ act(() => {
+ result.current.setSessionId('session-123');
+ });
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ // session_start is at index 0 (with old ID), play event is at index 1
+ const lastCallIndex = mockFetch.mock.calls.length - 1;
+ const body = JSON.parse((mockFetch.mock.calls[lastCallIndex] as any[])[1]?.body as string);
+ expect(body.sessionId).toBe('session-123');
+ });
+
+ it('includes updated session ID in subsequent requests', () => {
+ mockFetch.mockClear();
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ endpoint: 'https://example.com/track',
+ }),
+ });
+
+ act(() => {
+ result.current.setSessionId('session-abc');
+ });
+
+ // Clear after session_start to track only explicit events
+ mockFetch.mockClear();
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ const firstCallBody = JSON.parse((mockFetch.mock.calls[0] as any[])[1]?.body as string);
+ expect(firstCallBody.sessionId).toBe('session-abc');
+
+ act(() => {
+ result.current.setSessionId('session-xyz');
+ });
+
+ act(() => {
+ result.current.track(createEvent());
+ });
+
+ const secondCallBody = JSON.parse((mockFetch.mock.calls[1] as any[])[1]?.body as string);
+ expect(secondCallBody.sessionId).toBe('session-xyz');
+ });
+ });
+
+ describe('Config merging', () => {
+ it('merges user config with defaults', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ enabled: true,
+ endpoint: 'https://custom.com/events',
+ }),
+ });
+
+ expect(result.current.config.enabled).toBe(true);
+ expect(result.current.config.endpoint).toBe('https://custom.com/events');
+ // Defaults preserved
+ expect(result.current.config.events?.play).toBe(true);
+ expect(result.current.config.batchEvents).toBe(false);
+ });
+
+ it('overrides specific event types while keeping defaults', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ events: { play: false, seek: false },
+ }),
+ });
+
+ expect(result.current.config.events?.play).toBe(false);
+ expect(result.current.config.events?.seek).toBe(false);
+ expect(result.current.config.events?.pause).toBe(true);
+ expect(result.current.config.events?.complete).toBe(true);
+ });
+
+ it('accepts custom progress intervals', () => {
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper({
+ progressIntervals: [10, 20, 30, 40, 50],
+ }),
+ });
+
+ expect(result.current.config.progressIntervals).toEqual([10, 20, 30, 40, 50]);
+ });
+
+ it('uses createMockTrackingConfig helper correctly', () => {
+ const config = createMockTrackingConfig({ enabled: true });
+ const { result } = renderHook(() => useTracking(), {
+ wrapper: createWrapper(config),
+ });
+
+ expect(result.current.config.enabled).toBe(true);
+ expect(result.current.config.endpoint).toBe('https://example.com/track');
+ });
+ });
+});
diff --git a/src/context/TrackingContext.tsx b/src/context/TrackingContext.tsx
index b3376b8..876429e 100644
--- a/src/context/TrackingContext.tsx
+++ b/src/context/TrackingContext.tsx
@@ -1,5 +1,6 @@
-import React, { createContext, useContext, useCallback, useMemo, useRef, useState } from 'react';
+import React, { createContext, useContext, useCallback, useMemo, useRef, useState, useEffect } from 'react';
import type { TrackingConfig, TrackingContextValue, TrackingEvent } from '@/types/tracking';
+import { TrackingService } from '@/services/TrackingService';
const DEFAULT_CONFIG: TrackingConfig = {
enabled: false, // GDPR: default OFF
@@ -39,86 +40,38 @@ export function TrackingProvider({ children, config: userConfig = {} }: Tracking
},
}), [userConfig]);
- const [enabled, setEnabled] = useState(config.enabled);
- const [sessionId, setSessionId] = useState(undefined);
- const eventQueue = useRef([]);
- const batchTimer = useRef | null>(null);
-
- // Send events to endpoint
- const sendEvents = useCallback(async (events: TrackingEvent[]) => {
- if (!config.endpoint || events.length === 0) return;
-
- try {
- await fetch(config.endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...config.headers,
- },
- body: JSON.stringify({ events, sessionId }),
- });
- } catch (error) {
- console.error('Failed to send tracking events:', error);
- }
- }, [config.endpoint, config.headers, sessionId]);
+ const [enabled, setEnabledState] = useState(config.enabled);
+ const serviceRef = useRef(null);
- // Flush event queue
- const flush = useCallback(async () => {
- if (eventQueue.current.length === 0) return;
- const events = [...eventQueue.current];
- eventQueue.current = [];
- await sendEvents(events);
- }, [sendEvents]);
+ // Initialize / recreate the tracking service when config changes
+ useEffect(() => {
+ const service = new TrackingService({ ...config, enabled });
+ serviceRef.current = service;
+
+ return () => {
+ service.destroy();
+ serviceRef.current = null;
+ };
+ }, [config, enabled]);
+
+ const setEnabled = useCallback((value: boolean) => {
+ setEnabledState(value);
+ serviceRef.current?.setEnabled(value);
+ }, []);
- // Track an event
+ const setSessionId = useCallback((sessionId: string) => {
+ serviceRef.current?.setSessionId(sessionId);
+ }, []);
+
+ // Track an event by delegating to the service
const track = useCallback((event: TrackingEvent) => {
- if (!enabled) return;
-
- // Check if this event type is enabled
- const eventTypeKey = event.type.replace(/_([a-z])/g, (_, letter) =>
- letter.toUpperCase()
- ) as keyof typeof config.events;
-
- if (config.events && !config.events[eventTypeKey]) return;
-
- // Transform event if transformer provided
- let processedEvent = event;
- if (config.transformEvent) {
- const transformed = config.transformEvent(event);
- if (!transformed) return; // Event was filtered out
- processedEvent = transformed;
- }
-
- // Call onTrack callback if provided
- config.onTrack?.(processedEvent);
-
- // Handle batching
- if (config.batchEvents) {
- eventQueue.current.push(processedEvent);
-
- if (eventQueue.current.length >= (config.batchSize || 10)) {
- flush();
- }
- } else {
- // Send immediately
- sendEvents([processedEvent]);
- }
- }, [enabled, config, flush, sendEvents]);
-
- // Set up batch interval
- useMemo(() => {
- if (config.batchEvents && config.batchInterval) {
- batchTimer.current = setInterval(() => {
- flush();
- }, config.batchInterval);
-
- return () => {
- if (batchTimer.current) {
- clearInterval(batchTimer.current);
- }
- };
- }
- }, [config.batchEvents, config.batchInterval, flush]);
+ serviceRef.current?.track(event.type, event.data);
+ }, []);
+
+ // Flush event queue
+ const flush = useCallback(async () => {
+ await serviceRef.current?.flush();
+ }, []);
const contextValue = useMemo(() => ({
config: { ...config, enabled },
@@ -126,7 +79,7 @@ export function TrackingProvider({ children, config: userConfig = {} }: Tracking
setEnabled,
setSessionId,
flush,
- }), [config, enabled, track, flush]);
+ }), [config, enabled, track, setEnabled, setSessionId, flush]);
return (
diff --git a/src/context/VideoAdContext.test.tsx b/src/context/VideoAdContext.test.tsx
new file mode 100644
index 0000000..cfcc5a9
--- /dev/null
+++ b/src/context/VideoAdContext.test.tsx
@@ -0,0 +1,1952 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import React, { useEffect } from 'react';
+import { renderHook, act, render } from '@testing-library/react';
+import { VideoAdProvider, useVideoAds } from './VideoAdContext';
+import type { VideoAdConfig, VideoAd, VideoAdBreak, CustomAdComponentProps } from '@/types/video';
+
+// Mock fetch
+const mockFetch = vi.fn(() => Promise.resolve(new Response()));
+
+// Mock Hls
+vi.mock('hls.js', () => {
+ const MockHls = vi.fn(() => ({
+ loadSource: vi.fn(),
+ attachMedia: vi.fn(),
+ detachMedia: vi.fn(),
+ destroy: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ levels: [],
+ currentLevel: -1,
+ }));
+ (MockHls as unknown as Record).isSupported = vi.fn(() => true);
+ (MockHls as unknown as Record).Events = {
+ ERROR: 'hlsError',
+ MANIFEST_PARSED: 'hlsManifestParsed',
+ LEVEL_SWITCHED: 'hlsLevelSwitched',
+ };
+ return { default: MockHls };
+});
+
+// Mock HLS helpers
+vi.mock('@/hooks/useHLS', () => ({
+ isHLSSource: vi.fn((src: string) => src?.endsWith('.m3u8') ?? false),
+ supportsNativeHLS: vi.fn(() => false),
+}));
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
+ mockFetch.mockClear();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+// Helper to create a video ad
+function createVideoAd(overrides: Partial = {}): VideoAd {
+ return {
+ id: 'video-ad-1',
+ src: 'https://example.com/ad.mp4',
+ duration: 15,
+ skipAfterSeconds: 5,
+ clickThroughUrl: 'https://example.com/click',
+ ...overrides,
+ };
+}
+
+// Helper to create a video ad break
+function createVideoAdBreak(overrides: Partial = {}): VideoAdBreak {
+ return {
+ id: 'break-1',
+ position: 'pre-roll',
+ ads: [createVideoAd()],
+ played: false,
+ ...overrides,
+ };
+}
+
+// A mock component for component ads
+const MockAdComponent = (props: CustomAdComponentProps) => Ad: {props.ad.id}
;
+
+const createWrapper = (config: Partial = {}) => {
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+/**
+ * Helper wrapper that connects adVideoRef to an actual element,
+ * enabling non-component video ad tests to work.
+ */
+function createWrapperWithVideoElement(config: Partial = {}) {
+ function VideoRefConnector({ children }: { children: React.ReactNode }) {
+ const ctx = useVideoAds();
+ useEffect(() => {
+ const video = document.createElement('video');
+ // Override play() to resolve immediately
+ video.play = vi.fn(() => Promise.resolve());
+ video.pause = vi.fn();
+ (ctx.adVideoRef as React.MutableRefObject).current = video;
+ return () => {
+ (ctx.adVideoRef as React.MutableRefObject).current = null;
+ };
+ }, [ctx.adVideoRef]);
+ return <>{children}>;
+ }
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+}
+
+describe('VideoAdContext', () => {
+ describe('useVideoAds hook', () => {
+ it('throws error when used outside provider', () => {
+ expect(() => {
+ renderHook(() => useVideoAds());
+ }).toThrow('useVideoAds must be used within a VideoAdProvider');
+ });
+
+ it('returns context inside provider', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBeDefined();
+ expect(result.current.state).toBeDefined();
+ expect(result.current.controls).toBeDefined();
+ expect(result.current.config).toBeDefined();
+ expect(result.current.adVideoRef).toBeDefined();
+ });
+ });
+
+ describe('Initial state', () => {
+ it('is not playing ad initially', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ });
+
+ it('has no current ad initially', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.currentAd).toBeNull();
+ });
+
+ it('has no current ad break initially', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.currentAdBreak).toBeNull();
+ });
+
+ it('has zero ad progress', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.adProgress).toBe(0);
+ });
+
+ it('has zero ad duration', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.adDuration).toBe(0);
+ });
+
+ it('cannot skip initially', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.canSkip).toBe(false);
+ });
+
+ it('has zero skip countdown', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.skipCountdown).toBe(0);
+ });
+
+ it('has zero ads remaining', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.adsRemaining).toBe(0);
+ });
+
+ it('is not a component ad initially', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isComponentAd).toBe(false);
+ });
+
+ it('has null componentAdProps initially', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.componentAdProps).toBeNull();
+ });
+ });
+
+ describe('Config defaults', () => {
+ it('defaults enabled to false', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.enabled).toBe(false);
+ });
+
+ it('defaults adBreaks to empty', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.adBreaks).toEqual([]);
+ });
+
+ it('defaults skipAllowed to true', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.skipAllowed).toBe(true);
+ });
+
+ it('defaults defaultSkipAfter to 5', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.defaultSkipAfter).toBe(5);
+ });
+
+ it('merges user config with defaults', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({
+ enabled: true,
+ defaultSkipAfter: 10,
+ }),
+ });
+
+ expect(result.current.config.enabled).toBe(true);
+ expect(result.current.config.defaultSkipAfter).toBe(10);
+ expect(result.current.config.skipAllowed).toBe(true);
+ });
+ });
+
+ describe('Controls', () => {
+ it('has all ad controls', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.controls.skipAd).toBeDefined();
+ expect(result.current.controls.clickThrough).toBeDefined();
+ expect(result.current.controls.startAdBreak).toBeDefined();
+ expect(result.current.controls.stopAds).toBeDefined();
+ expect(result.current.controls.completeComponentAd).toBeDefined();
+ });
+ });
+
+ // ================================================================
+ // Video ad tests using the wrapper that connects adVideoRef
+ // ================================================================
+
+ describe('startAdBreak (with video element)', () => {
+ it('starts playing the first ad in the break', () => {
+ const onAdStart = vi.fn();
+ const ad = createVideoAd();
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true, onAdStart }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(true);
+ expect(result.current.state.currentAd?.id).toBe(ad.id);
+ expect(result.current.state.currentAdBreak?.id).toBe(adBreak.id);
+ expect(result.current.state.adDuration).toBe(ad.duration);
+ expect(onAdStart).toHaveBeenCalledWith(ad, adBreak);
+ });
+
+ it('sets ads remaining correctly', () => {
+ const ads = [
+ createVideoAd({ id: 'ad-1' }),
+ createVideoAd({ id: 'ad-2' }),
+ createVideoAd({ id: 'ad-3' }),
+ ];
+ const adBreak = createVideoAdBreak({ ads });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.adsRemaining).toBe(2);
+ });
+
+ it('resets ad progress to zero', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(createVideoAdBreak());
+ });
+
+ expect(result.current.state.adProgress).toBe(0);
+ });
+
+ it('fires impression and start tracking', () => {
+ const ad = createVideoAd({
+ trackingUrls: {
+ impression: 'https://example.com/impression',
+ start: 'https://example.com/start',
+ },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/impression', expect.objectContaining({
+ method: 'GET',
+ mode: 'no-cors',
+ }));
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/start', expect.objectContaining({
+ method: 'GET',
+ }));
+ });
+
+ it('sets isComponentAd to false for standard video ads', () => {
+ const ad = createVideoAd();
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isComponentAd).toBe(false);
+ });
+ });
+
+ describe('startAdBreak (general)', () => {
+ it('ignores empty ad breaks', () => {
+ const onAdStart = vi.fn();
+ const adBreak = createVideoAdBreak({ ads: [] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAdStart }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ expect(onAdStart).not.toHaveBeenCalled();
+ });
+
+ it('does not update state when video ref is not connected for non-component ad', () => {
+ // Without the video element wrapper, playAd returns early
+ const ad = createVideoAd();
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ // playAd returns early because adVideoRef.current is null
+ expect(result.current.state.isPlayingAd).toBe(false);
+ });
+ });
+
+ describe('Bumper ads', () => {
+ it('sets bumper ad as non-skippable', () => {
+ const ad = createVideoAd({
+ type: 'bumper',
+ duration: 6,
+ skipAfterSeconds: 5,
+ component: MockAdComponent, // Use component to avoid needing video ref
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.canSkip).toBe(false);
+
+ // Even after waiting, should still be non-skippable
+ act(() => {
+ vi.advanceTimersByTime(10000);
+ });
+
+ expect(result.current.state.canSkip).toBe(false);
+ });
+
+ it('calls onBumperStart callback', () => {
+ const onBumperStart = vi.fn();
+ const ad = createVideoAd({
+ type: 'bumper',
+ duration: 6,
+ component: MockAdComponent,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onBumperStart }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(onBumperStart).toHaveBeenCalledWith(ad);
+ });
+
+ it('does not start skip countdown for bumper ads', () => {
+ const ad = createVideoAd({
+ type: 'bumper',
+ duration: 6,
+ skipAfterSeconds: 3,
+ component: MockAdComponent,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(0);
+ expect(result.current.state.canSkip).toBe(false);
+ });
+
+ it('bumper ad with video element starts correctly', () => {
+ const onBumperStart = vi.fn();
+ const ad = createVideoAd({ type: 'bumper', duration: 6 });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true, onBumperStart }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(true);
+ expect(result.current.state.canSkip).toBe(false);
+ expect(onBumperStart).toHaveBeenCalledWith(ad);
+ });
+ });
+
+ describe('Skip countdown', () => {
+ it('sets skipCountdown from ad skipAfterSeconds', () => {
+ const ad = createVideoAd({ skipAfterSeconds: 5, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(5);
+ expect(result.current.state.canSkip).toBe(false);
+ });
+
+ it('decrements countdown every second', () => {
+ const ad = createVideoAd({ skipAfterSeconds: 3, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(2);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(1);
+ });
+
+ it('enables skip when countdown reaches zero', () => {
+ const ad = createVideoAd({ skipAfterSeconds: 2, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(result.current.state.canSkip).toBe(true);
+ expect(result.current.state.skipCountdown).toBe(0);
+ });
+
+ it('immediately allows skip when skipAfterSeconds is 0', () => {
+ const ad = createVideoAd({ skipAfterSeconds: 0, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.canSkip).toBe(true);
+ expect(result.current.state.skipCountdown).toBe(0);
+ });
+
+ it('uses defaultSkipAfter from config when ad has no skipAfterSeconds', () => {
+ const ad = createVideoAd({ skipAfterSeconds: undefined, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, defaultSkipAfter: 8 }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(8);
+ });
+
+ it('does not allow skip when skipAfterSeconds is null and no defaultSkipAfter', () => {
+ const ad = createVideoAd({ skipAfterSeconds: null, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, defaultSkipAfter: undefined }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ // null skipAfterSeconds causes skipAfter to remain null when no default
+ expect(result.current.state.canSkip).toBe(false);
+ });
+
+ it('disables skip globally when skipAllowed is false', () => {
+ const ad = createVideoAd({ skipAfterSeconds: 0, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, skipAllowed: false }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.canSkip).toBe(false);
+ });
+ });
+
+ describe('skipAd', () => {
+ it('skips the current ad when allowed', () => {
+ const onAdSkip = vi.fn();
+ const ad = createVideoAd({ skipAfterSeconds: 0, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAdSkip }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAdSkip).toHaveBeenCalledWith(ad, adBreak);
+ });
+
+ it('does not skip when canSkip is false', () => {
+ const onAdSkip = vi.fn();
+ const ad = createVideoAd({ skipAfterSeconds: 10, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAdSkip }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAdSkip).not.toHaveBeenCalled();
+ expect(result.current.state.isPlayingAd).toBe(true);
+ });
+
+ it('fires skip tracking URL', () => {
+ const ad = createVideoAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { skip: 'https://example.com/skip' },
+ component: MockAdComponent,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ mockFetch.mockClear();
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/skip', expect.objectContaining({
+ method: 'GET',
+ }));
+ });
+
+ it('advances to next component ad when skipping', () => {
+ const ad1 = createVideoAd({ id: 'ad-1', skipAfterSeconds: 0, component: MockAdComponent });
+ const ad2 = createVideoAd({ id: 'ad-2', skipAfterSeconds: 0, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad1, ad2] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-1');
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-2');
+ expect(result.current.state.isPlayingAd).toBe(true);
+ });
+
+ it('ends ad break when skipping last ad', () => {
+ const onAllAdsComplete = vi.fn();
+ const ad = createVideoAd({ skipAfterSeconds: 0, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAllAdsComplete }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ expect(result.current.state.currentAd).toBeNull();
+ expect(onAllAdsComplete).toHaveBeenCalledWith(adBreak);
+ });
+
+ it('does nothing when no ad is playing', () => {
+ const onAdSkip = vi.fn();
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAdSkip }),
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAdSkip).not.toHaveBeenCalled();
+ });
+
+ it('skips video ad with connected video element', () => {
+ const onAdSkip = vi.fn();
+ const ad = createVideoAd({ skipAfterSeconds: 0 });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true, onAdSkip }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAdSkip).toHaveBeenCalledWith(ad, adBreak);
+ });
+ });
+
+ describe('clickThrough', () => {
+ it('opens click-through URL in new tab', () => {
+ const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+ const ad = createVideoAd({ clickThroughUrl: 'https://advertiser.com', component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(windowOpenSpy).toHaveBeenCalledWith('https://advertiser.com', '_blank');
+ windowOpenSpy.mockRestore();
+ });
+
+ it('fires click tracking URL', () => {
+ const ad = createVideoAd({
+ trackingUrls: { click: 'https://example.com/click' },
+ component: MockAdComponent,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ mockFetch.mockClear();
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/click', expect.objectContaining({
+ method: 'GET',
+ }));
+ });
+
+ it('calls onAdClick callback', () => {
+ const onAdClick = vi.fn();
+ const ad = createVideoAd({ component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAdClick }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(onAdClick).toHaveBeenCalledWith(ad, adBreak);
+ });
+
+ it('does nothing when no ad is playing', () => {
+ const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(windowOpenSpy).not.toHaveBeenCalled();
+ windowOpenSpy.mockRestore();
+ });
+
+ it('handles ad without clickThroughUrl', () => {
+ const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+ const ad = createVideoAd({ clickThroughUrl: undefined, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.clickThrough();
+ });
+
+ expect(windowOpenSpy).not.toHaveBeenCalled();
+ windowOpenSpy.mockRestore();
+ });
+ });
+
+ describe('stopAds', () => {
+ it('resets all ad state', () => {
+ const ad = createVideoAd({ component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(true);
+
+ act(() => {
+ result.current.controls.stopAds();
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ expect(result.current.state.currentAd).toBeNull();
+ expect(result.current.state.currentAdBreak).toBeNull();
+ expect(result.current.state.adProgress).toBe(0);
+ expect(result.current.state.adDuration).toBe(0);
+ expect(result.current.state.canSkip).toBe(false);
+ expect(result.current.state.skipCountdown).toBe(0);
+ expect(result.current.state.adsRemaining).toBe(0);
+ expect(result.current.state.isComponentAd).toBe(false);
+ });
+
+ it('clears skip countdown timer', () => {
+ const ad = createVideoAd({ skipAfterSeconds: 10, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.stopAds();
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(15000);
+ });
+
+ expect(result.current.state.skipCountdown).toBe(0);
+ });
+
+ it('stops video ad with connected video element', () => {
+ const ad = createVideoAd();
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(true);
+
+ act(() => {
+ result.current.controls.stopAds();
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ });
+ });
+
+ describe('Multiple ads in break', () => {
+ it('starts with first component ad', () => {
+ const ads = [
+ createVideoAd({ id: 'ad-1', duration: 10, component: MockAdComponent }),
+ createVideoAd({ id: 'ad-2', duration: 20, component: MockAdComponent }),
+ createVideoAd({ id: 'ad-3', duration: 15, component: MockAdComponent }),
+ ];
+ const adBreak = createVideoAdBreak({ ads });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-1');
+ expect(result.current.state.adsRemaining).toBe(2);
+ });
+
+ it('starts with first video ad when ref is connected', () => {
+ const ads = [
+ createVideoAd({ id: 'ad-1', duration: 10 }),
+ createVideoAd({ id: 'ad-2', duration: 20 }),
+ createVideoAd({ id: 'ad-3', duration: 15 }),
+ ];
+ const adBreak = createVideoAdBreak({ ads });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-1');
+ expect(result.current.state.adsRemaining).toBe(2);
+ });
+
+ it('tracks remaining ads count after skip', () => {
+ const ads = [
+ createVideoAd({ id: 'ad-1', skipAfterSeconds: 0, component: MockAdComponent }),
+ createVideoAd({ id: 'ad-2', skipAfterSeconds: 0, component: MockAdComponent }),
+ createVideoAd({ id: 'ad-3', skipAfterSeconds: 0, component: MockAdComponent }),
+ ];
+ const adBreak = createVideoAdBreak({ ads });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.adsRemaining).toBe(2);
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-2');
+ expect(result.current.state.adsRemaining).toBe(1);
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('ad-3');
+ expect(result.current.state.adsRemaining).toBe(0);
+ });
+ });
+
+ describe('Component ads', () => {
+ it('sets isComponentAd for ads with component property', () => {
+ const ad = createVideoAd({ component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isComponentAd).toBe(true);
+ });
+
+ it('provides componentAdProps for component ads', () => {
+ const ad = createVideoAd({
+ id: 'comp-ad-1',
+ component: MockAdComponent,
+ duration: 10,
+ skipAfterSeconds: 3,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.componentAdProps).not.toBeNull();
+ expect(result.current.componentAdProps?.ad.id).toBe('comp-ad-1');
+ expect(result.current.componentAdProps?.duration).toBe(10);
+ expect(result.current.componentAdProps?.onComplete).toBeDefined();
+ expect(result.current.componentAdProps?.onSkip).toBeDefined();
+ });
+
+ it('has null componentAdProps for non-component ads', () => {
+ const ad = createVideoAd();
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.componentAdProps).toBeNull();
+ });
+
+ it('tracks progress for component ads via timer', () => {
+ const ad = createVideoAd({ component: MockAdComponent, duration: 10 });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ // Component ad progress is tracked via setInterval at 100ms
+ act(() => {
+ vi.advanceTimersByTime(500); // 5 ticks of 100ms = 0.5 progress
+ });
+
+ expect(result.current.state.adProgress).toBeGreaterThan(0);
+ });
+ });
+
+ describe('completeComponentAd', () => {
+ it('completes a component ad and advances to next', () => {
+ const onAdComplete = vi.fn();
+ const ad1 = createVideoAd({ id: 'comp-1', component: MockAdComponent, skipAfterSeconds: 0 });
+ const ad2 = createVideoAd({ id: 'comp-2', component: MockAdComponent, skipAfterSeconds: 0 });
+ const adBreak = createVideoAdBreak({ ads: [ad1, ad2] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAdComplete }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.currentAd?.id).toBe('comp-1');
+
+ act(() => {
+ result.current.controls.completeComponentAd();
+ });
+
+ expect(onAdComplete).toHaveBeenCalledWith(ad1, adBreak);
+ expect(result.current.state.currentAd?.id).toBe('comp-2');
+ });
+
+ it('ends ad break when completing last component ad', () => {
+ const onAllAdsComplete = vi.fn();
+ const ad = createVideoAd({ id: 'comp-1', component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAllAdsComplete }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.completeComponentAd();
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(false);
+ expect(onAllAdsComplete).toHaveBeenCalledWith(adBreak);
+ });
+
+ it('does nothing when not a component ad', () => {
+ const onAdComplete = vi.fn();
+ const ad = createVideoAd();
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true, onAdComplete }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.completeComponentAd();
+ });
+
+ expect(onAdComplete).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when no ad is playing', () => {
+ const onAdComplete = vi.fn();
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAdComplete }),
+ });
+
+ act(() => {
+ result.current.controls.completeComponentAd();
+ });
+
+ expect(onAdComplete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Ad lifecycle callbacks', () => {
+ it('calls onAdStart when component ad begins', () => {
+ const onAdStart = vi.fn();
+ const ad = createVideoAd({ component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAdStart }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(onAdStart).toHaveBeenCalledWith(ad, adBreak);
+ });
+
+ it('calls onAdStart when video ad begins with video ref', () => {
+ const onAdStart = vi.fn();
+ const ad = createVideoAd();
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true, onAdStart }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(onAdStart).toHaveBeenCalledWith(ad, adBreak);
+ });
+
+ it('calls onAllAdsComplete when all ads in break finish', () => {
+ const onAllAdsComplete = vi.fn();
+ const ad = createVideoAd({ skipAfterSeconds: 0, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onAllAdsComplete }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.skipAd();
+ });
+
+ expect(onAllAdsComplete).toHaveBeenCalledWith(adBreak);
+ });
+ });
+
+ describe('Tracking URLs', () => {
+ it('does not call fetch when no tracking URLs configured', () => {
+ const ad = createVideoAd({ trackingUrls: undefined, component: MockAdComponent });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ mockFetch.mockClear();
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it('handles fetch errors gracefully for component ads', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ const ad = createVideoAd({
+ trackingUrls: { impression: 'https://example.com/impression' },
+ component: MockAdComponent,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(true);
+ consoleSpy.mockRestore();
+ });
+
+ it('handles fetch errors gracefully for video ads', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ const ad = createVideoAd({
+ trackingUrls: { impression: 'https://example.com/impression' },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapperWithVideoElement({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ expect(result.current.state.isPlayingAd).toBe(true);
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('adVideoRef', () => {
+ it('provides adVideoRef', () => {
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.adVideoRef).toBeDefined();
+ });
+ });
+
+ // =============================================================
+ // Video element event handler tests
+ // =============================================================
+
+ /**
+ * Helper that renders the provider with a real video element connected
+ * via ref, starts an ad break, and returns both the hook result
+ * and the video element for dispatching events.
+ */
+ function renderAndStartVideoAd(config: Partial, adBreak: VideoAdBreak) {
+ // Use a mutable container so re-renders update the reference the caller reads
+ const hookRef = { current: null as ReturnType | null };
+ let videoEl: HTMLVideoElement | null = null;
+
+ function Consumer() {
+ const ctx = useVideoAds();
+ hookRef.current = ctx;
+
+ // Connect a video element to the ref on first render
+ React.useEffect(() => {
+ const video = document.createElement('video');
+ video.play = vi.fn(() => Promise.resolve());
+ video.pause = vi.fn();
+ (ctx.adVideoRef as React.MutableRefObject).current = video;
+ videoEl = video;
+ return () => {
+ (ctx.adVideoRef as React.MutableRefObject).current = null;
+ };
+ }, [ctx.adVideoRef]);
+
+ return null;
+ }
+
+ render(
+
+
+
+ );
+
+ // Start the ad break
+ act(() => {
+ hookRef.current!.controls.startAdBreak(adBreak);
+ });
+
+ return { hookRef, videoEl: videoEl! };
+ }
+
+ describe('Video element event handlers', () => {
+ describe('timeupdate handler', () => {
+ it('fires onAdProgress callback with progress info', () => {
+ const onAdProgress = vi.fn();
+ const ad = createVideoAd({
+ duration: 30,
+ skipAfterSeconds: 0,
+ trackingUrls: {},
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true, onAdProgress },
+ adBreak
+ );
+
+ Object.defineProperty(videoEl, 'currentTime', { writable: true, value: 15 });
+ Object.defineProperty(videoEl, 'duration', { writable: true, value: 30 });
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(onAdProgress).toHaveBeenCalledWith(
+ expect.objectContaining({
+ currentTime: 15,
+ duration: 30,
+ percentage: 50,
+ remainingTime: 15,
+ }),
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ });
+
+ it('fires firstQuartile callback at 25%', () => {
+ const onFirstQuartile = vi.fn();
+ const ad = createVideoAd({
+ duration: 40,
+ skipAfterSeconds: 0,
+ trackingUrls: { firstQuartile: 'https://example.com/vq1' },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true, onFirstQuartile },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ Object.defineProperty(videoEl, 'currentTime', { writable: true, value: 10 });
+ Object.defineProperty(videoEl, 'duration', { writable: true, value: 40 });
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(onFirstQuartile).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/vq1',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('fires midpoint callback at 50%', () => {
+ const onMidpoint = vi.fn();
+ const ad = createVideoAd({
+ duration: 40,
+ skipAfterSeconds: 0,
+ trackingUrls: { midpoint: 'https://example.com/vmid' },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true, onMidpoint },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ Object.defineProperty(videoEl, 'currentTime', { writable: true, value: 20 });
+ Object.defineProperty(videoEl, 'duration', { writable: true, value: 40 });
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(onMidpoint).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/vmid',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('fires thirdQuartile callback at 75%', () => {
+ const onThirdQuartile = vi.fn();
+ const ad = createVideoAd({
+ duration: 40,
+ skipAfterSeconds: 0,
+ trackingUrls: { thirdQuartile: 'https://example.com/vq3' },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true, onThirdQuartile },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ Object.defineProperty(videoEl, 'currentTime', { writable: true, value: 30 });
+ Object.defineProperty(videoEl, 'duration', { writable: true, value: 40 });
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(onThirdQuartile).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/vq3',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('tracks custom progress offsets for video ads', () => {
+ const ad = createVideoAd({
+ duration: 30,
+ skipAfterSeconds: 0,
+ trackingUrls: {
+ progress: [
+ { offset: 5, url: 'https://example.com/vprog-5' },
+ { offset: 10, url: 'https://example.com/vprog-10' },
+ ],
+ },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ Object.defineProperty(videoEl, 'currentTime', { writable: true, value: 5 });
+ Object.defineProperty(videoEl, 'duration', { writable: true, value: 30 });
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/vprog-5',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+
+ mockFetch.mockClear();
+
+ Object.defineProperty(videoEl, 'currentTime', { writable: true, value: 10 });
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('timeupdate'));
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/vprog-10',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+ });
+
+ describe('ended handler', () => {
+ it('fires complete tracking and onAdComplete callback', () => {
+ const onAdComplete = vi.fn();
+ const ad = createVideoAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { complete: 'https://example.com/vcomplete' },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true, onAdComplete },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('ended'));
+ });
+
+ expect(onAdComplete).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/vcomplete',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('advances to next video ad when current ad ends', () => {
+ const ad1 = createVideoAd({ id: 'vad-1', skipAfterSeconds: 0 });
+ const ad2 = createVideoAd({ id: 'vad-2', skipAfterSeconds: 0 });
+ const adBreak = createVideoAdBreak({ ads: [ad1, ad2] });
+
+ const { hookRef, videoEl } = renderAndStartVideoAd(
+ { enabled: true },
+ adBreak
+ );
+
+ expect(hookRef.current!.state.currentAd?.id).toBe('vad-1');
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('ended'));
+ });
+
+ expect(hookRef.current!.state.currentAd?.id).toBe('vad-2');
+ expect(hookRef.current!.state.isPlayingAd).toBe(true);
+ });
+
+ it('ends ad break and calls onAllAdsComplete when last video ad ends', () => {
+ const onAllAdsComplete = vi.fn();
+ const ad = createVideoAd({ skipAfterSeconds: 0 });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { hookRef, videoEl } = renderAndStartVideoAd(
+ { enabled: true, onAllAdsComplete },
+ adBreak
+ );
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('ended'));
+ });
+
+ expect(hookRef.current!.state.isPlayingAd).toBe(false);
+ expect(hookRef.current!.state.currentAd).toBeNull();
+ expect(onAllAdsComplete).toHaveBeenCalledWith(adBreak);
+ });
+
+ it('fires onBumperComplete when a bumper video ad ends', () => {
+ const onBumperComplete = vi.fn();
+ const ad = createVideoAd({
+ type: 'bumper',
+ duration: 6,
+ skipAfterSeconds: null,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true, onBumperComplete },
+ adBreak
+ );
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('ended'));
+ });
+
+ expect(onBumperComplete).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id, type: 'bumper' })
+ );
+ });
+ });
+
+ describe('error handler', () => {
+ it('fires error tracking and onAdError callback', () => {
+ const onAdError = vi.fn();
+ const ad = createVideoAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { error: 'https://example.com/verror' },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true, onAdError },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('error'));
+ });
+
+ expect(onAdError).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/verror',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('resets state after video ad error', () => {
+ const ad = createVideoAd({ skipAfterSeconds: 0 });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { hookRef, videoEl } = renderAndStartVideoAd(
+ { enabled: true },
+ adBreak
+ );
+
+ expect(hookRef.current!.state.isPlayingAd).toBe(true);
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('error'));
+ });
+
+ expect(hookRef.current!.state.isPlayingAd).toBe(false);
+ expect(hookRef.current!.state.currentAd).toBeNull();
+ });
+ });
+
+ describe('pause handler', () => {
+ it('fires pause tracking and onAdPause callback', () => {
+ const onAdPause = vi.fn();
+ const ad = createVideoAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { pause: 'https://example.com/vpause' },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true, onAdPause },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('pause'));
+ });
+
+ expect(onAdPause).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/vpause',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+ });
+
+ describe('resume (play) handler', () => {
+ it('fires resume tracking and onAdResume callback', () => {
+ const onAdResume = vi.fn();
+ const ad = createVideoAd({
+ skipAfterSeconds: 0,
+ trackingUrls: { resume: 'https://example.com/vresume' },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { videoEl } = renderAndStartVideoAd(
+ { enabled: true, onAdResume },
+ adBreak
+ );
+
+ mockFetch.mockClear();
+
+ act(() => {
+ videoEl.dispatchEvent(new Event('play'));
+ });
+
+ expect(onAdResume).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/vresume',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+ });
+ });
+
+ // =============================================================
+ // Skip timer cleanup in event handlers
+ // =============================================================
+
+ describe('Skip timer cleanup', () => {
+ it('clears skip timer when video ad ends before countdown finishes', () => {
+ const onAdComplete = vi.fn();
+ const ad = createVideoAd({
+ skipAfterSeconds: 10, // long countdown
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { hookRef, videoEl } = renderAndStartVideoAd(
+ { enabled: true, onAdComplete },
+ adBreak
+ );
+
+ // Skip timer is running
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(hookRef.current!.state.skipCountdown).toBe(8);
+
+ // Ad ends while skip timer is still running
+ act(() => {
+ videoEl.dispatchEvent(new Event('ended'));
+ });
+
+ expect(onAdComplete).toHaveBeenCalled();
+ expect(hookRef.current!.state.isPlayingAd).toBe(false);
+ });
+
+ it('clears skip timer when skipping a video ad after countdown completes', () => {
+ const onAdSkip = vi.fn();
+ const ad = createVideoAd({ skipAfterSeconds: 2 });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { hookRef } = renderAndStartVideoAd(
+ { enabled: true, onAdSkip },
+ adBreak
+ );
+
+ // Wait for countdown to finish
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(hookRef.current!.state.canSkip).toBe(true);
+
+ act(() => {
+ hookRef.current!.controls.skipAd();
+ });
+
+ expect(onAdSkip).toHaveBeenCalled();
+ });
+ });
+
+ // =============================================================
+ // Component ad progress timer quartile tracking
+ // =============================================================
+
+ describe('Component ad progress timer quartiles', () => {
+ it('fires firstQuartile when component ad progress reaches 25%', () => {
+ const onFirstQuartile = vi.fn();
+ const ad = createVideoAd({
+ component: MockAdComponent,
+ duration: 4, // 25% = 1s = 10 ticks
+ skipAfterSeconds: null,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onFirstQuartile }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ // Advance past 25% threshold (extra tick for floating point accumulation of 0.1)
+ act(() => {
+ vi.advanceTimersByTime(1100);
+ });
+
+ expect(onFirstQuartile).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ });
+
+ it('fires midpoint when component ad progress reaches 50%', () => {
+ const onMidpoint = vi.fn();
+ const ad = createVideoAd({
+ component: MockAdComponent,
+ duration: 4, // 50% = 2s = 20 ticks
+ skipAfterSeconds: null,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onMidpoint }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ // Advance to 2s (50% of 4s)
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(onMidpoint).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ });
+
+ it('fires thirdQuartile when component ad progress reaches 75%', () => {
+ const onThirdQuartile = vi.fn();
+ const ad = createVideoAd({
+ component: MockAdComponent,
+ duration: 4, // 75% = 3s = 30 ticks
+ skipAfterSeconds: null,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onThirdQuartile }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ // Advance to 3s (75% of 4s)
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+
+ expect(onThirdQuartile).toHaveBeenCalledWith(
+ expect.objectContaining({ id: ad.id }),
+ expect.objectContaining({ id: adBreak.id })
+ );
+ });
+
+ it('fires all quartile tracking URLs for component ads', () => {
+ const ad = createVideoAd({
+ component: MockAdComponent,
+ duration: 4,
+ skipAfterSeconds: null,
+ trackingUrls: {
+ firstQuartile: 'https://example.com/cq1',
+ midpoint: 'https://example.com/cmid',
+ thirdQuartile: 'https://example.com/cq3',
+ },
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ mockFetch.mockClear();
+
+ // Advance past 75% (3s of 4s)
+ act(() => {
+ vi.advanceTimersByTime(3100);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/cq1',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/cmid',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://example.com/cq3',
+ expect.objectContaining({ method: 'GET', mode: 'no-cors' })
+ );
+ });
+
+ it('cleans up component ad timer on stopAds', () => {
+ const onFirstQuartile = vi.fn();
+ const ad = createVideoAd({
+ component: MockAdComponent,
+ duration: 10,
+ skipAfterSeconds: null,
+ });
+ const adBreak = createVideoAdBreak({ ads: [ad] });
+
+ const { result } = renderHook(() => useVideoAds(), {
+ wrapper: createWrapper({ enabled: true, onFirstQuartile }),
+ });
+
+ act(() => {
+ result.current.controls.startAdBreak(adBreak);
+ });
+
+ act(() => {
+ result.current.controls.stopAds();
+ });
+
+ // Advance time - timer should be stopped, no quartile callback
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(onFirstQuartile).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/context/VideoContext.test.tsx b/src/context/VideoContext.test.tsx
new file mode 100644
index 0000000..6353cb7
--- /dev/null
+++ b/src/context/VideoContext.test.tsx
@@ -0,0 +1,564 @@
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
+import { VideoProvider, useVideoPlayer } from './VideoContext';
+import {
+ createMockVideoTrack,
+ createMockVideoPlaylist,
+} from '@/test/helpers';
+import type { VideoConfig } from '@/types/video';
+
+const createWrapper = (config: Partial = {}) => {
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe('VideoContext', () => {
+ describe('Provider rendering', () => {
+ it('renders children', () => {
+ render(
+
+ Hello
+
+ );
+ expect(screen.getByTestId('child')).toBeInTheDocument();
+ });
+
+ it('renders multiple children', () => {
+ render(
+
+ First
+ Second
+
+ );
+ expect(screen.getByTestId('child-1')).toBeInTheDocument();
+ expect(screen.getByTestId('child-2')).toBeInTheDocument();
+ });
+ });
+
+ describe('useVideoPlayer hook', () => {
+ it('throws error when used outside provider', () => {
+ expect(() => {
+ renderHook(() => useVideoPlayer());
+ }).toThrow('useVideoPlayer must be used within a VideoProvider');
+ });
+
+ it('returns context inside provider', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current).toBeDefined();
+ expect(result.current.state).toBeDefined();
+ expect(result.current.controls).toBeDefined();
+ expect(result.current.playlistState).toBeDefined();
+ expect(result.current.playlistControls).toBeDefined();
+ expect(result.current.config).toBeDefined();
+ expect(result.current.videoRef).toBeDefined();
+ expect(result.current.containerRef).toBeDefined();
+ });
+ });
+
+ describe('Initial state', () => {
+ it('has correct default playing state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isPlaying).toBe(false);
+ expect(result.current.state.isPaused).toBe(true);
+ expect(result.current.state.isEnded).toBe(false);
+ });
+
+ it('has correct default time state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.currentTime).toBe(0);
+ expect(result.current.state.duration).toBe(0);
+ expect(result.current.state.buffered).toBe(0);
+ });
+
+ it('has correct default volume state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.volume).toBe(1);
+ expect(result.current.state.isMuted).toBe(false);
+ });
+
+ it('has correct default playback rate', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.playbackRate).toBe(1);
+ });
+
+ it('has no error initially', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.error).toBeNull();
+ });
+
+ it('has default fullscreen state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isFullscreen).toBe(false);
+ });
+
+ it('has default picture-in-picture state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isPictureInPicture).toBe(false);
+ });
+
+ it('has default controls visible state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.controlsVisible).toBe(true);
+ });
+
+ it('has default quality state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.currentQuality).toBe('auto');
+ expect(result.current.state.availableQualities).toEqual([]);
+ });
+
+ it('has default watch progress', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.watchProgress).toEqual({
+ watchedSegments: [],
+ percentageWatched: 0,
+ isFullyWatched: false,
+ furthestPoint: 0,
+ });
+ });
+
+ it('has default HLS state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isHLS).toBe(false);
+ // isAutoQuality defaults to false when no HLS source is set
+ expect(result.current.state.isAutoQuality).toBe(false);
+ });
+
+ it('has default subtitle state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.currentSubtitle).toBeNull();
+ });
+
+ it('has default tab visibility state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isTabVisible).toBe(true);
+ });
+
+ it('has default casting state', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.state.isCasting).toBe(false);
+ });
+ });
+
+ describe('Config defaults', () => {
+ it('has default video features enabled', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.features?.fullscreen).toBe(true);
+ expect(result.current.config.features?.qualitySelector).toBe(true);
+ expect(result.current.config.features?.subtitles).toBe(true);
+ expect(result.current.config.features?.autoHideControls).toBe(true);
+ expect(result.current.config.features?.chapters).toBe(true);
+ expect(result.current.config.features?.volumeControl).toBe(true);
+ });
+
+ it('defaults PiP to false', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.features?.pictureInPicture).toBe(false);
+ });
+
+ it('has correct default skip seconds for video', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.skipForwardSeconds).toBe(10);
+ expect(result.current.config.skipBackwardSeconds).toBe(10);
+ });
+
+ it('has default controls hide delay', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.controlsHideDelay).toBe(3000);
+ });
+
+ it('defaults autoPlay to false', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.autoPlay).toBe(false);
+ });
+
+ it('defaults autoPlayNext to true', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.config.autoPlayNext).toBe(true);
+ });
+ });
+
+ describe('Config overrides', () => {
+ it('overrides volume', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ volume: 0.3 }),
+ });
+
+ expect(result.current.config.volume).toBe(0.3);
+ });
+
+ it('overrides muted', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ muted: true }),
+ });
+
+ expect(result.current.config.muted).toBe(true);
+ });
+
+ it('overrides skip seconds', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({
+ skipForwardSeconds: 30,
+ skipBackwardSeconds: 5,
+ }),
+ });
+
+ expect(result.current.config.skipForwardSeconds).toBe(30);
+ expect(result.current.config.skipBackwardSeconds).toBe(5);
+ });
+
+ it('overrides features while keeping defaults', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({
+ features: { pictureInPicture: true, fullscreen: false },
+ }),
+ });
+
+ expect(result.current.config.features?.pictureInPicture).toBe(true);
+ expect(result.current.config.features?.fullscreen).toBe(false);
+ // Defaults preserved
+ expect(result.current.config.features?.qualitySelector).toBe(true);
+ expect(result.current.config.features?.subtitles).toBe(true);
+ });
+
+ it('overrides controls hide delay', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ controlsHideDelay: 5000 }),
+ });
+
+ expect(result.current.config.controlsHideDelay).toBe(5000);
+ });
+
+ it('accepts custom playback speeds', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ playbackSpeeds: [0.25, 0.5, 1, 3] }),
+ });
+
+ expect(result.current.config.playbackSpeeds).toEqual([0.25, 0.5, 1, 3]);
+ });
+
+ it('accepts poster config', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ poster: 'https://example.com/poster.jpg' }),
+ });
+
+ expect(result.current.config.poster).toBe('https://example.com/poster.jpg');
+ });
+ });
+
+ describe('Track and playlist', () => {
+ it('sets current track from config track', () => {
+ const track = createMockVideoTrack();
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ track }),
+ });
+
+ expect(result.current.playlistState.currentTrack).toEqual(track);
+ expect(result.current.currentTrack).toEqual(track);
+ });
+
+ it('sets playlist tracks from config playlist', () => {
+ const playlist = createMockVideoPlaylist(3);
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ playlist }),
+ });
+
+ expect(result.current.playlistState.tracks).toHaveLength(3);
+ expect(result.current.playlistState.currentIndex).toBe(0);
+ expect(result.current.currentTrack).toEqual(playlist[0]);
+ });
+
+ it('has null currentTrack when no track or playlist', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.currentTrack).toBeNull();
+ expect(result.current.playlistState.currentTrack).toBeNull();
+ });
+
+ it('prefers playlist over single track', () => {
+ const track = createMockVideoTrack({ id: 'single' });
+ const playlist = createMockVideoPlaylist(2);
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ track, playlist }),
+ });
+
+ expect(result.current.playlistState.tracks).toHaveLength(2);
+ expect(result.current.currentTrack?.id).toBe(playlist[0].id);
+ });
+ });
+
+ describe('Controls', () => {
+ it('has all video controls', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.controls.play).toBeDefined();
+ expect(result.current.controls.pause).toBeDefined();
+ expect(result.current.controls.toggle).toBeDefined();
+ expect(result.current.controls.stop).toBeDefined();
+ expect(result.current.controls.seek).toBeDefined();
+ expect(result.current.controls.seekTo).toBeDefined();
+ expect(result.current.controls.skipForward).toBeDefined();
+ expect(result.current.controls.skipBackward).toBeDefined();
+ expect(result.current.controls.setVolume).toBeDefined();
+ expect(result.current.controls.toggleMute).toBeDefined();
+ expect(result.current.controls.setPlaybackRate).toBeDefined();
+ expect(result.current.controls.enterFullscreen).toBeDefined();
+ expect(result.current.controls.exitFullscreen).toBeDefined();
+ expect(result.current.controls.toggleFullscreen).toBeDefined();
+ expect(result.current.controls.enterPictureInPicture).toBeDefined();
+ expect(result.current.controls.exitPictureInPicture).toBeDefined();
+ expect(result.current.controls.togglePictureInPicture).toBeDefined();
+ expect(result.current.controls.setQuality).toBeDefined();
+ expect(result.current.controls.setSubtitle).toBeDefined();
+ expect(result.current.controls.showControls).toBeDefined();
+ expect(result.current.controls.hideControls).toBeDefined();
+ expect(result.current.controls.setAutoQuality).toBeDefined();
+ });
+
+ it('has all playlist controls', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.playlistControls.next).toBeDefined();
+ expect(result.current.playlistControls.previous).toBeDefined();
+ expect(result.current.playlistControls.goToTrack).toBeDefined();
+ expect(result.current.playlistControls.setRepeat).toBeDefined();
+ expect(result.current.playlistControls.toggleShuffle).toBeDefined();
+ expect(result.current.playlistControls.addToQueue).toBeDefined();
+ expect(result.current.playlistControls.removeFromQueue).toBeDefined();
+ expect(result.current.playlistControls.clearQueue).toBeDefined();
+ });
+ });
+
+ describe('Playlist controls integration', () => {
+ it('navigates to next track', () => {
+ const playlist = createMockVideoPlaylist(3);
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ playlist }),
+ });
+
+ expect(result.current.playlistState.currentIndex).toBe(0);
+
+ act(() => {
+ result.current.playlistControls.next();
+ });
+
+ expect(result.current.playlistState.currentIndex).toBe(1);
+ expect(result.current.currentTrack?.id).toBe(playlist[1].id);
+ });
+
+ it('navigates to previous track', () => {
+ const playlist = createMockVideoPlaylist(3);
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ playlist }),
+ });
+
+ act(() => {
+ result.current.playlistControls.next();
+ });
+
+ act(() => {
+ result.current.playlistControls.previous();
+ });
+
+ expect(result.current.playlistState.currentIndex).toBe(0);
+ });
+
+ it('goes to specific track by index', () => {
+ const playlist = createMockVideoPlaylist(4);
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ playlist }),
+ });
+
+ act(() => {
+ result.current.playlistControls.goToTrack(3);
+ });
+
+ expect(result.current.playlistState.currentIndex).toBe(3);
+ expect(result.current.currentTrack?.id).toBe(playlist[3].id);
+ });
+
+ it('toggles shuffle', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ playlist: createMockVideoPlaylist() }),
+ });
+
+ expect(result.current.playlistState.shuffle).toBe(false);
+
+ act(() => {
+ result.current.playlistControls.toggleShuffle();
+ });
+
+ expect(result.current.playlistState.shuffle).toBe(true);
+ });
+
+ it('sets repeat mode', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({ playlist: createMockVideoPlaylist() }),
+ });
+
+ act(() => {
+ result.current.playlistControls.setRepeat('one');
+ });
+
+ expect(result.current.playlistState.repeat).toBe('one');
+ });
+ });
+
+ describe('Callbacks', () => {
+ it('calls onTrackChange when track changes', () => {
+ const onTrackChange = vi.fn();
+ const playlist = createMockVideoPlaylist(3);
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useVideoPlayer(), { wrapper });
+
+ act(() => {
+ result.current.playlistControls.next();
+ });
+
+ expect(onTrackChange).toHaveBeenCalledWith(playlist[1], 1);
+ });
+
+ it('accepts onStart callback prop', () => {
+ const onStart = vi.fn();
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useVideoPlayer(), { wrapper });
+ expect(result.current).toBeDefined();
+ });
+
+ it('accepts onFinished callback prop', () => {
+ const onFinished = vi.fn();
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useVideoPlayer(), { wrapper });
+ expect(result.current).toBeDefined();
+ });
+
+ it('accepts onError callback prop', () => {
+ const onError = vi.fn();
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useVideoPlayer(), { wrapper });
+ expect(result.current).toBeDefined();
+ });
+ });
+
+ describe('Refs', () => {
+ it('provides videoRef', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.videoRef).toBeDefined();
+ });
+
+ it('provides containerRef', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.containerRef).toBeDefined();
+ });
+ });
+
+ describe('Labels integration', () => {
+ it('wraps children in LabelsProvider with config labels', () => {
+ const { result } = renderHook(() => useVideoPlayer(), {
+ wrapper: createWrapper({
+ labels: { play: 'Reproducir' },
+ }),
+ });
+
+ expect(result.current).toBeDefined();
+ });
+ });
+});
diff --git a/src/context/index.ts b/src/context/index.ts
index 407b9d2..3db1b98 100644
--- a/src/context/index.ts
+++ b/src/context/index.ts
@@ -1,3 +1,4 @@
+export { FairuProvider, type FairuProviderProps } from './FairuProvider';
export { PlayerContext, PlayerProvider, type PlayerProviderProps } from './PlayerContext';
export { TrackingContext, TrackingProvider, useTracking, type TrackingProviderProps } from './TrackingContext';
export { AdContext, AdProvider, useAds, type AdProviderProps } from './AdContext';
diff --git a/src/embed/parseConfig.test.ts b/src/embed/parseConfig.test.ts
new file mode 100644
index 0000000..1f74cff
--- /dev/null
+++ b/src/embed/parseConfig.test.ts
@@ -0,0 +1,805 @@
+import { describe, it, expect } from 'vitest';
+import { parseDataAttributes, parseUrlParams } from './parseConfig';
+
+/**
+ * Helper to create an HTMLElement with the given data attributes
+ */
+function createElement(dataAttributes: Record = {}): HTMLElement {
+ const el = document.createElement('div');
+ for (const [key, value] of Object.entries(dataAttributes)) {
+ el.dataset[key] = value;
+ }
+ return el;
+}
+
+describe('parseDataAttributes', () => {
+ describe('track parsing', () => {
+ it('parses a track when data-src is present', () => {
+ const el = createElement({ src: 'https://example.com/audio.mp3' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track).toBeDefined();
+ expect(config.player.track!.src).toBe('https://example.com/audio.mp3');
+ });
+
+ it('returns undefined track when data-src is missing', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track).toBeUndefined();
+ });
+
+ it('uses data-id for track id when present', () => {
+ const el = createElement({ src: 'audio.mp3', id: 'my-track-id' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.id).toBe('my-track-id');
+ });
+
+ it('generates an id when data-id is missing', () => {
+ const el = createElement({ src: 'audio.mp3' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.id).toBeDefined();
+ expect(config.player.track!.id).toContain('track-');
+ });
+
+ it('parses title from data-title', () => {
+ const el = createElement({ src: 'audio.mp3', title: 'My Song' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.title).toBe('My Song');
+ });
+
+ it('parses artist from data-artist', () => {
+ const el = createElement({ src: 'audio.mp3', artist: 'Artist Name' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.artist).toBe('Artist Name');
+ });
+
+ it('parses artwork from data-artwork', () => {
+ const el = createElement({ src: 'audio.mp3', artwork: 'https://example.com/cover.jpg' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.artwork).toBe('https://example.com/cover.jpg');
+ });
+
+ it('parses duration as a float', () => {
+ const el = createElement({ src: 'audio.mp3', duration: '180.5' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.duration).toBe(180.5);
+ });
+
+ it('returns undefined duration when not provided', () => {
+ const el = createElement({ src: 'audio.mp3' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.duration).toBeUndefined();
+ });
+
+ it('parses chapters from JSON data-chapters', () => {
+ const chapters = [{ id: '1', title: 'Intro', startTime: 0 }];
+ const el = createElement({ src: 'audio.mp3', chapters: JSON.stringify(chapters) });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.chapters).toEqual(chapters);
+ });
+
+ it('returns undefined chapters when not provided', () => {
+ const el = createElement({ src: 'audio.mp3' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.chapters).toBeUndefined();
+ });
+ });
+
+ describe('playlist parsing', () => {
+ it('parses playlist from JSON data-playlist', () => {
+ const playlist = [
+ { id: '1', src: 'track1.mp3', title: 'Track 1' },
+ { id: '2', src: 'track2.mp3', title: 'Track 2' },
+ ];
+ const el = createElement({ playlist: JSON.stringify(playlist) });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.playlist).toEqual(playlist);
+ });
+
+ it('returns undefined playlist when not provided', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.playlist).toBeUndefined();
+ });
+ });
+
+ describe('features parsing', () => {
+ it('defaults all features to true when not specified', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.features).toEqual({
+ chapters: true,
+ volumeControl: true,
+ playbackSpeed: true,
+ skipButtons: true,
+ progressBar: true,
+ timeDisplay: true,
+ playlistView: true,
+ });
+ });
+
+ it('disables chapters when data-chapters is "false"', () => {
+ const el = createElement({ chapters: 'false' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.features!.chapters).toBe(false);
+ });
+
+ it('disables volume control when data-volume is "false"', () => {
+ const el = createElement({ volume: 'false' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.features!.volumeControl).toBe(false);
+ });
+
+ it('disables playback speed when data-speed is "false"', () => {
+ const el = createElement({ speed: 'false' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.features!.playbackSpeed).toBe(false);
+ });
+
+ it('disables skip buttons when data-skip is "false"', () => {
+ const el = createElement({ skip: 'false' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.features!.skipButtons).toBe(false);
+ });
+
+ it('disables progress bar when data-progress is "false"', () => {
+ const el = createElement({ progress: 'false' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.features!.progressBar).toBe(false);
+ });
+
+ it('disables time display when data-time is "false"', () => {
+ const el = createElement({ time: 'false' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.features!.timeDisplay).toBe(false);
+ });
+
+ it('disables playlist view when data-playlist-view is "false"', () => {
+ const el = createElement({ playlistView: 'false' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.features!.playlistView).toBe(false);
+ });
+
+ it('keeps features true for any value other than "false"', () => {
+ const el = createElement({
+ volume: 'true',
+ speed: 'yes',
+ skip: '1',
+ progress: 'on',
+ });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.features!.volumeControl).toBe(true);
+ expect(config.player.features!.playbackSpeed).toBe(true);
+ expect(config.player.features!.skipButtons).toBe(true);
+ expect(config.player.features!.progressBar).toBe(true);
+ });
+ });
+
+ describe('player config parsing', () => {
+ it('defaults autoPlayNext to true', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.autoPlayNext).toBe(true);
+ });
+
+ it('disables autoPlayNext when data-auto-play-next is "false"', () => {
+ const el = createElement({ autoPlayNext: 'false' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.autoPlayNext).toBe(false);
+ });
+
+ it('defaults shuffle to false', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.shuffle).toBe(false);
+ });
+
+ it('enables shuffle when data-shuffle is "true"', () => {
+ const el = createElement({ shuffle: 'true' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.shuffle).toBe(true);
+ });
+
+ it('does not enable shuffle for value other than "true"', () => {
+ const el = createElement({ shuffle: 'yes' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.shuffle).toBe(false);
+ });
+
+ it('defaults repeat to "none"', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.repeat).toBe('none');
+ });
+
+ it('parses repeat mode "one"', () => {
+ const el = createElement({ repeat: 'one' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.repeat).toBe('one');
+ });
+
+ it('parses repeat mode "all"', () => {
+ const el = createElement({ repeat: 'all' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.repeat).toBe('all');
+ });
+
+ it('defaults skipForwardSeconds to 30', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.skipForwardSeconds).toBe(30);
+ });
+
+ it('parses custom skipForward value', () => {
+ const el = createElement({ skipForward: '15' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.skipForwardSeconds).toBe(15);
+ });
+
+ it('defaults skipBackwardSeconds to 10', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.skipBackwardSeconds).toBe(10);
+ });
+
+ it('parses custom skipBackward value', () => {
+ const el = createElement({ skipBackward: '5' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.skipBackwardSeconds).toBe(5);
+ });
+
+ it('parses playbackSpeeds from JSON', () => {
+ const speeds = [0.5, 1, 1.5, 2];
+ const el = createElement({ speeds: JSON.stringify(speeds) });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.playbackSpeeds).toEqual(speeds);
+ });
+
+ it('returns undefined playbackSpeeds when not provided', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.playbackSpeeds).toBeUndefined();
+ });
+
+ it('defaults volume to 1', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.volume).toBe(1);
+ });
+
+ it('parses custom initialVolume', () => {
+ const el = createElement({ initialVolume: '0.5' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.volume).toBe(0.5);
+ });
+
+ it('defaults muted to false', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.muted).toBe(false);
+ });
+
+ it('enables muted when data-muted is "true"', () => {
+ const el = createElement({ muted: 'true' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.muted).toBe(true);
+ });
+
+ it('defaults autoPlay to false', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.player.autoPlay).toBe(false);
+ });
+
+ it('enables autoPlay when data-auto-play is "true"', () => {
+ const el = createElement({ autoPlay: 'true' });
+ const config = parseDataAttributes(el);
+
+ expect(config.player.autoPlay).toBe(true);
+ });
+ });
+
+ describe('tracking config parsing', () => {
+ it('returns undefined tracking when data-tracking-endpoint is missing', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.tracking).toBeUndefined();
+ });
+
+ it('parses tracking config when endpoint is provided', () => {
+ const el = createElement({ trackingEndpoint: 'https://analytics.example.com/track' });
+ const config = parseDataAttributes(el);
+
+ expect(config.tracking).toBeDefined();
+ expect(config.tracking!.endpoint).toBe('https://analytics.example.com/track');
+ expect(config.tracking!.enabled).toBe(true);
+ });
+
+ it('disables tracking when data-tracking-enabled is "false"', () => {
+ const el = createElement({
+ trackingEndpoint: 'https://analytics.example.com/track',
+ trackingEnabled: 'false',
+ });
+ const config = parseDataAttributes(el);
+
+ expect(config.tracking!.enabled).toBe(false);
+ });
+ });
+
+ describe('theme parsing', () => {
+ it('defaults theme to "light"', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.theme).toBe('light');
+ });
+
+ it('parses theme "dark"', () => {
+ const el = createElement({ theme: 'dark' });
+ const config = parseDataAttributes(el);
+
+ expect(config.theme).toBe('dark');
+ });
+
+ it('parses theme "high-contrast"', () => {
+ const el = createElement({ theme: 'high-contrast' });
+ const config = parseDataAttributes(el);
+
+ expect(config.theme).toBe('high-contrast');
+ });
+
+ it('parses theme "auto"', () => {
+ const el = createElement({ theme: 'auto' });
+ const config = parseDataAttributes(el);
+
+ expect(config.theme).toBe('auto');
+ });
+ });
+
+ describe('container parsing', () => {
+ it('returns undefined container when not specified', () => {
+ const el = createElement({});
+ const config = parseDataAttributes(el);
+
+ expect(config.container).toBeUndefined();
+ });
+
+ it('parses container selector', () => {
+ const el = createElement({ container: '#player-root' });
+ const config = parseDataAttributes(el);
+
+ expect(config.container).toBe('#player-root');
+ });
+ });
+
+ describe('full config parsing', () => {
+ it('parses a complete configuration', () => {
+ const el = createElement({
+ src: 'https://example.com/song.mp3',
+ id: 'song-1',
+ title: 'Test Song',
+ artist: 'Test Artist',
+ artwork: 'https://example.com/art.jpg',
+ duration: '240',
+ volume: 'false',
+ shuffle: 'true',
+ repeat: 'all',
+ autoPlay: 'true',
+ muted: 'true',
+ initialVolume: '0.8',
+ theme: 'dark',
+ container: '#app',
+ trackingEndpoint: 'https://track.example.com',
+ });
+
+ const config = parseDataAttributes(el);
+
+ expect(config.player.track!.src).toBe('https://example.com/song.mp3');
+ expect(config.player.track!.id).toBe('song-1');
+ expect(config.player.track!.title).toBe('Test Song');
+ expect(config.player.features!.volumeControl).toBe(false);
+ expect(config.player.shuffle).toBe(true);
+ expect(config.player.repeat).toBe('all');
+ expect(config.player.autoPlay).toBe(true);
+ expect(config.player.muted).toBe(true);
+ expect(config.player.volume).toBe(0.8);
+ expect(config.theme).toBe('dark');
+ expect(config.container).toBe('#app');
+ expect(config.tracking!.endpoint).toBe('https://track.example.com');
+ });
+ });
+});
+
+describe('parseUrlParams', () => {
+ const baseUrl = 'https://embed.example.com/player';
+
+ describe('track parsing', () => {
+ it('parses a track when src param is present', () => {
+ const config = parseUrlParams(`${baseUrl}?src=https://example.com/audio.mp3`);
+
+ expect(config.player.track).toBeDefined();
+ expect(config.player.track!.src).toBe('https://example.com/audio.mp3');
+ });
+
+ it('returns undefined track when src param is missing', () => {
+ const config = parseUrlParams(`${baseUrl}?title=Test`);
+
+ expect(config.player.track).toBeUndefined();
+ });
+
+ it('uses id param for track id when present', () => {
+ const config = parseUrlParams(`${baseUrl}?src=audio.mp3&id=my-id`);
+
+ expect(config.player.track!.id).toBe('my-id');
+ });
+
+ it('generates an id when id param is missing', () => {
+ const config = parseUrlParams(`${baseUrl}?src=audio.mp3`);
+
+ expect(config.player.track!.id).toBeDefined();
+ expect(config.player.track!.id).toContain('track-');
+ });
+
+ it('parses title param', () => {
+ const config = parseUrlParams(`${baseUrl}?src=audio.mp3&title=My+Song`);
+
+ expect(config.player.track!.title).toBe('My Song');
+ });
+
+ it('returns undefined title when not provided', () => {
+ const config = parseUrlParams(`${baseUrl}?src=audio.mp3`);
+
+ expect(config.player.track!.title).toBeUndefined();
+ });
+
+ it('parses artist param', () => {
+ const config = parseUrlParams(`${baseUrl}?src=audio.mp3&artist=The+Band`);
+
+ expect(config.player.track!.artist).toBe('The Band');
+ });
+
+ it('parses artwork param', () => {
+ const config = parseUrlParams(`${baseUrl}?src=audio.mp3&artwork=https://img.example.com/cover.jpg`);
+
+ expect(config.player.track!.artwork).toBe('https://img.example.com/cover.jpg');
+ });
+
+ it('parses duration as float', () => {
+ const config = parseUrlParams(`${baseUrl}?src=audio.mp3&duration=245.5`);
+
+ expect(config.player.track!.duration).toBe(245.5);
+ });
+
+ it('returns undefined duration when not provided', () => {
+ const config = parseUrlParams(`${baseUrl}?src=audio.mp3`);
+
+ expect(config.player.track!.duration).toBeUndefined();
+ });
+ });
+
+ describe('playlist parsing', () => {
+ it('parses playlist from encoded JSON param', () => {
+ const playlist = [
+ { id: '1', src: 'track1.mp3', title: 'Track 1' },
+ { id: '2', src: 'track2.mp3', title: 'Track 2' },
+ ];
+ const encoded = encodeURIComponent(JSON.stringify(playlist));
+ const config = parseUrlParams(`${baseUrl}?playlist=${encoded}`);
+
+ expect(config.player.playlist).toEqual(playlist);
+ });
+
+ it('returns undefined playlist when not provided', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.playlist).toBeUndefined();
+ });
+ });
+
+ describe('features parsing', () => {
+ it('defaults all features to true when not specified', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.features).toEqual({
+ chapters: true,
+ volumeControl: true,
+ playbackSpeed: true,
+ skipButtons: true,
+ progressBar: true,
+ timeDisplay: true,
+ playlistView: true,
+ });
+ });
+
+ it('disables chapters when chapters=false', () => {
+ const config = parseUrlParams(`${baseUrl}?chapters=false`);
+
+ expect(config.player.features!.chapters).toBe(false);
+ });
+
+ it('disables volume when volume=false', () => {
+ const config = parseUrlParams(`${baseUrl}?volume=false`);
+
+ expect(config.player.features!.volumeControl).toBe(false);
+ });
+
+ it('disables speed when speed=false', () => {
+ const config = parseUrlParams(`${baseUrl}?speed=false`);
+
+ expect(config.player.features!.playbackSpeed).toBe(false);
+ });
+
+ it('disables skip when skip=false', () => {
+ const config = parseUrlParams(`${baseUrl}?skip=false`);
+
+ expect(config.player.features!.skipButtons).toBe(false);
+ });
+
+ it('disables progress when progress=false', () => {
+ const config = parseUrlParams(`${baseUrl}?progress=false`);
+
+ expect(config.player.features!.progressBar).toBe(false);
+ });
+
+ it('disables time when time=false', () => {
+ const config = parseUrlParams(`${baseUrl}?time=false`);
+
+ expect(config.player.features!.timeDisplay).toBe(false);
+ });
+
+ it('disables playlistView when playlistView=false', () => {
+ const config = parseUrlParams(`${baseUrl}?playlistView=false`);
+
+ expect(config.player.features!.playlistView).toBe(false);
+ });
+
+ it('keeps features true for any value other than "false"', () => {
+ const config = parseUrlParams(`${baseUrl}?volume=true&speed=yes&skip=1`);
+
+ expect(config.player.features!.volumeControl).toBe(true);
+ expect(config.player.features!.playbackSpeed).toBe(true);
+ expect(config.player.features!.skipButtons).toBe(true);
+ });
+ });
+
+ describe('player config parsing', () => {
+ it('defaults autoPlayNext to true', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.autoPlayNext).toBe(true);
+ });
+
+ it('disables autoPlayNext when autoPlayNext=false', () => {
+ const config = parseUrlParams(`${baseUrl}?autoPlayNext=false`);
+
+ expect(config.player.autoPlayNext).toBe(false);
+ });
+
+ it('defaults shuffle to false', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.shuffle).toBe(false);
+ });
+
+ it('enables shuffle when shuffle=true', () => {
+ const config = parseUrlParams(`${baseUrl}?shuffle=true`);
+
+ expect(config.player.shuffle).toBe(true);
+ });
+
+ it('defaults repeat to "none"', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.repeat).toBe('none');
+ });
+
+ it('parses repeat=one', () => {
+ const config = parseUrlParams(`${baseUrl}?repeat=one`);
+
+ expect(config.player.repeat).toBe('one');
+ });
+
+ it('parses repeat=all', () => {
+ const config = parseUrlParams(`${baseUrl}?repeat=all`);
+
+ expect(config.player.repeat).toBe('all');
+ });
+
+ it('defaults skipForwardSeconds to 30', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.skipForwardSeconds).toBe(30);
+ });
+
+ it('parses custom skipForward', () => {
+ const config = parseUrlParams(`${baseUrl}?skipForward=15`);
+
+ expect(config.player.skipForwardSeconds).toBe(15);
+ });
+
+ it('defaults skipBackwardSeconds to 10', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.skipBackwardSeconds).toBe(10);
+ });
+
+ it('parses custom skipBackward', () => {
+ const config = parseUrlParams(`${baseUrl}?skipBackward=5`);
+
+ expect(config.player.skipBackwardSeconds).toBe(5);
+ });
+
+ it('defaults volume to 1', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.volume).toBe(1);
+ });
+
+ it('parses custom initialVolume', () => {
+ const config = parseUrlParams(`${baseUrl}?initialVolume=0.7`);
+
+ expect(config.player.volume).toBe(0.7);
+ });
+
+ it('defaults muted to false', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.muted).toBe(false);
+ });
+
+ it('enables muted when muted=true', () => {
+ const config = parseUrlParams(`${baseUrl}?muted=true`);
+
+ expect(config.player.muted).toBe(true);
+ });
+
+ it('defaults autoPlay to false', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.autoPlay).toBe(false);
+ });
+
+ it('enables autoPlay when autoPlay=true', () => {
+ const config = parseUrlParams(`${baseUrl}?autoPlay=true`);
+
+ expect(config.player.autoPlay).toBe(true);
+ });
+ });
+
+ describe('theme parsing', () => {
+ it('defaults theme to "light"', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.theme).toBe('light');
+ });
+
+ it('parses theme=dark', () => {
+ const config = parseUrlParams(`${baseUrl}?theme=dark`);
+
+ expect(config.theme).toBe('dark');
+ });
+
+ it('parses theme=high-contrast', () => {
+ const config = parseUrlParams(`${baseUrl}?theme=high-contrast`);
+
+ expect(config.theme).toBe('high-contrast');
+ });
+
+ it('parses theme=auto', () => {
+ const config = parseUrlParams(`${baseUrl}?theme=auto`);
+
+ expect(config.theme).toBe('auto');
+ });
+ });
+
+ describe('tracking', () => {
+ it('does not include tracking in URL params config', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.tracking).toBeUndefined();
+ });
+ });
+
+ describe('container', () => {
+ it('does not include container in URL params config', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.container).toBeUndefined();
+ });
+ });
+
+ describe('full config parsing', () => {
+ it('parses a complete URL configuration', () => {
+ const params = new URLSearchParams({
+ src: 'https://example.com/song.mp3',
+ id: 'song-1',
+ title: 'Test Song',
+ artist: 'Test Artist',
+ artwork: 'https://example.com/art.jpg',
+ duration: '240',
+ volume: 'false',
+ shuffle: 'true',
+ repeat: 'all',
+ autoPlay: 'true',
+ muted: 'true',
+ initialVolume: '0.8',
+ theme: 'dark',
+ });
+
+ const config = parseUrlParams(`${baseUrl}?${params.toString()}`);
+
+ expect(config.player.track!.src).toBe('https://example.com/song.mp3');
+ expect(config.player.track!.id).toBe('song-1');
+ expect(config.player.track!.title).toBe('Test Song');
+ expect(config.player.track!.artist).toBe('Test Artist');
+ expect(config.player.features!.volumeControl).toBe(false);
+ expect(config.player.shuffle).toBe(true);
+ expect(config.player.repeat).toBe('all');
+ expect(config.player.autoPlay).toBe(true);
+ expect(config.player.muted).toBe(true);
+ expect(config.player.volume).toBe(0.8);
+ expect(config.theme).toBe('dark');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles URL with no search params', () => {
+ const config = parseUrlParams(baseUrl);
+
+ expect(config.player.track).toBeUndefined();
+ expect(config.player.playlist).toBeUndefined();
+ expect(config.player.shuffle).toBe(false);
+ expect(config.player.repeat).toBe('none');
+ expect(config.theme).toBe('light');
+ });
+
+ it('handles URL with empty string values', () => {
+ const config = parseUrlParams(`${baseUrl}?src=audio.mp3&title=&artist=`);
+
+ // empty string is falsy, so title/artist become undefined
+ expect(config.player.track!.title).toBeUndefined();
+ expect(config.player.track!.artist).toBeUndefined();
+ });
+ });
+});
diff --git a/src/examples/NewFeaturesDemo.stories.tsx b/src/examples/NewFeaturesDemo.stories.tsx
new file mode 100644
index 0000000..2b8a551
--- /dev/null
+++ b/src/examples/NewFeaturesDemo.stories.tsx
@@ -0,0 +1,432 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState, useEffect, useMemo } from 'react';
+import { VideoPlayer } from '@/components/VideoPlayer/VideoPlayer';
+import { SubtitleDisplay } from '@/components/VideoPlayer/SubtitleDisplay';
+import { PauseAd } from '@/components/ads/PauseAd';
+import { RewardedAdOverlay } from '@/components/ads/RewardedAd';
+import { ProgressBar } from '@/components/controls/ProgressBar';
+import { DEFAULT_SUBTITLE_STYLE, SUBTITLE_PRESETS } from '@/types/subtitleStyling';
+import type { VideoTrack } from '@/types/video';
+import type { PauseAd as PauseAdType } from '@/types/pauseAd';
+import type { RewardedAd as RewardedAdType } from '@/types/rewardedAd';
+import type { SubtitleStyle } from '@/types/subtitleStyling';
+
+const sampleVideo: VideoTrack = {
+ id: '1',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
+ title: 'Big Buck Bunny',
+ artist: 'Blender Foundation',
+ poster:
+ '',
+ duration: 596,
+};
+
+/** Convert a SubtitleStyle to React.CSSProperties */
+function styleToCss(s: SubtitleStyle): React.CSSProperties {
+ const hexToRgba = (hex: string, opacity: number): string => {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
+ };
+
+ return {
+ fontSize: `${s.fontSize}px`,
+ fontFamily: s.fontFamily,
+ color: s.textColor,
+ backgroundColor: hexToRgba(s.backgroundColor, s.backgroundOpacity),
+ textShadow: s.textShadow,
+ ...(s.position === 'top'
+ ? { top: '10%', bottom: 'auto' }
+ : { bottom: '10%', top: 'auto' }),
+ padding: '4px 8px',
+ borderRadius: '4px',
+ };
+}
+
+const meta: Meta = {
+ title: 'Examples/New Features Demo',
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+function IntegratedDemoComponent() {
+ // --- State ---
+ const [currentTime, setCurrentTime] = useState(0);
+ const [, setIsPlaying] = useState(false);
+ const [isPaused, setIsPaused] = useState(false);
+ const [hasPlayed, setHasPlayed] = useState(false);
+
+ // Subtitle state
+ const [subtitleMode, setSubtitleMode] = useState<'overlay' | 'below' | 'off'>(
+ 'overlay',
+ );
+ const [subtitleStyle, setSubtitleStyle] =
+ useState(DEFAULT_SUBTITLE_STYLE);
+ const [subtitleText, setSubtitleText] = useState(null);
+
+ // Pause ad state
+ const [showPauseAd, setShowPauseAd] = useState(false);
+
+ // Rewarded ad state
+ const [showRewardedAd, setShowRewardedAd] = useState(false);
+ const [isRewarded, setIsRewarded] = useState(false);
+
+ // A-B Loop state
+ const [loopStart, setLoopStart] = useState(null);
+ const [loopEnd, setLoopEnd] = useState(null);
+
+ // Event log
+ const [events, setEvents] = useState([]);
+ const addEvent = (msg: string) => {
+ setEvents((prev) => [
+ ...prev.slice(-9),
+ `${new Date().toLocaleTimeString()}: ${msg}`,
+ ]);
+ };
+
+ // Simulate subtitle cues based on time
+ useEffect(() => {
+ if (subtitleMode === 'off') {
+ setSubtitleText(null);
+ return;
+ }
+ const cues = [
+ { start: 0, end: 5, text: 'Welcome to Big Buck Bunny' },
+ { start: 5, end: 10, text: 'A short film by the Blender Foundation' },
+ { start: 10, end: 15, text: 'In a world of beauty and wonder...' },
+ { start: 15, end: 20, text: 'One bunny discovers adventure' },
+ { start: 25, end: 30, text: 'Big Buck Bunny - Enjoy the show!' },
+ { start: 35, end: 40, text: 'The forest is full of surprises' },
+ { start: 45, end: 50, text: 'Watch out for the butterflies!' },
+ ];
+ const activeCue = cues.find(
+ (c) => currentTime >= c.start && currentTime < c.end,
+ );
+ setSubtitleText(activeCue?.text ?? null);
+ }, [currentTime, subtitleMode]);
+
+ // Pause ad logic
+ useEffect(() => {
+ if (isPaused && hasPlayed && !showRewardedAd) {
+ const timer = setTimeout(() => setShowPauseAd(true), 500);
+ return () => clearTimeout(timer);
+ } else {
+ setShowPauseAd(false);
+ }
+ }, [isPaused, hasPlayed, showRewardedAd]);
+
+ // Convert subtitle style to CSS
+ const subtitleCss = useMemo(
+ () => styleToCss(subtitleStyle),
+ [subtitleStyle],
+ );
+
+ // Pause ad data
+ const pauseAd: PauseAdType = {
+ id: 'demo-pause-ad',
+ imageUrl: 'https://placehold.co/600x300/1a1a2e/00a99d?text=Sponsored+Content',
+ title: 'Check out our sponsor',
+ description: 'Premium podcast hosting for creators',
+ clickThroughUrl: 'https://example.com',
+ };
+
+ // Rewarded ad data
+ const rewardedAd: RewardedAdType = {
+ id: 'demo-rewarded',
+ src: 'https://files.fairu.app/41b8d7ef-3698-5c75-83e1-9325953a72a4/file.mp4',
+ duration: 15,
+ title: 'Watch to unlock bonus content',
+ rewardDescription:
+ "Watch this 15s ad to unlock the director's commentary",
+ poster:
+ 'https://placehold.co/800x450/1a1a2e/ffcc00?text=Rewarded+Ad',
+ };
+
+ return (
+
+ {/* Header */}
+
New Features Demo
+
+ This demo showcases: Custom Subtitles, Pause Ads, Rewarded Ads, A-B
+ Loop, and Subtitle Styling.
+
+
+ {/* Video Player Container */}
+
+
{
+ setIsPlaying(true);
+ setIsPaused(false);
+ setHasPlayed(true);
+ addEvent('Play');
+ }}
+ onPause={() => {
+ setIsPlaying(false);
+ setIsPaused(true);
+ addEvent('Pause');
+ }}
+ onTimeUpdate={(time) => setCurrentTime(time)}
+ />
+
+ {/* Subtitle Overlay (rendered on top of video) */}
+ {subtitleMode === 'overlay' && (
+
+ )}
+
+ {/* Pause Ad Overlay */}
+ {
+ setShowPauseAd(false);
+ addEvent('Pause Ad dismissed');
+ }}
+ onClick={() => addEvent('Pause Ad clicked')}
+ />
+
+
+ {/* Subtitle Below Mode */}
+ {subtitleMode === 'below' && (
+
+ )}
+
+ {/* Controls Panel */}
+
+ {/* Left: Subtitle Controls */}
+
+
Subtitles
+
+ {/* Mode toggle */}
+
+ {(['overlay', 'below', 'off'] as const).map((mode) => (
+ {
+ setSubtitleMode(mode);
+ addEvent(`Subtitles: ${mode}`);
+ }}
+ className={`flex-1 px-2 py-1 rounded text-xs capitalize border transition-colors ${
+ subtitleMode === mode
+ ? 'border-[var(--fp-color-accent)] text-[var(--fp-color-accent)]'
+ : 'border-gray-600 text-gray-400 hover:border-gray-400'
+ }`}
+ >
+ {mode}
+
+ ))}
+
+
+ {/* Style presets */}
+
Style Presets
+
+ {SUBTITLE_PRESETS.map((preset) => (
+ {
+ setSubtitleStyle(preset.style);
+ addEvent(`Subtitle style: ${preset.label}`);
+ }}
+ className="px-2 py-1 rounded text-xs border border-gray-600 text-gray-400 hover:border-[var(--fp-color-accent)] hover:text-[var(--fp-color-accent)] transition-colors"
+ >
+ {preset.label}
+
+ ))}
+
+
+ {/* Font size slider */}
+
+ Font Size
+
+ {subtitleStyle.fontSize}px
+
+
+
+ setSubtitleStyle((prev) => ({
+ ...prev,
+ fontSize: Number(e.target.value),
+ }))
+ }
+ className="w-full h-1 rounded-full appearance-none bg-gray-700 accent-[var(--fp-color-accent)] mb-2"
+ />
+
+
+ {/* Right: Ad Controls */}
+
+
Ad Formats
+
+ {/* Pause Ad info */}
+
+
Pause Ad
+
+ Pause the video to see the Pause Ad overlay.
+ {showPauseAd && (
+ Active
+ )}
+
+
+
+ {/* Rewarded Ad */}
+
+
Rewarded Ad
+ {isRewarded ? (
+
+
+
+
+
+ Bonus content unlocked!
+
+
+ ) : (
+
{
+ setShowRewardedAd(true);
+ addEvent('Rewarded Ad started');
+ }}
+ className="px-3 py-1.5 rounded-lg text-xs font-medium bg-yellow-500 text-black hover:bg-yellow-400 transition-colors"
+ >
+ Watch Ad to Unlock Bonus
+
+ )}
+
+
+ {/* A-B Loop */}
+
+
A-B Loop
+
+ {
+ setLoopStart(currentTime);
+ addEvent(`Loop A set: ${Math.floor(currentTime)}s`);
+ }}
+ className={`flex-1 px-2 py-1 rounded text-xs border transition-colors ${
+ loopStart !== null
+ ? 'border-blue-400 text-blue-400'
+ : 'border-gray-600 text-gray-400'
+ }`}
+ >
+ Set A{' '}
+ {loopStart !== null ? `(${Math.floor(loopStart)}s)` : ''}
+
+ {
+ setLoopEnd(currentTime);
+ addEvent(`Loop B set: ${Math.floor(currentTime)}s`);
+ }}
+ className={`flex-1 px-2 py-1 rounded text-xs border transition-colors ${
+ loopEnd !== null
+ ? 'border-blue-400 text-blue-400'
+ : 'border-gray-600 text-gray-400'
+ }`}
+ >
+ Set B {loopEnd !== null ? `(${Math.floor(loopEnd)}s)` : ''}
+
+ {
+ setLoopStart(null);
+ setLoopEnd(null);
+ addEvent('Loop cleared');
+ }}
+ className="px-2 py-1 rounded text-xs border border-gray-600 text-gray-400 hover:border-red-400 hover:text-red-400 transition-colors"
+ >
+ Clear
+
+
+ {loopStart !== null && loopEnd !== null && (
+
+ Looping: {Math.floor(loopStart)}s →{' '}
+ {Math.floor(loopEnd)}s
+
+ )}
+
+
+
+
+ {/* A-B Loop Progress Bar Preview */}
+ {loopStart !== null && loopEnd !== null && (
+
+
+ Loop Region Preview
+
+
+
+ )}
+
+ {/* Event Log */}
+
+
+ Event Log
+
+
+ {events.length === 0 ? (
+
+ Interact with the player to see events...
+
+ ) : (
+ events.map((e, i) =>
{e}
)
+ )}
+
+
+
+ {/* Rewarded Ad Overlay (renders on top of everything) */}
+
{
+ setIsRewarded(true);
+ addEvent('Reward earned!');
+ }}
+ onClose={(_, completed) => {
+ setShowRewardedAd(false);
+ addEvent(`Rewarded Ad closed (completed: ${completed})`);
+ }}
+ onClick={() => addEvent('Rewarded Ad clicked')}
+ />
+
+ );
+}
+
+export const IntegratedDemo: Story = {
+ render: () => ,
+};
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index ddec5c2..a00864e 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -11,6 +11,22 @@ export { usePlaylist, type UsePlaylistOptions, type UsePlaylistReturn } from './
export { useChapters } from './useChapters';
export { useMarkers } from './useMarkers';
export { useKeyboardControls, type UseKeyboardControlsOptions } from './useKeyboardControls';
+export { useSleepTimer } from './useSleepTimer';
+export { useResumePosition } from './useResumePosition';
+export { useGestures, type UseGesturesOptions } from './useGestures';
+export { useAutoplayDetection, type UseAutoplayDetectionOptions, type AutoplayPolicy } from './useAutoplayDetection';
+export { useShareableTimestamp, formatTimestamp, parseTimestamp, type UseShareableTimestampOptions, type UseShareableTimestampReturn } from './useShareableTimestamp';
+export { useABLoop, type UseABLoopOptions, type UseABLoopReturn, type ABLoopState, type ABLoopControls } from './useABLoop';
+export { usePlaylistPersistence } from './usePlaylistPersistence';
+export { usePlaybackHistory } from './usePlaybackHistory';
+export { useSubtitleStyling } from './useSubtitleStyling';
+export { useSubtitleParser, parseVTTCues, type UseSubtitleParserOptions, type UseSubtitleParserReturn, type SubtitleCue } from './useSubtitleParser';
+export { useFocusTrap, type UseFocusTrapOptions, type UseFocusTrapReturn } from './useFocusTrap';
+export { useEqualizer } from './useEqualizer';
+export { useSyncPlayback } from './useSyncPlayback';
+export type { UseSyncPlaybackOptions, UseSyncPlaybackReturn } from '@/types/sync';
+export { usePauseAd } from './usePauseAd';
+export { useRewardedAd } from './useRewardedAd';
export { useAds } from '@/context/AdContext';
export { useVideoPlayer } from '@/context/VideoContext';
export { useVideoAds } from '@/context/VideoAdContext';
diff --git a/src/hooks/useABLoop.ts b/src/hooks/useABLoop.ts
new file mode 100644
index 0000000..de5d56b
--- /dev/null
+++ b/src/hooks/useABLoop.ts
@@ -0,0 +1,110 @@
+import { useState, useCallback, useEffect, useRef } from 'react';
+
+export interface ABLoopState {
+ /** Start time of the loop (point A), null if not set */
+ loopStart: number | null;
+ /** End time of the loop (point B), null if not set */
+ loopEnd: number | null;
+ /** Whether a complete loop is active (both A and B set) */
+ isLooping: boolean;
+}
+
+export interface ABLoopControls {
+ /** Set point A at the given time (or current time if not provided) */
+ setA: (time?: number) => void;
+ /** Set point B at the given time (or current time if not provided). If B < A, swap them. */
+ setB: (time?: number) => void;
+ /** Clear both loop points */
+ clearLoop: () => void;
+}
+
+export interface UseABLoopOptions {
+ /** Current playback time - used to auto-seek back to A when reaching B */
+ currentTime: number;
+ /** Seek function from the media controls */
+ onSeek: (time: number) => void;
+ /** Whether the hook is enabled. Default: true */
+ enabled?: boolean;
+}
+
+export interface UseABLoopReturn {
+ state: ABLoopState;
+ controls: ABLoopControls;
+}
+
+export function useABLoop(options: UseABLoopOptions): UseABLoopReturn {
+ const { currentTime, onSeek, enabled = true } = options;
+
+ const [loopStart, setLoopStart] = useState(null);
+ const [loopEnd, setLoopEnd] = useState(null);
+
+ const isLooping = loopStart !== null && loopEnd !== null;
+
+ // Ref to track whether we just seeked, to avoid infinite re-seeks
+ const justSeekedRef = useRef(false);
+
+ const setA = useCallback((time?: number) => {
+ if (!enabled) return;
+ const t = time ?? currentTime;
+ setLoopStart(t);
+ // If B is already set and is less than the new A, swap them
+ setLoopEnd((prevEnd) => {
+ if (prevEnd !== null && prevEnd < t) {
+ setLoopStart(prevEnd);
+ return t;
+ }
+ return prevEnd;
+ });
+ }, [enabled, currentTime]);
+
+ const setB = useCallback((time?: number) => {
+ if (!enabled) return;
+ const t = time ?? currentTime;
+ setLoopEnd(t);
+ // If A is already set and B < A, swap them
+ setLoopStart((prevStart) => {
+ if (prevStart !== null && t < prevStart) {
+ setLoopEnd(prevStart);
+ return t;
+ }
+ return prevStart;
+ });
+ }, [enabled, currentTime]);
+
+ const clearLoop = useCallback(() => {
+ setLoopStart(null);
+ setLoopEnd(null);
+ justSeekedRef.current = false;
+ }, []);
+
+ // Auto-seek back to A when currentTime reaches B
+ useEffect(() => {
+ if (!enabled || !isLooping || loopStart === null || loopEnd === null) return;
+
+ if (justSeekedRef.current) {
+ // After a seek, wait until the currentTime moves back below loopEnd - tolerance
+ if (currentTime < loopEnd - 0.15) {
+ justSeekedRef.current = false;
+ }
+ return;
+ }
+
+ if (currentTime >= loopEnd) {
+ justSeekedRef.current = true;
+ onSeek(loopStart);
+ }
+ }, [enabled, isLooping, currentTime, loopStart, loopEnd, onSeek]);
+
+ return {
+ state: {
+ loopStart,
+ loopEnd,
+ isLooping,
+ },
+ controls: {
+ setA,
+ setB,
+ clearLoop,
+ },
+ };
+}
diff --git a/src/hooks/useAudio.test.ts b/src/hooks/useAudio.test.ts
new file mode 100644
index 0000000..426eb0d
--- /dev/null
+++ b/src/hooks/useAudio.test.ts
@@ -0,0 +1,210 @@
+import { renderHook, act } from '@testing-library/react';
+import { useAudio } from './useAudio';
+
+describe('useAudio', () => {
+ // ── Return shape ──────────────────────────────────────────────────
+
+ it('should return audioRef, state, and controls', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current).toHaveProperty('audioRef');
+ expect(result.current).toHaveProperty('state');
+ expect(result.current).toHaveProperty('controls');
+ });
+
+ it('should return audioRef as a ref object', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current.audioRef).toHaveProperty('current');
+ });
+
+ // ── Default state ─────────────────────────────────────────────────
+
+ it('should start with isPlaying false', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current.state.isPlaying).toBe(false);
+ });
+
+ it('should start with isPaused true', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current.state.isPaused).toBe(true);
+ });
+
+ it('should start with volume 1', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current.state.volume).toBe(1);
+ });
+
+ it('should start with isMuted false', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current.state.isMuted).toBe(false);
+ });
+
+ it('should start with currentTime 0', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current.state.currentTime).toBe(0);
+ });
+
+ it('should start with duration 0', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current.state.duration).toBe(0);
+ });
+
+ it('should start with playbackRate 1', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current.state.playbackRate).toBe(1);
+ });
+
+ it('should start with error null', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(result.current.state.error).toBeNull();
+ });
+
+ // ── Custom initial options ────────────────────────────────────────
+
+ it('should accept a custom initial volume', () => {
+ const { result } = renderHook(() => useAudio({ volume: 0.5 }));
+ expect(result.current.state.volume).toBe(0.5);
+ });
+
+ it('should accept initial muted state', () => {
+ const { result } = renderHook(() => useAudio({ muted: true }));
+ expect(result.current.state.isMuted).toBe(true);
+ });
+
+ it('should accept initial playback rate', () => {
+ const { result } = renderHook(() => useAudio({ playbackRate: 1.5 }));
+ expect(result.current.state.playbackRate).toBe(1.5);
+ });
+
+ // ── Controls shape ────────────────────────────────────────────────
+
+ it('should expose play control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.play).toBe('function');
+ });
+
+ it('should expose pause control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.pause).toBe('function');
+ });
+
+ it('should expose toggle control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.toggle).toBe('function');
+ });
+
+ it('should expose stop control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.stop).toBe('function');
+ });
+
+ it('should expose seek control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.seek).toBe('function');
+ });
+
+ it('should expose seekTo control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.seekTo).toBe('function');
+ });
+
+ it('should expose skipForward control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.skipForward).toBe('function');
+ });
+
+ it('should expose skipBackward control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.skipBackward).toBe('function');
+ });
+
+ it('should expose setVolume control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.setVolume).toBe('function');
+ });
+
+ it('should expose toggleMute control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.toggleMute).toBe('function');
+ });
+
+ it('should expose setPlaybackRate control', () => {
+ const { result } = renderHook(() => useAudio());
+ expect(typeof result.current.controls.setPlaybackRate).toBe('function');
+ });
+
+ // ── Controls functionality (via useMedia) ────────────────────────
+
+ it('should update volume via setVolume', () => {
+ const { result } = renderHook(() => useAudio());
+
+ act(() => {
+ result.current.controls.setVolume(0.3);
+ });
+
+ expect(result.current.state.volume).toBe(0.3);
+ });
+
+ it('should clamp volume to 0-1 range', () => {
+ const { result } = renderHook(() => useAudio());
+
+ act(() => {
+ result.current.controls.setVolume(1.5);
+ });
+ expect(result.current.state.volume).toBe(1);
+
+ act(() => {
+ result.current.controls.setVolume(-0.5);
+ });
+ expect(result.current.state.volume).toBe(0);
+ });
+
+ it('should toggle mute', () => {
+ const { result } = renderHook(() => useAudio());
+
+ act(() => {
+ result.current.controls.toggleMute();
+ });
+
+ expect(result.current.state.isMuted).toBe(true);
+
+ act(() => {
+ result.current.controls.toggleMute();
+ });
+
+ expect(result.current.state.isMuted).toBe(false);
+ });
+
+ // ── Options passthrough (no-op without real ) ─────────────
+
+ it('should accept empty options', () => {
+ expect(() => {
+ renderHook(() => useAudio({}));
+ }).not.toThrow();
+ });
+
+ it('should accept no arguments', () => {
+ expect(() => {
+ renderHook(() => useAudio());
+ }).not.toThrow();
+ });
+
+ it('should accept src option', () => {
+ expect(() => {
+ renderHook(() => useAudio({ src: 'https://example.com/audio.mp3' }));
+ }).not.toThrow();
+ });
+
+ it('should accept callback options', () => {
+ const onPlay = vi.fn();
+ const onPause = vi.fn();
+ const onEnded = vi.fn();
+ const onError = vi.fn();
+ const onTimeUpdate = vi.fn();
+
+ expect(() => {
+ renderHook(() =>
+ useAudio({ onPlay, onPause, onEnded, onError, onTimeUpdate }),
+ );
+ }).not.toThrow();
+ });
+});
diff --git a/src/hooks/useAutoplayDetection.ts b/src/hooks/useAutoplayDetection.ts
new file mode 100644
index 0000000..6153aa9
--- /dev/null
+++ b/src/hooks/useAutoplayDetection.ts
@@ -0,0 +1,118 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+
+export interface AutoplayPolicy {
+ /** Whether autoplay with sound is allowed */
+ canAutoplay: boolean;
+ /** Whether autoplay is allowed when muted */
+ canAutoplayMuted: boolean;
+ /** Whether detection has completed */
+ detected: boolean;
+ /** If autoplay was blocked, call this to attempt playing after user gesture */
+ requestPlay: () => void;
+}
+
+export interface UseAutoplayDetectionOptions {
+ /** Whether to run the detection. Default: true */
+ enabled?: boolean;
+ /** Callback when autoplay is blocked */
+ onBlocked?: () => void;
+ /** Callback when autoplay is allowed */
+ onAllowed?: () => void;
+}
+
+// Minimal valid silent mp4 data URI for autoplay detection
+const SILENT_VIDEO_SRC =
+ 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAABRG1kaWEAAAAgbWRoZAAAAAAAAAAAAAAAAAAAKAAAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAAKAAAAAAAAAAAAMYZXNkcwAAAAADgICAIgACAASAgIAUQBUAAAAAAfQAAAHz+QWAgIACEhAGgICAAQIAAAAYc3R0cwAAAAAAAAABAAAAAgAABAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAIAAAABAAAAHHN0c3oAAAAAAAAAAAAAAAIAAAAaAAAAFgAAABRzdGNvAAAAAAAAAAEAAAAs';
+
+/**
+ * Hook that detects browser autoplay policy on mount.
+ *
+ * Creates a temporary video element (not attached to the DOM) and attempts
+ * to play it — first with sound, then muted — to determine what the browser
+ * allows.
+ */
+export function useAutoplayDetection(
+ options: UseAutoplayDetectionOptions = {}
+): AutoplayPolicy {
+ const { enabled = true } = options;
+
+ const [canAutoplay, setCanAutoplay] = useState(false);
+ const [canAutoplayMuted, setCanAutoplayMuted] = useState(false);
+ const [detected, setDetected] = useState(false);
+
+ // Store callbacks in refs so the effect always calls the latest version
+ const onBlockedRef = useRef(options.onBlocked);
+ const onAllowedRef = useRef(options.onAllowed);
+ onBlockedRef.current = options.onBlocked;
+ onAllowedRef.current = options.onAllowed;
+
+ const requestPlay = useCallback(() => {
+ // No-op placeholder — consumers can replace this to trigger play after a user gesture
+ }, []);
+
+ useEffect(() => {
+ if (!enabled) return;
+ if (typeof document === 'undefined') return;
+
+ let cancelled = false;
+
+ const detect = async () => {
+ const video = document.createElement('video');
+ video.setAttribute('playsinline', '');
+ video.src = SILENT_VIDEO_SRC;
+
+ try {
+ // First try: autoplay with sound
+ await video.play();
+
+ if (!cancelled) {
+ setCanAutoplay(true);
+ setCanAutoplayMuted(true);
+ setDetected(true);
+ onAllowedRef.current?.();
+ }
+ } catch {
+ // Autoplay with sound was blocked — try muted
+ video.pause();
+ video.muted = true;
+
+ try {
+ await video.play();
+
+ if (!cancelled) {
+ setCanAutoplay(false);
+ setCanAutoplayMuted(true);
+ setDetected(true);
+ onBlockedRef.current?.();
+ }
+ } catch {
+ // Both attempts failed
+ if (!cancelled) {
+ setCanAutoplay(false);
+ setCanAutoplayMuted(false);
+ setDetected(true);
+ onBlockedRef.current?.();
+ }
+ }
+ } finally {
+ // Clean up the temporary video element
+ video.pause();
+ video.removeAttribute('src');
+ video.load();
+ }
+ };
+
+ detect();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [enabled]);
+
+ return {
+ canAutoplay,
+ canAutoplayMuted,
+ detected,
+ requestPlay,
+ };
+}
diff --git a/src/hooks/useCast.test.ts b/src/hooks/useCast.test.ts
new file mode 100644
index 0000000..048d409
--- /dev/null
+++ b/src/hooks/useCast.test.ts
@@ -0,0 +1,463 @@
+import { renderHook, act } from '@testing-library/react';
+import { useCast } from './useCast';
+
+/** Create a full Remote Playback API mock suitable for both prototype and instance. */
+function createRemoteMock() {
+ return {
+ prompt: vi.fn().mockResolvedValue(undefined),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
+}
+
+/** Create a plain video element with Remote Playback API mock on the instance. */
+function createVideoWithRemote() {
+ const video = document.createElement('video') as HTMLVideoElement;
+ const remote = createRemoteMock();
+
+ Object.defineProperty(video, 'remote', {
+ configurable: true,
+ value: remote,
+ });
+
+ return { video, remote };
+}
+
+/** Create a video element with WebKit AirPlay APIs. */
+function createVideoWithAirPlay() {
+ const video = document.createElement('video') as HTMLVideoElement & {
+ webkitShowPlaybackTargetPicker: () => void;
+ webkitCurrentPlaybackTargetIsWireless: boolean;
+ };
+
+ video.webkitShowPlaybackTargetPicker = vi.fn();
+ Object.defineProperty(video, 'webkitCurrentPlaybackTargetIsWireless', {
+ writable: true,
+ configurable: true,
+ value: false,
+ });
+
+ return video;
+}
+
+describe('useCast', () => {
+ // ── Initial state ─────────────────────────────────────────────────
+
+ it('should return isCasting false initially', () => {
+ const { video } = createVideoWithRemote();
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+ expect(result.current.isCasting).toBe(false);
+ });
+
+ it('should return toggleCast as a function', () => {
+ const ref = { current: document.createElement('video') };
+ const { result } = renderHook(() => useCast(ref));
+ expect(typeof result.current.toggleCast).toBe('function');
+ });
+
+ // ── isSupported detection ─────────────────────────────────────────
+
+ it('should detect support when Remote Playback API is available on prototype', () => {
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLVideoElement.prototype,
+ 'remote',
+ );
+
+ // Set a full remote mock on the prototype so the effect doesn't crash
+ const remoteMock = createRemoteMock();
+ Object.defineProperty(HTMLVideoElement.prototype, 'remote', {
+ configurable: true,
+ value: remoteMock,
+ });
+
+ const ref = { current: document.createElement('video') };
+ const { result, unmount } = renderHook(() => useCast(ref));
+ expect(result.current.isSupported).toBe(true);
+
+ // Unmount before restoring, so the cleanup can still access video.remote
+ unmount();
+
+ // Restore to avoid polluting other tests
+ if (originalDescriptor) {
+ Object.defineProperty(HTMLVideoElement.prototype, 'remote', originalDescriptor);
+ } else {
+ delete (HTMLVideoElement.prototype as unknown as Record).remote;
+ }
+ });
+
+ it('should detect support when webkitShowPlaybackTargetPicker is on the element', () => {
+ const video = createVideoWithAirPlay();
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+ expect(result.current.isSupported).toBe(true);
+ });
+
+ it('should return isSupported false when no cast APIs are available', () => {
+ // Ensure the prototype doesn't have remote
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLVideoElement.prototype,
+ 'remote',
+ );
+ if (originalDescriptor) {
+ delete (HTMLVideoElement.prototype as unknown as Record).remote;
+ }
+
+ try {
+ const video = document.createElement('video');
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+ expect(result.current.isSupported).toBe(false);
+ } finally {
+ if (originalDescriptor) {
+ Object.defineProperty(HTMLVideoElement.prototype, 'remote', originalDescriptor);
+ }
+ }
+ });
+
+ it('should evaluate support on mount using the video element', () => {
+ // Ensure the prototype doesn't have remote so support comes from the instance
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLVideoElement.prototype,
+ 'remote',
+ );
+ if (originalDescriptor) {
+ delete (HTMLVideoElement.prototype as unknown as Record).remote;
+ }
+
+ try {
+ // Mount with an AirPlay-capable video element already set
+ const video = createVideoWithAirPlay();
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+ expect(result.current.isSupported).toBe(true);
+ } finally {
+ if (originalDescriptor) {
+ Object.defineProperty(HTMLVideoElement.prototype, 'remote', originalDescriptor);
+ }
+ }
+ });
+
+ it('should evaluate as unsupported when videoRef is null on mount', () => {
+ // Ensure the prototype doesn't have remote
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLVideoElement.prototype,
+ 'remote',
+ );
+ if (originalDescriptor) {
+ delete (HTMLVideoElement.prototype as unknown as Record).remote;
+ }
+
+ try {
+ const ref = { current: null as HTMLVideoElement | null };
+ const { result } = renderHook(() => useCast(ref));
+ expect(result.current.isSupported).toBe(false);
+ } finally {
+ if (originalDescriptor) {
+ Object.defineProperty(HTMLVideoElement.prototype, 'remote', originalDescriptor);
+ }
+ }
+ });
+
+ // ── toggleCast (Remote Playback / Chromecast) ─────────────────────
+
+ it('should call video.remote.prompt() when Remote Playback is available', async () => {
+ const { video, remote } = createVideoWithRemote();
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+
+ await act(async () => {
+ await result.current.toggleCast();
+ });
+
+ expect(remote.prompt).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle NotAllowedError silently (user cancelled picker)', async () => {
+ const { video, remote } = createVideoWithRemote();
+ const error = new DOMException('User cancelled', 'NotAllowedError');
+ remote.prompt.mockRejectedValueOnce(error);
+
+ const ref = { current: video };
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useCast(ref));
+
+ await act(async () => {
+ await result.current.toggleCast();
+ });
+
+ expect(consoleSpy).not.toHaveBeenCalled();
+ consoleSpy.mockRestore();
+ });
+
+ it('should log other errors from remote.prompt()', async () => {
+ const { video, remote } = createVideoWithRemote();
+ const error = new Error('Some network error');
+ remote.prompt.mockRejectedValueOnce(error);
+
+ const ref = { current: video };
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useCast(ref));
+
+ await act(async () => {
+ await result.current.toggleCast();
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to open cast picker:', error);
+ consoleSpy.mockRestore();
+ });
+
+ // ── toggleCast (AirPlay) ──────────────────────────────────────────
+
+ it('should call webkitShowPlaybackTargetPicker when AirPlay is available', async () => {
+ const video = createVideoWithAirPlay();
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+
+ await act(async () => {
+ await result.current.toggleCast();
+ });
+
+ expect(video.webkitShowPlaybackTargetPicker).toHaveBeenCalledTimes(1);
+ });
+
+ it('should prefer AirPlay over Remote Playback when both are available', async () => {
+ const video = createVideoWithAirPlay();
+ // Also add remote
+ const remote = createRemoteMock();
+ Object.defineProperty(video, 'remote', {
+ configurable: true,
+ value: remote,
+ });
+
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+
+ await act(async () => {
+ await result.current.toggleCast();
+ });
+
+ expect(video.webkitShowPlaybackTargetPicker).toHaveBeenCalledTimes(1);
+ expect(remote.prompt).not.toHaveBeenCalled();
+ });
+
+ // ── toggleCast with null videoRef ─────────────────────────────────
+
+ it('should do nothing when videoRef.current is null', async () => {
+ const ref = { current: null as HTMLVideoElement | null };
+ const { result } = renderHook(() => useCast(ref));
+
+ await act(async () => {
+ await result.current.toggleCast();
+ });
+ // No errors thrown
+ });
+
+ // ── Remote Playback event listeners ───────────────────────────────
+
+ it('should register connect/disconnect listeners on video.remote', () => {
+ const { video, remote } = createVideoWithRemote();
+ const ref = { current: video };
+ renderHook(() => useCast(ref));
+
+ expect(remote.addEventListener).toHaveBeenCalledWith(
+ 'connect',
+ expect.any(Function),
+ );
+ expect(remote.addEventListener).toHaveBeenCalledWith(
+ 'disconnect',
+ expect.any(Function),
+ );
+ });
+
+ it('should set isCasting to true on remote connect event', () => {
+ const { video, remote } = createVideoWithRemote();
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+
+ const connectCall = (remote.addEventListener.mock.calls as [string, Function][]).find(
+ (c) => c[0] === 'connect',
+ )!;
+ const connectHandler = connectCall[1];
+
+ act(() => {
+ connectHandler();
+ });
+
+ expect(result.current.isCasting).toBe(true);
+ });
+
+ it('should set isCasting to false on remote disconnect event', () => {
+ const { video, remote } = createVideoWithRemote();
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+
+ // First connect
+ const connectCall = (remote.addEventListener.mock.calls as [string, Function][]).find(
+ (c) => c[0] === 'connect',
+ )!;
+ act(() => {
+ connectCall[1]();
+ });
+ expect(result.current.isCasting).toBe(true);
+
+ // Then disconnect
+ const disconnectCall = (remote.addEventListener.mock.calls as [string, Function][]).find(
+ (c) => c[0] === 'disconnect',
+ )!;
+ act(() => {
+ disconnectCall[1]();
+ });
+ expect(result.current.isCasting).toBe(false);
+ });
+
+ it('should remove remote event listeners on unmount', () => {
+ const { video, remote } = createVideoWithRemote();
+ const ref = { current: video };
+ const { unmount } = renderHook(() => useCast(ref));
+
+ unmount();
+
+ expect(remote.removeEventListener).toHaveBeenCalledWith(
+ 'connect',
+ expect.any(Function),
+ );
+ expect(remote.removeEventListener).toHaveBeenCalledWith(
+ 'disconnect',
+ expect.any(Function),
+ );
+ });
+
+ // ── AirPlay event listeners ───────────────────────────────────────
+
+ it('should register wireless change listener for AirPlay', () => {
+ const video = createVideoWithAirPlay();
+ const addSpy = vi.spyOn(video, 'addEventListener');
+ const ref = { current: video };
+ renderHook(() => useCast(ref));
+
+ expect(addSpy).toHaveBeenCalledWith(
+ 'webkitcurrentplaybacktargetiswirelesschanged',
+ expect.any(Function),
+ );
+ });
+
+ it('should update isCasting when AirPlay wireless state changes', () => {
+ const video = createVideoWithAirPlay();
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+
+ (video as unknown as { webkitCurrentPlaybackTargetIsWireless: boolean })
+ .webkitCurrentPlaybackTargetIsWireless = true;
+
+ act(() => {
+ video.dispatchEvent(
+ new Event('webkitcurrentplaybacktargetiswirelesschanged'),
+ );
+ });
+
+ expect(result.current.isCasting).toBe(true);
+ });
+
+ it('should update isCasting to false when AirPlay disconnects', () => {
+ const video = createVideoWithAirPlay();
+ const ref = { current: video };
+ const { result } = renderHook(() => useCast(ref));
+
+ // First connect
+ (video as unknown as { webkitCurrentPlaybackTargetIsWireless: boolean })
+ .webkitCurrentPlaybackTargetIsWireless = true;
+ act(() => {
+ video.dispatchEvent(
+ new Event('webkitcurrentplaybacktargetiswirelesschanged'),
+ );
+ });
+ expect(result.current.isCasting).toBe(true);
+
+ // Then disconnect
+ (video as unknown as { webkitCurrentPlaybackTargetIsWireless: boolean })
+ .webkitCurrentPlaybackTargetIsWireless = false;
+ act(() => {
+ video.dispatchEvent(
+ new Event('webkitcurrentplaybacktargetiswirelesschanged'),
+ );
+ });
+ expect(result.current.isCasting).toBe(false);
+ });
+
+ it('should remove AirPlay listener on unmount', () => {
+ const video = createVideoWithAirPlay();
+ const removeSpy = vi.spyOn(video, 'removeEventListener');
+ const ref = { current: video };
+ const { unmount } = renderHook(() => useCast(ref));
+
+ unmount();
+
+ expect(removeSpy).toHaveBeenCalledWith(
+ 'webkitcurrentplaybacktargetiswirelesschanged',
+ expect.any(Function),
+ );
+ });
+
+ // ── onChange callback ─────────────────────────────────────────────
+
+ it('should call onChange with true when casting starts (Remote Playback)', () => {
+ const onChange = vi.fn();
+ const { video, remote } = createVideoWithRemote();
+ const ref = { current: video };
+ renderHook(() => useCast(ref, { onChange }));
+
+ const connectCall = (remote.addEventListener.mock.calls as [string, Function][]).find(
+ (c) => c[0] === 'connect',
+ )!;
+
+ act(() => {
+ connectCall[1]();
+ });
+
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it('should call onChange with false when casting stops (Remote Playback)', () => {
+ const onChange = vi.fn();
+ const { video, remote } = createVideoWithRemote();
+ const ref = { current: video };
+ renderHook(() => useCast(ref, { onChange }));
+
+ const disconnectCall = (remote.addEventListener.mock.calls as [string, Function][]).find(
+ (c) => c[0] === 'disconnect',
+ )!;
+
+ act(() => {
+ disconnectCall[1]();
+ });
+
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+
+ it('should call onChange when AirPlay state changes', () => {
+ const onChange = vi.fn();
+ const video = createVideoWithAirPlay();
+ const ref = { current: video };
+ renderHook(() => useCast(ref, { onChange }));
+
+ (video as unknown as { webkitCurrentPlaybackTargetIsWireless: boolean })
+ .webkitCurrentPlaybackTargetIsWireless = true;
+ act(() => {
+ video.dispatchEvent(
+ new Event('webkitcurrentplaybacktargetiswirelesschanged'),
+ );
+ });
+
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ // ── No video element ──────────────────────────────────────────────
+
+ it('should handle null videoRef gracefully in effect', () => {
+ const ref = { current: null as HTMLVideoElement | null };
+ expect(() => {
+ renderHook(() => useCast(ref));
+ }).not.toThrow();
+ });
+});
diff --git a/src/hooks/useChapters.test.ts b/src/hooks/useChapters.test.ts
new file mode 100644
index 0000000..ec3a829
--- /dev/null
+++ b/src/hooks/useChapters.test.ts
@@ -0,0 +1,342 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useChapters } from './useChapters';
+import { createMockChapters } from '@/test/helpers';
+import type { Chapter } from '@/types/player';
+
+describe('useChapters', () => {
+ const chapters = createMockChapters();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ─── Initial State ──────────────────────────────────────────────────
+
+ describe('initial state', () => {
+ it('returns chapters as provided', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 0 })
+ );
+
+ expect(result.current.chapters).toEqual(chapters);
+ });
+
+ it('sets currentChapter to first chapter when time is 0', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 0 })
+ );
+
+ expect(result.current.currentChapter).toEqual(chapters[0]);
+ expect(result.current.currentChapterIndex).toBe(0);
+ });
+
+ it('returns null currentChapter when chapters array is empty', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters: [], currentTime: 0 })
+ );
+
+ expect(result.current.currentChapter).toBeNull();
+ expect(result.current.currentChapterIndex).toBe(-1);
+ });
+ });
+
+ // ─── Current Chapter Detection ─────────────────────────────────────
+
+ describe('current chapter detection', () => {
+ it('detects first chapter at time 0', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 0 })
+ );
+
+ expect(result.current.currentChapter?.id).toBe('ch-1');
+ expect(result.current.currentChapterIndex).toBe(0);
+ });
+
+ it('detects first chapter within its range', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 15 })
+ );
+
+ expect(result.current.currentChapter?.id).toBe('ch-1');
+ });
+
+ it('detects second chapter at its start time', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 30 })
+ );
+
+ expect(result.current.currentChapter?.id).toBe('ch-2');
+ expect(result.current.currentChapterIndex).toBe(1);
+ });
+
+ it('detects second chapter in the middle of its range', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 75 })
+ );
+
+ expect(result.current.currentChapter?.id).toBe('ch-2');
+ });
+
+ it('detects third chapter at its start time', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 120 })
+ );
+
+ expect(result.current.currentChapter?.id).toBe('ch-3');
+ expect(result.current.currentChapterIndex).toBe(2);
+ });
+
+ it('detects last chapter near the end', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 175 })
+ );
+
+ expect(result.current.currentChapter?.id).toBe('ch-3');
+ });
+
+ it('updates currentChapter when time changes across chapters', () => {
+ const onChapterChange = vi.fn();
+ const { result, rerender } = renderHook(
+ ({ currentTime }) =>
+ useChapters({ chapters, currentTime, onChapterChange }),
+ { initialProps: { currentTime: 10 } }
+ );
+
+ expect(result.current.currentChapter?.id).toBe('ch-1');
+
+ rerender({ currentTime: 50 });
+
+ expect(result.current.currentChapter?.id).toBe('ch-2');
+ expect(onChapterChange).toHaveBeenCalledWith(chapters[1], 1);
+ });
+
+ it('does not call onChapterChange when staying in same chapter', () => {
+ const onChapterChange = vi.fn();
+ const { rerender } = renderHook(
+ ({ currentTime }) =>
+ useChapters({ chapters, currentTime, onChapterChange }),
+ { initialProps: { currentTime: 10 } }
+ );
+
+ // Initial chapter detection triggers once
+ const initialCalls = onChapterChange.mock.calls.length;
+
+ rerender({ currentTime: 20 });
+
+ // Should not have been called again (still in chapter 1)
+ expect(onChapterChange.mock.calls.length).toBe(initialCalls);
+ });
+
+ it('returns null when currentTime is before all chapters', () => {
+ const laterChapters: Chapter[] = [
+ { id: 'ch-1', title: 'Chapter 1', startTime: 10, endTime: 30 },
+ { id: 'ch-2', title: 'Chapter 2', startTime: 30, endTime: 60 },
+ ];
+
+ const { result } = renderHook(() =>
+ useChapters({ chapters: laterChapters, currentTime: 5 })
+ );
+
+ expect(result.current.currentChapter).toBeNull();
+ expect(result.current.currentChapterIndex).toBe(-1);
+ });
+ });
+
+ // ─── Navigation Controls ───────────────────────────────────────────
+
+ describe('navigation controls', () => {
+ it('goToChapter() calls onChapterChange with the chapter and index', () => {
+ const onChapterChange = vi.fn();
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 0, onChapterChange })
+ );
+
+ result.current.goToChapter(2);
+
+ expect(onChapterChange).toHaveBeenCalledWith(chapters[2], 2);
+ });
+
+ it('goToChapter() does nothing for negative index', () => {
+ const onChapterChange = vi.fn();
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 0, onChapterChange })
+ );
+
+ // Clear any initial calls
+ onChapterChange.mockClear();
+
+ result.current.goToChapter(-1);
+
+ expect(onChapterChange).not.toHaveBeenCalled();
+ });
+
+ it('goToChapter() does nothing for out-of-range index', () => {
+ const onChapterChange = vi.fn();
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 0, onChapterChange })
+ );
+
+ onChapterChange.mockClear();
+
+ result.current.goToChapter(10);
+
+ expect(onChapterChange).not.toHaveBeenCalled();
+ });
+
+ it('nextChapter() goes to the next chapter', () => {
+ const onChapterChange = vi.fn();
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 10, onChapterChange })
+ );
+
+ // Currently in chapter 0
+ onChapterChange.mockClear();
+
+ result.current.nextChapter();
+
+ expect(onChapterChange).toHaveBeenCalledWith(chapters[1], 1);
+ });
+
+ it('nextChapter() does nothing when at the last chapter', () => {
+ const onChapterChange = vi.fn();
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 150, onChapterChange })
+ );
+
+ // Currently in last chapter (index 2)
+ onChapterChange.mockClear();
+
+ result.current.nextChapter();
+
+ expect(onChapterChange).not.toHaveBeenCalled();
+ });
+
+ it('previousChapter() goes to the previous chapter', () => {
+ const onChapterChange = vi.fn();
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 50, onChapterChange })
+ );
+
+ // Currently in chapter 1
+ onChapterChange.mockClear();
+
+ result.current.previousChapter();
+
+ expect(onChapterChange).toHaveBeenCalledWith(chapters[0], 0);
+ });
+
+ it('previousChapter() does nothing when at the first chapter', () => {
+ const onChapterChange = vi.fn();
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 10, onChapterChange })
+ );
+
+ // Currently in chapter 0
+ onChapterChange.mockClear();
+
+ result.current.previousChapter();
+
+ expect(onChapterChange).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── Chapter Change Callback ───────────────────────────────────────
+
+ describe('chapter change callback', () => {
+ it('calls onChapterChange on initial render for the first chapter', () => {
+ const onChapterChange = vi.fn();
+ renderHook(() =>
+ useChapters({ chapters, currentTime: 0, onChapterChange })
+ );
+
+ expect(onChapterChange).toHaveBeenCalledWith(chapters[0], 0);
+ });
+
+ it('calls onChapterChange each time chapter transitions', () => {
+ const onChapterChange = vi.fn();
+ const { rerender } = renderHook(
+ ({ currentTime }) =>
+ useChapters({ chapters, currentTime, onChapterChange }),
+ { initialProps: { currentTime: 0 } }
+ );
+
+ onChapterChange.mockClear();
+
+ // Move to chapter 2
+ rerender({ currentTime: 31 });
+ expect(onChapterChange).toHaveBeenCalledWith(chapters[1], 1);
+
+ onChapterChange.mockClear();
+
+ // Move to chapter 3
+ rerender({ currentTime: 121 });
+ expect(onChapterChange).toHaveBeenCalledWith(chapters[2], 2);
+ });
+
+ it('works without onChapterChange callback', () => {
+ const { result, rerender } = renderHook(
+ ({ currentTime }) =>
+ useChapters({ chapters, currentTime }),
+ { initialProps: { currentTime: 0 } }
+ );
+
+ // Should not throw
+ rerender({ currentTime: 50 });
+
+ expect(result.current.currentChapter?.id).toBe('ch-2');
+ });
+ });
+
+ // ─── Edge Cases ────────────────────────────────────────────────────
+
+ describe('edge cases', () => {
+ it('handles chapters with no endTime', () => {
+ const openChapters: Chapter[] = [
+ { id: 'ch-1', title: 'First', startTime: 0 },
+ { id: 'ch-2', title: 'Second', startTime: 60 },
+ ];
+
+ const { result } = renderHook(() =>
+ useChapters({ chapters: openChapters, currentTime: 30 })
+ );
+
+ expect(result.current.currentChapter?.id).toBe('ch-1');
+ });
+
+ it('handles single chapter', () => {
+ const singleChapter: Chapter[] = [
+ { id: 'ch-1', title: 'Only Chapter', startTime: 0, endTime: 300 },
+ ];
+
+ const { result } = renderHook(() =>
+ useChapters({ chapters: singleChapter, currentTime: 150 })
+ );
+
+ expect(result.current.currentChapter?.id).toBe('ch-1');
+ expect(result.current.currentChapterIndex).toBe(0);
+ });
+
+ it('handles time exactly at chapter boundary', () => {
+ const { result } = renderHook(() =>
+ useChapters({ chapters, currentTime: 30 })
+ );
+
+ // At startTime 30, chapter 2 should be active
+ expect(result.current.currentChapter?.id).toBe('ch-2');
+ });
+
+ it('handles chapters with images', () => {
+ const chaptersWithImages: Chapter[] = [
+ { id: 'ch-1', title: 'Intro', startTime: 0, endTime: 30, image: 'intro.jpg' },
+ { id: 'ch-2', title: 'Main', startTime: 30, endTime: 120, image: 'main.jpg' },
+ ];
+
+ const { result } = renderHook(() =>
+ useChapters({ chapters: chaptersWithImages, currentTime: 40 })
+ );
+
+ expect(result.current.currentChapter?.image).toBe('main.jpg');
+ });
+ });
+});
diff --git a/src/hooks/useEqualizer.ts b/src/hooks/useEqualizer.ts
new file mode 100644
index 0000000..7baa9ec
--- /dev/null
+++ b/src/hooks/useEqualizer.ts
@@ -0,0 +1,228 @@
+import { useState, useCallback, useEffect, useRef } from 'react';
+import type {
+ EqualizerBand,
+ UseEqualizerOptions,
+ UseEqualizerReturn,
+} from '@/types/equalizer';
+import {
+ DEFAULT_BANDS,
+ EQUALIZER_PRESETS,
+} from '@/types/equalizer';
+
+const DEFAULT_STORAGE_KEY = 'fairu_equalizer';
+
+interface StoredState {
+ gains: number[];
+ enabled: boolean;
+ preset: string | null;
+}
+
+function loadFromStorage(key: string): StoredState | null {
+ if (typeof window === 'undefined') return null;
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ return JSON.parse(raw) as StoredState;
+ } catch {
+ return null;
+ }
+}
+
+function saveToStorage(key: string, state: StoredState): void {
+ if (typeof window === 'undefined') return;
+ try {
+ localStorage.setItem(key, JSON.stringify(state));
+ } catch {
+ // Silently ignore
+ }
+}
+
+export function useEqualizer(options: UseEqualizerOptions): UseEqualizerReturn {
+ const {
+ mediaRef,
+ enabled: initialEnabled = false,
+ initialPreset = 'flat',
+ persist = true,
+ storageKey = DEFAULT_STORAGE_KEY,
+ } = options;
+
+ // Load stored state
+ const stored = persist ? loadFromStorage(storageKey) : null;
+
+ const [enabled, setEnabledState] = useState(stored?.enabled ?? initialEnabled);
+ const [currentPreset, setCurrentPreset] = useState(stored?.preset ?? initialPreset);
+ const [bands, setBands] = useState(() => {
+ if (stored?.gains) {
+ return DEFAULT_BANDS.map((band, i) => ({
+ ...band,
+ gain: stored.gains[i] ?? 0,
+ }));
+ }
+ // Apply initial preset
+ const preset = EQUALIZER_PRESETS.find((p) => p.name === initialPreset);
+ if (preset) {
+ return DEFAULT_BANDS.map((band, i) => ({
+ ...band,
+ gain: preset.bands[i] ?? 0,
+ }));
+ }
+ return DEFAULT_BANDS.map((b) => ({ ...b }));
+ });
+
+ const audioContextRef = useRef(null);
+ const sourceRef = useRef(null);
+ const filtersRef = useRef([]);
+ const [isConnected, setIsConnected] = useState(false);
+
+ // Persist state
+ const persistState = useCallback((b: EqualizerBand[], en: boolean, preset: string | null) => {
+ if (persist) {
+ saveToStorage(storageKey, {
+ gains: b.map((band) => band.gain),
+ enabled: en,
+ preset,
+ });
+ }
+ }, [persist, storageKey]);
+
+ // Connect Web Audio API
+ const connect = useCallback(() => {
+ const media = mediaRef.current;
+ if (!media || isConnected) return;
+
+ try {
+ // Create or reuse AudioContext
+ if (!audioContextRef.current) {
+ audioContextRef.current = new AudioContext();
+ }
+ const ctx = audioContextRef.current;
+
+ // Create source (only once per element)
+ if (!sourceRef.current) {
+ sourceRef.current = ctx.createMediaElementSource(media);
+ }
+ const source = sourceRef.current;
+
+ // Create filter chain
+ const filters = bands.map((band) => {
+ const filter = ctx.createBiquadFilter();
+ filter.type = band.type;
+ filter.frequency.value = band.frequency;
+ filter.gain.value = band.gain;
+ filter.Q.value = band.Q;
+ return filter;
+ });
+
+ // Connect: source -> filter1 -> filter2 -> ... -> destination
+ source.disconnect();
+ if (filters.length > 0) {
+ source.connect(filters[0]);
+ for (let i = 0; i < filters.length - 1; i++) {
+ filters[i].connect(filters[i + 1]);
+ }
+ filters[filters.length - 1].connect(ctx.destination);
+ } else {
+ source.connect(ctx.destination);
+ }
+
+ filtersRef.current = filters;
+ setIsConnected(true);
+ } catch {
+ // Web Audio API not supported or other error
+ setIsConnected(false);
+ }
+ }, [mediaRef, isConnected, bands]);
+
+ // Disconnect
+ const disconnect = useCallback(() => {
+ const source = sourceRef.current;
+ const ctx = audioContextRef.current;
+
+ if (source && ctx) {
+ try {
+ source.disconnect();
+ source.connect(ctx.destination);
+ } catch {
+ // Ignore
+ }
+ }
+
+ filtersRef.current = [];
+ setIsConnected(false);
+ }, []);
+
+ // Connect/disconnect based on enabled state
+ useEffect(() => {
+ if (enabled) {
+ connect();
+ } else {
+ disconnect();
+ }
+ }, [enabled, connect, disconnect]);
+
+ // Update filter gains when bands change
+ useEffect(() => {
+ filtersRef.current.forEach((filter, i) => {
+ if (bands[i]) {
+ filter.gain.value = bands[i].gain;
+ }
+ });
+ }, [bands]);
+
+ // Clean up on unmount
+ useEffect(() => {
+ return () => {
+ disconnect();
+ if (audioContextRef.current?.state !== 'closed') {
+ audioContextRef.current?.close();
+ }
+ };
+ }, [disconnect]);
+
+ const setBandGain = useCallback((index: number, gain: number) => {
+ setBands((prev) => {
+ const next = prev.map((band, i) =>
+ i === index ? { ...band, gain: Math.max(-12, Math.min(12, gain)) } : band
+ );
+ setCurrentPreset(null);
+ persistState(next, enabled, null);
+ return next;
+ });
+ }, [enabled, persistState]);
+
+ const applyPreset = useCallback((presetName: string) => {
+ const preset = EQUALIZER_PRESETS.find((p) => p.name === presetName);
+ if (!preset) return;
+
+ setBands((prev) => {
+ const next = prev.map((band, i) => ({
+ ...band,
+ gain: preset.bands[i] ?? 0,
+ }));
+ persistState(next, enabled, presetName);
+ return next;
+ });
+ setCurrentPreset(presetName);
+ }, [enabled, persistState]);
+
+ const reset = useCallback(() => {
+ applyPreset('flat');
+ }, [applyPreset]);
+
+ const setEnabled = useCallback((value: boolean) => {
+ setEnabledState(value);
+ persistState(bands, value, currentPreset);
+ }, [bands, currentPreset, persistState]);
+
+ return {
+ bands,
+ setBandGain,
+ applyPreset,
+ reset,
+ isConnected,
+ enabled,
+ setEnabled,
+ presets: EQUALIZER_PRESETS,
+ currentPreset,
+ };
+}
diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts
new file mode 100644
index 0000000..c47e0ac
--- /dev/null
+++ b/src/hooks/useFocusTrap.ts
@@ -0,0 +1,89 @@
+import { useEffect, useRef, useCallback } from 'react';
+
+export interface UseFocusTrapOptions {
+ /** Whether the focus trap is active */
+ active: boolean;
+ /** Callback when Escape is pressed */
+ onEscape?: () => void;
+ /** Whether to return focus to the trigger element on deactivation. Default: true */
+ returnFocus?: boolean;
+}
+
+export interface UseFocusTrapReturn {
+ /** Ref to attach to the container element that should trap focus */
+ trapRef: React.RefObject;
+}
+
+export function useFocusTrap(options: UseFocusTrapOptions): UseFocusTrapReturn {
+ const { active, onEscape, returnFocus = true } = options;
+ const trapRef = useRef(null);
+ const previousFocusRef = useRef(null);
+
+ const getFocusableElements = useCallback((): HTMLElement[] => {
+ if (!trapRef.current) return [];
+ const selectors = [
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ 'a[href]',
+ '[tabindex]:not([tabindex="-1"])',
+ ].join(', ');
+ return Array.from(trapRef.current.querySelectorAll(selectors));
+ }, []);
+
+ useEffect(() => {
+ if (!active) {
+ // Return focus when deactivating
+ if (returnFocus && previousFocusRef.current) {
+ previousFocusRef.current.focus();
+ previousFocusRef.current = null;
+ }
+ return;
+ }
+
+ // Store currently focused element
+ previousFocusRef.current = document.activeElement as HTMLElement;
+
+ // Focus first element in trap
+ const focusables = getFocusableElements();
+ if (focusables.length > 0) {
+ focusables[0].focus();
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ onEscape?.();
+ return;
+ }
+
+ if (e.key !== 'Tab') return;
+
+ const focusables = getFocusableElements();
+ if (focusables.length === 0) return;
+
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [active, onEscape, returnFocus, getFocusableElements]);
+
+ return { trapRef };
+}
diff --git a/src/hooks/useFullscreen.test.ts b/src/hooks/useFullscreen.test.ts
new file mode 100644
index 0000000..dff937f
--- /dev/null
+++ b/src/hooks/useFullscreen.test.ts
@@ -0,0 +1,436 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useFullscreen } from './useFullscreen';
+import type { RefObject } from 'react';
+
+function createContainerRef(): RefObject {
+ const div = document.createElement('div');
+ return { current: div };
+}
+
+describe('useFullscreen', () => {
+ let originalFullscreenEnabled: boolean;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset fullscreen state
+ (document as any).fullscreenElement = null;
+ originalFullscreenEnabled = (document as any).fullscreenEnabled;
+ Object.defineProperty(document, 'fullscreenEnabled', {
+ writable: true,
+ value: true,
+ });
+ });
+
+ afterEach(() => {
+ Object.defineProperty(document, 'fullscreenEnabled', {
+ writable: true,
+ value: originalFullscreenEnabled,
+ });
+ });
+
+ // ─── Initial State ──────────────────────────────────────────────────
+
+ describe('initial state', () => {
+ it('starts not fullscreen', () => {
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ expect(result.current.isFullscreen).toBe(false);
+ });
+
+ it('reports fullscreen as supported when fullscreenEnabled is true', () => {
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ expect(result.current.isSupported).toBe(true);
+ });
+
+ it('reports fullscreen as supported via webkit prefix', () => {
+ Object.defineProperty(document, 'fullscreenEnabled', {
+ writable: true,
+ value: false,
+ });
+ (document as any).webkitFullscreenEnabled = true;
+
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ expect(result.current.isSupported).toBe(true);
+
+ delete (document as any).webkitFullscreenEnabled;
+ });
+
+ it('provides enterFullscreen, exitFullscreen, toggleFullscreen methods', () => {
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ expect(typeof result.current.enterFullscreen).toBe('function');
+ expect(typeof result.current.exitFullscreen).toBe('function');
+ expect(typeof result.current.toggleFullscreen).toBe('function');
+ });
+ });
+
+ // ─── Enter Fullscreen ──────────────────────────────────────────────
+
+ describe('enterFullscreen', () => {
+ it('calls requestFullscreen on the container ref element', async () => {
+ const ref = createContainerRef();
+ const requestSpy = vi.fn().mockResolvedValue(undefined);
+ ref.current!.requestFullscreen = requestSpy;
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.enterFullscreen();
+ });
+
+ expect(requestSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls requestFullscreen on a custom element when provided', async () => {
+ const ref = createContainerRef();
+ const customEl = document.createElement('div');
+ const requestSpy = vi.fn().mockResolvedValue(undefined);
+ customEl.requestFullscreen = requestSpy;
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.enterFullscreen(customEl);
+ });
+
+ expect(requestSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing when ref.current is null and no element passed', async () => {
+ const ref = { current: null } as RefObject;
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ // Should not throw
+ await act(async () => {
+ await result.current.enterFullscreen();
+ });
+ });
+
+ it('falls back to webkitRequestFullscreen', async () => {
+ const ref = createContainerRef();
+ // Shadow the prototype method with undefined on the instance
+ Object.defineProperty(ref.current!, 'requestFullscreen', {
+ value: undefined,
+ configurable: true,
+ writable: true,
+ });
+ const webkitSpy = vi.fn().mockResolvedValue(undefined);
+ (ref.current as any).webkitRequestFullscreen = webkitSpy;
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.enterFullscreen();
+ });
+
+ expect(webkitSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to mozRequestFullScreen', async () => {
+ const ref = createContainerRef();
+ Object.defineProperty(ref.current!, 'requestFullscreen', {
+ value: undefined,
+ configurable: true,
+ writable: true,
+ });
+ const mozSpy = vi.fn().mockResolvedValue(undefined);
+ (ref.current as any).mozRequestFullScreen = mozSpy;
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.enterFullscreen();
+ });
+
+ expect(mozSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to msRequestFullscreen', async () => {
+ const ref = createContainerRef();
+ Object.defineProperty(ref.current!, 'requestFullscreen', {
+ value: undefined,
+ configurable: true,
+ writable: true,
+ });
+ const msSpy = vi.fn().mockResolvedValue(undefined);
+ (ref.current as any).msRequestFullscreen = msSpy;
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.enterFullscreen();
+ });
+
+ expect(msSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles requestFullscreen rejection gracefully', async () => {
+ const ref = createContainerRef();
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ ref.current!.requestFullscreen = vi.fn().mockRejectedValue(new Error('Not allowed'));
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.enterFullscreen();
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to enter fullscreen:',
+ expect.any(Error)
+ );
+ consoleSpy.mockRestore();
+ });
+ });
+
+ // ─── Exit Fullscreen ───────────────────────────────────────────────
+
+ describe('exitFullscreen', () => {
+ it('calls document.exitFullscreen', async () => {
+ const ref = createContainerRef();
+ const exitSpy = vi.fn().mockResolvedValue(undefined);
+ (document as any).exitFullscreen = exitSpy;
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.exitFullscreen();
+ });
+
+ expect(exitSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to webkitExitFullscreen', async () => {
+ const ref = createContainerRef();
+ delete (document as any).exitFullscreen;
+ const webkitSpy = vi.fn().mockResolvedValue(undefined);
+ (document as any).webkitExitFullscreen = webkitSpy;
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.exitFullscreen();
+ });
+
+ expect(webkitSpy).toHaveBeenCalledTimes(1);
+
+ // Restore
+ delete (document as any).webkitExitFullscreen;
+ (document as any).exitFullscreen = vi.fn().mockResolvedValue(undefined);
+ });
+
+ it('handles exitFullscreen rejection gracefully', async () => {
+ const ref = createContainerRef();
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ (document as any).exitFullscreen = vi.fn().mockRejectedValue(new Error('Not allowed'));
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.exitFullscreen();
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to exit fullscreen:',
+ expect.any(Error)
+ );
+ consoleSpy.mockRestore();
+
+ // Restore
+ (document as any).exitFullscreen = vi.fn().mockResolvedValue(undefined);
+ });
+ });
+
+ // ─── Toggle Fullscreen ─────────────────────────────────────────────
+
+ describe('toggleFullscreen', () => {
+ it('enters fullscreen when not in fullscreen', async () => {
+ const ref = createContainerRef();
+ const requestSpy = vi.fn().mockResolvedValue(undefined);
+ ref.current!.requestFullscreen = requestSpy;
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ await act(async () => {
+ await result.current.toggleFullscreen();
+ });
+
+ expect(requestSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('exits fullscreen when in fullscreen', async () => {
+ const ref = createContainerRef();
+ const exitSpy = vi.fn().mockResolvedValue(undefined);
+ (document as any).exitFullscreen = exitSpy;
+
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ // Simulate entering fullscreen
+ (document as any).fullscreenElement = ref.current;
+ act(() => {
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+
+ expect(result.current.isFullscreen).toBe(true);
+
+ await act(async () => {
+ await result.current.toggleFullscreen();
+ });
+
+ expect(exitSpy).toHaveBeenCalled();
+ });
+ });
+
+ // ─── Fullscreen Change Events ──────────────────────────────────────
+
+ describe('fullscreen change events', () => {
+ it('updates isFullscreen to true when entering fullscreen', () => {
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ (document as any).fullscreenElement = ref.current;
+
+ act(() => {
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+
+ expect(result.current.isFullscreen).toBe(true);
+ });
+
+ it('updates isFullscreen to false when exiting fullscreen', () => {
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ // Enter fullscreen
+ (document as any).fullscreenElement = ref.current;
+ act(() => {
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+ expect(result.current.isFullscreen).toBe(true);
+
+ // Exit fullscreen
+ (document as any).fullscreenElement = null;
+ act(() => {
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+ expect(result.current.isFullscreen).toBe(false);
+ });
+
+ it('calls onChange callback when entering fullscreen', () => {
+ const onChange = vi.fn();
+ const ref = createContainerRef();
+ renderHook(() => useFullscreen(ref, { onChange }));
+
+ (document as any).fullscreenElement = ref.current;
+
+ act(() => {
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it('calls onChange callback when exiting fullscreen', () => {
+ const onChange = vi.fn();
+ const ref = createContainerRef();
+ renderHook(() => useFullscreen(ref, { onChange }));
+
+ // Enter
+ (document as any).fullscreenElement = ref.current;
+ act(() => {
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+
+ // Exit
+ (document as any).fullscreenElement = null;
+ act(() => {
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+
+ it('responds to webkitfullscreenchange event', () => {
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ (document as any).fullscreenElement = ref.current;
+
+ act(() => {
+ document.dispatchEvent(new Event('webkitfullscreenchange'));
+ });
+
+ expect(result.current.isFullscreen).toBe(true);
+ });
+
+ it('responds to mozfullscreenchange event', () => {
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ (document as any).fullscreenElement = ref.current;
+
+ act(() => {
+ document.dispatchEvent(new Event('mozfullscreenchange'));
+ });
+
+ expect(result.current.isFullscreen).toBe(true);
+ });
+
+ it('responds to MSFullscreenChange event', () => {
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ (document as any).fullscreenElement = ref.current;
+
+ act(() => {
+ document.dispatchEvent(new Event('MSFullscreenChange'));
+ });
+
+ expect(result.current.isFullscreen).toBe(true);
+ });
+ });
+
+ // ─── Cleanup ───────────────────────────────────────────────────────
+
+ describe('cleanup', () => {
+ it('removes event listeners on unmount', () => {
+ const ref = createContainerRef();
+ const removeSpy = vi.spyOn(document, 'removeEventListener');
+
+ const { unmount } = renderHook(() => useFullscreen(ref));
+
+ unmount();
+
+ const removedEvents = removeSpy.mock.calls.map(([event]) => event);
+ expect(removedEvents).toContain('fullscreenchange');
+ expect(removedEvents).toContain('webkitfullscreenchange');
+ expect(removedEvents).toContain('mozfullscreenchange');
+ expect(removedEvents).toContain('MSFullscreenChange');
+
+ removeSpy.mockRestore();
+ });
+ });
+
+ // ─── isSupported ───────────────────────────────────────────────────
+
+ describe('isSupported', () => {
+ it('returns false when fullscreen is not enabled', () => {
+ Object.defineProperty(document, 'fullscreenEnabled', {
+ writable: true,
+ value: false,
+ });
+
+ const ref = createContainerRef();
+ const { result } = renderHook(() => useFullscreen(ref));
+
+ expect(result.current.isSupported).toBeFalsy();
+ });
+ });
+});
diff --git a/src/hooks/useGestures.ts b/src/hooks/useGestures.ts
new file mode 100644
index 0000000..0b30529
--- /dev/null
+++ b/src/hooks/useGestures.ts
@@ -0,0 +1,205 @@
+import { useEffect, useCallback, useRef } from 'react';
+
+export interface UseGesturesOptions {
+ /** The element to listen for gestures on */
+ containerRef: React.RefObject;
+ /** Called when user double-taps the left third of the container */
+ onDoubleTapLeft?: () => void;
+ /** Called when user double-taps the right third of the container */
+ onDoubleTapRight?: () => void;
+ /** Called when user swipes left */
+ onSwipeLeft?: () => void;
+ /** Called when user swipes right */
+ onSwipeRight?: () => void;
+ /** Called when user swipes up */
+ onSwipeUp?: () => void;
+ /** Called when user swipes down */
+ onSwipeDown?: () => void;
+ /** Enable or disable gesture handling (default: true) */
+ enabled?: boolean;
+ /** Maximum delay between taps for double-tap in ms (default: 300) */
+ doubleTapDelay?: number;
+ /** Minimum distance in px for a swipe to register (default: 50) */
+ swipeThreshold?: number;
+}
+
+interface TouchData {
+ startX: number;
+ startY: number;
+ startTime: number;
+}
+
+/**
+ * Hook for detecting touch gestures (double-tap, swipe) on a container element.
+ * Designed for video player touch controls.
+ */
+export function useGestures(options: UseGesturesOptions): void {
+ const {
+ containerRef,
+ onDoubleTapLeft,
+ onDoubleTapRight,
+ onSwipeLeft,
+ onSwipeRight,
+ onSwipeUp,
+ onSwipeDown,
+ enabled = true,
+ doubleTapDelay = 300,
+ swipeThreshold = 50,
+ } = options;
+
+ const touchDataRef = useRef(null);
+ const lastTapTimeRef = useRef(0);
+ const lastTapXRef = useRef(0);
+ const tapTimerRef = useRef | null>(null);
+
+ const handleTouchStart = useCallback(
+ (event: TouchEvent) => {
+ if (!event.touches.length) return;
+
+ const touch = event.touches[0];
+ touchDataRef.current = {
+ startX: touch.clientX,
+ startY: touch.clientY,
+ startTime: Date.now(),
+ };
+ },
+ []
+ );
+
+ const handleTouchEnd = useCallback(
+ (event: TouchEvent) => {
+ const touchData = touchDataRef.current;
+ if (!touchData) return;
+
+ const container = containerRef.current;
+ if (!container) return;
+
+ const touch = event.changedTouches[0];
+ if (!touch) return;
+
+ const deltaX = touch.clientX - touchData.startX;
+ const deltaY = touch.clientY - touchData.startY;
+ const absDeltaX = Math.abs(deltaX);
+ const absDeltaY = Math.abs(deltaY);
+ const elapsed = Date.now() - touchData.startTime;
+
+ // Check for swipe (moved enough distance and completed quickly enough)
+ if (absDeltaX > swipeThreshold || absDeltaY > swipeThreshold) {
+ if (elapsed < 500) {
+ // Determine swipe direction - use the dominant axis
+ if (absDeltaX > absDeltaY) {
+ // Horizontal swipe
+ if (deltaX < 0) {
+ onSwipeLeft?.();
+ } else {
+ onSwipeRight?.();
+ }
+ } else {
+ // Vertical swipe
+ if (deltaY < 0) {
+ onSwipeUp?.();
+ } else {
+ onSwipeDown?.();
+ }
+ }
+ }
+ touchDataRef.current = null;
+ return;
+ }
+
+ // It's a tap (no significant movement)
+ const now = Date.now();
+ const timeSinceLastTap = now - lastTapTimeRef.current;
+ const rect = container.getBoundingClientRect();
+ const tapX = touch.clientX - rect.left;
+
+ if (timeSinceLastTap < doubleTapDelay) {
+ // Double tap detected - clear any pending single-tap timer
+ if (tapTimerRef.current) {
+ clearTimeout(tapTimerRef.current);
+ tapTimerRef.current = null;
+ }
+
+ // Use the position of the first tap to determine the side
+ const firstTapX = lastTapXRef.current;
+ const containerWidth = rect.width;
+ const leftThreshold = containerWidth / 3;
+ const rightThreshold = (containerWidth * 2) / 3;
+
+ if (firstTapX < leftThreshold) {
+ event.preventDefault();
+ onDoubleTapLeft?.();
+ } else if (firstTapX > rightThreshold) {
+ event.preventDefault();
+ onDoubleTapRight?.();
+ }
+
+ lastTapTimeRef.current = 0;
+ lastTapXRef.current = 0;
+ } else {
+ // First tap - record and wait for potential second tap
+ lastTapTimeRef.current = now;
+ lastTapXRef.current = tapX;
+ }
+
+ touchDataRef.current = null;
+ },
+ [
+ containerRef,
+ doubleTapDelay,
+ swipeThreshold,
+ onDoubleTapLeft,
+ onDoubleTapRight,
+ onSwipeLeft,
+ onSwipeRight,
+ onSwipeUp,
+ onSwipeDown,
+ ]
+ );
+
+ const handleTouchMove = useCallback(
+ (event: TouchEvent) => {
+ // Prevent native scroll only when a gesture is likely in progress
+ const touchData = touchDataRef.current;
+ if (!touchData) return;
+
+ const touch = event.touches[0];
+ if (!touch) return;
+
+ const deltaX = Math.abs(touch.clientX - touchData.startX);
+ const deltaY = Math.abs(touch.clientY - touchData.startY);
+
+ // If the dominant direction is horizontal and the user has moved enough,
+ // prevent default to avoid horizontal scroll conflicts
+ if (deltaX > 10 && deltaX > deltaY) {
+ event.preventDefault();
+ }
+ },
+ []
+ );
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const container = containerRef.current;
+ if (!container) return;
+
+ // Use passive listeners where possible.
+ // touchstart can be passive since we only record data.
+ // touchend can be passive in most cases but we may need to preventDefault for double-tap.
+ // touchmove needs non-passive to call preventDefault for scroll prevention.
+ container.addEventListener('touchstart', handleTouchStart, { passive: true });
+ container.addEventListener('touchend', handleTouchEnd);
+ container.addEventListener('touchmove', handleTouchMove, { passive: false });
+
+ return () => {
+ container.removeEventListener('touchstart', handleTouchStart);
+ container.removeEventListener('touchend', handleTouchEnd);
+ container.removeEventListener('touchmove', handleTouchMove);
+
+ if (tapTimerRef.current) {
+ clearTimeout(tapTimerRef.current);
+ }
+ };
+ }, [enabled, containerRef, handleTouchStart, handleTouchEnd, handleTouchMove]);
+}
diff --git a/src/hooks/useHLS.test.ts b/src/hooks/useHLS.test.ts
new file mode 100644
index 0000000..94025b3
--- /dev/null
+++ b/src/hooks/useHLS.test.ts
@@ -0,0 +1,964 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import type { RefObject } from 'react';
+import { createMockVideoElement } from '@/test/helpers';
+
+// ─── Hls.js mock ─────────────────────────────────────────────────────
+// We need to set up the mock before importing the module under test.
+
+type HlsEventCallback = (event: string, data: any) => void;
+
+let hlsIsSupported = true;
+
+interface MockHlsInstance {
+ loadSource: ReturnType;
+ attachMedia: ReturnType;
+ detachMedia: ReturnType;
+ destroy: ReturnType;
+ startLoad: ReturnType;
+ recoverMediaError: ReturnType;
+ currentLevel: number;
+ on: ReturnType;
+ off: ReturnType;
+ _listeners: Record;
+ _emit: (event: string, data: any) => void;
+}
+
+let mockHlsInstance: MockHlsInstance;
+/** Track all constructor calls for assertions */
+const hlsConstructorCalls: any[][] = [];
+
+vi.mock('hls.js', () => {
+ class MockHls {
+ loadSource = vi.fn();
+ attachMedia = vi.fn();
+ detachMedia = vi.fn();
+ destroy = vi.fn();
+ startLoad = vi.fn();
+ recoverMediaError = vi.fn();
+ currentLevel = -1;
+ _listeners: Record = {};
+
+ on = vi.fn((event: string, cb: HlsEventCallback) => {
+ if (!this._listeners[event]) this._listeners[event] = [];
+ this._listeners[event].push(cb);
+ });
+
+ off = vi.fn();
+
+ _emit(event: string, data: any) {
+ (this._listeners[event] || []).forEach((cb) => cb(event, data));
+ }
+
+ constructor(...args: any[]) {
+ hlsConstructorCalls.push(args);
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ mockHlsInstance = this as unknown as MockHlsInstance;
+ }
+
+ static isSupported() {
+ return hlsIsSupported;
+ }
+
+ static Events = {
+ MANIFEST_PARSED: 'hlsManifestParsed',
+ LEVEL_SWITCHED: 'hlsLevelSwitched',
+ ERROR: 'hlsError',
+ };
+
+ static ErrorTypes = {
+ NETWORK_ERROR: 'networkError',
+ MEDIA_ERROR: 'mediaError',
+ OTHER_ERROR: 'otherError',
+ };
+ }
+
+ return { default: MockHls };
+});
+
+// Import AFTER mock is set up
+import { useHLS, isHLSSource, supportsNativeHLS } from './useHLS';
+
+// ─── Helpers ─────────────────────────────────────────────────────────
+
+function createVideoRef(video?: HTMLVideoElement): RefObject {
+ return { current: video ?? createMockVideoElement() };
+}
+
+/** canPlayType returns '' by default in jsdom, meaning no native HLS */
+function mockNativeHLSSupport(supported: boolean) {
+ const original = HTMLVideoElement.prototype.canPlayType;
+ if (supported) {
+ HTMLVideoElement.prototype.canPlayType = ((type: string) =>
+ type === 'application/vnd.apple.mpegurl' ? 'probably' : '') as any;
+ } else {
+ HTMLVideoElement.prototype.canPlayType = (() => '') as any;
+ }
+ return () => {
+ HTMLVideoElement.prototype.canPlayType = original;
+ };
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────
+
+describe('isHLSSource', () => {
+ it('returns false for undefined', () => {
+ expect(isHLSSource(undefined)).toBe(false);
+ });
+
+ it('returns false for empty string', () => {
+ expect(isHLSSource('')).toBe(false);
+ });
+
+ it('returns true for .m3u8 extension', () => {
+ expect(isHLSSource('https://cdn.example.com/video.m3u8')).toBe(true);
+ });
+
+ it('returns true for .m3u8 with query params', () => {
+ expect(isHLSSource('https://cdn.example.com/video.m3u8?token=abc')).toBe(true);
+ });
+
+ it('returns true for /manifest/ path', () => {
+ expect(isHLSSource('https://cdn.example.com/manifest/video')).toBe(true);
+ });
+
+ it('returns false for regular mp4', () => {
+ expect(isHLSSource('https://cdn.example.com/video.mp4')).toBe(false);
+ });
+
+ it('returns false for regular webm', () => {
+ expect(isHLSSource('https://cdn.example.com/video.webm')).toBe(false);
+ });
+});
+
+describe('supportsNativeHLS', () => {
+ it('returns false when canPlayType returns empty string', () => {
+ const restore = mockNativeHLSSupport(false);
+ expect(supportsNativeHLS()).toBe(false);
+ restore();
+ });
+
+ it('returns true when canPlayType returns a truthy string', () => {
+ const restore = mockNativeHLSSupport(true);
+ expect(supportsNativeHLS()).toBe(true);
+ restore();
+ });
+});
+
+describe('useHLS', () => {
+ let restoreNative: (() => void) | undefined;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ hlsIsSupported = true;
+ hlsConstructorCalls.length = 0;
+ // Default: no native HLS (non-Safari)
+ restoreNative = mockNativeHLSSupport(false);
+ });
+
+ afterEach(() => {
+ restoreNative?.();
+ });
+
+ // ─── Basic return values ─────────────────────────────────────────
+
+ describe('initial state', () => {
+ it('returns isHLS false for non-HLS source', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.mp4', videoRef }),
+ );
+
+ expect(result.current.isHLS).toBe(false);
+ expect(result.current.isUsingHlsJs).toBe(false);
+ });
+
+ it('returns isHLS true for .m3u8 source', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(result.current.isHLS).toBe(true);
+ });
+
+ it('returns isUsingHlsJs true when hls.js is supported and no native HLS', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(result.current.isUsingHlsJs).toBe(true);
+ });
+
+ it('starts with empty levels', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.mp4', videoRef }),
+ );
+
+ expect(result.current.levels).toEqual([]);
+ });
+
+ it('starts with auto quality enabled by default', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(result.current.isAutoQuality).toBe(true);
+ });
+
+ it('uses custom autoQuality from config', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ config: { autoQuality: false },
+ }),
+ );
+
+ expect(result.current.isAutoQuality).toBe(false);
+ });
+ });
+
+ // ─── HLS.js instance creation ────────────────────────────────────
+
+ describe('hls.js instance creation', () => {
+ it('creates Hls instance for HLS source when hls.js is supported', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(hlsConstructorCalls.length).toBe(1);
+ });
+
+ it('does not create Hls instance for non-HLS source', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.mp4', videoRef }),
+ );
+
+ expect(hlsConstructorCalls.length).toBe(0);
+ });
+
+ it('does not create Hls instance when enabled is false', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ config: { enabled: false },
+ }),
+ );
+
+ expect(hlsConstructorCalls.length).toBe(0);
+ });
+
+ it('does not create Hls instance when hls.js is not supported', () => {
+ hlsIsSupported = false;
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(hlsConstructorCalls.length).toBe(0);
+ });
+
+ it('passes startLevel config to Hls constructor', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ config: { startLevel: 2 },
+ }),
+ );
+
+ expect(hlsConstructorCalls[0][0]).toMatchObject({ startLevel: 2 });
+ });
+
+ it('passes lowLatencyMode config to Hls constructor', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ config: { lowLatencyMode: true },
+ }),
+ );
+
+ expect(hlsConstructorCalls[0][0]).toMatchObject({ lowLatencyMode: true });
+ });
+
+ it('passes maxBufferLength config when provided', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ config: { maxBufferLength: 60 },
+ }),
+ );
+
+ expect(hlsConstructorCalls[0][0]).toMatchObject({ maxBufferLength: 60 });
+ });
+
+ it('does not pass maxBufferLength when not provided', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(hlsConstructorCalls[0][0]).not.toHaveProperty('maxBufferLength');
+ });
+
+ it('always enables worker in config', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(hlsConstructorCalls[0][0]).toMatchObject({ enableWorker: true });
+ });
+
+ it('loads source and attaches media', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(mockHlsInstance.loadSource).toHaveBeenCalledWith(
+ 'https://example.com/video.m3u8',
+ );
+ expect(mockHlsInstance.attachMedia).toHaveBeenCalledWith(videoRef.current);
+ });
+ });
+
+ // ─── Manifest parsed ─────────────────────────────────────────────
+
+ describe('manifest parsed', () => {
+ it('extracts quality levels from manifest', () => {
+ const videoRef = createVideoRef();
+ const onQualityLevelsLoaded = vi.fn();
+ const { result } = renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ onQualityLevelsLoaded,
+ }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsManifestParsed', {
+ levels: [
+ { height: 360, width: 640, bitrate: 800000 },
+ { height: 720, width: 1280, bitrate: 2500000 },
+ { height: 1080, width: 1920, bitrate: 5000000 },
+ ],
+ });
+ });
+
+ expect(result.current.levels).toHaveLength(4); // 3 levels + Auto
+ expect(result.current.levels[0].label).toBe('Auto');
+ expect(result.current.levels[1].label).toBe('360p');
+ expect(result.current.levels[2].label).toBe('720p');
+ expect(result.current.levels[3].label).toBe('1080p');
+ });
+
+ it('calls onQualityLevelsLoaded callback', () => {
+ const videoRef = createVideoRef();
+ const onQualityLevelsLoaded = vi.fn();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ onQualityLevelsLoaded,
+ }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsManifestParsed', {
+ levels: [
+ { height: 720, width: 1280, bitrate: 2500000 },
+ ],
+ });
+ });
+
+ expect(onQualityLevelsLoaded).toHaveBeenCalledTimes(1);
+ expect(onQualityLevelsLoaded).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ label: 'Auto' }),
+ expect.objectContaining({ label: '720p' }),
+ ]),
+ );
+ });
+
+ it('uses "Level N" label when height is missing', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsManifestParsed', {
+ levels: [
+ { height: 0, width: 0, bitrate: 500000 },
+ { height: 720, width: 1280, bitrate: 2500000 },
+ ],
+ });
+ });
+
+ expect(result.current.levels[1].label).toBe('Level 1');
+ expect(result.current.levels[2].label).toBe('720p');
+ });
+
+ it('includes bitrate, width, and height in quality levels', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsManifestParsed', {
+ levels: [
+ { height: 720, width: 1280, bitrate: 2500000 },
+ ],
+ });
+ });
+
+ expect(result.current.levels[1]).toMatchObject({
+ bitrate: 2500000,
+ width: 1280,
+ height: 720,
+ });
+ });
+
+ it('sets src to empty string for HLS quality levels', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsManifestParsed', {
+ levels: [
+ { height: 720, width: 1280, bitrate: 2500000 },
+ ],
+ });
+ });
+
+ expect(result.current.levels[0].src).toBe('');
+ expect(result.current.levels[1].src).toBe('');
+ });
+ });
+
+ // ─── Level switching ─────────────────────────────────────────────
+
+ describe('level switching', () => {
+ it('updates currentLevel when LEVEL_SWITCHED fires', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsLevelSwitched', { level: 1 });
+ });
+
+ // level 1 from HLS + 1 offset for Auto = index 2
+ expect(result.current.currentLevel).toBe(2);
+ });
+
+ it('setLevel(0) sets auto quality', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ act(() => {
+ result.current.setLevel(0);
+ });
+
+ expect(mockHlsInstance.currentLevel).toBe(-1);
+ expect(result.current.isAutoQuality).toBe(true);
+ expect(result.current.currentLevel).toBe(0);
+ });
+
+ it('setLevel(N) sets specific quality level', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ config: { autoQuality: false, startLevel: 0 },
+ }),
+ );
+
+ act(() => {
+ result.current.setLevel(2);
+ });
+
+ // setLevel(2) sets hls.currentLevel = 2 - 1 = 1
+ expect(result.current.isAutoQuality).toBe(false);
+ expect(result.current.currentLevel).toBe(2);
+ });
+
+ it('setLevel does nothing without hls instance', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.mp4', videoRef }),
+ );
+
+ // Should not throw
+ act(() => {
+ result.current.setLevel(1);
+ });
+ });
+ });
+
+ // ─── Auto quality ────────────────────────────────────────────────
+
+ describe('auto quality', () => {
+ it('setAutoQuality(true) enables auto quality', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ config: { autoQuality: false },
+ }),
+ );
+
+ act(() => {
+ result.current.setAutoQuality(true);
+ });
+
+ expect(mockHlsInstance.currentLevel).toBe(-1);
+ expect(result.current.isAutoQuality).toBe(true);
+ expect(result.current.currentLevel).toBe(0);
+ });
+
+ it('setAutoQuality(false) disables auto quality state', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ act(() => {
+ result.current.setAutoQuality(false);
+ });
+
+ expect(result.current.isAutoQuality).toBe(false);
+ });
+
+ it('setAutoQuality without hls instance only updates state', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.mp4', videoRef }),
+ );
+
+ act(() => {
+ result.current.setAutoQuality(true);
+ });
+
+ expect(result.current.isAutoQuality).toBe(true);
+ });
+ });
+
+ // ─── Error handling ──────────────────────────────────────────────
+
+ describe('error handling', () => {
+ it('recovers from network error by calling startLoad', () => {
+ const videoRef = createVideoRef();
+ const onError = vi.fn();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ onError,
+ }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsError', {
+ fatal: true,
+ type: 'networkError',
+ details: 'manifestLoadError',
+ });
+ });
+
+ expect(mockHlsInstance.startLoad).toHaveBeenCalledTimes(1);
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it('recovers from media error by calling recoverMediaError', () => {
+ const videoRef = createVideoRef();
+ const onError = vi.fn();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ onError,
+ }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsError', {
+ fatal: true,
+ type: 'mediaError',
+ details: 'bufferStalledError',
+ });
+ });
+
+ expect(mockHlsInstance.recoverMediaError).toHaveBeenCalledTimes(1);
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it('destroys and reports other fatal errors', () => {
+ const videoRef = createVideoRef();
+ const onError = vi.fn();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ onError,
+ }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsError', {
+ fatal: true,
+ type: 'otherError',
+ details: 'internalException',
+ });
+ });
+
+ expect(mockHlsInstance.destroy).toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.stringContaining('otherError'),
+ }),
+ );
+ });
+
+ it('ignores non-fatal errors', () => {
+ const videoRef = createVideoRef();
+ const onError = vi.fn();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ onError,
+ }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsError', {
+ fatal: false,
+ type: 'networkError',
+ details: 'fragLoadError',
+ });
+ });
+
+ expect(mockHlsInstance.startLoad).not.toHaveBeenCalled();
+ expect(mockHlsInstance.recoverMediaError).not.toHaveBeenCalled();
+ expect(onError).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── Attach / Detach ─────────────────────────────────────────────
+
+ describe('attachHLS', () => {
+ it('does nothing when video ref is null', () => {
+ const videoRef: RefObject = { current: null };
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ // Hls constructor should not be called because shouldUseHlsJs triggers
+ // auto-attach, but videoRef is null so attachHLS returns early
+ // The auto-attach effect fires, but the guard `if (!video)` blocks it
+ });
+
+ it('does nothing without a src', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: undefined, videoRef }),
+ );
+
+ expect(result.current.isHLS).toBe(false);
+ expect(result.current.isUsingHlsJs).toBe(false);
+ });
+
+ it('sets initial auto quality on attach', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ config: { autoQuality: true },
+ }),
+ );
+
+ expect(mockHlsInstance.currentLevel).toBe(-1);
+ });
+
+ it('sets specific startLevel on attach when auto quality is disabled', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ config: { autoQuality: false, startLevel: 2 },
+ }),
+ );
+
+ expect(mockHlsInstance.currentLevel).toBe(2);
+ });
+ });
+
+ describe('detachHLS', () => {
+ it('destroys hls instance and resets state', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ // First, get some levels set
+ act(() => {
+ mockHlsInstance._emit('hlsManifestParsed', {
+ levels: [{ height: 720, width: 1280, bitrate: 2500000 }],
+ });
+ });
+ expect(result.current.levels.length).toBeGreaterThan(0);
+
+ act(() => {
+ result.current.detachHLS();
+ });
+
+ expect(mockHlsInstance.destroy).toHaveBeenCalled();
+ expect(result.current.levels).toEqual([]);
+ expect(result.current.currentLevel).toBe(-1);
+ });
+ });
+
+ // ─── Native HLS (Safari) ────────────────────────────────────────
+
+ describe('native HLS (Safari fallback)', () => {
+ it('sets video src directly when native HLS is supported', () => {
+ restoreNative?.();
+ restoreNative = mockNativeHLSSupport(true);
+
+ const video = createMockVideoElement();
+ const videoRef = createVideoRef(video);
+
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(video.src).toContain('video.m3u8');
+ expect(hlsConstructorCalls.length).toBe(0);
+ });
+
+ it('returns isUsingHlsJs false when native HLS is supported', () => {
+ restoreNative?.();
+ restoreNative = mockNativeHLSSupport(true);
+
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ expect(result.current.isHLS).toBe(true);
+ expect(result.current.isUsingHlsJs).toBe(false);
+ });
+ });
+
+ // ─── Cleanup on unmount ──────────────────────────────────────────
+
+ describe('cleanup', () => {
+ it('destroys hls instance on unmount', () => {
+ const videoRef = createVideoRef();
+ const { unmount } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ const instance = mockHlsInstance;
+ unmount();
+
+ expect(instance.destroy).toHaveBeenCalled();
+ });
+
+ it('destroys previous hls instance when src changes', () => {
+ const videoRef = createVideoRef();
+ const { rerender } = renderHook(
+ ({ src }: { src: string }) =>
+ useHLS({ src, videoRef }),
+ { initialProps: { src: 'https://example.com/v1.m3u8' } },
+ );
+
+ const firstInstance = mockHlsInstance;
+
+ rerender({ src: 'https://example.com/v2.m3u8' });
+
+ // The first instance should have been destroyed during cleanup
+ expect(firstInstance.destroy).toHaveBeenCalled();
+ });
+ });
+
+ // ─── Source change ───────────────────────────────────────────────
+
+ describe('source changes', () => {
+ it('re-attaches when src changes to a new HLS source', () => {
+ const videoRef = createVideoRef();
+ const { rerender } = renderHook(
+ ({ src }: { src: string }) =>
+ useHLS({ src, videoRef }),
+ { initialProps: { src: 'https://example.com/v1.m3u8' } },
+ );
+
+ expect(mockHlsInstance.loadSource).toHaveBeenCalledWith('https://example.com/v1.m3u8');
+
+ rerender({ src: 'https://example.com/v2.m3u8' });
+
+ // New instance created
+ expect(mockHlsInstance.loadSource).toHaveBeenCalledWith('https://example.com/v2.m3u8');
+ });
+
+ it('detaches when src changes from HLS to non-HLS', () => {
+ const videoRef = createVideoRef();
+ const { result, rerender } = renderHook(
+ ({ src }: { src: string }) =>
+ useHLS({ src, videoRef }),
+ { initialProps: { src: 'https://example.com/video.m3u8' } },
+ );
+
+ expect(result.current.isHLS).toBe(true);
+
+ rerender({ src: 'https://example.com/video.mp4' });
+
+ expect(result.current.isHLS).toBe(false);
+ expect(result.current.isUsingHlsJs).toBe(false);
+ });
+ });
+
+ // ─── Edge cases ──────────────────────────────────────────────────
+
+ describe('edge cases', () => {
+ it('destroys existing instance before creating new one in createHlsInstance', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ const firstInstance = mockHlsInstance;
+
+ // Manually trigger attachHLS again (simulates effect re-run)
+ // The effect cleanup + re-run will destroy the old one
+ // We can verify the first one was destroyed
+ expect(firstInstance.destroy).toHaveBeenCalledTimes(0);
+ });
+
+ it('handles undefined src in native HLS path', () => {
+ restoreNative?.();
+ restoreNative = mockNativeHLSSupport(true);
+
+ const video = createMockVideoElement();
+ const videoRef = createVideoRef(video);
+ const originalSrc = video.src;
+
+ renderHook(() =>
+ useHLS({ src: undefined, videoRef }),
+ );
+
+ // src should not be changed
+ expect(video.src).toBe(originalSrc);
+ });
+
+ it('error message includes type and details', () => {
+ const videoRef = createVideoRef();
+ const onError = vi.fn();
+ renderHook(() =>
+ useHLS({
+ src: 'https://example.com/video.m3u8',
+ videoRef,
+ onError,
+ }),
+ );
+
+ act(() => {
+ mockHlsInstance._emit('hlsError', {
+ fatal: true,
+ type: 'otherError',
+ details: 'specificDetail',
+ });
+ });
+
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'HLS fatal error: otherError - specificDetail',
+ }),
+ );
+ });
+
+ it('works without onQualityLevelsLoaded callback', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ // Should not throw
+ act(() => {
+ mockHlsInstance._emit('hlsManifestParsed', {
+ levels: [{ height: 720, width: 1280, bitrate: 2500000 }],
+ });
+ });
+
+ expect(result.current.levels).toHaveLength(2);
+ });
+
+ it('works without onError callback on fatal error', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ // Should not throw
+ act(() => {
+ mockHlsInstance._emit('hlsError', {
+ fatal: true,
+ type: 'otherError',
+ details: 'someDetail',
+ });
+ });
+
+ expect(mockHlsInstance.destroy).toHaveBeenCalled();
+ });
+
+ it('returns hlsInstance as null initially when not using hls.js', () => {
+ const videoRef = createVideoRef();
+ const { result } = renderHook(() =>
+ useHLS({ src: 'https://example.com/video.mp4', videoRef }),
+ );
+
+ expect(result.current.hlsInstance).toBeNull();
+ });
+
+ it('default config values are applied when no config is provided', () => {
+ const videoRef = createVideoRef();
+ renderHook(() =>
+ useHLS({ src: 'https://example.com/video.m3u8', videoRef }),
+ );
+
+ // enabled defaults to true, autoQuality to true, startLevel to -1
+ expect(hlsConstructorCalls[0][0]).toMatchObject({
+ startLevel: -1,
+ lowLatencyMode: false,
+ });
+ });
+ });
+});
diff --git a/src/hooks/useKeyboardControls.test.ts b/src/hooks/useKeyboardControls.test.ts
new file mode 100644
index 0000000..98196ca
--- /dev/null
+++ b/src/hooks/useKeyboardControls.test.ts
@@ -0,0 +1,321 @@
+import { renderHook } from '@testing-library/react';
+import { useKeyboardControls } from './useKeyboardControls';
+import type { PlayerControls } from '@/types/player';
+
+function createMockControls(): PlayerControls {
+ return {
+ play: vi.fn().mockResolvedValue(undefined),
+ pause: vi.fn(),
+ toggle: vi.fn().mockResolvedValue(undefined),
+ stop: vi.fn(),
+ seek: vi.fn(),
+ seekTo: vi.fn(),
+ skipForward: vi.fn(),
+ skipBackward: vi.fn(),
+ setVolume: vi.fn(),
+ toggleMute: vi.fn(),
+ setPlaybackRate: vi.fn(),
+ };
+}
+
+function dispatchKey(
+ key: string,
+ options: Partial = {},
+ target: EventTarget = document,
+) {
+ const event = new KeyboardEvent('keydown', {
+ key,
+ bubbles: true,
+ cancelable: true,
+ ...options,
+ });
+ target.dispatchEvent(event);
+ return event;
+}
+
+describe('useKeyboardControls', () => {
+ let controls: PlayerControls;
+
+ beforeEach(() => {
+ controls = createMockControls();
+ });
+
+ // ── Basic toggle (Space / k) ────────────────────────────────────────
+
+ it('should toggle play/pause on Space key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey(' ');
+ expect(controls.toggle).toHaveBeenCalledTimes(1);
+ });
+
+ it('should toggle play/pause on "k" key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('k');
+ expect(controls.toggle).toHaveBeenCalledTimes(1);
+ });
+
+ // ── Arrow seeking ───────────────────────────────────────────────────
+
+ it('should skip backward on ArrowLeft', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('ArrowLeft');
+ expect(controls.skipBackward).toHaveBeenCalledWith(5);
+ });
+
+ it('should skip backward by double amount on Shift+ArrowLeft', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('ArrowLeft', { shiftKey: true });
+ expect(controls.skipBackward).toHaveBeenCalledWith(10);
+ });
+
+ it('should skip forward on ArrowRight', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('ArrowRight');
+ expect(controls.skipForward).toHaveBeenCalledWith(5);
+ });
+
+ it('should skip forward by double amount on Shift+ArrowRight', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('ArrowRight', { shiftKey: true });
+ expect(controls.skipForward).toHaveBeenCalledWith(10);
+ });
+
+ it('should use custom skipAmount', () => {
+ renderHook(() => useKeyboardControls({ controls, skipAmount: 15 }));
+ dispatchKey('ArrowRight');
+ expect(controls.skipForward).toHaveBeenCalledWith(15);
+ });
+
+ it('should use custom skipAmount * 2 with Shift', () => {
+ renderHook(() => useKeyboardControls({ controls, skipAmount: 15 }));
+ dispatchKey('ArrowLeft', { shiftKey: true });
+ expect(controls.skipBackward).toHaveBeenCalledWith(30);
+ });
+
+ // ── Volume ──────────────────────────────────────────────────────────
+
+ it('should call setVolume on ArrowUp', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('ArrowUp');
+ expect(controls.setVolume).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call setVolume on ArrowDown', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('ArrowDown');
+ expect(controls.setVolume).toHaveBeenCalledTimes(1);
+ });
+
+ // ── Mute ────────────────────────────────────────────────────────────
+
+ it('should toggle mute on "m" key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('m');
+ expect(controls.toggleMute).toHaveBeenCalledTimes(1);
+ });
+
+ // ── Skip forward/backward (j/l) ────────────────────────────────────
+
+ it('should skip forward 10s on "l" key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('l');
+ expect(controls.skipForward).toHaveBeenCalledWith(10);
+ });
+
+ it('should skip backward 10s on "j" key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('j');
+ expect(controls.skipBackward).toHaveBeenCalledWith(10);
+ });
+
+ // ── Seek to start/end ──────────────────────────────────────────────
+
+ it('should seek to start on "0" key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('0');
+ expect(controls.seek).toHaveBeenCalledWith(0);
+ });
+
+ it('should seek to start on Home key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('Home');
+ expect(controls.seek).toHaveBeenCalledWith(0);
+ });
+
+ it('should seek to end on End key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('End');
+ expect(controls.seekTo).toHaveBeenCalledWith(100);
+ });
+
+ // ── Number keys 1-9 (percentage seek) ──────────────────────────────
+
+ it('should seekTo 10% on "1" key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('1');
+ expect(controls.seekTo).toHaveBeenCalledWith(10);
+ });
+
+ it('should seekTo 50% on "5" key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('5');
+ expect(controls.seekTo).toHaveBeenCalledWith(50);
+ });
+
+ it('should seekTo 90% on "9" key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('9');
+ expect(controls.seekTo).toHaveBeenCalledWith(90);
+ });
+
+ // ── preventDefault ─────────────────────────────────────────────────
+
+ it('should call preventDefault on Space key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ const event = dispatchKey(' ');
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it('should call preventDefault on ArrowLeft key', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ const event = dispatchKey('ArrowLeft');
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it('should call preventDefault on number keys', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ const event = dispatchKey('3');
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ // ── Input/textarea/contenteditable filtering ───────────────────────
+
+ it('should not handle keys when target is an INPUT element', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ const input = document.createElement('input');
+ document.body.appendChild(input);
+ dispatchKey(' ', {}, input);
+ expect(controls.toggle).not.toHaveBeenCalled();
+ document.body.removeChild(input);
+ });
+
+ it('should not handle keys when target is a TEXTAREA element', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ const textarea = document.createElement('textarea');
+ document.body.appendChild(textarea);
+ dispatchKey('k', {}, textarea);
+ expect(controls.toggle).not.toHaveBeenCalled();
+ document.body.removeChild(textarea);
+ });
+
+ it('should not handle keys when target is contentEditable', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ const div = document.createElement('div');
+ div.contentEditable = 'true';
+ // jsdom does not implement isContentEditable, so we mock it
+ Object.defineProperty(div, 'isContentEditable', { value: true });
+ document.body.appendChild(div);
+ dispatchKey(' ', {}, div);
+ expect(controls.toggle).not.toHaveBeenCalled();
+ document.body.removeChild(div);
+ });
+
+ // ── enabled option ─────────────────────────────────────────────────
+
+ it('should be enabled by default', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey(' ');
+ expect(controls.toggle).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not handle keys when enabled is false', () => {
+ renderHook(() => useKeyboardControls({ controls, enabled: false }));
+ dispatchKey(' ');
+ expect(controls.toggle).not.toHaveBeenCalled();
+ });
+
+ it('should start handling keys when enabled changes from false to true', () => {
+ const { rerender } = renderHook(
+ ({ enabled }) => useKeyboardControls({ controls, enabled }),
+ { initialProps: { enabled: false } },
+ );
+
+ dispatchKey(' ');
+ expect(controls.toggle).not.toHaveBeenCalled();
+
+ rerender({ enabled: true });
+ dispatchKey(' ');
+ expect(controls.toggle).toHaveBeenCalledTimes(1);
+ });
+
+ it('should stop handling keys when enabled changes from true to false', () => {
+ const { rerender } = renderHook(
+ ({ enabled }) => useKeyboardControls({ controls, enabled }),
+ { initialProps: { enabled: true } },
+ );
+
+ dispatchKey(' ');
+ expect(controls.toggle).toHaveBeenCalledTimes(1);
+
+ rerender({ enabled: false });
+ dispatchKey(' ');
+ expect(controls.toggle).toHaveBeenCalledTimes(1); // still 1
+ });
+
+ // ── No controls provided ──────────────────────────────────────────
+
+ it('should not throw when controls is undefined', () => {
+ expect(() => {
+ renderHook(() => useKeyboardControls({}));
+ dispatchKey(' ');
+ }).not.toThrow();
+ });
+
+ // ── containerRef scoping ───────────────────────────────────────────
+
+ it('should only handle keys within the containerRef element', () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const containerRef = { current: container };
+
+ renderHook(() => useKeyboardControls({ controls, containerRef }));
+
+ // Event on an element inside the container
+ const child = document.createElement('div');
+ container.appendChild(child);
+ dispatchKey(' ', {}, child);
+ expect(controls.toggle).toHaveBeenCalledTimes(1);
+
+ // Event on an element outside the container
+ const outside = document.createElement('div');
+ document.body.appendChild(outside);
+ dispatchKey(' ', {}, outside);
+ expect(controls.toggle).toHaveBeenCalledTimes(1); // still 1
+
+ document.body.removeChild(container);
+ document.body.removeChild(outside);
+ });
+
+ // ── Cleanup ────────────────────────────────────────────────────────
+
+ it('should remove event listener on unmount', () => {
+ const { unmount } = renderHook(() => useKeyboardControls({ controls }));
+ unmount();
+ dispatchKey(' ');
+ expect(controls.toggle).not.toHaveBeenCalled();
+ });
+
+ // ── Unrecognised keys are ignored ─────────────────────────────────
+
+ it('should not call any control for unrecognised keys', () => {
+ renderHook(() => useKeyboardControls({ controls }));
+ dispatchKey('x');
+ expect(controls.toggle).not.toHaveBeenCalled();
+ expect(controls.seek).not.toHaveBeenCalled();
+ expect(controls.seekTo).not.toHaveBeenCalled();
+ expect(controls.skipForward).not.toHaveBeenCalled();
+ expect(controls.skipBackward).not.toHaveBeenCalled();
+ expect(controls.setVolume).not.toHaveBeenCalled();
+ expect(controls.toggleMute).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/useKeyboardControls.ts b/src/hooks/useKeyboardControls.ts
index 6a1db03..489e346 100644
--- a/src/hooks/useKeyboardControls.ts
+++ b/src/hooks/useKeyboardControls.ts
@@ -1,5 +1,5 @@
import { useEffect, useCallback } from 'react';
-import type { PlayerControls } from '@/types/player';
+import type { PlayerControls, Chapter } from '@/types/player';
export interface UseKeyboardControlsOptions {
controls?: PlayerControls;
@@ -7,6 +7,12 @@ export interface UseKeyboardControlsOptions {
skipAmount?: number;
volumeStep?: number;
containerRef?: React.RefObject;
+ /** Chapters for [ / ] key navigation */
+ chapters?: Chapter[];
+ /** Current playback time, needed for chapter navigation */
+ currentTime?: number;
+ /** A-B loop controls for keyboard shortcuts */
+ abLoopControls?: { setA: (time?: number) => void; setB: (time?: number) => void; clearLoop: () => void };
}
export function useKeyboardControls(options: UseKeyboardControlsOptions): void {
@@ -16,6 +22,8 @@ export function useKeyboardControls(options: UseKeyboardControlsOptions): void {
skipAmount = 5,
volumeStep = 0.1,
containerRef,
+ chapters,
+ currentTime = 0,
} = options;
// Early return if no controls provided
@@ -100,6 +108,62 @@ export function useKeyboardControls(options: UseKeyboardControlsOptions): void {
controls.seekTo(100);
break;
+ case '[':
+ // Jump to previous chapter start
+ if (chapters && chapters.length > 0) {
+ event.preventDefault();
+ // Find the chapter that starts before the current time (with a small tolerance)
+ const tolerance = 1;
+ let prevChapter: typeof chapters[0] | null = null;
+ for (let i = chapters.length - 1; i >= 0; i--) {
+ if (chapters[i].startTime < currentTime - tolerance) {
+ prevChapter = chapters[i];
+ break;
+ }
+ }
+ if (prevChapter) {
+ controls.seek(prevChapter.startTime);
+ } else {
+ // Already at or before first chapter, seek to beginning
+ controls.seek(0);
+ }
+ }
+ break;
+
+ case ']':
+ // Jump to next chapter start
+ if (chapters && chapters.length > 0) {
+ event.preventDefault();
+ const nextChapter = chapters.find(
+ (ch) => ch.startTime > currentTime + 0.5
+ );
+ if (nextChapter) {
+ controls.seek(nextChapter.startTime);
+ }
+ }
+ break;
+
+ case 'A': // Shift+A: Set loop point A
+ if (options.abLoopControls) {
+ event.preventDefault();
+ options.abLoopControls.setA();
+ }
+ break;
+
+ case 'B': // Shift+B: Set loop point B
+ if (options.abLoopControls) {
+ event.preventDefault();
+ options.abLoopControls.setB();
+ }
+ break;
+
+ case 'Backspace': // Clear A-B loop
+ if (options.abLoopControls) {
+ event.preventDefault();
+ options.abLoopControls.clearLoop();
+ }
+ break;
+
default:
// Handle number keys 1-9 for seeking to percentage
if (event.key >= '1' && event.key <= '9') {
@@ -109,7 +173,7 @@ export function useKeyboardControls(options: UseKeyboardControlsOptions): void {
}
break;
}
- }, [controls, skipAmount, volumeStep, containerRef]);
+ }, [controls, skipAmount, volumeStep, containerRef, chapters, currentTime, options.abLoopControls]);
useEffect(() => {
if (!enabled || !hasControls) return;
diff --git a/src/hooks/useMarkers.test.ts b/src/hooks/useMarkers.test.ts
new file mode 100644
index 0000000..f78e5e7
--- /dev/null
+++ b/src/hooks/useMarkers.test.ts
@@ -0,0 +1,378 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useMarkers } from './useMarkers';
+import { createMockMarkers } from '@/test/helpers';
+import type { TimelineMarker } from '@/types/markers';
+
+describe('useMarkers', () => {
+ const markers = createMockMarkers();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ─── Initial State ──────────────────────────────────────────────────
+
+ describe('initial state', () => {
+ it('returns markers sorted by time', () => {
+ const unsortedMarkers: TimelineMarker[] = [
+ { id: 'm-3', time: 120, title: 'Third' },
+ { id: 'm-1', time: 15, title: 'First' },
+ { id: 'm-2', time: 60, title: 'Second' },
+ ];
+
+ const { result } = renderHook(() =>
+ useMarkers({ markers: unsortedMarkers, currentTime: 0 })
+ );
+
+ expect(result.current.markers[0].id).toBe('m-1');
+ expect(result.current.markers[1].id).toBe('m-2');
+ expect(result.current.markers[2].id).toBe('m-3');
+ });
+
+ it('returns no active marker when time is far from all markers', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 0 })
+ );
+
+ expect(result.current.activeMarker).toBeNull();
+ expect(result.current.activeMarkerIndex).toBe(-1);
+ });
+
+ it('handles empty markers array', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers: [], currentTime: 0 })
+ );
+
+ expect(result.current.markers).toEqual([]);
+ expect(result.current.activeMarker).toBeNull();
+ expect(result.current.activeMarkerIndex).toBe(-1);
+ });
+ });
+
+ // ─── Active Marker Detection ──────────────────────────────────────
+
+ describe('active marker detection', () => {
+ it('detects active marker when within default proximity (2s)', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 14 })
+ );
+
+ // 14 is within 2 seconds of marker at time 15
+ expect(result.current.activeMarker?.id).toBe('m-1');
+ });
+
+ it('detects active marker at exact marker time', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 60 })
+ );
+
+ expect(result.current.activeMarker?.id).toBe('m-2');
+ expect(result.current.activeMarkerIndex).toBe(1);
+ });
+
+ it('detects marker when slightly after marker time', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 16 })
+ );
+
+ // 16 is within 2 seconds of marker at time 15
+ expect(result.current.activeMarker?.id).toBe('m-1');
+ });
+
+ it('returns null when time is beyond proximity threshold', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 40 })
+ );
+
+ // 40 is >2 seconds from all markers (15, 60, 120)
+ expect(result.current.activeMarker).toBeNull();
+ });
+
+ it('uses custom proximity threshold', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 10, proximityThreshold: 5 })
+ );
+
+ // 10 is within 5 seconds of marker at time 15
+ expect(result.current.activeMarker?.id).toBe('m-1');
+ });
+
+ it('custom proximity threshold - no match', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 10, proximityThreshold: 1 })
+ );
+
+ // 10 is 5 seconds from marker at time 15, threshold is 1
+ expect(result.current.activeMarker).toBeNull();
+ });
+
+ it('picks the closest marker when two are within threshold', () => {
+ const closeMarkers: TimelineMarker[] = [
+ { id: 'm-1', time: 10, title: 'A' },
+ { id: 'm-2', time: 14, title: 'B' },
+ ];
+
+ const { result } = renderHook(() =>
+ useMarkers({ markers: closeMarkers, currentTime: 13, proximityThreshold: 5 })
+ );
+
+ // 13 is 3 from m-1 and 1 from m-2 => m-2 is closer
+ expect(result.current.activeMarker?.id).toBe('m-2');
+ });
+
+ it('updates active marker when time changes', () => {
+ const onMarkerChange = vi.fn();
+ const { result, rerender } = renderHook(
+ ({ currentTime }) =>
+ useMarkers({ markers, currentTime, onMarkerChange }),
+ { initialProps: { currentTime: 15 } }
+ );
+
+ expect(result.current.activeMarker?.id).toBe('m-1');
+
+ rerender({ currentTime: 60 });
+
+ expect(result.current.activeMarker?.id).toBe('m-2');
+ });
+
+ it('clears active marker when time moves away', () => {
+ const { result, rerender } = renderHook(
+ ({ currentTime }) => useMarkers({ markers, currentTime }),
+ { initialProps: { currentTime: 15 } }
+ );
+
+ expect(result.current.activeMarker?.id).toBe('m-1');
+
+ rerender({ currentTime: 40 });
+
+ expect(result.current.activeMarker).toBeNull();
+ expect(result.current.activeMarkerIndex).toBe(-1);
+ });
+ });
+
+ // ─── Marker Change Callback ────────────────────────────────────────
+
+ describe('marker change callback', () => {
+ it('calls onMarkerChange when active marker changes', () => {
+ const onMarkerChange = vi.fn();
+ const { rerender } = renderHook(
+ ({ currentTime }) =>
+ useMarkers({ markers, currentTime, onMarkerChange }),
+ { initialProps: { currentTime: 0 } }
+ );
+
+ rerender({ currentTime: 15 });
+
+ expect(onMarkerChange).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'm-1' }),
+ 0
+ );
+ });
+
+ it('does not call onMarkerChange when staying near same marker', () => {
+ const onMarkerChange = vi.fn();
+ const { rerender } = renderHook(
+ ({ currentTime }) =>
+ useMarkers({ markers, currentTime, onMarkerChange }),
+ { initialProps: { currentTime: 14 } }
+ );
+
+ const callCount = onMarkerChange.mock.calls.length;
+
+ rerender({ currentTime: 15 });
+
+ // Should not fire again since active marker is still m-1
+ expect(onMarkerChange.mock.calls.length).toBe(callCount);
+ });
+
+ it('works without onMarkerChange callback', () => {
+ const { result, rerender } = renderHook(
+ ({ currentTime }) => useMarkers({ markers, currentTime }),
+ { initialProps: { currentTime: 0 } }
+ );
+
+ // Should not throw
+ rerender({ currentTime: 15 });
+
+ expect(result.current.activeMarker?.id).toBe('m-1');
+ });
+ });
+
+ // ─── Navigation Controls ──────────────────────────────────────────
+
+ describe('navigation controls', () => {
+ it('goToMarker() calls onMarkerChange with the marker at index', () => {
+ const onMarkerChange = vi.fn();
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 15, onMarkerChange })
+ );
+
+ onMarkerChange.mockClear();
+
+ result.current.goToMarker(1);
+
+ expect(onMarkerChange).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'm-2' }),
+ 1
+ );
+ });
+
+ it('goToMarker() does nothing for negative index', () => {
+ const onMarkerChange = vi.fn();
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 15, onMarkerChange })
+ );
+
+ onMarkerChange.mockClear();
+
+ result.current.goToMarker(-1);
+
+ expect(onMarkerChange).not.toHaveBeenCalled();
+ });
+
+ it('goToMarker() does nothing for out-of-range index', () => {
+ const onMarkerChange = vi.fn();
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 15, onMarkerChange })
+ );
+
+ onMarkerChange.mockClear();
+
+ result.current.goToMarker(10);
+
+ expect(onMarkerChange).not.toHaveBeenCalled();
+ });
+
+ it('nextMarker() goes to the next marker', () => {
+ const onMarkerChange = vi.fn();
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 15, onMarkerChange })
+ );
+
+ // Active marker is index 0 (m-1)
+ onMarkerChange.mockClear();
+
+ result.current.nextMarker();
+
+ expect(onMarkerChange).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'm-2' }),
+ 1
+ );
+ });
+
+ it('nextMarker() does nothing at last marker', () => {
+ const onMarkerChange = vi.fn();
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 120, onMarkerChange })
+ );
+
+ // Active marker is index 2 (m-3, last)
+ onMarkerChange.mockClear();
+
+ result.current.nextMarker();
+
+ expect(onMarkerChange).not.toHaveBeenCalled();
+ });
+
+ it('previousMarker() goes to the previous marker', () => {
+ const onMarkerChange = vi.fn();
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 60, onMarkerChange })
+ );
+
+ // Active marker is index 1 (m-2)
+ onMarkerChange.mockClear();
+
+ result.current.previousMarker();
+
+ expect(onMarkerChange).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'm-1' }),
+ 0
+ );
+ });
+
+ it('previousMarker() does nothing at first marker', () => {
+ const onMarkerChange = vi.fn();
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 15, onMarkerChange })
+ );
+
+ // Active marker is index 0 (m-1, first)
+ onMarkerChange.mockClear();
+
+ result.current.previousMarker();
+
+ expect(onMarkerChange).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── Edge Cases ────────────────────────────────────────────────────
+
+ describe('edge cases', () => {
+ it('handles single marker', () => {
+ const singleMarker: TimelineMarker[] = [
+ { id: 'm-1', time: 50, title: 'Only' },
+ ];
+
+ const { result } = renderHook(() =>
+ useMarkers({ markers: singleMarker, currentTime: 50 })
+ );
+
+ expect(result.current.activeMarker?.id).toBe('m-1');
+ expect(result.current.activeMarkerIndex).toBe(0);
+ });
+
+ it('handles markers with color property', () => {
+ const colorMarkers: TimelineMarker[] = [
+ { id: 'm-1', time: 10, title: 'Red', color: '#ff0000' },
+ { id: 'm-2', time: 20, title: 'Blue', color: '#0000ff' },
+ ];
+
+ const { result } = renderHook(() =>
+ useMarkers({ markers: colorMarkers, currentTime: 10 })
+ );
+
+ expect(result.current.activeMarker?.color).toBe('#ff0000');
+ });
+
+ it('handles markers with previewImage', () => {
+ const imageMarkers: TimelineMarker[] = [
+ { id: 'm-1', time: 10, title: 'Preview', previewImage: 'https://example.com/thumb.jpg' },
+ ];
+
+ const { result } = renderHook(() =>
+ useMarkers({ markers: imageMarkers, currentTime: 10 })
+ );
+
+ expect(result.current.activeMarker?.previewImage).toBe('https://example.com/thumb.jpg');
+ });
+
+ it('handles proximity threshold of 0', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 15, proximityThreshold: 0 })
+ );
+
+ // Exact match only
+ expect(result.current.activeMarker?.id).toBe('m-1');
+ });
+
+ it('proximity threshold of 0 does not match nearby times', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 14.5, proximityThreshold: 0 })
+ );
+
+ expect(result.current.activeMarker).toBeNull();
+ });
+
+ it('already sorted markers are returned as-is', () => {
+ const { result } = renderHook(() =>
+ useMarkers({ markers, currentTime: 0 })
+ );
+
+ expect(result.current.markers[0].time).toBe(15);
+ expect(result.current.markers[1].time).toBe(60);
+ expect(result.current.markers[2].time).toBe(120);
+ });
+ });
+});
diff --git a/src/hooks/useMedia.test.ts b/src/hooks/useMedia.test.ts
new file mode 100644
index 0000000..7b03630
--- /dev/null
+++ b/src/hooks/useMedia.test.ts
@@ -0,0 +1,825 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useMedia } from './useMedia';
+import type { UseMediaOptions } from '@/types/media';
+import {
+ createMockVideoElement,
+ fireMediaEvent,
+ simulateTimeUpdate,
+ simulateLoadedMetadata,
+} from '@/test/helpers';
+
+/**
+ * Helper: render the hook, attach a video element to the ref, then force
+ * the event-listener effect to re-run so it discovers the element.
+ *
+ * The strategy: render once (effect sees null ref), set the ref, then
+ * rerender with a changed callback identity in the effect dependency list.
+ */
+function renderMediaHook(options: UseMediaOptions = {}) {
+ const video = createMockVideoElement();
+
+ // We wrap onCanPlayThrough with two different wrapper identities.
+ // This changes the effect dependency without altering observable behaviour.
+ const userCb = options.onCanPlayThrough;
+ const wrapper1 = () => { userCb?.(); };
+ const wrapper2 = () => { userCb?.(); };
+
+ const hookResult = renderHook(
+ ({ opts }: { opts: UseMediaOptions }) => useMedia(opts),
+ {
+ initialProps: {
+ opts: { ...options, onCanPlayThrough: wrapper1 },
+ },
+ }
+ );
+
+ // Attach the mock element to the ref
+ (hookResult.result.current.mediaRef as React.MutableRefObject).current = video;
+
+ // Rerender with a new wrapper identity to force the effect to re-run
+ hookResult.rerender({
+ opts: { ...options, onCanPlayThrough: wrapper2 },
+ });
+
+ return { ...hookResult, video };
+}
+
+describe('useMedia', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ─── Initial State ──────────────────────────────────────────────────
+
+ describe('initial state', () => {
+ it('returns default initial state', () => {
+ const { result } = renderHook(() => useMedia());
+ const { state } = result.current;
+
+ expect(state.isPlaying).toBe(false);
+ expect(state.isPaused).toBe(true);
+ expect(state.isLoading).toBe(true);
+ expect(state.isBuffering).toBe(false);
+ expect(state.isEnded).toBe(false);
+ expect(state.isMuted).toBe(false);
+ expect(state.currentTime).toBe(0);
+ expect(state.duration).toBe(0);
+ expect(state.buffered).toBe(0);
+ expect(state.volume).toBe(1);
+ expect(state.playbackRate).toBe(1);
+ expect(state.error).toBeNull();
+ });
+
+ it('accepts custom initial volume', () => {
+ const { result } = renderHook(() => useMedia({ volume: 0.5 }));
+ expect(result.current.state.volume).toBe(0.5);
+ });
+
+ it('accepts custom initial muted', () => {
+ const { result } = renderHook(() => useMedia({ muted: true }));
+ expect(result.current.state.isMuted).toBe(true);
+ });
+
+ it('accepts custom initial playback rate', () => {
+ const { result } = renderHook(() => useMedia({ playbackRate: 2 }));
+ expect(result.current.state.playbackRate).toBe(2);
+ });
+
+ it('provides a mediaRef', () => {
+ const { result } = renderHook(() => useMedia());
+ expect(result.current.mediaRef).toBeDefined();
+ expect(result.current.mediaRef.current).toBeNull();
+ });
+ });
+
+ // ─── Event Handling ─────────────────────────────────────────────────
+
+ describe('event handling', () => {
+ it('updates state on loadstart', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ fireMediaEvent(video, 'loadstart');
+ });
+
+ expect(result.current.state.isLoading).toBe(true);
+ expect(result.current.state.error).toBeNull();
+ });
+
+ it('updates state on loadedmetadata', () => {
+ const onLoadedMetadata = vi.fn();
+ const { result, video } = renderMediaHook({ onLoadedMetadata });
+
+ act(() => {
+ simulateLoadedMetadata(video, 120);
+ });
+
+ expect(result.current.state.isLoading).toBe(false);
+ expect(result.current.state.duration).toBe(120);
+ expect(onLoadedMetadata).toHaveBeenCalledWith(120);
+ });
+
+ it('calls onLoadedData callback on loadeddata', () => {
+ const onLoadedData = vi.fn();
+ const { video } = renderMediaHook({ onLoadedData });
+
+ act(() => {
+ fireMediaEvent(video, 'loadeddata');
+ });
+
+ expect(onLoadedData).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates state on canplay', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ fireMediaEvent(video, 'loadstart');
+ });
+ expect(result.current.state.isLoading).toBe(true);
+
+ act(() => {
+ fireMediaEvent(video, 'canplay');
+ });
+ expect(result.current.state.isLoading).toBe(false);
+ expect(result.current.state.isBuffering).toBe(false);
+ });
+
+ it('calls onCanPlayThrough callback', () => {
+ const onCanPlayThrough = vi.fn();
+ const { video } = renderMediaHook({ onCanPlayThrough });
+
+ act(() => {
+ fireMediaEvent(video, 'canplaythrough');
+ });
+
+ expect(onCanPlayThrough).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates state on waiting', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ fireMediaEvent(video, 'waiting');
+ });
+
+ expect(result.current.state.isBuffering).toBe(true);
+ });
+
+ it('updates state on playing', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ fireMediaEvent(video, 'playing');
+ });
+
+ expect(result.current.state.isPlaying).toBe(true);
+ expect(result.current.state.isPaused).toBe(false);
+ expect(result.current.state.isBuffering).toBe(false);
+ expect(result.current.state.isEnded).toBe(false);
+ });
+
+ it('updates state on play event and calls onPlay', () => {
+ const onPlay = vi.fn();
+ const { result, video } = renderMediaHook({ onPlay });
+
+ act(() => {
+ fireMediaEvent(video, 'play');
+ });
+
+ expect(result.current.state.isPlaying).toBe(true);
+ expect(result.current.state.isPaused).toBe(false);
+ expect(result.current.state.isEnded).toBe(false);
+ expect(onPlay).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates state on pause event and calls onPause', () => {
+ const onPause = vi.fn();
+ const { result, video } = renderMediaHook({ onPause });
+
+ act(() => {
+ fireMediaEvent(video, 'play');
+ });
+ act(() => {
+ fireMediaEvent(video, 'pause');
+ });
+
+ expect(result.current.state.isPlaying).toBe(false);
+ expect(result.current.state.isPaused).toBe(true);
+ expect(onPause).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates state on ended event and calls onEnded', () => {
+ const onEnded = vi.fn();
+ const { result, video } = renderMediaHook({ onEnded });
+
+ act(() => {
+ fireMediaEvent(video, 'ended');
+ });
+
+ expect(result.current.state.isPlaying).toBe(false);
+ expect(result.current.state.isPaused).toBe(true);
+ expect(result.current.state.isEnded).toBe(true);
+ expect(onEnded).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates currentTime on timeupdate and calls onTimeUpdate', () => {
+ const onTimeUpdate = vi.fn();
+ const { result, video } = renderMediaHook({ onTimeUpdate });
+
+ act(() => {
+ simulateTimeUpdate(video, 42);
+ });
+
+ expect(result.current.state.currentTime).toBe(42);
+ expect(onTimeUpdate).toHaveBeenCalledWith(42);
+ });
+
+ it('updates buffered on progress', () => {
+ const { result, video } = renderMediaHook();
+
+ // Mock the buffered TimeRanges
+ Object.defineProperty(video, 'buffered', {
+ writable: true,
+ value: {
+ length: 1,
+ start: () => 0,
+ end: () => 60,
+ },
+ });
+
+ act(() => {
+ fireMediaEvent(video, 'progress');
+ });
+
+ expect(result.current.state.buffered).toBe(60);
+ });
+
+ it('handles progress event with empty buffer', () => {
+ const { result, video } = renderMediaHook();
+
+ Object.defineProperty(video, 'buffered', {
+ writable: true,
+ value: { length: 0, start: () => 0, end: () => 0 },
+ });
+
+ act(() => {
+ fireMediaEvent(video, 'progress');
+ });
+
+ expect(result.current.state.buffered).toBe(0);
+ });
+
+ it('updates volume and muted on volumechange', () => {
+ const { result, video } = renderMediaHook();
+
+ (video as any).volume = 0.7;
+ (video as any).muted = true;
+
+ act(() => {
+ fireMediaEvent(video, 'volumechange');
+ });
+
+ expect(result.current.state.volume).toBe(0.7);
+ expect(result.current.state.isMuted).toBe(true);
+ });
+
+ it('updates playbackRate on ratechange', () => {
+ const { result, video } = renderMediaHook();
+
+ (video as any).playbackRate = 1.5;
+
+ act(() => {
+ fireMediaEvent(video, 'ratechange');
+ });
+
+ expect(result.current.state.playbackRate).toBe(1.5);
+ });
+
+ it('updates error state on error event and calls onError', () => {
+ vi.useFakeTimers();
+ const onError = vi.fn();
+ const { result, video } = renderMediaHook({ onError });
+
+ Object.defineProperty(video, 'error', {
+ writable: true,
+ value: { message: 'Decode error' },
+ });
+
+ // The error handler now retries up to 3 times before setting error state.
+ // We need to fire enough error events to exhaust all retries (4 errors total).
+ for (let i = 0; i < 4; i++) {
+ act(() => {
+ fireMediaEvent(video, 'error');
+ });
+ }
+
+ expect(result.current.state.error).toBeInstanceOf(Error);
+ expect(result.current.state.error?.message).toBe('Decode error');
+ expect(result.current.state.isLoading).toBe(false);
+ expect(onError).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
+
+ it('handles error event with no error message', () => {
+ vi.useFakeTimers();
+ const { result, video } = renderMediaHook();
+
+ Object.defineProperty(video, 'error', {
+ writable: true,
+ value: {},
+ });
+
+ // Fire enough errors to exhaust retries
+ for (let i = 0; i < 4; i++) {
+ act(() => {
+ fireMediaEvent(video, 'error');
+ });
+ }
+
+ expect(result.current.state.error?.message).toBe('Media error');
+ vi.useRealTimers();
+ });
+ });
+
+ // ─── Controls ───────────────────────────────────────────────────────
+
+ describe('controls', () => {
+ it('play() calls media.play()', async () => {
+ const { result, video } = renderMediaHook();
+ const playSpy = vi.spyOn(video, 'play');
+
+ await act(async () => {
+ await result.current.controls.play();
+ });
+
+ expect(playSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('play() handles play rejection', async () => {
+ const onError = vi.fn();
+ const { result, video } = renderMediaHook({ onError });
+
+ vi.spyOn(video, 'play').mockRejectedValue(new Error('Autoplay blocked'));
+
+ await act(async () => {
+ await result.current.controls.play();
+ });
+
+ expect(result.current.state.error?.message).toBe('Autoplay blocked');
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ it('play() handles non-Error rejections', async () => {
+ const { result, video } = renderMediaHook();
+
+ vi.spyOn(video, 'play').mockRejectedValue('string error');
+
+ await act(async () => {
+ await result.current.controls.play();
+ });
+
+ expect(result.current.state.error?.message).toBe('Failed to play');
+ });
+
+ it('pause() calls media.pause()', () => {
+ const { result, video } = renderMediaHook();
+ const pauseSpy = vi.spyOn(video, 'pause');
+
+ act(() => {
+ result.current.controls.pause();
+ });
+
+ expect(pauseSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('toggle() plays when paused', async () => {
+ const { result, video } = renderMediaHook();
+ const playSpy = vi.spyOn(video, 'play');
+
+ await act(async () => {
+ await result.current.controls.toggle();
+ });
+
+ expect(playSpy).toHaveBeenCalled();
+ });
+
+ it('toggle() pauses when playing', async () => {
+ const { result, video } = renderMediaHook();
+ const pauseSpy = vi.spyOn(video, 'pause');
+
+ // First set playing state
+ act(() => {
+ fireMediaEvent(video, 'play');
+ });
+
+ await act(async () => {
+ await result.current.controls.toggle();
+ });
+
+ expect(pauseSpy).toHaveBeenCalled();
+ });
+
+ it('stop() pauses and resets currentTime', () => {
+ const { result, video } = renderMediaHook();
+ const pauseSpy = vi.spyOn(video, 'pause');
+
+ (video as any).currentTime = 50;
+
+ act(() => {
+ result.current.controls.stop();
+ });
+
+ expect(pauseSpy).toHaveBeenCalled();
+ expect(video.currentTime).toBe(0);
+ });
+
+ it('seek() sets currentTime directly', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ result.current.controls.seek(45);
+ });
+
+ expect(video.currentTime).toBe(45);
+ });
+
+ it('seek() clamps to 0 for negative values', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ result.current.controls.seek(-10);
+ });
+
+ expect(video.currentTime).toBe(0);
+ });
+
+ it('seek() clamps to duration for values beyond duration', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ result.current.controls.seek(999);
+ });
+
+ expect(video.currentTime).toBe(video.duration);
+ });
+
+ it('seekTo() seeks to a percentage of duration', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ result.current.controls.seekTo(50);
+ });
+
+ // 50% of 300 = 150
+ expect(video.currentTime).toBe(150);
+ });
+
+ it('seekTo() does nothing when duration is 0', () => {
+ const { result, video } = renderMediaHook();
+
+ Object.defineProperty(video, 'duration', { writable: true, value: 0 });
+
+ act(() => {
+ result.current.controls.seekTo(50);
+ });
+
+ expect(video.currentTime).toBe(0);
+ });
+
+ it('skipForward() skips by default amount (30s)', () => {
+ const { result, video } = renderMediaHook();
+
+ (video as any).currentTime = 10;
+
+ act(() => {
+ result.current.controls.skipForward();
+ });
+
+ expect(video.currentTime).toBe(40);
+ });
+
+ it('skipForward() skips by custom amount', () => {
+ const { result, video } = renderMediaHook();
+
+ (video as any).currentTime = 10;
+
+ act(() => {
+ result.current.controls.skipForward(15);
+ });
+
+ expect(video.currentTime).toBe(25);
+ });
+
+ it('skipForward() respects custom skipForwardSeconds option', () => {
+ const { result, video } = renderMediaHook({ skipForwardSeconds: 10 });
+
+ (video as any).currentTime = 5;
+
+ act(() => {
+ result.current.controls.skipForward();
+ });
+
+ expect(video.currentTime).toBe(15);
+ });
+
+ it('skipForward() clamps to duration', () => {
+ const { result, video } = renderMediaHook();
+
+ (video as any).currentTime = 290;
+
+ act(() => {
+ result.current.controls.skipForward();
+ });
+
+ expect(video.currentTime).toBe(300);
+ });
+
+ it('skipBackward() skips by default amount (10s)', () => {
+ const { result, video } = renderMediaHook();
+
+ (video as any).currentTime = 50;
+
+ act(() => {
+ result.current.controls.skipBackward();
+ });
+
+ expect(video.currentTime).toBe(40);
+ });
+
+ it('skipBackward() skips by custom amount', () => {
+ const { result, video } = renderMediaHook();
+
+ (video as any).currentTime = 50;
+
+ act(() => {
+ result.current.controls.skipBackward(20);
+ });
+
+ expect(video.currentTime).toBe(30);
+ });
+
+ it('skipBackward() respects custom skipBackwardSeconds option', () => {
+ const { result, video } = renderMediaHook({ skipBackwardSeconds: 5 });
+
+ (video as any).currentTime = 20;
+
+ act(() => {
+ result.current.controls.skipBackward();
+ });
+
+ expect(video.currentTime).toBe(15);
+ });
+
+ it('skipBackward() clamps to 0', () => {
+ const { result, video } = renderMediaHook();
+
+ (video as any).currentTime = 3;
+
+ act(() => {
+ result.current.controls.skipBackward();
+ });
+
+ expect(video.currentTime).toBe(0);
+ });
+
+ it('setVolume() updates volume', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ result.current.controls.setVolume(0.5);
+ });
+
+ expect(video.volume).toBe(0.5);
+ expect(result.current.state.volume).toBe(0.5);
+ });
+
+ it('setVolume() clamps to 0', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ result.current.controls.setVolume(-0.5);
+ });
+
+ expect(video.volume).toBe(0);
+ expect(result.current.state.volume).toBe(0);
+ });
+
+ it('setVolume() clamps to 1', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ result.current.controls.setVolume(1.5);
+ });
+
+ expect(video.volume).toBe(1);
+ expect(result.current.state.volume).toBe(1);
+ });
+
+ it('toggleMute() mutes when unmuted', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ result.current.controls.toggleMute();
+ });
+
+ expect(video.muted).toBe(true);
+ expect(result.current.state.isMuted).toBe(true);
+ });
+
+ it('toggleMute() unmutes when muted', () => {
+ const { result, video } = renderMediaHook({ muted: true });
+
+ act(() => {
+ result.current.controls.toggleMute();
+ });
+
+ expect(video.muted).toBe(false);
+ expect(result.current.state.isMuted).toBe(false);
+ });
+
+ it('toggleMute() works without media element', () => {
+ const { result } = renderHook(() => useMedia());
+
+ act(() => {
+ result.current.controls.toggleMute();
+ });
+
+ expect(result.current.state.isMuted).toBe(true);
+
+ act(() => {
+ result.current.controls.toggleMute();
+ });
+
+ expect(result.current.state.isMuted).toBe(false);
+ });
+
+ it('setPlaybackRate() updates playback rate', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ result.current.controls.setPlaybackRate(2);
+ });
+
+ expect(video.playbackRate).toBe(2);
+ expect(result.current.state.playbackRate).toBe(2);
+ });
+ });
+
+ // ─── Source handling ────────────────────────────────────────────────
+
+ describe('source handling', () => {
+ it('sets src and calls load when src changes', () => {
+ const video = createMockVideoElement();
+ const loadSpy = vi.spyOn(video, 'load');
+
+ const hookResult = renderHook(
+ ({ src }: { src?: string }) => useMedia({ src }),
+ { initialProps: { src: undefined as string | undefined } }
+ );
+
+ // Attach video to the ref
+ (hookResult.result.current.mediaRef as React.MutableRefObject).current = video;
+
+ // Now rerender with a src to trigger the src effect
+ hookResult.rerender({ src: 'https://example.com/video.mp4' });
+
+ expect(video.src).toBe('https://example.com/video.mp4');
+ expect(loadSpy).toHaveBeenCalled();
+ });
+ });
+
+ // ─── Controls without media element ─────────────────────────────────
+
+ describe('controls without media element', () => {
+ it('play() does nothing without media element', async () => {
+ const { result } = renderHook(() => useMedia());
+
+ await act(async () => {
+ await result.current.controls.play();
+ });
+
+ // Should not throw
+ expect(result.current.state.isPlaying).toBe(false);
+ });
+
+ it('pause() does nothing without media element', () => {
+ const { result } = renderHook(() => useMedia());
+
+ act(() => {
+ result.current.controls.pause();
+ });
+
+ expect(result.current.state.isPaused).toBe(true);
+ });
+
+ it('stop() does nothing without media element', () => {
+ const { result } = renderHook(() => useMedia());
+
+ act(() => {
+ result.current.controls.stop();
+ });
+
+ expect(result.current.state.currentTime).toBe(0);
+ });
+
+ it('seek() does nothing without media element', () => {
+ const { result } = renderHook(() => useMedia());
+
+ act(() => {
+ result.current.controls.seek(50);
+ });
+
+ expect(result.current.state.currentTime).toBe(0);
+ });
+
+ it('setPlaybackRate() does nothing without media element', () => {
+ const { result } = renderHook(() => useMedia());
+
+ act(() => {
+ result.current.controls.setPlaybackRate(2);
+ });
+
+ expect(result.current.state.playbackRate).toBe(1);
+ });
+ });
+
+ // ─── Full lifecycle ─────────────────────────────────────────────────
+
+ describe('full lifecycle', () => {
+ it('handles a complete play-pause-seek-ended lifecycle', async () => {
+ const onPlay = vi.fn();
+ const onPause = vi.fn();
+ const onEnded = vi.fn();
+ const onTimeUpdate = vi.fn();
+
+ const { result, video } = renderMediaHook({
+ onPlay,
+ onPause,
+ onEnded,
+ onTimeUpdate,
+ });
+
+ // Load metadata
+ act(() => {
+ simulateLoadedMetadata(video, 100);
+ });
+ expect(result.current.state.duration).toBe(100);
+
+ // Play
+ act(() => {
+ fireMediaEvent(video, 'play');
+ });
+ expect(result.current.state.isPlaying).toBe(true);
+ expect(onPlay).toHaveBeenCalledTimes(1);
+
+ // Time update
+ act(() => {
+ simulateTimeUpdate(video, 50);
+ });
+ expect(result.current.state.currentTime).toBe(50);
+ expect(onTimeUpdate).toHaveBeenCalledWith(50);
+
+ // Pause
+ act(() => {
+ fireMediaEvent(video, 'pause');
+ });
+ expect(result.current.state.isPaused).toBe(true);
+ expect(onPause).toHaveBeenCalledTimes(1);
+
+ // Seek
+ act(() => {
+ result.current.controls.seek(90);
+ });
+ expect(video.currentTime).toBe(90);
+
+ // Ended
+ act(() => {
+ fireMediaEvent(video, 'ended');
+ });
+ expect(result.current.state.isEnded).toBe(true);
+ expect(onEnded).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles buffering during playback', () => {
+ const { result, video } = renderMediaHook();
+
+ act(() => {
+ fireMediaEvent(video, 'play');
+ });
+ expect(result.current.state.isPlaying).toBe(true);
+
+ act(() => {
+ fireMediaEvent(video, 'waiting');
+ });
+ expect(result.current.state.isBuffering).toBe(true);
+
+ act(() => {
+ fireMediaEvent(video, 'playing');
+ });
+ expect(result.current.state.isBuffering).toBe(false);
+ expect(result.current.state.isPlaying).toBe(true);
+ });
+ });
+});
diff --git a/src/hooks/useMedia.ts b/src/hooks/useMedia.ts
index 4fbc329..968a139 100644
--- a/src/hooks/useMedia.ts
+++ b/src/hooks/useMedia.ts
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MediaState, MediaControls, UseMediaOptions, UseMediaReturn } from '@/types/media';
+const MAX_RETRIES = 3;
+const RETRY_DELAY_MS = 2000;
+const RETRY_WINDOW_MS = 5000;
+
const initialState: MediaState = {
isPlaying: false,
isPaused: true,
@@ -14,6 +18,8 @@ const initialState: MediaState = {
volume: 1,
playbackRate: 1,
error: null,
+ retryCount: 0,
+ isRetrying: false,
};
/**
@@ -54,6 +60,38 @@ export function useMedia(
setState((prev) => ({ ...prev, ...updates }));
}, []);
+ // Retry state refs (kept outside React state to avoid stale closures in event handlers)
+ const retryCountRef = useRef(0);
+ const lastRetryTimestampRef = useRef(0);
+ const retryTimerRef = useRef | null>(null);
+
+ // Retry: reload media source and resume from stored position
+ const retry = useCallback(async () => {
+ const media = mediaRef.current;
+ if (!media) return;
+
+ const savedTime = media.currentTime;
+ updateState({ isRetrying: true, error: null });
+
+ media.load();
+
+ // Wait for media to be ready before seeking and playing
+ const onCanPlay = async () => {
+ media.removeEventListener('canplay', onCanPlay);
+ media.currentTime = savedTime;
+ try {
+ await media.play();
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error('Failed to play after retry');
+ updateState({ error: err, isRetrying: false });
+ onError?.(err);
+ }
+ updateState({ isRetrying: false });
+ };
+
+ media.addEventListener('canplay', onCanPlay);
+ }, [onError, updateState]);
+
// Play
const play = useCallback(async () => {
const media = mediaRef.current;
@@ -189,11 +227,15 @@ export function useMedia(
};
const handlePlaying = () => {
+ // Reset retry count on successful playback
+ retryCountRef.current = 0;
updateState({
isPlaying: true,
isPaused: false,
isBuffering: false,
isEnded: false,
+ retryCount: 0,
+ isRetrying: false,
});
};
@@ -238,8 +280,31 @@ export function useMedia(
const handleError = () => {
const error = media.error;
const err = new Error(error?.message || 'Media error');
- updateState({ error: err, isLoading: false });
- onError?.(err);
+
+ const now = Date.now();
+ const timeSinceLastRetry = now - lastRetryTimestampRef.current;
+
+ // If error occurs within the retry window of a previous retry, increment count
+ if (timeSinceLastRetry < RETRY_WINDOW_MS) {
+ retryCountRef.current += 1;
+ } else {
+ // First error or error after a long time — start fresh retry sequence
+ retryCountRef.current = retryCountRef.current === 0 ? 1 : retryCountRef.current + 1;
+ }
+
+ if (retryCountRef.current <= MAX_RETRIES) {
+ // Auto-retry: wait then attempt recovery
+ updateState({ isRetrying: true, retryCount: retryCountRef.current, error: null, isLoading: false });
+ lastRetryTimestampRef.current = now;
+
+ retryTimerRef.current = setTimeout(() => {
+ retry();
+ }, RETRY_DELAY_MS);
+ } else {
+ // Max retries exceeded — give up and show error (existing behavior)
+ updateState({ error: err, isLoading: false, isRetrying: false, retryCount: retryCountRef.current });
+ onError?.(err);
+ }
};
// Add event listeners
@@ -265,6 +330,10 @@ export function useMedia(
media.playbackRate = state.playbackRate;
return () => {
+ if (retryTimerRef.current) {
+ clearTimeout(retryTimerRef.current);
+ retryTimerRef.current = null;
+ }
media.removeEventListener('loadstart', handleLoadStart);
media.removeEventListener('loadedmetadata', handleLoadedMetadata);
media.removeEventListener('loadeddata', handleLoadedData);
@@ -294,6 +363,7 @@ export function useMedia(
onLoadedData,
onCanPlayThrough,
updateState,
+ retry,
]);
// Handle source changes
@@ -321,6 +391,7 @@ export function useMedia(
setVolume,
toggleMute,
setPlaybackRate,
+ retry,
};
return {
diff --git a/src/hooks/usePauseAd.test.ts b/src/hooks/usePauseAd.test.ts
new file mode 100644
index 0000000..4f7cf98
--- /dev/null
+++ b/src/hooks/usePauseAd.test.ts
@@ -0,0 +1,126 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { usePauseAd } from './usePauseAd';
+import type { PauseAd } from '@/types/pauseAd';
+
+const mockAd: PauseAd = {
+ id: 'pause-ad-1',
+ imageUrl: 'https://example.com/ad.jpg',
+ clickThroughUrl: 'https://example.com/landing',
+ title: 'Test Ad',
+ altText: 'Test advertisement',
+};
+
+describe('usePauseAd', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should not show ad initially', () => {
+ const { result } = renderHook(() =>
+ usePauseAd({ ad: mockAd, isPaused: false, isPlaying: false })
+ );
+ expect(result.current.state.isVisible).toBe(false);
+ expect(result.current.state.currentAd).toBeNull();
+ });
+
+ it('should not show ad on initial pause (before any play)', () => {
+ const { result } = renderHook(() =>
+ usePauseAd({ ad: mockAd, isPaused: true, isPlaying: false })
+ );
+ expect(result.current.state.isVisible).toBe(false);
+ });
+
+ it('should show ad when paused after playing', () => {
+ const onShow = vi.fn();
+ const { result, rerender } = renderHook(
+ (props) => usePauseAd(props),
+ { initialProps: { ad: mockAd, isPaused: false, isPlaying: true, onShow } }
+ );
+
+ // Pause
+ rerender({ ad: mockAd, isPaused: true, isPlaying: false, onShow });
+
+ expect(result.current.state.isVisible).toBe(true);
+ expect(result.current.state.currentAd).toEqual(mockAd);
+ expect(onShow).toHaveBeenCalledWith(mockAd);
+ });
+
+ it('should hide ad when playback resumes', () => {
+ const onHide = vi.fn();
+ const { result, rerender } = renderHook(
+ (props) => usePauseAd(props),
+ { initialProps: { ad: mockAd, isPaused: false, isPlaying: true, onHide } }
+ );
+
+ // Pause
+ rerender({ ad: mockAd, isPaused: true, isPlaying: false, onHide });
+ expect(result.current.state.isVisible).toBe(true);
+
+ // Resume
+ rerender({ ad: mockAd, isPaused: false, isPlaying: true, onHide });
+ expect(result.current.state.isVisible).toBe(false);
+ expect(onHide).toHaveBeenCalledWith(mockAd);
+ });
+
+ it('should respect minPauseDuration', () => {
+ const adWithDelay: PauseAd = { ...mockAd, minPauseDuration: 3 };
+ const onShow = vi.fn();
+
+ const { result, rerender } = renderHook(
+ (props) => usePauseAd(props),
+ { initialProps: { ad: adWithDelay, isPaused: false, isPlaying: true, onShow } }
+ );
+
+ // Pause
+ rerender({ ad: adWithDelay, isPaused: true, isPlaying: false, onShow });
+
+ // Not visible yet
+ expect(result.current.state.isVisible).toBe(false);
+
+ // Advance time
+ act(() => { vi.advanceTimersByTime(3000); });
+
+ expect(result.current.state.isVisible).toBe(true);
+ expect(onShow).toHaveBeenCalled();
+ });
+
+ it('should not show ad when disabled', () => {
+ const { result, rerender } = renderHook(
+ (props) => usePauseAd(props),
+ { initialProps: { ad: mockAd, isPaused: false, isPlaying: true, enabled: false } }
+ );
+
+ rerender({ ad: mockAd, isPaused: true, isPlaying: false, enabled: false });
+ expect(result.current.state.isVisible).toBe(false);
+ });
+
+ it('should not show ad when no ad provided', () => {
+ const { result, rerender } = renderHook(
+ (props) => usePauseAd(props),
+ { initialProps: { ad: undefined, isPaused: false, isPlaying: true } }
+ );
+
+ rerender({ ad: undefined, isPaused: true, isPlaying: false });
+ expect(result.current.state.isVisible).toBe(false);
+ });
+
+ it('should dismiss ad manually', () => {
+ const onHide = vi.fn();
+ const { result, rerender } = renderHook(
+ (props) => usePauseAd(props),
+ { initialProps: { ad: mockAd, isPaused: false, isPlaying: true, onHide } }
+ );
+
+ rerender({ ad: mockAd, isPaused: true, isPlaying: false, onHide });
+ expect(result.current.state.isVisible).toBe(true);
+
+ act(() => { result.current.dismiss(); });
+ expect(result.current.state.isVisible).toBe(false);
+ expect(onHide).toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/usePauseAd.ts b/src/hooks/usePauseAd.ts
new file mode 100644
index 0000000..508ff6e
--- /dev/null
+++ b/src/hooks/usePauseAd.ts
@@ -0,0 +1,96 @@
+import { useState, useCallback, useEffect, useRef } from 'react';
+import type { PauseAdState, UsePauseAdOptions, UsePauseAdReturn } from '@/types/pauseAd';
+
+export function usePauseAd(options: UsePauseAdOptions): UsePauseAdReturn {
+ const {
+ ad,
+ isPaused,
+ isPlaying,
+ enabled = true,
+ onShow,
+ onHide,
+ onClick: _onClick,
+ } = options;
+
+ const [state, setState] = useState({
+ isVisible: false,
+ currentAd: null,
+ pauseDuration: 0,
+ });
+
+ const pauseStartRef = useRef(null);
+ const timerRef = useRef | null>(null);
+ const hasBeenPlayingRef = useRef(false);
+ const onShowRef = useRef(onShow);
+ onShowRef.current = onShow;
+ const onHideRef = useRef(onHide);
+ onHideRef.current = onHide;
+
+ // Track if playback has started (don't show ad on initial load)
+ useEffect(() => {
+ if (isPlaying) {
+ hasBeenPlayingRef.current = true;
+ }
+ }, [isPlaying]);
+
+ // Handle pause/play state changes
+ useEffect(() => {
+ if (!enabled || !ad || !hasBeenPlayingRef.current) {
+ return;
+ }
+
+ if (isPaused && !isPlaying) {
+ // Video was paused
+ pauseStartRef.current = Date.now();
+ const minDuration = (ad.minPauseDuration ?? 0) * 1000;
+
+ if (minDuration <= 0) {
+ // Show immediately
+ setState({ isVisible: true, currentAd: ad, pauseDuration: 0 });
+ onShowRef.current?.(ad);
+ } else {
+ // Wait for minimum pause duration
+ const timeout = setTimeout(() => {
+ setState({ isVisible: true, currentAd: ad, pauseDuration: ad.minPauseDuration ?? 0 });
+ onShowRef.current?.(ad);
+ }, minDuration);
+ return () => clearTimeout(timeout);
+ }
+
+ // Start tracking pause duration
+ timerRef.current = setInterval(() => {
+ if (pauseStartRef.current) {
+ const elapsed = (Date.now() - pauseStartRef.current) / 1000;
+ setState((prev) => ({ ...prev, pauseDuration: elapsed }));
+ }
+ }, 1000);
+
+ return () => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ };
+ } else if (isPlaying) {
+ // Video resumed - hide ad
+ if (state.isVisible && state.currentAd) {
+ onHideRef.current?.(state.currentAd);
+ }
+ pauseStartRef.current = null;
+ setState({ isVisible: false, currentAd: null, pauseDuration: 0 });
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ }
+ }, [isPaused, isPlaying, enabled, ad]);
+
+ const dismiss = useCallback(() => {
+ if (state.isVisible && state.currentAd) {
+ onHideRef.current?.(state.currentAd);
+ }
+ setState({ isVisible: false, currentAd: null, pauseDuration: 0 });
+ }, [state.isVisible, state.currentAd]);
+
+ return { state, dismiss };
+}
diff --git a/src/hooks/usePictureInPicture.test.ts b/src/hooks/usePictureInPicture.test.ts
new file mode 100644
index 0000000..511276c
--- /dev/null
+++ b/src/hooks/usePictureInPicture.test.ts
@@ -0,0 +1,353 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { usePictureInPicture } from './usePictureInPicture';
+import { createMockVideoElement } from '@/test/helpers';
+import type { RefObject } from 'react';
+
+function createVideoRef(overrides: Partial = {}): RefObject {
+ const video = createMockVideoElement(overrides);
+ return { current: video };
+}
+
+describe('usePictureInPicture', () => {
+ let originalPipEnabled: boolean;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ originalPipEnabled = (document as any).pictureInPictureEnabled;
+ Object.defineProperty(document, 'pictureInPictureEnabled', {
+ writable: true,
+ value: true,
+ });
+ (document as any).pictureInPictureElement = null;
+ });
+
+ afterEach(() => {
+ Object.defineProperty(document, 'pictureInPictureEnabled', {
+ writable: true,
+ value: originalPipEnabled,
+ });
+ });
+
+ // ─── Initial State ──────────────────────────────────────────────────
+
+ describe('initial state', () => {
+ it('starts not in PiP mode', () => {
+ const ref = createVideoRef();
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ expect(result.current.isPictureInPicture).toBe(false);
+ });
+
+ it('reports PiP as supported when pictureInPictureEnabled is true', () => {
+ const ref = createVideoRef();
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ expect(result.current.isSupported).toBe(true);
+ });
+
+ it('reports PiP as not supported when pictureInPictureEnabled is false', () => {
+ Object.defineProperty(document, 'pictureInPictureEnabled', {
+ writable: true,
+ value: false,
+ });
+
+ const ref = createVideoRef();
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ expect(result.current.isSupported).toBe(false);
+ });
+
+ it('provides all control methods', () => {
+ const ref = createVideoRef();
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ expect(typeof result.current.enterPictureInPicture).toBe('function');
+ expect(typeof result.current.exitPictureInPicture).toBe('function');
+ expect(typeof result.current.togglePictureInPicture).toBe('function');
+ });
+ });
+
+ // ─── Enter PiP ────────────────────────────────────────────────────
+
+ describe('enterPictureInPicture', () => {
+ it('calls requestPictureInPicture on the video element', async () => {
+ const ref = createVideoRef();
+ const requestSpy = vi.fn().mockResolvedValue(document.createElement('div'));
+ ref.current!.requestPictureInPicture = requestSpy;
+
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ await act(async () => {
+ await result.current.enterPictureInPicture();
+ });
+
+ expect(requestSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing when video ref is null', async () => {
+ const ref = { current: null } as RefObject;
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ // Should not throw
+ await act(async () => {
+ await result.current.enterPictureInPicture();
+ });
+ });
+
+ it('does nothing when PiP is not supported', async () => {
+ Object.defineProperty(document, 'pictureInPictureEnabled', {
+ writable: true,
+ value: false,
+ });
+
+ const ref = createVideoRef();
+ const requestSpy = vi.fn().mockResolvedValue(document.createElement('div'));
+ ref.current!.requestPictureInPicture = requestSpy;
+
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ await act(async () => {
+ await result.current.enterPictureInPicture();
+ });
+
+ expect(requestSpy).not.toHaveBeenCalled();
+ });
+
+ it('handles requestPictureInPicture rejection gracefully', async () => {
+ const ref = createVideoRef();
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ ref.current!.requestPictureInPicture = vi.fn().mockRejectedValue(new Error('PiP blocked'));
+
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ await act(async () => {
+ await result.current.enterPictureInPicture();
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to enter Picture-in-Picture:',
+ expect.any(Error)
+ );
+ consoleSpy.mockRestore();
+ });
+ });
+
+ // ─── Exit PiP ─────────────────────────────────────────────────────
+
+ describe('exitPictureInPicture', () => {
+ it('calls document.exitPictureInPicture when a PiP element exists', async () => {
+ const ref = createVideoRef();
+ const exitSpy = vi.fn().mockResolvedValue(undefined);
+ (document as any).exitPictureInPicture = exitSpy;
+ (document as any).pictureInPictureElement = ref.current;
+
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ await act(async () => {
+ await result.current.exitPictureInPicture();
+ });
+
+ expect(exitSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing when no PiP element exists', async () => {
+ const ref = createVideoRef();
+ const exitSpy = vi.fn().mockResolvedValue(undefined);
+ (document as any).exitPictureInPicture = exitSpy;
+ (document as any).pictureInPictureElement = null;
+
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ await act(async () => {
+ await result.current.exitPictureInPicture();
+ });
+
+ expect(exitSpy).not.toHaveBeenCalled();
+ });
+
+ it('handles exitPictureInPicture rejection gracefully', async () => {
+ const ref = createVideoRef();
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ (document as any).exitPictureInPicture = vi.fn().mockRejectedValue(new Error('Exit failed'));
+ (document as any).pictureInPictureElement = ref.current;
+
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ await act(async () => {
+ await result.current.exitPictureInPicture();
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to exit Picture-in-Picture:',
+ expect.any(Error)
+ );
+ consoleSpy.mockRestore();
+
+ // Restore
+ (document as any).exitPictureInPicture = vi.fn().mockResolvedValue(undefined);
+ });
+ });
+
+ // ─── Toggle PiP ───────────────────────────────────────────────────
+
+ describe('togglePictureInPicture', () => {
+ it('enters PiP when not in PiP mode', async () => {
+ const ref = createVideoRef();
+ const requestSpy = vi.fn().mockResolvedValue(document.createElement('div'));
+ ref.current!.requestPictureInPicture = requestSpy;
+
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ await act(async () => {
+ await result.current.togglePictureInPicture();
+ });
+
+ expect(requestSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('exits PiP when already in PiP mode', async () => {
+ const ref = createVideoRef();
+ const exitSpy = vi.fn().mockResolvedValue(undefined);
+ (document as any).exitPictureInPicture = exitSpy;
+ (document as any).pictureInPictureElement = ref.current;
+
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ // Simulate enter PiP
+ act(() => {
+ ref.current!.dispatchEvent(new Event('enterpictureinpicture'));
+ });
+
+ expect(result.current.isPictureInPicture).toBe(true);
+
+ await act(async () => {
+ await result.current.togglePictureInPicture();
+ });
+
+ expect(exitSpy).toHaveBeenCalled();
+ });
+ });
+
+ // ─── PiP Events ───────────────────────────────────────────────────
+
+ describe('PiP events', () => {
+ it('updates isPictureInPicture to true on enterpictureinpicture', () => {
+ const ref = createVideoRef();
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ act(() => {
+ ref.current!.dispatchEvent(new Event('enterpictureinpicture'));
+ });
+
+ expect(result.current.isPictureInPicture).toBe(true);
+ });
+
+ it('updates isPictureInPicture to false on leavepictureinpicture', () => {
+ const ref = createVideoRef();
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ // Enter first
+ act(() => {
+ ref.current!.dispatchEvent(new Event('enterpictureinpicture'));
+ });
+ expect(result.current.isPictureInPicture).toBe(true);
+
+ // Leave
+ act(() => {
+ ref.current!.dispatchEvent(new Event('leavepictureinpicture'));
+ });
+ expect(result.current.isPictureInPicture).toBe(false);
+ });
+
+ it('calls onChange(true) when entering PiP', () => {
+ const onChange = vi.fn();
+ const ref = createVideoRef();
+ renderHook(() => usePictureInPicture(ref, { onChange }));
+
+ act(() => {
+ ref.current!.dispatchEvent(new Event('enterpictureinpicture'));
+ });
+
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it('calls onChange(false) when leaving PiP', () => {
+ const onChange = vi.fn();
+ const ref = createVideoRef();
+ renderHook(() => usePictureInPicture(ref, { onChange }));
+
+ act(() => {
+ ref.current!.dispatchEvent(new Event('enterpictureinpicture'));
+ });
+ act(() => {
+ ref.current!.dispatchEvent(new Event('leavepictureinpicture'));
+ });
+
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+ });
+
+ // ─── Cleanup ───────────────────────────────────────────────────────
+
+ describe('cleanup', () => {
+ it('removes event listeners on unmount', () => {
+ const ref = createVideoRef();
+ const removeSpy = vi.spyOn(ref.current!, 'removeEventListener');
+
+ const { unmount } = renderHook(() => usePictureInPicture(ref));
+
+ unmount();
+
+ const removedEvents = removeSpy.mock.calls.map(([event]) => event);
+ expect(removedEvents).toContain('enterpictureinpicture');
+ expect(removedEvents).toContain('leavepictureinpicture');
+
+ removeSpy.mockRestore();
+ });
+ });
+
+ // ─── Edge Cases ────────────────────────────────────────────────────
+
+ describe('edge cases', () => {
+ it('handles entering and leaving PiP multiple times', () => {
+ const onChange = vi.fn();
+ const ref = createVideoRef();
+ const { result } = renderHook(() =>
+ usePictureInPicture(ref, { onChange })
+ );
+
+ // Enter
+ act(() => {
+ ref.current!.dispatchEvent(new Event('enterpictureinpicture'));
+ });
+ expect(result.current.isPictureInPicture).toBe(true);
+
+ // Leave
+ act(() => {
+ ref.current!.dispatchEvent(new Event('leavepictureinpicture'));
+ });
+ expect(result.current.isPictureInPicture).toBe(false);
+
+ // Enter again
+ act(() => {
+ ref.current!.dispatchEvent(new Event('enterpictureinpicture'));
+ });
+ expect(result.current.isPictureInPicture).toBe(true);
+
+ expect(onChange).toHaveBeenCalledTimes(3);
+ });
+
+ it('works without onChange callback', () => {
+ const ref = createVideoRef();
+ const { result } = renderHook(() => usePictureInPicture(ref));
+
+ // Should not throw
+ act(() => {
+ ref.current!.dispatchEvent(new Event('enterpictureinpicture'));
+ });
+
+ expect(result.current.isPictureInPicture).toBe(true);
+ });
+ });
+});
diff --git a/src/hooks/usePlaybackHistory.ts b/src/hooks/usePlaybackHistory.ts
new file mode 100644
index 0000000..3a8f32f
--- /dev/null
+++ b/src/hooks/usePlaybackHistory.ts
@@ -0,0 +1,146 @@
+import { useState, useCallback, useEffect } from 'react';
+import type { PlaybackHistoryEntry, PlaybackHistoryConfig, UsePlaybackHistoryReturn } from '@/types/history';
+
+const DEFAULT_STORAGE_KEY = 'fairu_history';
+const DEFAULT_MAX_ENTRIES = 100;
+const DEFAULT_EXPIRY_DAYS = 90;
+
+function readHistory(storageKey: string): PlaybackHistoryEntry[] {
+ if (typeof window === 'undefined') return [];
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (!raw) return [];
+ const data = JSON.parse(raw);
+ if (!Array.isArray(data)) return [];
+ return data as PlaybackHistoryEntry[];
+ } catch {
+ return [];
+ }
+}
+
+function writeHistory(storageKey: string, entries: PlaybackHistoryEntry[]): void {
+ if (typeof window === 'undefined') return;
+ try {
+ localStorage.setItem(storageKey, JSON.stringify(entries));
+ } catch {
+ // Storage quota exceeded - silently ignore
+ }
+}
+
+function cleanupExpired(entries: PlaybackHistoryEntry[], expiryDays: number): PlaybackHistoryEntry[] {
+ const expiryMs = expiryDays * 24 * 60 * 60 * 1000;
+ const now = Date.now();
+ return entries.filter((e) => now - e.lastPlayedAt < expiryMs);
+}
+
+function enforceMaxEntries(entries: PlaybackHistoryEntry[], maxEntries: number): PlaybackHistoryEntry[] {
+ if (entries.length <= maxEntries) return entries;
+ // Sort by lastPlayedAt desc and trim
+ return entries
+ .sort((a, b) => b.lastPlayedAt - a.lastPlayedAt)
+ .slice(0, maxEntries);
+}
+
+export function usePlaybackHistory(config: PlaybackHistoryConfig = {}): UsePlaybackHistoryReturn {
+ const {
+ enabled = true,
+ maxEntries = DEFAULT_MAX_ENTRIES,
+ expiryDays = DEFAULT_EXPIRY_DAYS,
+ storageKey = DEFAULT_STORAGE_KEY,
+ } = config;
+
+ const [count, setCount] = useState(0);
+
+ // Initialize count on mount
+ useEffect(() => {
+ if (!enabled) return;
+ const entries = readHistory(storageKey);
+ const cleaned = cleanupExpired(entries, expiryDays);
+ if (cleaned.length !== entries.length) {
+ writeHistory(storageKey, cleaned);
+ }
+ setCount(cleaned.length);
+ }, [enabled, storageKey, expiryDays]);
+
+ const getHistory = useCallback((): PlaybackHistoryEntry[] => {
+ if (!enabled) return [];
+ return readHistory(storageKey)
+ .sort((a, b) => b.lastPlayedAt - a.lastPlayedAt);
+ }, [enabled, storageKey]);
+
+ const getResumeList = useCallback((): PlaybackHistoryEntry[] => {
+ if (!enabled) return [];
+ return readHistory(storageKey)
+ .filter((e) => !e.completed && e.progress > 0)
+ .sort((a, b) => b.lastPlayedAt - a.lastPlayedAt);
+ }, [enabled, storageKey]);
+
+ const recordPlay = useCallback((
+ entry: Omit
+ ) => {
+ if (!enabled) return;
+
+ let entries = readHistory(storageKey);
+ const existingIndex = entries.findIndex((e) => e.trackId === entry.trackId);
+
+ if (existingIndex >= 0) {
+ // Update existing entry
+ const existing = entries[existingIndex];
+ entries[existingIndex] = {
+ ...entry,
+ lastPlayedAt: Date.now(),
+ playCount: existing.playCount + 1,
+ };
+ } else {
+ // Add new entry
+ entries.push({
+ ...entry,
+ lastPlayedAt: Date.now(),
+ playCount: 1,
+ });
+ }
+
+ entries = cleanupExpired(entries, expiryDays);
+ entries = enforceMaxEntries(entries, maxEntries);
+ writeHistory(storageKey, entries);
+ setCount(entries.length);
+ }, [enabled, storageKey, expiryDays, maxEntries]);
+
+ const isPlayed = useCallback((trackId: string): boolean => {
+ if (!enabled) return false;
+ return readHistory(storageKey).some((e) => e.trackId === trackId);
+ }, [enabled, storageKey]);
+
+ const getEntry = useCallback((trackId: string): PlaybackHistoryEntry | null => {
+ if (!enabled) return null;
+ return readHistory(storageKey).find((e) => e.trackId === trackId) ?? null;
+ }, [enabled, storageKey]);
+
+ const removeEntry = useCallback((trackId: string) => {
+ if (!enabled) return;
+ const entries = readHistory(storageKey).filter((e) => e.trackId !== trackId);
+ writeHistory(storageKey, entries);
+ setCount(entries.length);
+ }, [enabled, storageKey]);
+
+ const clearHistory = useCallback(() => {
+ if (typeof window === 'undefined') return;
+ try {
+ localStorage.removeItem(storageKey);
+ } catch {
+ // Silently ignore
+ }
+ setCount(0);
+ }, [storageKey]);
+
+ return {
+ getHistory,
+ getResumeList,
+ recordPlay,
+ isPlayed,
+ getEntry,
+ removeEntry,
+ clearHistory,
+ count,
+ };
+}
diff --git a/src/hooks/usePlayer.test.tsx b/src/hooks/usePlayer.test.tsx
new file mode 100644
index 0000000..71accf0
--- /dev/null
+++ b/src/hooks/usePlayer.test.tsx
@@ -0,0 +1,152 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react';
+import { usePlayer } from './usePlayer';
+import { PlayerProvider } from '@/context/PlayerContext';
+import { TrackingProvider } from '@/context/TrackingContext';
+import { createMockTrack } from '@/test/helpers';
+
+function createWrapper(config = {}) {
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+ };
+}
+
+describe('usePlayer', () => {
+ // ── Throws without provider ────────────────────────────────────────
+
+ it('should throw when used outside of PlayerProvider', () => {
+ // Suppress console.error for the expected React error
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => {
+ renderHook(() => usePlayer());
+ }).toThrow('usePlayer must be used within a PlayerProvider');
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should throw with the correct error message', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => {
+ renderHook(() => usePlayer());
+ }).toThrow(/PlayerProvider/);
+
+ consoleSpy.mockRestore();
+ });
+
+ // ── Returns context value within provider ─────────────────────────
+
+ it('should not throw when used within PlayerProvider', () => {
+ expect(() => {
+ renderHook(() => usePlayer(), { wrapper: createWrapper() });
+ }).not.toThrow();
+ });
+
+ it('should return state object', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.state).toBeDefined();
+ expect(result.current.state).toHaveProperty('isPlaying');
+ expect(result.current.state).toHaveProperty('isPaused');
+ expect(result.current.state).toHaveProperty('currentTime');
+ expect(result.current.state).toHaveProperty('duration');
+ expect(result.current.state).toHaveProperty('volume');
+ });
+
+ it('should return controls object', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.controls).toBeDefined();
+ expect(typeof result.current.controls.play).toBe('function');
+ expect(typeof result.current.controls.pause).toBe('function');
+ expect(typeof result.current.controls.toggle).toBe('function');
+ expect(typeof result.current.controls.seek).toBe('function');
+ expect(typeof result.current.controls.setVolume).toBe('function');
+ });
+
+ it('should return playlistState object', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.playlistState).toBeDefined();
+ expect(result.current.playlistState).toHaveProperty('tracks');
+ expect(result.current.playlistState).toHaveProperty('currentIndex');
+ expect(result.current.playlistState).toHaveProperty('currentTrack');
+ });
+
+ it('should return playlistControls object', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.playlistControls).toBeDefined();
+ expect(typeof result.current.playlistControls.next).toBe('function');
+ expect(typeof result.current.playlistControls.previous).toBe('function');
+ expect(typeof result.current.playlistControls.goToTrack).toBe('function');
+ });
+
+ it('should return config object', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.config).toBeDefined();
+ });
+
+ it('should return audioRef', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.audioRef).toBeDefined();
+ expect(result.current.audioRef).toHaveProperty('current');
+ });
+
+ // ── Config passthrough ────────────────────────────────────────────
+
+ it('should reflect provided track in playlistState', () => {
+ const track = createMockTrack({ id: 'test-track', title: 'My Track' });
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ track }),
+ });
+ expect(result.current.playlistState.currentTrack).toBeDefined();
+ expect(result.current.playlistState.currentTrack?.id).toBe('test-track');
+ });
+
+ it('should reflect provided playlist', () => {
+ const playlist = [
+ createMockTrack({ id: 't1' }),
+ createMockTrack({ id: 't2' }),
+ createMockTrack({ id: 't3' }),
+ ];
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ playlist }),
+ });
+ expect(result.current.playlistState.tracks).toHaveLength(3);
+ });
+
+ it('should use default config values when none provided', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper(),
+ });
+ expect(result.current.config.autoPlayNext).toBe(true);
+ expect(result.current.config.shuffle).toBe(false);
+ expect(result.current.config.repeat).toBe('none');
+ });
+
+ it('should merge user config with defaults', () => {
+ const { result } = renderHook(() => usePlayer(), {
+ wrapper: createWrapper({ shuffle: true, repeat: 'all' }),
+ });
+ expect(result.current.config.shuffle).toBe(true);
+ expect(result.current.config.repeat).toBe('all');
+ // Default values still present
+ expect(result.current.config.autoPlayNext).toBe(true);
+ });
+});
diff --git a/src/hooks/usePlaylist.test.ts b/src/hooks/usePlaylist.test.ts
new file mode 100644
index 0000000..3454c72
--- /dev/null
+++ b/src/hooks/usePlaylist.test.ts
@@ -0,0 +1,565 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { usePlaylist } from './usePlaylist';
+import { createMockTrack, createMockPlaylist } from '@/test/helpers';
+
+describe('usePlaylist', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ─── Initial State ──────────────────────────────────────────────────
+
+ describe('initial state', () => {
+ it('returns empty state when no tracks provided', () => {
+ const { result } = renderHook(() => usePlaylist());
+
+ expect(result.current.state.tracks).toEqual([]);
+ expect(result.current.state.currentIndex).toBe(0);
+ expect(result.current.state.currentTrack).toBeNull();
+ expect(result.current.state.shuffle).toBe(false);
+ expect(result.current.state.repeat).toBe('none');
+ expect(result.current.state.queue).toEqual([]);
+ expect(result.current.state.history).toEqual([]);
+ });
+
+ it('initializes with provided tracks', () => {
+ const tracks = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ expect(result.current.state.tracks).toEqual(tracks);
+ expect(result.current.state.currentTrack).toEqual(tracks[0]);
+ expect(result.current.state.currentIndex).toBe(0);
+ });
+
+ it('initializes with custom initial index', () => {
+ const tracks = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlaylist({ tracks, initialIndex: 2 }));
+
+ expect(result.current.state.currentIndex).toBe(2);
+ expect(result.current.state.currentTrack).toEqual(tracks[2]);
+ });
+
+ it('initializes with shuffle enabled', () => {
+ const tracks = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlaylist({ tracks, shuffle: true }));
+
+ expect(result.current.state.shuffle).toBe(true);
+ });
+
+ it('initializes with repeat mode', () => {
+ const tracks = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlaylist({ tracks, repeat: 'all' }));
+
+ expect(result.current.state.repeat).toBe('all');
+ });
+ });
+
+ // ─── Track Navigation ──────────────────────────────────────────────
+
+ describe('track navigation', () => {
+ it('next() advances to the next track', () => {
+ const tracks = createMockPlaylist(3);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(result.current.state.currentIndex).toBe(1);
+ expect(result.current.state.currentTrack).toEqual(tracks[1]);
+ expect(onTrackChange).toHaveBeenCalledWith(tracks[1], 1);
+ });
+
+ it('next() calls onQueueEnd at end of playlist with repeat none', () => {
+ const tracks = createMockPlaylist(3);
+ const onQueueEnd = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, initialIndex: 2, onQueueEnd })
+ );
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(onQueueEnd).toHaveBeenCalledTimes(1);
+ expect(result.current.state.currentIndex).toBe(2);
+ });
+
+ it('previous() goes to the previous track in order when no history', () => {
+ const tracks = createMockPlaylist(3);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, initialIndex: 2, onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.previous();
+ });
+
+ expect(result.current.state.currentIndex).toBe(1);
+ expect(onTrackChange).toHaveBeenCalledWith(tracks[1], 1);
+ });
+
+ it('previous() does nothing at start of playlist with repeat none', () => {
+ const tracks = createMockPlaylist(3);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.previous();
+ });
+
+ expect(result.current.state.currentIndex).toBe(0);
+ expect(onTrackChange).not.toHaveBeenCalled();
+ });
+
+ it('goToTrack() navigates to a specific track', () => {
+ const tracks = createMockPlaylist(5);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.goToTrack(3);
+ });
+
+ expect(result.current.state.currentIndex).toBe(3);
+ expect(result.current.state.currentTrack).toEqual(tracks[3]);
+ expect(onTrackChange).toHaveBeenCalledWith(tracks[3], 3);
+ });
+
+ it('goToTrack() ignores invalid indices (negative)', () => {
+ const tracks = createMockPlaylist(3);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.goToTrack(-1);
+ });
+
+ expect(result.current.state.currentIndex).toBe(0);
+ expect(onTrackChange).not.toHaveBeenCalled();
+ });
+
+ it('goToTrack() ignores invalid indices (out of range)', () => {
+ const tracks = createMockPlaylist(3);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.goToTrack(10);
+ });
+
+ expect(result.current.state.currentIndex).toBe(0);
+ expect(onTrackChange).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── Repeat Modes ──────────────────────────────────────────────────
+
+ describe('repeat modes', () => {
+ it('repeat "one" re-triggers onTrackChange with the same track', () => {
+ const tracks = createMockPlaylist(3);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, repeat: 'one', onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(result.current.state.currentIndex).toBe(0);
+ expect(onTrackChange).toHaveBeenCalledWith(tracks[0], 0);
+ });
+
+ it('repeat "all" wraps to first track at end of playlist', () => {
+ const tracks = createMockPlaylist(3);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, initialIndex: 2, repeat: 'all', onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(result.current.state.currentIndex).toBe(0);
+ expect(onTrackChange).toHaveBeenCalledWith(tracks[0], 0);
+ });
+
+ it('repeat "all" wraps to last track when going previous at start', () => {
+ const tracks = createMockPlaylist(3);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, initialIndex: 0, repeat: 'all', onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.previous();
+ });
+
+ expect(result.current.state.currentIndex).toBe(2);
+ expect(onTrackChange).toHaveBeenCalledWith(tracks[2], 2);
+ });
+
+ it('setRepeat() changes the repeat mode', () => {
+ const tracks = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ act(() => {
+ result.current.controls.setRepeat('all');
+ });
+
+ expect(result.current.state.repeat).toBe('all');
+
+ act(() => {
+ result.current.controls.setRepeat('one');
+ });
+
+ expect(result.current.state.repeat).toBe('one');
+
+ act(() => {
+ result.current.controls.setRepeat('none');
+ });
+
+ expect(result.current.state.repeat).toBe('none');
+ });
+ });
+
+ // ─── Shuffle Mode ─────────────────────────────────────────────────
+
+ describe('shuffle mode', () => {
+ it('toggleShuffle() enables and disables shuffle', () => {
+ const tracks = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ expect(result.current.state.shuffle).toBe(false);
+
+ act(() => {
+ result.current.controls.toggleShuffle();
+ });
+
+ expect(result.current.state.shuffle).toBe(true);
+
+ act(() => {
+ result.current.controls.toggleShuffle();
+ });
+
+ expect(result.current.state.shuffle).toBe(false);
+ });
+
+ it('next() picks a different index when shuffle is enabled', () => {
+ const tracks = createMockPlaylist(10);
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, shuffle: true })
+ );
+
+ // With 10 tracks and shuffle, at least one next() call should
+ // not just go to index 1. We run several and check diversity.
+ const visitedIndices = new Set();
+ visitedIndices.add(result.current.state.currentIndex);
+
+ for (let i = 0; i < 9; i++) {
+ act(() => {
+ result.current.controls.next();
+ });
+ visitedIndices.add(result.current.state.currentIndex);
+ }
+
+ // Shuffled order should visit multiple different indices
+ expect(visitedIndices.size).toBeGreaterThan(1);
+ });
+ });
+
+ // ─── Queue ─────────────────────────────────────────────────────────
+
+ describe('queue', () => {
+ it('addToQueue() adds a track to the queue', () => {
+ const tracks = createMockPlaylist(3);
+ const extraTrack = createMockTrack({ id: 'extra-1', title: 'Extra Track' });
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ act(() => {
+ result.current.controls.addToQueue(extraTrack);
+ });
+
+ expect(result.current.state.queue).toHaveLength(1);
+ expect(result.current.state.queue[0]).toEqual(extraTrack);
+ });
+
+ it('addToQueue() appends multiple tracks', () => {
+ const tracks = createMockPlaylist(3);
+ const extra1 = createMockTrack({ id: 'extra-1' });
+ const extra2 = createMockTrack({ id: 'extra-2' });
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ act(() => {
+ result.current.controls.addToQueue(extra1);
+ });
+ act(() => {
+ result.current.controls.addToQueue(extra2);
+ });
+
+ expect(result.current.state.queue).toHaveLength(2);
+ expect(result.current.state.queue[0].id).toBe('extra-1');
+ expect(result.current.state.queue[1].id).toBe('extra-2');
+ });
+
+ it('next() plays from queue before advancing playlist', () => {
+ const tracks = createMockPlaylist(3);
+ const queuedTrack = createMockTrack({ id: 'track-2', title: 'Queued' });
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.addToQueue(queuedTrack);
+ });
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ // Should play the queued track (which is track-2 in the tracks array)
+ expect(onTrackChange).toHaveBeenCalledWith(queuedTrack, 1);
+ expect(result.current.state.queue).toHaveLength(0);
+ });
+
+ it('removeFromQueue() removes track at index', () => {
+ const tracks = createMockPlaylist(3);
+ const q1 = createMockTrack({ id: 'q-1' });
+ const q2 = createMockTrack({ id: 'q-2' });
+ const q3 = createMockTrack({ id: 'q-3' });
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ act(() => {
+ result.current.controls.addToQueue(q1);
+ result.current.controls.addToQueue(q2);
+ result.current.controls.addToQueue(q3);
+ });
+
+ act(() => {
+ result.current.controls.removeFromQueue(1);
+ });
+
+ expect(result.current.state.queue).toHaveLength(2);
+ expect(result.current.state.queue[0].id).toBe('q-1');
+ expect(result.current.state.queue[1].id).toBe('q-3');
+ });
+
+ it('clearQueue() empties the queue', () => {
+ const tracks = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ act(() => {
+ result.current.controls.addToQueue(createMockTrack({ id: 'q-1' }));
+ result.current.controls.addToQueue(createMockTrack({ id: 'q-2' }));
+ });
+
+ act(() => {
+ result.current.controls.clearQueue();
+ });
+
+ expect(result.current.state.queue).toEqual([]);
+ });
+ });
+
+ // ─── History ───────────────────────────────────────────────────────
+
+ describe('history', () => {
+ it('next() adds current track to history', () => {
+ const tracks = createMockPlaylist(3);
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(result.current.state.history).toHaveLength(1);
+ expect(result.current.state.history[0]).toEqual(tracks[0]);
+ });
+
+ it('goToTrack() adds current track to history', () => {
+ const tracks = createMockPlaylist(5);
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ act(() => {
+ result.current.controls.goToTrack(3);
+ });
+
+ expect(result.current.state.history).toHaveLength(1);
+ expect(result.current.state.history[0]).toEqual(tracks[0]);
+ });
+
+ it('previous() navigates back through history', () => {
+ const tracks = createMockPlaylist(5);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, onTrackChange })
+ );
+
+ // Navigate forward: 0 -> 1 -> 2
+ act(() => {
+ result.current.controls.next();
+ });
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(result.current.state.currentIndex).toBe(2);
+ expect(result.current.state.history).toHaveLength(2);
+
+ // Navigate back: 2 -> 1
+ act(() => {
+ result.current.controls.previous();
+ });
+
+ expect(result.current.state.currentIndex).toBe(1);
+ expect(result.current.state.history).toHaveLength(1);
+ });
+
+ it('previous() pops from history before sequential navigation', () => {
+ const tracks = createMockPlaylist(5);
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, onTrackChange })
+ );
+
+ // Navigate: 0 -> 3 (skip ahead via goToTrack)
+ act(() => {
+ result.current.controls.goToTrack(3);
+ });
+
+ // previous() should go back to 0 (from history), not to 2
+ act(() => {
+ result.current.controls.previous();
+ });
+
+ expect(result.current.state.currentIndex).toBe(0);
+ });
+
+ it('multiple forward and back navigations build correct history', () => {
+ const tracks = createMockPlaylist(5);
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ // 0 -> 1 -> 2 -> 3
+ act(() => result.current.controls.next());
+ act(() => result.current.controls.next());
+ act(() => result.current.controls.next());
+
+ expect(result.current.state.history).toHaveLength(3);
+ expect(result.current.state.currentIndex).toBe(3);
+
+ // Back: 3 -> 2
+ act(() => result.current.controls.previous());
+ expect(result.current.state.currentIndex).toBe(2);
+ expect(result.current.state.history).toHaveLength(2);
+
+ // Back: 2 -> 1
+ act(() => result.current.controls.previous());
+ expect(result.current.state.currentIndex).toBe(1);
+ expect(result.current.state.history).toHaveLength(1);
+
+ // Back: 1 -> 0
+ act(() => result.current.controls.previous());
+ expect(result.current.state.currentIndex).toBe(0);
+ expect(result.current.state.history).toHaveLength(0);
+ });
+ });
+
+ // ─── Edge Cases ────────────────────────────────────────────────────
+
+ describe('edge cases', () => {
+ it('handles empty playlist gracefully', () => {
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks: [], onTrackChange })
+ );
+
+ expect(result.current.state.currentTrack).toBeNull();
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(result.current.state.currentTrack).toBeNull();
+ });
+
+ it('handles single track playlist', () => {
+ const tracks = [createMockTrack({ id: 'only-track' })];
+ const onQueueEnd = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, onQueueEnd })
+ );
+
+ expect(result.current.state.currentTrack?.id).toBe('only-track');
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(onQueueEnd).toHaveBeenCalledTimes(1);
+ });
+
+ it('single track with repeat one keeps playing same track', () => {
+ const tracks = [createMockTrack({ id: 'only-track' })];
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, repeat: 'one', onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(result.current.state.currentTrack?.id).toBe('only-track');
+ expect(onTrackChange).toHaveBeenCalledWith(tracks[0], 0);
+ });
+
+ it('single track with repeat all wraps to same track', () => {
+ const tracks = [createMockTrack({ id: 'only-track' })];
+ const onTrackChange = vi.fn();
+ const { result } = renderHook(() =>
+ usePlaylist({ tracks, repeat: 'all', onTrackChange })
+ );
+
+ act(() => {
+ result.current.controls.next();
+ });
+
+ expect(result.current.state.currentIndex).toBe(0);
+ expect(onTrackChange).toHaveBeenCalledWith(tracks[0], 0);
+ });
+
+ it('queue track not found in tracks array does not crash', () => {
+ const tracks = createMockPlaylist(3);
+ const unknownTrack = createMockTrack({ id: 'unknown-id' });
+ const { result } = renderHook(() => usePlaylist({ tracks }));
+
+ act(() => {
+ result.current.controls.addToQueue(unknownTrack);
+ });
+
+ // next() consumes from queue but findIndex returns -1
+ act(() => {
+ result.current.controls.next();
+ });
+
+ // Should not throw; queue is consumed
+ expect(result.current.state.queue).toHaveLength(0);
+ });
+ });
+});
diff --git a/src/hooks/usePlaylistPersistence.ts b/src/hooks/usePlaylistPersistence.ts
new file mode 100644
index 0000000..3b3400c
--- /dev/null
+++ b/src/hooks/usePlaylistPersistence.ts
@@ -0,0 +1,183 @@
+import { useState, useCallback, useEffect, useRef } from 'react';
+import type { PlaylistPersistenceConfig, PlaylistPersistenceData, UsePlaylistPersistenceReturn } from '@/types/playlistPersistence';
+
+const STORAGE_PREFIX = 'fairu_playlist_';
+
+/**
+ * Get the localStorage key for a given playlist ID
+ */
+function getStorageKey(playlistId: string): string {
+ return `${STORAGE_PREFIX}${playlistId}`;
+}
+
+/**
+ * Safely read from localStorage
+ */
+function readFromStorage(key: string): PlaylistPersistenceData | null {
+ if (typeof window === 'undefined') return null;
+
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+
+ const data: unknown = JSON.parse(raw);
+
+ // Validate the shape of the data
+ if (
+ data !== null &&
+ typeof data === 'object' &&
+ 'playlistId' in data &&
+ 'currentIndex' in data &&
+ 'shuffle' in data &&
+ 'repeat' in data &&
+ 'trackIds' in data &&
+ 'timestamp' in data &&
+ typeof (data as PlaylistPersistenceData).playlistId === 'string' &&
+ typeof (data as PlaylistPersistenceData).currentIndex === 'number' &&
+ typeof (data as PlaylistPersistenceData).shuffle === 'boolean' &&
+ typeof (data as PlaylistPersistenceData).repeat === 'string' &&
+ Array.isArray((data as PlaylistPersistenceData).trackIds) &&
+ typeof (data as PlaylistPersistenceData).timestamp === 'number'
+ ) {
+ return data as PlaylistPersistenceData;
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Safely write to localStorage
+ */
+function writeToStorage(key: string, data: PlaylistPersistenceData): void {
+ if (typeof window === 'undefined') return;
+
+ try {
+ localStorage.setItem(key, JSON.stringify(data));
+ } catch {
+ // Storage quota exceeded or other error - silently ignore
+ }
+}
+
+/**
+ * Safely remove from localStorage
+ */
+function removeFromStorage(key: string): void {
+ if (typeof window === 'undefined') return;
+
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ // Silently ignore
+ }
+}
+
+/**
+ * Clean up expired playlist entries from localStorage
+ */
+function cleanupExpiredEntries(expiryDays: number): void {
+ if (typeof window === 'undefined') return;
+
+ try {
+ const now = Date.now();
+ const expiryMs = expiryDays * 24 * 60 * 60 * 1000;
+ const keysToRemove: string[] = [];
+
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (!key || !key.startsWith(STORAGE_PREFIX)) continue;
+
+ const data = readFromStorage(key);
+ if (data && now - data.timestamp > expiryMs) {
+ keysToRemove.push(key);
+ }
+ }
+
+ for (const key of keysToRemove) {
+ removeFromStorage(key);
+ }
+ } catch {
+ // Silently ignore
+ }
+}
+
+/**
+ * Hook that persists and restores playlist state using localStorage.
+ * Allows users to resume a playlist from where they left off, including
+ * track index, shuffle, and repeat mode.
+ */
+export function usePlaylistPersistence(config: PlaylistPersistenceConfig): UsePlaylistPersistenceReturn {
+ const {
+ playlistId,
+ enabled = true,
+ expiryDays = 30,
+ saveDebounce = 1000,
+ } = config;
+
+ const [hasSavedState, setHasSavedState] = useState(false);
+ const debounceRef = useRef | null>(null);
+
+ // Check for existing saved state on mount
+ useEffect(() => {
+ if (!enabled) return;
+
+ const data = readFromStorage(getStorageKey(playlistId));
+ setHasSavedState(data !== null && data.playlistId === playlistId);
+
+ // Clean up expired entries periodically
+ cleanupExpiredEntries(expiryDays);
+ }, [playlistId, enabled, expiryDays]);
+
+ const restore = useCallback((): PlaylistPersistenceData | null => {
+ if (!enabled) return null;
+
+ const data = readFromStorage(getStorageKey(playlistId));
+ if (data && data.playlistId === playlistId) {
+ return data;
+ }
+
+ return null;
+ }, [playlistId, enabled]);
+
+ const save = useCallback((data: Omit) => {
+ if (!enabled) return;
+
+ // Debounce saves
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+
+ debounceRef.current = setTimeout(() => {
+ const fullData: PlaylistPersistenceData = {
+ ...data,
+ playlistId,
+ timestamp: Date.now(),
+ };
+ writeToStorage(getStorageKey(playlistId), fullData);
+ setHasSavedState(true);
+ }, saveDebounce);
+ }, [playlistId, enabled, saveDebounce]);
+
+ const clear = useCallback(() => {
+ removeFromStorage(getStorageKey(playlistId));
+ setHasSavedState(false);
+ }, [playlistId]);
+
+ // Cleanup debounce on unmount
+ useEffect(() => {
+ return () => {
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+ };
+ }, []);
+
+ return {
+ restore,
+ save,
+ clear,
+ hasSavedState,
+ };
+}
diff --git a/src/hooks/useResumePosition.ts b/src/hooks/useResumePosition.ts
new file mode 100644
index 0000000..198a68d
--- /dev/null
+++ b/src/hooks/useResumePosition.ts
@@ -0,0 +1,261 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { ResumeConfig, ResumeData, UseResumePositionReturn } from '@/types/resume';
+
+const STORAGE_PREFIX = 'fairu_resume_';
+
+/**
+ * Get the localStorage key for a given track ID
+ */
+function getStorageKey(trackId: string): string {
+ return `${STORAGE_PREFIX}${trackId}`;
+}
+
+/**
+ * Safely read from localStorage
+ */
+function readFromStorage(key: string): ResumeData | null {
+ if (typeof window === 'undefined') return null;
+
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+
+ const data: unknown = JSON.parse(raw);
+
+ // Validate the shape of the data
+ if (
+ data !== null &&
+ typeof data === 'object' &&
+ 'position' in data &&
+ 'timestamp' in data &&
+ 'duration' in data &&
+ 'trackId' in data &&
+ typeof (data as ResumeData).position === 'number' &&
+ typeof (data as ResumeData).timestamp === 'number' &&
+ typeof (data as ResumeData).duration === 'number' &&
+ typeof (data as ResumeData).trackId === 'string'
+ ) {
+ return data as ResumeData;
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Safely write to localStorage
+ */
+function writeToStorage(key: string, data: ResumeData): void {
+ if (typeof window === 'undefined') return;
+
+ try {
+ localStorage.setItem(key, JSON.stringify(data));
+ } catch {
+ // Storage quota exceeded or other error - silently ignore
+ }
+}
+
+/**
+ * Safely remove from localStorage
+ */
+function removeFromStorage(key: string): void {
+ if (typeof window === 'undefined') return;
+
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ // Silently ignore
+ }
+}
+
+/**
+ * Clean up expired resume entries from localStorage
+ */
+function cleanupExpiredEntries(expiryDays: number): void {
+ if (typeof window === 'undefined') return;
+
+ try {
+ const now = Date.now();
+ const expiryMs = expiryDays * 24 * 60 * 60 * 1000;
+ const keysToRemove: string[] = [];
+
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (!key || !key.startsWith(STORAGE_PREFIX)) continue;
+
+ const data = readFromStorage(key);
+ if (data && now - data.timestamp > expiryMs) {
+ keysToRemove.push(key);
+ }
+ }
+
+ for (const key of keysToRemove) {
+ removeFromStorage(key);
+ }
+ } catch {
+ // Silently ignore
+ }
+}
+
+/**
+ * Hook that persists and restores playback position using localStorage.
+ * Allows users to resume playback from where they left off.
+ */
+export function useResumePosition(config: ResumeConfig): UseResumePositionReturn {
+ const {
+ trackId,
+ mediaRef,
+ enabled = true,
+ threshold = 10,
+ saveInterval = 5000,
+ expiryDays = 30,
+ onResume,
+ } = config;
+
+ const [savedPosition, setSavedPosition] = useState(null);
+ const hasResumedRef = useRef(false);
+ const onResumeRef = useRef(onResume);
+ onResumeRef.current = onResume;
+
+ // Clear saved position for the current track
+ const clearPosition = useCallback(() => {
+ removeFromStorage(getStorageKey(trackId));
+ setSavedPosition(null);
+ }, [trackId]);
+
+ // Save current position to localStorage
+ const savePosition = useCallback(() => {
+ const media = mediaRef.current;
+ if (!media || !enabled) return;
+
+ const currentTime = media.currentTime;
+ const duration = media.duration;
+
+ // Don't save if not enough has been played
+ if (currentTime < threshold) return;
+
+ // Don't save if duration is unknown
+ if (!duration || !isFinite(duration)) return;
+
+ // Don't save if past 95% (treat as completed)
+ if (currentTime / duration >= 0.95) {
+ removeFromStorage(getStorageKey(trackId));
+ return;
+ }
+
+ const data: ResumeData = {
+ position: currentTime,
+ timestamp: Date.now(),
+ duration,
+ trackId,
+ };
+
+ writeToStorage(getStorageKey(trackId), data);
+ }, [mediaRef, enabled, threshold, trackId]);
+
+ // On mount: load saved position and clean up expired entries
+ useEffect(() => {
+ if (!enabled) return;
+
+ const data = readFromStorage(getStorageKey(trackId));
+ if (data && data.trackId === trackId) {
+ // Don't resume past 95% of duration
+ if (data.duration > 0 && data.position / data.duration >= 0.95) {
+ removeFromStorage(getStorageKey(trackId));
+ setSavedPosition(null);
+ } else {
+ setSavedPosition(data.position);
+ }
+ } else {
+ setSavedPosition(null);
+ }
+
+ // Reset resume flag when track changes
+ hasResumedRef.current = false;
+
+ // Clean up expired entries periodically
+ cleanupExpiredEntries(expiryDays);
+ }, [trackId, enabled, expiryDays]);
+
+ // Seek to saved position once media is ready
+ useEffect(() => {
+ if (!enabled || savedPosition === null || hasResumedRef.current) return;
+
+ const media = mediaRef.current;
+ if (!media) return;
+
+ const handleCanPlay = () => {
+ if (hasResumedRef.current) return;
+
+ // Verify saved position is still valid
+ if (media.duration && savedPosition / media.duration >= 0.95) {
+ removeFromStorage(getStorageKey(trackId));
+ setSavedPosition(null);
+ return;
+ }
+
+ media.currentTime = savedPosition;
+ hasResumedRef.current = true;
+ onResumeRef.current?.(savedPosition);
+ };
+
+ // If media is already ready, seek immediately
+ if (media.readyState >= 2) {
+ handleCanPlay();
+ } else {
+ media.addEventListener('canplay', handleCanPlay, { once: true });
+ return () => {
+ media.removeEventListener('canplay', handleCanPlay);
+ };
+ }
+ }, [savedPosition, enabled, mediaRef, trackId]);
+
+ // Save position on interval during playback
+ useEffect(() => {
+ if (!enabled) return;
+
+ const interval = setInterval(() => {
+ const media = mediaRef.current;
+ if (media && !media.paused && !media.ended) {
+ savePosition();
+ }
+ }, saveInterval);
+
+ return () => clearInterval(interval);
+ }, [enabled, saveInterval, savePosition, mediaRef]);
+
+ // Clear position when playback completes
+ useEffect(() => {
+ if (!enabled) return;
+
+ const media = mediaRef.current;
+ if (!media) return;
+
+ const handleEnded = () => {
+ removeFromStorage(getStorageKey(trackId));
+ setSavedPosition(null);
+ };
+
+ media.addEventListener('ended', handleEnded);
+ return () => {
+ media.removeEventListener('ended', handleEnded);
+ };
+ }, [enabled, trackId, mediaRef]);
+
+ // Save position on unmount
+ useEffect(() => {
+ if (!enabled) return;
+
+ return () => {
+ savePosition();
+ };
+ }, [enabled, savePosition]);
+
+ return {
+ savedPosition,
+ clearPosition,
+ hasSavedPosition: savedPosition !== null,
+ };
+}
diff --git a/src/hooks/useRewardedAd.test.ts b/src/hooks/useRewardedAd.test.ts
new file mode 100644
index 0000000..358647a
--- /dev/null
+++ b/src/hooks/useRewardedAd.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useRewardedAd } from './useRewardedAd';
+import type { RewardedAd } from '@/types/rewardedAd';
+
+const mockAd: RewardedAd = {
+ id: 'rewarded-1',
+ src: 'https://example.com/ad.mp4',
+ duration: 30,
+ title: 'Watch to unlock',
+ rewardDescription: 'Watch this ad to unlock premium content',
+};
+
+describe('useRewardedAd', () => {
+ it('should start with initial state', () => {
+ const { result } = renderHook(() => useRewardedAd({ ad: mockAd }));
+ expect(result.current.state.isShowing).toBe(false);
+ expect(result.current.state.isRewarded).toBe(false);
+ expect(result.current.state.currentAd).toBeNull();
+ expect(result.current.isAvailable).toBe(true);
+ });
+
+ it('should not be available when no ad provided', () => {
+ const { result } = renderHook(() => useRewardedAd());
+ expect(result.current.isAvailable).toBe(false);
+ });
+
+ it('should show ad when show() is called', () => {
+ const onStart = vi.fn();
+ const { result } = renderHook(() => useRewardedAd({ ad: mockAd, onStart }));
+
+ act(() => { result.current.show(); });
+
+ expect(result.current.state.isShowing).toBe(true);
+ expect(result.current.state.currentAd).toEqual(mockAd);
+ expect(result.current.state.duration).toBe(30);
+ expect(onStart).toHaveBeenCalledWith(mockAd);
+ });
+
+ it('should not show when no ad', () => {
+ const { result } = renderHook(() => useRewardedAd());
+
+ act(() => { result.current.show(); });
+ expect(result.current.state.isShowing).toBe(false);
+ });
+
+ it('should close and call onClose', () => {
+ const onClose = vi.fn();
+ const { result } = renderHook(() => useRewardedAd({ ad: mockAd, onClose }));
+
+ act(() => { result.current.show(); });
+ act(() => { result.current.close(); });
+
+ expect(result.current.state.isShowing).toBe(false);
+ expect(onClose).toHaveBeenCalledWith(mockAd, false);
+ });
+
+ it('should reset state on close', () => {
+ const { result } = renderHook(() => useRewardedAd({ ad: mockAd }));
+
+ act(() => { result.current.show(); });
+ expect(result.current.state.isShowing).toBe(true);
+
+ act(() => { result.current.close(); });
+ expect(result.current.state.isShowing).toBe(false);
+ expect(result.current.state.currentAd).toBeNull();
+ expect(result.current.state.progress).toBe(0);
+ });
+});
diff --git a/src/hooks/useRewardedAd.ts b/src/hooks/useRewardedAd.ts
new file mode 100644
index 0000000..f8e15c6
--- /dev/null
+++ b/src/hooks/useRewardedAd.ts
@@ -0,0 +1,50 @@
+import { useState, useCallback, useRef } from 'react';
+import type { RewardedAdState, UseRewardedAdOptions, UseRewardedAdReturn } from '@/types/rewardedAd';
+
+const initialState: RewardedAdState = {
+ isShowing: false,
+ isPlaying: false,
+ isRewarded: false,
+ progress: 0,
+ duration: 0,
+ percentage: 0,
+ currentAd: null,
+};
+
+export function useRewardedAd(options: UseRewardedAdOptions = {}): UseRewardedAdReturn {
+ const { ad, onReward, onStart, onClose } = options;
+ const [state, setState] = useState(initialState);
+
+ const callbacksRef = useRef({ onReward, onStart, onClose });
+ callbacksRef.current = { onReward, onStart, onClose };
+
+ const show = useCallback(() => {
+ if (!ad) return;
+ setState({
+ isShowing: true,
+ isPlaying: false,
+ isRewarded: false,
+ progress: 0,
+ duration: ad.duration,
+ percentage: 0,
+ currentAd: ad,
+ });
+ callbacksRef.current.onStart?.(ad);
+ }, [ad]);
+
+ const close = useCallback(() => {
+ const currentAd = state.currentAd;
+ const wasRewarded = state.isRewarded;
+ setState(initialState);
+ if (currentAd) {
+ callbacksRef.current.onClose?.(currentAd, wasRewarded);
+ }
+ }, [state.currentAd, state.isRewarded]);
+
+ return {
+ state,
+ show,
+ close,
+ isAvailable: !!ad,
+ };
+}
diff --git a/src/hooks/useShareableTimestamp.ts b/src/hooks/useShareableTimestamp.ts
new file mode 100644
index 0000000..0f3b7fa
--- /dev/null
+++ b/src/hooks/useShareableTimestamp.ts
@@ -0,0 +1,141 @@
+import { useState, useCallback, useEffect, useRef } from 'react';
+
+/**
+ * Format seconds into a human-readable timestamp string.
+ * Examples: 90 → "1m30s", 3661 → "1h1m1s", 45 → "45s"
+ */
+export function formatTimestamp(seconds: number): string {
+ const s = Math.floor(seconds);
+ if (s <= 0) return '0s';
+
+ const hours = Math.floor(s / 3600);
+ const minutes = Math.floor((s % 3600) / 60);
+ const secs = s % 60;
+
+ let result = '';
+ if (hours > 0) result += `${hours}h`;
+ if (minutes > 0) result += `${minutes}m`;
+ if (secs > 0 || result === '') result += `${secs}s`;
+ return result;
+}
+
+/**
+ * Parse a timestamp string back to seconds.
+ * Supports: "1h2m3s", "1m30s", "90" (plain seconds), "45s", "1:30", "1:02:03"
+ */
+export function parseTimestamp(timestamp: string): number | null {
+ if (!timestamp) return null;
+
+ // Try plain number (seconds)
+ const plainNum = Number(timestamp);
+ if (!isNaN(plainNum) && plainNum >= 0) return plainNum;
+
+ // Try "XhYmZs" format
+ const hmsMatch = timestamp.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
+ if (hmsMatch && (hmsMatch[1] || hmsMatch[2] || hmsMatch[3])) {
+ const h = parseInt(hmsMatch[1] || '0');
+ const m = parseInt(hmsMatch[2] || '0');
+ const s = parseInt(hmsMatch[3] || '0');
+ return h * 3600 + m * 60 + s;
+ }
+
+ // Try "H:MM:SS" or "MM:SS" format
+ const colonParts = timestamp.split(':').map(Number);
+ if (colonParts.length === 2 && colonParts.every((n) => !isNaN(n))) {
+ return colonParts[0] * 60 + colonParts[1];
+ }
+ if (colonParts.length === 3 && colonParts.every((n) => !isNaN(n))) {
+ return colonParts[0] * 3600 + colonParts[1] * 60 + colonParts[2];
+ }
+
+ return null;
+}
+
+export interface UseShareableTimestampOptions {
+ /** Current playback time in seconds */
+ currentTime: number;
+ /** Seek function to jump to a time */
+ onSeek?: (time: number) => void;
+ /** Whether to auto-parse the URL on mount and seek. Default: true */
+ parseOnMount?: boolean;
+ /** The URL parameter name for the timestamp. Default: 't' */
+ paramName?: string;
+ /** Called when a timestamp is parsed from the URL */
+ onTimestampParsed?: (time: number) => void;
+}
+
+export interface UseShareableTimestampReturn {
+ /** Generate a shareable URL with the current time (or a specific time) */
+ getShareUrl: (time?: number) => string;
+ /** Copy the share URL to clipboard. Returns true if successful. */
+ copyShareUrl: (time?: number) => Promise;
+ /** Whether a timestamp was found in the current URL */
+ hasUrlTimestamp: boolean;
+ /** The timestamp parsed from the URL (null if none) */
+ urlTimestamp: number | null;
+}
+
+export function useShareableTimestamp({
+ currentTime,
+ onSeek,
+ parseOnMount = true,
+ paramName = 't',
+ onTimestampParsed,
+}: UseShareableTimestampOptions): UseShareableTimestampReturn {
+ const [urlTimestamp, setUrlTimestamp] = useState(null);
+ const [hasUrlTimestamp, setHasUrlTimestamp] = useState(false);
+ const parsedRef = useRef(false);
+
+ // Parse URL timestamp on mount
+ useEffect(() => {
+ if (!parseOnMount || parsedRef.current) return;
+ if (typeof window === 'undefined') return;
+
+ parsedRef.current = true;
+
+ const params = new URLSearchParams(window.location.search);
+ const raw = params.get(paramName);
+ if (!raw) return;
+
+ const parsed = parseTimestamp(raw);
+ if (parsed !== null && parsed >= 0) {
+ setUrlTimestamp(parsed);
+ setHasUrlTimestamp(true);
+ onSeek?.(parsed);
+ onTimestampParsed?.(parsed);
+ }
+ }, [parseOnMount, paramName, onSeek, onTimestampParsed]);
+
+ const getShareUrl = useCallback(
+ (time?: number): string => {
+ if (typeof window === 'undefined') return '';
+
+ const url = new URL(window.location.href);
+ const t = time ?? currentTime;
+ url.searchParams.set(paramName, formatTimestamp(t));
+ return url.toString();
+ },
+ [currentTime, paramName]
+ );
+
+ const copyShareUrl = useCallback(
+ async (time?: number): Promise => {
+ try {
+ const url = getShareUrl(time);
+ if (!url) return false;
+ await navigator.clipboard.writeText(url);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ [getShareUrl]
+ );
+
+ return {
+ getShareUrl,
+ copyShareUrl,
+ hasUrlTimestamp,
+ urlTimestamp,
+ };
+}
diff --git a/src/hooks/useSleepTimer.ts b/src/hooks/useSleepTimer.ts
new file mode 100644
index 0000000..8a107c4
--- /dev/null
+++ b/src/hooks/useSleepTimer.ts
@@ -0,0 +1,220 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type {
+ SleepTimerState,
+ UseSleepTimerOptions,
+ UseSleepTimerReturn,
+} from '@/types/sleepTimer';
+
+const initialState: SleepTimerState = {
+ isActive: false,
+ remainingTime: 0,
+ selectedDuration: null,
+ isFadingOut: false,
+};
+
+/**
+ * Hook for managing a sleep timer that pauses media playback after a set duration.
+ * Supports preset minute durations and "end of track" mode.
+ * Optionally fades out volume in the last 30 seconds before pausing.
+ */
+export function useSleepTimer(options: UseSleepTimerOptions): UseSleepTimerReturn {
+ const {
+ mediaRef,
+ onTimerEnd,
+ fadeOut = false,
+ fadeOutDuration = 30,
+ currentTime = 0,
+ duration = 0,
+ } = options;
+
+ const [state, setState] = useState(initialState);
+ const intervalRef = useRef | null>(null);
+ const volumeBeforeFadeRef = useRef(1);
+ const isFadingRef = useRef(false);
+
+ // Cleanup interval
+ const clearTimer = useCallback(() => {
+ if (intervalRef.current !== null) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ }, []);
+
+ // Restore volume after fade out
+ const restoreVolume = useCallback(() => {
+ const media = mediaRef.current;
+ if (media && isFadingRef.current) {
+ media.volume = volumeBeforeFadeRef.current;
+ isFadingRef.current = false;
+ }
+ }, [mediaRef]);
+
+ // Stop the timer
+ const stopTimer = useCallback(() => {
+ clearTimer();
+ restoreVolume();
+ setState(initialState);
+ }, [clearTimer, restoreVolume]);
+
+ // Handle timer end: pause playback and call callback
+ const handleTimerEnd = useCallback(() => {
+ const media = mediaRef.current;
+ if (media) {
+ media.pause();
+ // Restore volume after pausing so next play has normal volume
+ if (isFadingRef.current) {
+ media.volume = volumeBeforeFadeRef.current;
+ isFadingRef.current = false;
+ }
+ }
+ clearTimer();
+ setState(initialState);
+ onTimerEnd?.();
+ }, [mediaRef, clearTimer, onTimerEnd]);
+
+ // Start the timer
+ const startTimer = useCallback(
+ (durationValue: number | 'endOfTrack') => {
+ // Clear any existing timer
+ clearTimer();
+ restoreVolume();
+
+ let remainingSeconds: number;
+
+ if (durationValue === 'endOfTrack') {
+ // Calculate remaining time from current position to end of track
+ remainingSeconds = Math.max(0, Math.ceil(duration - currentTime));
+ if (remainingSeconds <= 0) {
+ // Track is already at the end, trigger immediately
+ handleTimerEnd();
+ return;
+ }
+ } else {
+ remainingSeconds = durationValue * 60;
+ }
+
+ // Save current volume for potential fade out
+ const media = mediaRef.current;
+ if (media) {
+ volumeBeforeFadeRef.current = media.volume;
+ }
+
+ setState({
+ isActive: true,
+ remainingTime: remainingSeconds,
+ selectedDuration: durationValue,
+ isFadingOut: false,
+ });
+
+ intervalRef.current = setInterval(() => {
+ setState((prev) => {
+ if (!prev.isActive) return prev;
+
+ const newRemaining = prev.remainingTime - 1;
+
+ if (newRemaining <= 0) {
+ // Timer reached zero, will be handled by the effect below
+ return {
+ ...prev,
+ remainingTime: 0,
+ };
+ }
+
+ // Handle fade out
+ if (fadeOut && newRemaining <= fadeOutDuration && !isFadingRef.current) {
+ const mediaEl = mediaRef.current;
+ if (mediaEl) {
+ volumeBeforeFadeRef.current = mediaEl.volume;
+ isFadingRef.current = true;
+ }
+ }
+
+ if (fadeOut && isFadingRef.current && newRemaining <= fadeOutDuration) {
+ const mediaEl = mediaRef.current;
+ if (mediaEl) {
+ const fadeProgress = newRemaining / fadeOutDuration;
+ mediaEl.volume = volumeBeforeFadeRef.current * fadeProgress;
+ }
+ }
+
+ return {
+ ...prev,
+ remainingTime: newRemaining,
+ isFadingOut: fadeOut && newRemaining <= fadeOutDuration,
+ };
+ });
+ }, 1000);
+ },
+ [clearTimer, restoreVolume, duration, currentTime, mediaRef, fadeOut, fadeOutDuration, handleTimerEnd]
+ );
+
+ // Watch for remaining time reaching zero
+ useEffect(() => {
+ if (state.isActive && state.remainingTime <= 0) {
+ handleTimerEnd();
+ }
+ }, [state.isActive, state.remainingTime, handleTimerEnd]);
+
+ // For "end of track" mode, update remaining time based on current playback position
+ useEffect(() => {
+ if (state.isActive && state.selectedDuration === 'endOfTrack' && duration > 0) {
+ const newRemaining = Math.max(0, Math.ceil(duration - currentTime));
+ setState((prev) => ({
+ ...prev,
+ remainingTime: newRemaining,
+ }));
+ }
+ }, [state.isActive, state.selectedDuration, currentTime, duration]);
+
+ // Extend the timer by additional minutes
+ const extendTimer = useCallback(
+ (minutes: number) => {
+ if (!state.isActive) return;
+
+ setState((prev) => {
+ if (!prev.isActive) return prev;
+
+ const additionalSeconds = minutes * 60;
+ const newRemaining = prev.remainingTime + additionalSeconds;
+
+ // If we were fading out and now have more time, restore volume
+ if (prev.isFadingOut && newRemaining > fadeOutDuration) {
+ restoreVolume();
+ }
+
+ return {
+ ...prev,
+ remainingTime: newRemaining,
+ isFadingOut: fadeOut && newRemaining <= fadeOutDuration,
+ // Switch away from endOfTrack mode when extending
+ selectedDuration:
+ prev.selectedDuration === 'endOfTrack'
+ ? minutes
+ : prev.selectedDuration,
+ };
+ });
+ },
+ [state.isActive, fadeOut, fadeOutDuration, restoreVolume]
+ );
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ clearTimer();
+ // Restore volume if we were fading
+ const media = mediaRef.current;
+ if (media && isFadingRef.current) {
+ media.volume = volumeBeforeFadeRef.current;
+ }
+ };
+ }, [clearTimer, mediaRef]);
+
+ return {
+ state,
+ controls: {
+ startTimer,
+ stopTimer,
+ extendTimer,
+ },
+ };
+}
diff --git a/src/hooks/useSubtitleParser.test.ts b/src/hooks/useSubtitleParser.test.ts
new file mode 100644
index 0000000..35c4832
--- /dev/null
+++ b/src/hooks/useSubtitleParser.test.ts
@@ -0,0 +1,140 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useSubtitleParser, parseVTTCues } from './useSubtitleParser';
+
+// Mock fetch
+const mockFetch = vi.fn();
+(globalThis as Record).fetch = mockFetch;
+
+const SAMPLE_VTT = `WEBVTT
+
+1
+00:00:01.000 --> 00:00:04.000
+Hello, welcome to the show.
+
+2
+00:00:05.000 --> 00:00:08.000
+Today we're going to talk about
+something interesting.
+
+3
+00:00:10.000 --> 00:00:12.500
+Let's get started!
+`;
+
+describe('parseVTTCues', () => {
+ it('should parse valid VTT content', () => {
+ const cues = parseVTTCues(SAMPLE_VTT);
+ expect(cues).toHaveLength(3);
+ expect(cues[0]).toEqual({
+ id: '1',
+ startTime: 1,
+ endTime: 4,
+ text: 'Hello, welcome to the show.',
+ });
+ });
+
+ it('should handle multi-line cue text', () => {
+ const cues = parseVTTCues(SAMPLE_VTT);
+ expect(cues[1].text).toBe("Today we're going to talk about\nsomething interesting.");
+ });
+
+ it('should handle empty content', () => {
+ expect(parseVTTCues('')).toEqual([]);
+ expect(parseVTTCues('WEBVTT')).toEqual([]);
+ });
+
+ it('should strip HTML tags from cue text', () => {
+ const vtt = `WEBVTT
+
+00:00:01.000 --> 00:00:04.000
+Bold and italic text`;
+ const cues = parseVTTCues(vtt);
+ expect(cues[0].text).toBe('Bold and italic text');
+ });
+
+ it('should handle MM:SS.mmm format timestamps', () => {
+ const vtt = `WEBVTT
+
+01:30.000 --> 02:00.000
+Short format`;
+ const cues = parseVTTCues(vtt);
+ expect(cues[0].startTime).toBe(90);
+ expect(cues[0].endTime).toBe(120);
+ });
+});
+
+describe('useSubtitleParser', () => {
+ beforeEach(() => {
+ mockFetch.mockReset();
+ });
+
+ it('should return null activeCue when disabled', () => {
+ const { result } = renderHook(() =>
+ useSubtitleParser({ currentTime: 2, enabled: false })
+ );
+ expect(result.current.activeCue).toBeNull();
+ expect(result.current.cues).toEqual([]);
+ });
+
+ it('should return null activeCue when no src', () => {
+ const { result } = renderHook(() =>
+ useSubtitleParser({ currentTime: 2, enabled: true })
+ );
+ expect(result.current.activeCue).toBeNull();
+ });
+
+ it('should fetch and parse VTT file', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve(SAMPLE_VTT),
+ });
+
+ const { result, rerender } = renderHook(
+ (props) => useSubtitleParser(props),
+ { initialProps: { src: 'https://example.com/subs.vtt', currentTime: 0, enabled: true } }
+ );
+
+ // Wait for fetch
+ await vi.waitFor(() => {
+ expect(result.current.isLoaded).toBe(true);
+ });
+
+ expect(result.current.cues).toHaveLength(3);
+
+ // Change time to match first cue
+ rerender({ src: 'https://example.com/subs.vtt', currentTime: 2, enabled: true });
+ expect(result.current.activeCue).toBe('Hello, welcome to the show.');
+ });
+
+ it('should return null when time is between cues', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve(SAMPLE_VTT),
+ });
+
+ const { result, rerender } = renderHook(
+ (props) => useSubtitleParser(props),
+ { initialProps: { src: 'https://example.com/subs.vtt', currentTime: 0, enabled: true } }
+ );
+
+ await vi.waitFor(() => expect(result.current.isLoaded).toBe(true));
+
+ rerender({ src: 'https://example.com/subs.vtt', currentTime: 4.5, enabled: true });
+ expect(result.current.activeCue).toBeNull();
+ });
+
+ it('should handle fetch errors', async () => {
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ const { result } = renderHook(() =>
+ useSubtitleParser({ src: 'https://example.com/subs.vtt', currentTime: 0, enabled: true })
+ );
+
+ await vi.waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ expect(result.current.isLoaded).toBe(false);
+ });
+});
diff --git a/src/hooks/useSubtitleParser.ts b/src/hooks/useSubtitleParser.ts
new file mode 100644
index 0000000..4f2a4e0
--- /dev/null
+++ b/src/hooks/useSubtitleParser.ts
@@ -0,0 +1,182 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+
+export interface SubtitleCue {
+ id: string;
+ startTime: number;
+ endTime: number;
+ text: string;
+}
+
+export interface UseSubtitleParserOptions {
+ /** URL to the subtitle file (VTT) */
+ src?: string;
+ /** Current playback time */
+ currentTime: number;
+ /** Whether subtitle display is enabled */
+ enabled?: boolean;
+}
+
+export interface UseSubtitleParserReturn {
+ /** Currently active cue text (null if no cue active) */
+ activeCue: string | null;
+ /** All parsed cues */
+ cues: SubtitleCue[];
+ /** Whether the file is loaded */
+ isLoaded: boolean;
+ /** Error if parsing failed */
+ error: Error | null;
+}
+
+function parseVTTTimestamp(ts: string): number {
+ const parts = ts.trim().split(':');
+ if (parts.length === 3) {
+ return parseFloat(parts[0]) * 3600 + parseFloat(parts[1]) * 60 + parseFloat(parts[2]);
+ }
+ if (parts.length === 2) {
+ return parseFloat(parts[0]) * 60 + parseFloat(parts[1]);
+ }
+ return parseFloat(parts[0]);
+}
+
+export function parseVTTCues(vttContent: string): SubtitleCue[] {
+ if (!vttContent || !vttContent.trim()) {
+ return [];
+ }
+
+ const lines = vttContent.replace(/\r\n/g, '\n').split('\n');
+ const cues: SubtitleCue[] = [];
+ let i = 0;
+
+ // Skip WEBVTT header and any metadata
+ while (i < lines.length && !lines[i].includes('-->')) {
+ i++;
+ }
+
+ if (i >= lines.length) {
+ return [];
+ }
+
+ // Back up to check for cue ID
+ let cueIndex = 0;
+
+ while (i < lines.length) {
+ // Skip empty lines
+ if (!lines[i].trim()) {
+ i++;
+ continue;
+ }
+
+ // Check if this line is a timestamp line
+ if (lines[i].includes('-->')) {
+ const timestampLine = lines[i];
+ const [startStr, endStr] = timestampLine.split('-->').map((s) => s.trim());
+ const startTime = parseVTTTimestamp(startStr);
+ const endTime = parseVTTTimestamp(endStr);
+
+ // Check if the line before the timestamp was a cue ID
+ let id: string;
+ const prevLine = i > 0 ? lines[i - 1].trim() : '';
+ if (prevLine && !prevLine.includes('-->') && prevLine !== 'WEBVTT') {
+ id = prevLine;
+ } else {
+ id = `cue-${cueIndex}`;
+ }
+
+ i++;
+
+ // Collect cue text lines
+ const textLines: string[] = [];
+ while (i < lines.length && lines[i].trim() !== '') {
+ textLines.push(lines[i].trim());
+ i++;
+ }
+
+ const text = textLines
+ .join('\n')
+ .replace(/<[^>]+>/g, ''); // Strip HTML tags
+
+ if (text) {
+ cues.push({ id, startTime, endTime, text });
+ }
+
+ cueIndex++;
+ } else {
+ i++;
+ }
+ }
+
+ return cues;
+}
+
+export function useSubtitleParser(options: UseSubtitleParserOptions): UseSubtitleParserReturn {
+ const { src, currentTime, enabled = true } = options;
+ const [cues, setCues] = useState([]);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [error, setError] = useState(null);
+ const prevSrcRef = useRef(undefined);
+
+ useEffect(() => {
+ if (!enabled || !src) {
+ setCues([]);
+ setIsLoaded(false);
+ setError(null);
+ return;
+ }
+
+ // Don't refetch if src hasn't changed
+ if (src === prevSrcRef.current && isLoaded) {
+ return;
+ }
+
+ let cancelled = false;
+ prevSrcRef.current = src;
+
+ setIsLoaded(false);
+ setError(null);
+
+ fetch(src)
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch subtitle file: ${response.status} ${response.statusText}`);
+ }
+ return response.text();
+ })
+ .then((text) => {
+ if (!cancelled) {
+ const parsedCues = parseVTTCues(text);
+ setCues(parsedCues);
+ setIsLoaded(true);
+ }
+ })
+ .catch((err) => {
+ if (!cancelled) {
+ setError(err instanceof Error ? err : new Error(String(err)));
+ setCues([]);
+ setIsLoaded(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [src, enabled]);
+
+ const activeCue = useCallback((): string | null => {
+ if (!enabled || cues.length === 0) {
+ return null;
+ }
+
+ const active = cues.find(
+ (cue) => currentTime >= cue.startTime && currentTime < cue.endTime
+ );
+
+ return active?.text ?? null;
+ }, [enabled, cues, currentTime]);
+
+ return {
+ activeCue: activeCue(),
+ cues,
+ isLoaded,
+ error,
+ };
+}
diff --git a/src/hooks/useSubtitleStyling.ts b/src/hooks/useSubtitleStyling.ts
new file mode 100644
index 0000000..b980bd5
--- /dev/null
+++ b/src/hooks/useSubtitleStyling.ts
@@ -0,0 +1,103 @@
+import { useState, useCallback, useMemo } from 'react';
+import type {
+ SubtitleStyle,
+ UseSubtitleStylingOptions,
+ UseSubtitleStylingReturn,
+} from '@/types/subtitleStyling';
+import {
+ DEFAULT_SUBTITLE_STYLE,
+ SUBTITLE_PRESETS,
+} from '@/types/subtitleStyling';
+
+const DEFAULT_STORAGE_KEY = 'fairu_subtitle_style';
+
+function loadFromStorage(key: string): Partial | null {
+ if (typeof window === 'undefined') return null;
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ return JSON.parse(raw) as Partial;
+ } catch {
+ return null;
+ }
+}
+
+function saveToStorage(key: string, style: SubtitleStyle): void {
+ if (typeof window === 'undefined') return;
+ try {
+ localStorage.setItem(key, JSON.stringify(style));
+ } catch {
+ // Silently ignore
+ }
+}
+
+export function useSubtitleStyling(options: UseSubtitleStylingOptions = {}): UseSubtitleStylingReturn {
+ const {
+ initialStyle,
+ persist = true,
+ storageKey = DEFAULT_STORAGE_KEY,
+ } = options;
+
+ const [style, setStyle] = useState(() => {
+ // Load from storage first, then merge with initialStyle and defaults
+ const stored = persist ? loadFromStorage(storageKey) : null;
+ return {
+ ...DEFAULT_SUBTITLE_STYLE,
+ ...initialStyle,
+ ...stored,
+ };
+ });
+
+ const updateStyle = useCallback((updates: Partial) => {
+ setStyle((prev) => {
+ const next = { ...prev, ...updates };
+ if (persist) saveToStorage(storageKey, next);
+ return next;
+ });
+ }, [persist, storageKey]);
+
+ const applyPreset = useCallback((presetName: string) => {
+ const preset = SUBTITLE_PRESETS.find((p) => p.name === presetName);
+ if (preset) {
+ setStyle(preset.style);
+ if (persist) saveToStorage(storageKey, preset.style);
+ }
+ }, [persist, storageKey]);
+
+ const resetStyle = useCallback(() => {
+ setStyle(DEFAULT_SUBTITLE_STYLE);
+ if (persist) saveToStorage(storageKey, DEFAULT_SUBTITLE_STYLE);
+ }, [persist, storageKey]);
+
+ const cssProperties = useMemo((): React.CSSProperties => {
+ // Convert hex + opacity to rgba
+ const hexToRgba = (hex: string, opacity: number): string => {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
+ };
+
+ return {
+ fontSize: `${style.fontSize}px`,
+ fontFamily: style.fontFamily,
+ color: style.textColor,
+ backgroundColor: hexToRgba(style.backgroundColor, style.backgroundOpacity),
+ textShadow: style.textShadow,
+ ...(style.position === 'top'
+ ? { top: '10%', bottom: 'auto' }
+ : { bottom: '10%', top: 'auto' }),
+ padding: '4px 8px',
+ borderRadius: '4px',
+ };
+ }, [style]);
+
+ return {
+ style,
+ updateStyle,
+ applyPreset,
+ resetStyle,
+ cssProperties,
+ presets: SUBTITLE_PRESETS,
+ };
+}
diff --git a/src/hooks/useSyncPlayback.ts b/src/hooks/useSyncPlayback.ts
new file mode 100644
index 0000000..9c5b305
--- /dev/null
+++ b/src/hooks/useSyncPlayback.ts
@@ -0,0 +1,233 @@
+import { useState, useCallback, useEffect, useRef } from 'react';
+import type {
+ SyncEvent,
+ SyncPeer,
+ SyncConnectionState,
+ SyncRoomInfo,
+ UseSyncPlaybackOptions,
+ UseSyncPlaybackReturn,
+} from '@/types/sync';
+import { WebSocketSyncTransport } from '@/services/SyncService';
+
+function generatePeerId(): string {
+ return `peer-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
+}
+
+function generateRoomId(): string {
+ return `room-${Math.random().toString(36).substring(2, 8)}`;
+}
+
+export function useSyncPlayback(options: UseSyncPlaybackOptions = {}): UseSyncPlaybackReturn {
+ const {
+ transport: customTransport,
+ serverUrl,
+ isLeader: initialIsLeader = false,
+ onPeerJoin,
+ onPeerLeave,
+ onConnectionChange: onConnectionChangeCallback,
+ onError,
+ } = options;
+
+ const [peerId] = useState(() => generatePeerId());
+ const [isLeader, setIsLeader] = useState(initialIsLeader);
+ const [connectionState, setConnectionState] = useState('disconnected');
+ const [room, setRoom] = useState(null);
+ const [peers, setPeers] = useState([]);
+
+ const transportRef = useRef(customTransport ?? (serverUrl ? new WebSocketSyncTransport(serverUrl) : null));
+
+ // Stable refs for callbacks
+ const onPeerJoinRef = useRef(onPeerJoin);
+ onPeerJoinRef.current = onPeerJoin;
+ const onPeerLeaveRef = useRef(onPeerLeave);
+ onPeerLeaveRef.current = onPeerLeave;
+ const onConnectionChangeRef = useRef(onConnectionChangeCallback);
+ onConnectionChangeRef.current = onConnectionChangeCallback;
+ const onErrorRef = useRef(onError);
+ onErrorRef.current = onError;
+
+ // Set up transport listeners
+ useEffect(() => {
+ const transport = transportRef.current;
+ if (!transport) return;
+
+ transport.onConnectionChange((state) => {
+ setConnectionState(state);
+ onConnectionChangeRef.current?.(state);
+ });
+
+ transport.onMessage((event: SyncEvent) => {
+ const data = event.data;
+
+ switch (data.type) {
+ case 'join': {
+ const newPeer: SyncPeer = {
+ id: data.peerId,
+ isLeader: data.isLeader,
+ joinedAt: Date.now(),
+ };
+ setPeers((prev) => {
+ if (prev.some((p) => p.id === data.peerId)) return prev;
+ return [...prev, newPeer];
+ });
+ onPeerJoinRef.current?.(newPeer);
+ break;
+ }
+
+ case 'leave': {
+ setPeers((prev) => prev.filter((p) => p.id !== data.peerId));
+ onPeerLeaveRef.current?.(data.peerId);
+ break;
+ }
+
+ default:
+ break;
+ }
+ });
+ }, []);
+
+ const createRoom = useCallback(async (): Promise => {
+ const transport = transportRef.current;
+ if (!transport) {
+ throw new Error('No sync transport configured. Provide a transport or serverUrl.');
+ }
+
+ const roomId = generateRoomId();
+ setIsLeader(true);
+
+ try {
+ await transport.connect(roomId, peerId);
+
+ const selfPeer: SyncPeer = { id: peerId, isLeader: true, joinedAt: Date.now() };
+ setPeers([selfPeer]);
+ setRoom({ roomId, peers: [selfPeer], leaderId: peerId });
+
+ // Announce join
+ transport.send({
+ type: 'join',
+ timestamp: Date.now(),
+ peerId,
+ data: { type: 'join', peerId, isLeader: true },
+ });
+
+ return roomId;
+ } catch (err) {
+ onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
+ throw err;
+ }
+ }, [peerId]);
+
+ const joinRoom = useCallback(async (roomId: string): Promise => {
+ const transport = transportRef.current;
+ if (!transport) {
+ throw new Error('No sync transport configured. Provide a transport or serverUrl.');
+ }
+
+ setIsLeader(false);
+
+ try {
+ await transport.connect(roomId, peerId);
+
+ const selfPeer: SyncPeer = { id: peerId, isLeader: false, joinedAt: Date.now() };
+ setPeers([selfPeer]);
+ setRoom({ roomId, peers: [selfPeer], leaderId: null });
+
+ // Announce join
+ transport.send({
+ type: 'join',
+ timestamp: Date.now(),
+ peerId,
+ data: { type: 'join', peerId, isLeader: false },
+ });
+ } catch (err) {
+ onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
+ throw err;
+ }
+ }, [peerId]);
+
+ const leaveRoom = useCallback(() => {
+ const transport = transportRef.current;
+ if (!transport) return;
+
+ transport.send({
+ type: 'leave',
+ timestamp: Date.now(),
+ peerId,
+ data: { type: 'leave', peerId },
+ });
+
+ transport.disconnect();
+ setRoom(null);
+ setPeers([]);
+ setIsLeader(false);
+ }, [peerId]);
+
+ const syncPlay = useCallback((currentTime: number) => {
+ transportRef.current?.send({
+ type: 'play',
+ timestamp: Date.now(),
+ peerId,
+ data: { type: 'play', currentTime },
+ });
+ }, [peerId]);
+
+ const syncPause = useCallback((currentTime: number) => {
+ transportRef.current?.send({
+ type: 'pause',
+ timestamp: Date.now(),
+ peerId,
+ data: { type: 'pause', currentTime },
+ });
+ }, [peerId]);
+
+ const syncSeek = useCallback((currentTime: number) => {
+ transportRef.current?.send({
+ type: 'seek',
+ timestamp: Date.now(),
+ peerId,
+ data: { type: 'seek', currentTime },
+ });
+ }, [peerId]);
+
+ const syncPlaybackRate = useCallback((rate: number) => {
+ transportRef.current?.send({
+ type: 'playbackRate',
+ timestamp: Date.now(),
+ peerId,
+ data: { type: 'playbackRate', rate },
+ });
+ }, [peerId]);
+
+ const requestState = useCallback(() => {
+ // Request full state from leader — leader should respond with a 'state' event
+ transportRef.current?.send({
+ type: 'state',
+ timestamp: Date.now(),
+ peerId,
+ data: { type: 'state', currentTime: 0, isPlaying: false, playbackRate: 1 },
+ });
+ }, [peerId]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ transportRef.current?.disconnect();
+ };
+ }, []);
+
+ return {
+ createRoom,
+ joinRoom,
+ leaveRoom,
+ syncPlay,
+ syncPause,
+ syncSeek,
+ syncPlaybackRate,
+ requestState,
+ connectionState,
+ room,
+ peerId,
+ isLeader,
+ peers,
+ };
+}
diff --git a/src/hooks/useTabVisibility.test.ts b/src/hooks/useTabVisibility.test.ts
new file mode 100644
index 0000000..2fdddfc
--- /dev/null
+++ b/src/hooks/useTabVisibility.test.ts
@@ -0,0 +1,307 @@
+import { renderHook, act } from '@testing-library/react';
+import { useTabVisibility } from './useTabVisibility';
+
+function setDocumentHidden(hidden: boolean) {
+ Object.defineProperty(document, 'hidden', {
+ writable: true,
+ configurable: true,
+ value: hidden,
+ });
+ Object.defineProperty(document, 'visibilityState', {
+ writable: true,
+ configurable: true,
+ value: hidden ? 'hidden' : 'visible',
+ });
+}
+
+function fireVisibilityChange() {
+ document.dispatchEvent(new Event('visibilitychange'));
+}
+
+describe('useTabVisibility', () => {
+ beforeEach(() => {
+ // Reset to visible for each test
+ setDocumentHidden(false);
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // ── Initial state ──────────────────────────────────────────────────
+
+ it('should return isTabVisible true when document is visible', () => {
+ setDocumentHidden(false);
+ const { result } = renderHook(() => useTabVisibility());
+ expect(result.current.isTabVisible).toBe(true);
+ });
+
+ it('should return isTabVisible false when document starts hidden', () => {
+ setDocumentHidden(true);
+ const { result } = renderHook(() => useTabVisibility());
+ expect(result.current.isTabVisible).toBe(false);
+ });
+
+ it('should have null hiddenSince initially when tab is visible', () => {
+ setDocumentHidden(false);
+ const { result } = renderHook(() => useTabVisibility());
+ expect(result.current.hiddenSince).toBeNull();
+ });
+
+ // ── Visibility changes ─────────────────────────────────────────────
+
+ it('should update isTabVisible to false when tab becomes hidden', () => {
+ const { result } = renderHook(() => useTabVisibility());
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+
+ expect(result.current.isTabVisible).toBe(false);
+ });
+
+ it('should update isTabVisible to true when tab becomes visible again', () => {
+ const { result } = renderHook(() => useTabVisibility());
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+
+ act(() => {
+ setDocumentHidden(false);
+ fireVisibilityChange();
+ });
+
+ expect(result.current.isTabVisible).toBe(true);
+ });
+
+ // ── onHidden callback ─────────────────────────────────────────────
+
+ it('should call onHidden when tab becomes hidden', () => {
+ const onHidden = vi.fn();
+ renderHook(() => useTabVisibility({ onHidden }));
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+
+ expect(onHidden).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call onHidden when tab becomes visible', () => {
+ const onHidden = vi.fn();
+ renderHook(() => useTabVisibility({ onHidden }));
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+ onHidden.mockClear();
+
+ act(() => {
+ setDocumentHidden(false);
+ fireVisibilityChange();
+ });
+
+ expect(onHidden).not.toHaveBeenCalled();
+ });
+
+ // ── onVisible callback ────────────────────────────────────────────
+
+ it('should call onVisible when tab becomes visible', () => {
+ const onVisible = vi.fn();
+ renderHook(() => useTabVisibility({ onVisible }));
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+
+ act(() => {
+ setDocumentHidden(false);
+ fireVisibilityChange();
+ });
+
+ expect(onVisible).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call onVisible when tab becomes hidden', () => {
+ const onVisible = vi.fn();
+ renderHook(() => useTabVisibility({ onVisible }));
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+
+ expect(onVisible).not.toHaveBeenCalled();
+ });
+
+ // ── hiddenDuration ─────────────────────────────────────────────────
+
+ it('should pass hiddenDuration in seconds to onVisible', () => {
+ const onVisible = vi.fn();
+ renderHook(() => useTabVisibility({ onVisible }));
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+
+ // Advance time by 5 seconds
+ vi.advanceTimersByTime(5000);
+
+ act(() => {
+ setDocumentHidden(false);
+ fireVisibilityChange();
+ });
+
+ expect(onVisible).toHaveBeenCalledTimes(1);
+ const hiddenDuration = onVisible.mock.calls[0][0];
+ expect(hiddenDuration).toBeCloseTo(5, 0);
+ });
+
+ it('should pass 0 as hiddenDuration when onVisible called without prior hidden', () => {
+ const onVisible = vi.fn();
+ renderHook(() => useTabVisibility({ onVisible }));
+
+ // Directly fire visible without going hidden first
+ act(() => {
+ setDocumentHidden(false);
+ fireVisibilityChange();
+ });
+
+ expect(onVisible).toHaveBeenCalledTimes(1);
+ expect(onVisible).toHaveBeenCalledWith(0);
+ });
+
+ it('should track different hidden durations across multiple hide/show cycles', () => {
+ const onVisible = vi.fn();
+ renderHook(() => useTabVisibility({ onVisible }));
+
+ // First cycle: 2 seconds hidden
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+ vi.advanceTimersByTime(2000);
+ act(() => {
+ setDocumentHidden(false);
+ fireVisibilityChange();
+ });
+
+ // Second cycle: 10 seconds hidden
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+ vi.advanceTimersByTime(10000);
+ act(() => {
+ setDocumentHidden(false);
+ fireVisibilityChange();
+ });
+
+ expect(onVisible).toHaveBeenCalledTimes(2);
+ expect(onVisible.mock.calls[0][0]).toBeCloseTo(2, 0);
+ expect(onVisible.mock.calls[1][0]).toBeCloseTo(10, 0);
+ });
+
+ // ── Ref-based callbacks (no stale closures) ───────────────────────
+
+ it('should use the latest onHidden callback without re-registering listener', () => {
+ const onHidden1 = vi.fn();
+ const onHidden2 = vi.fn();
+
+ const { rerender } = renderHook(
+ ({ onHidden }) => useTabVisibility({ onHidden }),
+ { initialProps: { onHidden: onHidden1 } },
+ );
+
+ rerender({ onHidden: onHidden2 });
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+
+ expect(onHidden1).not.toHaveBeenCalled();
+ expect(onHidden2).toHaveBeenCalledTimes(1);
+ });
+
+ it('should use the latest onVisible callback without re-registering listener', () => {
+ const onVisible1 = vi.fn();
+ const onVisible2 = vi.fn();
+
+ const { rerender } = renderHook(
+ ({ onVisible }) => useTabVisibility({ onVisible }),
+ { initialProps: { onVisible: onVisible1 } },
+ );
+
+ rerender({ onVisible: onVisible2 });
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+ act(() => {
+ setDocumentHidden(false);
+ fireVisibilityChange();
+ });
+
+ expect(onVisible1).not.toHaveBeenCalled();
+ expect(onVisible2).toHaveBeenCalledTimes(1);
+ });
+
+ // ── No callbacks provided ─────────────────────────────────────────
+
+ it('should work correctly without any callbacks', () => {
+ const { result } = renderHook(() => useTabVisibility());
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+
+ expect(result.current.isTabVisible).toBe(false);
+
+ act(() => {
+ setDocumentHidden(false);
+ fireVisibilityChange();
+ });
+
+ expect(result.current.isTabVisible).toBe(true);
+ });
+
+ // ── Cleanup ────────────────────────────────────────────────────────
+
+ it('should remove visibilitychange listener on unmount', () => {
+ const onHidden = vi.fn();
+ const { unmount } = renderHook(() => useTabVisibility({ onHidden }));
+ unmount();
+
+ act(() => {
+ setDocumentHidden(true);
+ fireVisibilityChange();
+ });
+
+ expect(onHidden).not.toHaveBeenCalled();
+ });
+
+ // ── Default options ───────────────────────────────────────────────
+
+ it('should accept empty options object', () => {
+ expect(() => {
+ renderHook(() => useTabVisibility({}));
+ }).not.toThrow();
+ });
+
+ it('should accept no arguments', () => {
+ expect(() => {
+ renderHook(() => useTabVisibility());
+ }).not.toThrow();
+ });
+});
diff --git a/src/hooks/useVideo.test.ts b/src/hooks/useVideo.test.ts
new file mode 100644
index 0000000..7cecd2e
--- /dev/null
+++ b/src/hooks/useVideo.test.ts
@@ -0,0 +1,1273 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import type { MediaState, MediaControls } from '@/types/media';
+import type { VideoQuality } from '@/types/video';
+import { initialWatchProgress } from '@/types/video';
+
+// ─── Mock sub-hooks ──────────────────────────────────────────────────
+// We mock every sub-hook so we can isolate the logic that lives in useVideo itself.
+
+const mockMediaState: MediaState = {
+ isPlaying: false,
+ isPaused: true,
+ isLoading: false,
+ isBuffering: false,
+ isEnded: false,
+ isMuted: false,
+ currentTime: 0,
+ duration: 100,
+ buffered: 0,
+ volume: 1,
+ playbackRate: 1,
+ error: null,
+ retryCount: 0,
+ isRetrying: false,
+};
+
+const mockMediaControls: MediaControls = {
+ play: vi.fn().mockResolvedValue(undefined),
+ pause: vi.fn(),
+ toggle: vi.fn().mockResolvedValue(undefined),
+ stop: vi.fn(),
+ seek: vi.fn(),
+ seekTo: vi.fn(),
+ skipForward: vi.fn(),
+ skipBackward: vi.fn(),
+ setVolume: vi.fn(),
+ toggleMute: vi.fn(),
+ setPlaybackRate: vi.fn(),
+ retry: vi.fn().mockResolvedValue(undefined),
+};
+
+let currentMediaState = { ...mockMediaState };
+let currentMediaControls = { ...mockMediaControls };
+const mockVideoRef = { current: document.createElement('video') };
+
+vi.mock('./useMedia', () => ({
+ useMedia: vi.fn(() => ({
+ mediaRef: mockVideoRef,
+ state: currentMediaState,
+ controls: currentMediaControls,
+ })),
+}));
+
+vi.mock('./useFullscreen', () => ({
+ useFullscreen: vi.fn(() => ({
+ isFullscreen: false,
+ enterFullscreen: vi.fn().mockResolvedValue(undefined),
+ exitFullscreen: vi.fn().mockResolvedValue(undefined),
+ toggleFullscreen: vi.fn().mockResolvedValue(undefined),
+ isSupported: true,
+ })),
+}));
+
+vi.mock('./usePictureInPicture', () => ({
+ usePictureInPicture: vi.fn(() => ({
+ isPictureInPicture: false,
+ enterPictureInPicture: vi.fn().mockResolvedValue(undefined),
+ exitPictureInPicture: vi.fn().mockResolvedValue(undefined),
+ togglePictureInPicture: vi.fn().mockResolvedValue(undefined),
+ isSupported: true,
+ })),
+}));
+
+vi.mock('./useCast', () => ({
+ useCast: vi.fn(() => ({
+ isCasting: false,
+ toggleCast: vi.fn().mockResolvedValue(undefined),
+ isSupported: false,
+ })),
+}));
+
+vi.mock('./useTabVisibility', () => ({
+ useTabVisibility: vi.fn(() => ({
+ isTabVisible: true,
+ hiddenSince: null,
+ })),
+}));
+
+let mockHlsReturn = {
+ isHLS: false,
+ isUsingHlsJs: false,
+ hlsInstance: null,
+ levels: [] as VideoQuality[],
+ currentLevel: -1,
+ setLevel: vi.fn(),
+ isAutoQuality: true,
+ setAutoQuality: vi.fn(),
+ attachHLS: vi.fn(),
+ detachHLS: vi.fn(),
+};
+
+/** Captures the onQualityLevelsLoaded callback from useHLS calls */
+let capturedHlsOnQualityLevelsLoaded: ((levels: VideoQuality[]) => void) | undefined;
+
+vi.mock('./useHLS', () => ({
+ useHLS: vi.fn((opts: any) => {
+ capturedHlsOnQualityLevelsLoaded = opts?.onQualityLevelsLoaded;
+ return mockHlsReturn;
+ }),
+ isHLSSource: vi.fn((src: string | undefined) => {
+ if (!src) return false;
+ return src.endsWith('.m3u8') || src.includes('.m3u8?') || src.includes('/manifest/');
+ }),
+}));
+
+// Import after mocks
+import { useVideo } from './useVideo';
+import type { UseVideoOptions } from './useVideo';
+import { useMedia } from './useMedia';
+import { useTabVisibility } from './useTabVisibility';
+
+// ─── Helpers ─────────────────────────────────────────────────────────
+
+function resetMocks() {
+ currentMediaState = { ...mockMediaState };
+ currentMediaControls = {
+ play: vi.fn().mockResolvedValue(undefined),
+ pause: vi.fn(),
+ toggle: vi.fn().mockResolvedValue(undefined),
+ stop: vi.fn(),
+ seek: vi.fn(),
+ seekTo: vi.fn(),
+ skipForward: vi.fn(),
+ skipBackward: vi.fn(),
+ setVolume: vi.fn(),
+ toggleMute: vi.fn(),
+ setPlaybackRate: vi.fn(),
+ retry: vi.fn().mockResolvedValue(undefined),
+ };
+ mockHlsReturn = {
+ isHLS: false,
+ isUsingHlsJs: false,
+ hlsInstance: null,
+ levels: [],
+ currentLevel: -1,
+ setLevel: vi.fn(),
+ isAutoQuality: true,
+ setAutoQuality: vi.fn(),
+ attachHLS: vi.fn(),
+ detachHLS: vi.fn(),
+ };
+ capturedHlsOnQualityLevelsLoaded = undefined;
+ mockVideoRef.current = document.createElement('video');
+}
+
+/**
+ * Stable empty array used as default `qualities` to avoid infinite
+ * render loops. Inside useVideo, `qualities = []` creates a new
+ * array identity on every render, which destabilises the effect
+ * dep list and triggers an infinite update cycle in tests.
+ */
+const EMPTY_QUALITIES: VideoQuality[] = [];
+
+function renderVideoHook(options: UseVideoOptions = {}) {
+ const stableOpts: UseVideoOptions = { qualities: EMPTY_QUALITIES, ...options };
+ const hookReturn = renderHook(
+ ({ opts }: { opts: UseVideoOptions }) =>
+ useVideo({ qualities: EMPTY_QUALITIES, ...opts }),
+ { initialProps: { opts: stableOpts } as { opts: UseVideoOptions } },
+ );
+ return hookReturn;
+}
+
+/**
+ * Create a mock video element with a textTracks-like structure.
+ * jsdom does not implement addTextTrack, so we mock the textTracks property.
+ */
+function createVideoWithTextTracks(
+ tracks: Array<{ kind: string; label: string; mode: string }>,
+) {
+ const video = document.createElement('video');
+ const trackObjects = tracks.map((t) => ({
+ kind: t.kind,
+ label: t.label,
+ mode: t.mode,
+ }));
+ Object.defineProperty(video, 'textTracks', {
+ value: {
+ length: trackObjects.length,
+ ...trackObjects.reduce(
+ (acc, track, i) => {
+ acc[i] = track;
+ return acc;
+ },
+ {} as Record,
+ ),
+ },
+ writable: true,
+ });
+ return { video, tracks: trackObjects };
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────
+
+describe('useVideo', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ resetMocks();
+ });
+
+ // ─── Initial state ─────────────────────────────────────────────
+
+ describe('initial state', () => {
+ it('returns videoRef and containerRef', () => {
+ const { result } = renderVideoHook();
+
+ expect(result.current.videoRef).toBeDefined();
+ expect(result.current.containerRef).toBeDefined();
+ });
+
+ it('returns composed state from media and video-specific state', () => {
+ const { result } = renderVideoHook();
+ const { state } = result.current;
+
+ expect(state.isPlaying).toBe(false);
+ expect(state.isPaused).toBe(true);
+ expect(state.isFullscreen).toBe(false);
+ expect(state.isPictureInPicture).toBe(false);
+ expect(state.isCasting).toBe(false);
+ expect(state.isTabVisible).toBe(true);
+ expect(state.controlsVisible).toBe(true);
+ expect(state.currentQuality).toBe('auto');
+ expect(state.aspectRatio).toBe(16 / 9);
+ expect(state.posterLoaded).toBe(false);
+ expect(state.currentSubtitle).toBeNull();
+ expect(state.isHLS).toBe(false);
+ expect(state.isAutoQuality).toBe(false); // isUsingHlsJs is false
+ });
+
+ it('returns initial watch progress', () => {
+ const { result } = renderVideoHook();
+
+ expect(result.current.state.watchProgress).toEqual(initialWatchProgress);
+ });
+
+ it('returns video-specific controls', () => {
+ const { result } = renderVideoHook();
+ const { controls } = result.current;
+
+ expect(typeof controls.enterFullscreen).toBe('function');
+ expect(typeof controls.exitFullscreen).toBe('function');
+ expect(typeof controls.toggleFullscreen).toBe('function');
+ expect(typeof controls.enterPictureInPicture).toBe('function');
+ expect(typeof controls.exitPictureInPicture).toBe('function');
+ expect(typeof controls.togglePictureInPicture).toBe('function');
+ expect(typeof controls.toggleCast).toBe('function');
+ expect(typeof controls.setQuality).toBe('function');
+ expect(typeof controls.setSubtitle).toBe('function');
+ expect(typeof controls.showControls).toBe('function');
+ expect(typeof controls.hideControls).toBe('function');
+ expect(typeof controls.setAutoQuality).toBe('function');
+ });
+
+ it('includes media controls in return value', () => {
+ const { result } = renderVideoHook();
+ const { controls } = result.current;
+
+ expect(typeof controls.play).toBe('function');
+ expect(typeof controls.pause).toBe('function');
+ expect(typeof controls.toggle).toBe('function');
+ expect(typeof controls.seek).toBe('function');
+ });
+ });
+
+ // ─── Controls visibility ───────────────────────────────────────
+
+ describe('controls visibility', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('shows controls and sets hide timeout', () => {
+ const { result } = renderVideoHook({ controlsHideDelay: 3000 });
+
+ act(() => {
+ result.current.controls.hideControls();
+ });
+ expect(result.current.state.controlsVisible).toBe(false);
+
+ act(() => {
+ result.current.controls.showControls();
+ });
+ expect(result.current.state.controlsVisible).toBe(true);
+ });
+
+ it('hides controls after timeout when playing', () => {
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ const { result } = renderVideoHook({ controlsHideDelay: 3000 });
+
+ act(() => {
+ result.current.controls.showControls();
+ });
+ expect(result.current.state.controlsVisible).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(result.current.state.controlsVisible).toBe(false);
+ });
+
+ it('does not hide controls after timeout when paused', () => {
+ currentMediaState = { ...mockMediaState, isPlaying: false };
+ const { result } = renderVideoHook({ controlsHideDelay: 3000 });
+
+ act(() => {
+ result.current.controls.showControls();
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+
+ expect(result.current.state.controlsVisible).toBe(true);
+ });
+
+ it('uses custom controlsHideDelay', () => {
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ const { result } = renderVideoHook({ controlsHideDelay: 1000 });
+
+ act(() => {
+ result.current.controls.showControls();
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(999);
+ });
+ expect(result.current.state.controlsVisible).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(result.current.state.controlsVisible).toBe(false);
+ });
+
+ it('hideControls immediately hides and clears timeout', () => {
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.showControls();
+ });
+ expect(result.current.state.controlsVisible).toBe(true);
+
+ act(() => {
+ result.current.controls.hideControls();
+ });
+ expect(result.current.state.controlsVisible).toBe(false);
+ });
+
+ it('resets hide timeout when showControls is called again', () => {
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ const { result } = renderVideoHook({ controlsHideDelay: 3000 });
+
+ act(() => {
+ result.current.controls.showControls();
+ });
+
+ // Advance 2s
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current.state.controlsVisible).toBe(true);
+
+ // Show again, resetting the timer
+ act(() => {
+ result.current.controls.showControls();
+ });
+
+ // Advance another 2s (total 4s from first show, but only 2s from second)
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current.state.controlsVisible).toBe(true);
+
+ // Now 3s from second show
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.state.controlsVisible).toBe(false);
+ });
+
+ it('shows controls and auto-hides when playback starts', () => {
+ const { result, rerender } = renderVideoHook();
+
+ // Initially paused, controls visible
+ expect(result.current.state.controlsVisible).toBe(true);
+
+ // Start playing
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ rerender({ opts: {} });
+
+ // Controls should still be visible (auto-hide timer started)
+ expect(result.current.state.controlsVisible).toBe(true);
+
+ // After delay, controls should hide
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(result.current.state.controlsVisible).toBe(false);
+ });
+
+ it('shows controls when playback pauses', () => {
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ const { result, rerender } = renderVideoHook();
+
+ // Hide controls
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(result.current.state.controlsVisible).toBe(false);
+
+ // Pause
+ currentMediaState = { ...mockMediaState, isPlaying: false };
+ rerender({ opts: {} });
+
+ expect(result.current.state.controlsVisible).toBe(true);
+ });
+ });
+
+ // ─── Quality selection ─────────────────────────────────────────
+
+ describe('quality selection', () => {
+ it('sets quality for progressive video by finding matching quality', () => {
+ const qualities: VideoQuality[] = [
+ { label: '720p', src: 'https://example.com/720.mp4' },
+ { label: '1080p', src: 'https://example.com/1080.mp4' },
+ ];
+ const { result } = renderVideoHook({ qualities });
+
+ act(() => {
+ result.current.controls.setQuality('1080p');
+ });
+
+ expect(result.current.state.currentQuality).toBe('1080p');
+ });
+
+ it('sets video src and calls load for progressive quality switch', () => {
+ const video = document.createElement('video');
+ Object.defineProperty(video, 'currentTime', { writable: true, value: 50 });
+ Object.defineProperty(video, 'paused', { writable: true, value: true });
+ const loadSpy = vi.spyOn(video, 'load');
+ mockVideoRef.current = video;
+
+ const qualities: VideoQuality[] = [
+ { label: '720p', src: 'https://example.com/720.mp4' },
+ { label: '1080p', src: 'https://example.com/1080.mp4' },
+ ];
+ const { result } = renderVideoHook({ qualities });
+
+ act(() => {
+ result.current.controls.setQuality('1080p');
+ });
+
+ expect(video.src).toContain('1080.mp4');
+ expect(loadSpy).toHaveBeenCalled();
+ // Should restore currentTime
+ expect(video.currentTime).toBe(50);
+ });
+
+ it('resumes playback after quality switch if was playing', () => {
+ const video = document.createElement('video');
+ Object.defineProperty(video, 'currentTime', { writable: true, value: 50 });
+ Object.defineProperty(video, 'paused', { writable: true, value: false });
+ const playSpy = vi.spyOn(video, 'play');
+ mockVideoRef.current = video;
+
+ const qualities: VideoQuality[] = [
+ { label: '720p', src: 'https://example.com/720.mp4' },
+ { label: '1080p', src: 'https://example.com/1080.mp4' },
+ ];
+ const { result } = renderVideoHook({ qualities });
+
+ act(() => {
+ result.current.controls.setQuality('1080p');
+ });
+
+ expect(playSpy).toHaveBeenCalled();
+ });
+
+ it('does not change src if same quality src is already set', () => {
+ const video = document.createElement('video');
+ video.src = 'https://example.com/720.mp4';
+ mockVideoRef.current = video;
+ const loadSpy = vi.spyOn(video, 'load');
+
+ const qualities: VideoQuality[] = [
+ { label: '720p', src: 'https://example.com/720.mp4' },
+ ];
+ const { result } = renderVideoHook({ qualities });
+
+ act(() => {
+ result.current.controls.setQuality('720p');
+ });
+
+ // src is the same, so load should not be called
+ expect(loadSpy).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when video ref is null', () => {
+ mockVideoRef.current = null as any;
+ const { result } = renderVideoHook({
+ qualities: [{ label: '720p', src: 'https://example.com/720.mp4' }],
+ });
+
+ // Should not throw
+ act(() => {
+ result.current.controls.setQuality('720p');
+ });
+ });
+
+ it('uses HLS level switching when using hls.js', () => {
+ const hlsLevels: VideoQuality[] = [
+ { label: 'Auto', src: '', bitrate: 0 },
+ { label: '720p', src: '', bitrate: 2500000 },
+ { label: '1080p', src: '', bitrate: 5000000 },
+ ];
+ mockHlsReturn = {
+ ...mockHlsReturn,
+ isHLS: true,
+ isUsingHlsJs: true,
+ levels: hlsLevels,
+ };
+
+ const { result } = renderVideoHook();
+
+ // Simulate HLS quality levels being loaded (triggers internal hlsQualityLevels state)
+ act(() => {
+ capturedHlsOnQualityLevelsLoaded?.(hlsLevels);
+ });
+
+ act(() => {
+ result.current.controls.setQuality('1080p');
+ });
+
+ expect(mockHlsReturn.setLevel).toHaveBeenCalledWith(2);
+ expect(result.current.state.currentQuality).toBe('1080p');
+ });
+
+ it('uses Auto label correctly with HLS', () => {
+ const hlsLevels: VideoQuality[] = [
+ { label: 'Auto', src: '', bitrate: 0 },
+ { label: '720p', src: '', bitrate: 2500000 },
+ ];
+ mockHlsReturn = {
+ ...mockHlsReturn,
+ isHLS: true,
+ isUsingHlsJs: true,
+ levels: hlsLevels,
+ };
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ capturedHlsOnQualityLevelsLoaded?.(hlsLevels);
+ });
+
+ act(() => {
+ result.current.controls.setQuality('Auto');
+ });
+
+ expect(mockHlsReturn.setLevel).toHaveBeenCalledWith(0);
+ expect(result.current.state.currentQuality).toBe('Auto');
+ });
+
+ it('ignores invalid quality label with HLS', () => {
+ const hlsLevels: VideoQuality[] = [
+ { label: 'Auto', src: '', bitrate: 0 },
+ { label: '720p', src: '', bitrate: 2500000 },
+ ];
+ mockHlsReturn = {
+ ...mockHlsReturn,
+ isHLS: true,
+ isUsingHlsJs: true,
+ levels: hlsLevels,
+ };
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ capturedHlsOnQualityLevelsLoaded?.(hlsLevels);
+ });
+
+ act(() => {
+ result.current.controls.setQuality('4K');
+ });
+
+ expect(mockHlsReturn.setLevel).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── Subtitle selection ────────────────────────────────────────
+
+ describe('subtitle selection', () => {
+ it('sets currentSubtitle in state', () => {
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.setSubtitle('English');
+ });
+
+ expect(result.current.state.currentSubtitle).toBe('English');
+ });
+
+ it('sets subtitle to null to disable', () => {
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.setSubtitle('English');
+ });
+ expect(result.current.state.currentSubtitle).toBe('English');
+
+ act(() => {
+ result.current.controls.setSubtitle(null);
+ });
+ expect(result.current.state.currentSubtitle).toBeNull();
+ });
+
+ it('activates matching text track on video element', () => {
+ const { video, tracks } = createVideoWithTextTracks([
+ { kind: 'subtitles', label: 'English', mode: 'hidden' },
+ { kind: 'subtitles', label: 'German', mode: 'hidden' },
+ ]);
+ mockVideoRef.current = video;
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.setSubtitle('English');
+ });
+
+ expect(tracks[0].mode).toBe('showing');
+ expect(tracks[1].mode).toBe('hidden');
+ });
+
+ it('hides all tracks when setting subtitle to null', () => {
+ const { video, tracks } = createVideoWithTextTracks([
+ { kind: 'subtitles', label: 'English', mode: 'showing' },
+ { kind: 'subtitles', label: 'German', mode: 'showing' },
+ ]);
+ mockVideoRef.current = video;
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.setSubtitle(null);
+ });
+
+ expect(tracks[0].mode).toBe('hidden');
+ expect(tracks[1].mode).toBe('hidden');
+ });
+
+ it('handles setSubtitle when video ref is null', () => {
+ mockVideoRef.current = null as any;
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.setSubtitle('English');
+ });
+
+ expect(result.current.state.currentSubtitle).toBe('English');
+ });
+
+ it('ignores non-subtitle tracks (e.g. descriptions)', () => {
+ const { video, tracks } = createVideoWithTextTracks([
+ { kind: 'subtitles', label: 'English', mode: 'hidden' },
+ { kind: 'descriptions', label: 'Audio Desc', mode: 'hidden' },
+ ]);
+ mockVideoRef.current = video;
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.setSubtitle('English');
+ });
+
+ expect(tracks[0].mode).toBe('showing');
+ expect(tracks[1].mode).toBe('hidden'); // descriptions track unchanged
+ });
+
+ it('handles captions kind tracks', () => {
+ const { video, tracks } = createVideoWithTextTracks([
+ { kind: 'captions', label: 'English CC', mode: 'hidden' },
+ ]);
+ mockVideoRef.current = video;
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.setSubtitle('English CC');
+ });
+
+ expect(tracks[0].mode).toBe('showing');
+ });
+ });
+
+ // ─── Watch progress tracking ───────────────────────────────────
+
+ describe('watch progress tracking', () => {
+ it('tracks a segment when pausing after playing', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate });
+
+ // Start playing at time 10
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 10 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ // Pause at time 30
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 30 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ expect(onWatchProgressUpdate).toHaveBeenCalled();
+ const lastCall = onWatchProgressUpdate.mock.calls[onWatchProgressUpdate.mock.calls.length - 1][0];
+ expect(lastCall.watchedSegments.length).toBeGreaterThan(0);
+ expect(lastCall.percentageWatched).toBeGreaterThan(0);
+ });
+
+ it('calculates percentageWatched correctly', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate });
+
+ // Play from 0 to 50 (50% of 100s)
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 50, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ const lastCall = onWatchProgressUpdate.mock.calls[onWatchProgressUpdate.mock.calls.length - 1][0];
+ expect(lastCall.percentageWatched).toBe(50);
+ });
+
+ it('merges overlapping segments', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate });
+
+ // First segment: 0-30
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 30, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ // Second segment: 20-50 (overlaps with first)
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 20, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 50, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ const lastCall = onWatchProgressUpdate.mock.calls[onWatchProgressUpdate.mock.calls.length - 1][0];
+ // Merged: 0-50 = 50%
+ expect(lastCall.percentageWatched).toBe(50);
+ expect(lastCall.watchedSegments).toHaveLength(1);
+ });
+
+ it('merges adjacent segments within 0.5s', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate });
+
+ // First segment: 0-30
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 30, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ // Second segment: 30.4-50 (adjacent, within 0.5s tolerance)
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 30.4, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 50, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ const lastCall = onWatchProgressUpdate.mock.calls[onWatchProgressUpdate.mock.calls.length - 1][0];
+ expect(lastCall.watchedSegments).toHaveLength(1);
+ expect(lastCall.watchedSegments[0].start).toBe(0);
+ expect(lastCall.watchedSegments[0].end).toBe(50);
+ });
+
+ it('keeps separate non-overlapping segments', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate });
+
+ // First segment: 0-20
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 20, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ // Second segment: 50-80 (non-overlapping)
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 50, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 80, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ const lastCall = onWatchProgressUpdate.mock.calls[onWatchProgressUpdate.mock.calls.length - 1][0];
+ expect(lastCall.watchedSegments).toHaveLength(2);
+ expect(lastCall.percentageWatched).toBe(50); // 20 + 30 = 50 out of 100
+ });
+
+ it('tracks furthest point', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate });
+
+ // Play to 80
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 80, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 80, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ const lastCall = onWatchProgressUpdate.mock.calls[onWatchProgressUpdate.mock.calls.length - 1][0];
+ expect(lastCall.furthestPoint).toBeGreaterThanOrEqual(80);
+ });
+
+ it('marks as fully watched at 95% or more', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const onFinished = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate, onFinished });
+
+ // Watch 0-96 of a 100s video
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate, onFinished } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 96, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate, onFinished } });
+
+ const lastCall = onWatchProgressUpdate.mock.calls[onWatchProgressUpdate.mock.calls.length - 1][0];
+ expect(lastCall.isFullyWatched).toBe(true);
+ expect(lastCall.percentageWatched).toBe(96);
+ });
+
+ it('fires onFinished when fully watched', () => {
+ const onFinished = vi.fn();
+ const { rerender } = renderVideoHook({ onFinished });
+
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 100 };
+ rerender({ opts: { onFinished } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 96, duration: 100 };
+ rerender({ opts: { onFinished } });
+
+ expect(onFinished).toHaveBeenCalledTimes(1);
+ });
+
+ it('fires onFinished only once', () => {
+ const onFinished = vi.fn();
+ const { rerender } = renderVideoHook({ onFinished });
+
+ // First full watch
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 100 };
+ rerender({ opts: { onFinished } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 96, duration: 100 };
+ rerender({ opts: { onFinished } });
+
+ // Watch more
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 96, duration: 100 };
+ rerender({ opts: { onFinished } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 100, duration: 100 };
+ rerender({ opts: { onFinished } });
+
+ expect(onFinished).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not update progress when duration is 0', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate });
+
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 0 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 10, duration: 0 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ expect(onWatchProgressUpdate).not.toHaveBeenCalled();
+ });
+
+ it('does not create a segment when segment end equals start', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate });
+
+ // Play and immediately pause at the same time
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 10, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 10, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ if (onWatchProgressUpdate.mock.calls.length > 0) {
+ const lastCall = onWatchProgressUpdate.mock.calls[onWatchProgressUpdate.mock.calls.length - 1][0];
+ expect(lastCall.watchedSegments).toHaveLength(0);
+ }
+ });
+
+ it('caps percentageWatched at 100', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { rerender } = renderVideoHook({ onWatchProgressUpdate });
+
+ // Watch entire video
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 100, duration: 100 };
+ rerender({ opts: { onWatchProgressUpdate } });
+
+ const lastCall = onWatchProgressUpdate.mock.calls[onWatchProgressUpdate.mock.calls.length - 1][0];
+ expect(lastCall.percentageWatched).toBeLessThanOrEqual(100);
+ });
+ });
+
+ // ─── onStart callback ──────────────────────────────────────────
+
+ describe('onStart callback', () => {
+ it('fires onStart on first play', () => {
+ const onStart = vi.fn();
+ const { rerender } = renderVideoHook({ onStart });
+
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ rerender({ opts: { onStart } });
+
+ expect(onStart).toHaveBeenCalledTimes(1);
+ });
+
+ it('fires onStart only once', () => {
+ const onStart = vi.fn();
+ const { rerender } = renderVideoHook({ onStart });
+
+ // First play
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ rerender({ opts: { onStart } });
+
+ // Pause and play again
+ currentMediaState = { ...mockMediaState, isPlaying: false };
+ rerender({ opts: { onStart } });
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ rerender({ opts: { onStart } });
+
+ expect(onStart).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not fire onStart when not playing', () => {
+ const onStart = vi.fn();
+ renderVideoHook({ onStart });
+
+ expect(onStart).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── Source change resets ──────────────────────────────────────
+
+ describe('source change resets', () => {
+ it('resets watch progress when src changes', () => {
+ const onWatchProgressUpdate = vi.fn();
+ const { result, rerender } = renderVideoHook({
+ src: 'https://example.com/v1.mp4',
+ onWatchProgressUpdate,
+ });
+
+ // Watch something
+ currentMediaState = { ...mockMediaState, isPlaying: true, currentTime: 0, duration: 100 };
+ rerender({ opts: { src: 'https://example.com/v1.mp4', onWatchProgressUpdate } });
+ currentMediaState = { ...mockMediaState, isPlaying: false, currentTime: 50, duration: 100 };
+ rerender({ opts: { src: 'https://example.com/v1.mp4', onWatchProgressUpdate } });
+
+ // Change source
+ rerender({ opts: { src: 'https://example.com/v2.mp4', onWatchProgressUpdate } });
+
+ expect(result.current.state.watchProgress).toEqual(initialWatchProgress);
+ });
+ });
+
+ // ─── Available qualities ───────────────────────────────────────
+
+ describe('available qualities', () => {
+ it('uses HLS quality levels when available and using hls.js', () => {
+ const hlsLevels: VideoQuality[] = [
+ { label: 'Auto', src: '', bitrate: 0 },
+ { label: '720p', src: '', bitrate: 2500000 },
+ ];
+ mockHlsReturn = {
+ ...mockHlsReturn,
+ isHLS: true,
+ isUsingHlsJs: true,
+ levels: hlsLevels,
+ };
+
+ const { result } = renderVideoHook({
+ qualities: [{ label: '480p', src: 'https://example.com/480.mp4' }],
+ });
+
+ // Simulate HLS quality levels callback
+ act(() => {
+ capturedHlsOnQualityLevelsLoaded?.(hlsLevels);
+ });
+
+ expect(result.current.state.availableQualities).toEqual(hlsLevels);
+ });
+
+ it('falls back to provided qualities when not using hls.js', () => {
+ const qualities: VideoQuality[] = [
+ { label: '480p', src: 'https://example.com/480.mp4' },
+ { label: '720p', src: 'https://example.com/720.mp4' },
+ ];
+
+ const { result } = renderVideoHook({ qualities });
+
+ expect(result.current.state.availableQualities).toEqual(qualities);
+ });
+
+ it('returns empty array when no qualities provided', () => {
+ const { result } = renderVideoHook();
+
+ expect(result.current.state.availableQualities).toEqual([]);
+ });
+ });
+
+ // ─── isAutoQuality composition ─────────────────────────────────
+
+ describe('isAutoQuality', () => {
+ it('is false when not using hls.js', () => {
+ mockHlsReturn = { ...mockHlsReturn, isUsingHlsJs: false, isAutoQuality: true };
+ const { result } = renderVideoHook();
+
+ expect(result.current.state.isAutoQuality).toBe(false);
+ });
+
+ it('reflects HLS auto quality when using hls.js', () => {
+ mockHlsReturn = { ...mockHlsReturn, isUsingHlsJs: true, isAutoQuality: true };
+ const { result } = renderVideoHook();
+
+ expect(result.current.state.isAutoQuality).toBe(true);
+ });
+
+ it('delegates setAutoQuality to HLS hook', () => {
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.setAutoQuality(true);
+ });
+
+ expect(mockHlsReturn.setAutoQuality).toHaveBeenCalledWith(true);
+ });
+ });
+
+ // ─── isHLS composition ────────────────────────────────────────
+
+ describe('isHLS state', () => {
+ it('reflects HLS hook state', () => {
+ mockHlsReturn = { ...mockHlsReturn, isHLS: true };
+ const { result } = renderVideoHook();
+
+ expect(result.current.state.isHLS).toBe(true);
+ });
+
+ it('is false for non-HLS sources', () => {
+ mockHlsReturn = { ...mockHlsReturn, isHLS: false };
+ const { result } = renderVideoHook();
+
+ expect(result.current.state.isHLS).toBe(false);
+ });
+ });
+
+ // ─── HLS source handling ──────────────────────────────────────
+
+ describe('HLS source handling', () => {
+ it('passes undefined src to useMedia for HLS sources', () => {
+ renderVideoHook({ src: 'https://example.com/video.m3u8' });
+
+ expect(useMedia).toHaveBeenCalledWith(
+ expect.objectContaining({ src: undefined }),
+ );
+ });
+
+ it('passes src to useMedia for non-HLS sources', () => {
+ renderVideoHook({ src: 'https://example.com/video.mp4' });
+
+ expect(useMedia).toHaveBeenCalledWith(
+ expect.objectContaining({ src: 'https://example.com/video.mp4' }),
+ );
+ });
+ });
+
+ // ─── Tab visibility callbacks ──────────────────────────────────
+
+ describe('tab visibility callbacks', () => {
+ it('calls useTabVisibility with onHidden and onVisible handlers', () => {
+ renderVideoHook();
+
+ expect(useTabVisibility).toHaveBeenCalledWith(
+ expect.objectContaining({
+ onHidden: expect.any(Function),
+ onVisible: expect.any(Function),
+ }),
+ );
+ });
+ });
+
+ // ─── Default options ──────────────────────────────────────────
+
+ describe('default options', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('uses default controlsHideDelay of 3000', () => {
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ const { result } = renderVideoHook();
+
+ act(() => {
+ result.current.controls.showControls();
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(2999);
+ });
+ expect(result.current.state.controlsVisible).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(result.current.state.controlsVisible).toBe(false);
+ });
+
+ it('works with no options', () => {
+ const { result } = renderVideoHook();
+
+ expect(result.current.state).toBeDefined();
+ expect(result.current.controls).toBeDefined();
+ });
+ });
+
+ // ─── Cleanup ──────────────────────────────────────────────────
+
+ describe('cleanup', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('clears controls timeout on unmount', () => {
+ currentMediaState = { ...mockMediaState, isPlaying: true };
+ const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
+ const { unmount } = renderVideoHook();
+
+ unmount();
+
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+ clearTimeoutSpy.mockRestore();
+ });
+ });
+
+ // ─── Poster loading ───────────────────────────────────────────
+
+ describe('poster loading', () => {
+ it('sets posterLoaded when poster image loads', () => {
+ const originalImage = globalThis.Image;
+ let imageOnload: (() => void) | null = null;
+
+ (globalThis as any).Image = class MockImage {
+ onload: (() => void) | null = null;
+ set src(_url: string) {
+ imageOnload = this.onload;
+ }
+ };
+
+ const { result } = renderVideoHook({ poster: 'https://example.com/poster.jpg' });
+
+ expect(result.current.state.posterLoaded).toBe(false);
+
+ if (imageOnload) {
+ act(() => {
+ imageOnload!();
+ });
+ expect(result.current.state.posterLoaded).toBe(true);
+ }
+
+ globalThis.Image = originalImage;
+ });
+ });
+
+ // ─── Aspect ratio tracking ────────────────────────────────────
+
+ describe('aspect ratio tracking', () => {
+ it('updates aspect ratio on loadedmetadata', () => {
+ const video = document.createElement('video');
+ Object.defineProperty(video, 'videoWidth', { writable: true, value: 1920 });
+ Object.defineProperty(video, 'videoHeight', { writable: true, value: 1080 });
+ mockVideoRef.current = video;
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ video.dispatchEvent(new Event('loadedmetadata'));
+ });
+
+ expect(result.current.state.aspectRatio).toBeCloseTo(1920 / 1080);
+ });
+
+ it('does not update aspect ratio when dimensions are 0', () => {
+ const video = document.createElement('video');
+ Object.defineProperty(video, 'videoWidth', { writable: true, value: 0 });
+ Object.defineProperty(video, 'videoHeight', { writable: true, value: 0 });
+ mockVideoRef.current = video;
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ video.dispatchEvent(new Event('loadedmetadata'));
+ });
+
+ // Should remain default 16/9
+ expect(result.current.state.aspectRatio).toBe(16 / 9);
+ });
+ });
+
+ // ─── HLS quality level sync ───────────────────────────────────
+
+ describe('HLS quality level sync', () => {
+ it('syncs availableQualities when HLS levels are loaded', () => {
+ const hlsLevels: VideoQuality[] = [
+ { label: 'Auto', src: '', bitrate: 0 },
+ { label: '1080p', src: '', bitrate: 5000000 },
+ ];
+ mockHlsReturn = {
+ ...mockHlsReturn,
+ isHLS: true,
+ isUsingHlsJs: true,
+ levels: hlsLevels,
+ };
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ capturedHlsOnQualityLevelsLoaded?.(hlsLevels);
+ });
+
+ expect(result.current.state.availableQualities).toEqual(hlsLevels);
+ });
+
+ it('updates currentQuality label when HLS level changes', () => {
+ const hlsLevels: VideoQuality[] = [
+ { label: 'Auto', src: '', bitrate: 0 },
+ { label: '720p', src: '', bitrate: 2500000 },
+ { label: '1080p', src: '', bitrate: 5000000 },
+ ];
+ mockHlsReturn = {
+ ...mockHlsReturn,
+ isHLS: true,
+ isUsingHlsJs: true,
+ levels: hlsLevels,
+ currentLevel: 2,
+ };
+
+ const { result } = renderVideoHook();
+
+ act(() => {
+ capturedHlsOnQualityLevelsLoaded?.(hlsLevels);
+ });
+
+ expect(result.current.state.currentQuality).toBe('1080p');
+ });
+ });
+});
diff --git a/src/index.ts b/src/index.ts
index b23d1d1..02d0343 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,17 +7,24 @@ export {
VideoOverlay,
VideoControls,
LogoOverlay,
+ GestureOverlay,
EndScreen,
RecommendedCard,
AutoPlayCountdown,
+ SubtitleDisplay,
type VideoPlayerWithProviderProps,
type VideoPlayerRef,
type VideoOverlayProps,
type VideoControlsProps,
type LogoOverlayProps,
+ type GestureOverlayProps,
+ type GestureFeedback,
+ type GestureFeedbackType,
type EndScreenProps,
type RecommendedCardProps,
type AutoPlayCountdownProps,
+ type SubtitleDisplayProps,
+ type SubtitleDisplayMode,
} from './components/VideoPlayer';
export {
PlayButton,
@@ -32,6 +39,10 @@ export {
SubtitleSelector,
PictureInPictureButton,
CastButton,
+ SleepTimer,
+ ShareButton,
+ SubtitleSettings,
+ Equalizer,
type PlayButtonProps,
type ProgressBarProps,
type TimeDisplayProps,
@@ -44,6 +55,10 @@ export {
type SubtitleSelectorProps,
type PictureInPictureButtonProps,
type CastButtonProps,
+ type SleepTimerProps,
+ type ShareButtonProps,
+ type SubtitleSettingsProps,
+ type EqualizerProps,
} from './components/controls';
export {
ChapterMarker,
@@ -69,11 +84,15 @@ export {
OverlayAd,
InfoCard,
InfoCardIcon,
+ PauseAd,
+ RewardedAdOverlay,
type AdOverlayProps,
type AdSkipButtonProps,
type OverlayAdProps,
type InfoCardProps,
type InfoCardIconProps,
+ type PauseAdComponentProps,
+ type RewardedAdOverlayProps,
} from './components/ads';
export {
Rating,
@@ -82,6 +101,14 @@ export {
type RatingProps,
type StatsProps,
} from './components/stats';
+export {
+ PlayerErrorBoundary,
+ type PlayerErrorBoundaryProps,
+} from './components/ErrorBoundary';
+export {
+ ScreenReaderAnnouncer,
+ type ScreenReaderAnnouncerProps,
+} from './components/a11y';
export {
PodcastPage,
PodcastPageContent,
@@ -103,6 +130,10 @@ export {
} from './components/podcast';
// Context providers
+export {
+ FairuProvider,
+ type FairuProviderProps,
+} from './context/FairuProvider';
export {
PlayerContext,
PlayerProvider,
@@ -164,6 +195,24 @@ export {
useChapters,
useMarkers,
useKeyboardControls,
+ useSleepTimer,
+ useGestures,
+ useResumePosition,
+ usePlaylistPersistence,
+ usePlaybackHistory,
+ useSubtitleStyling,
+ useSubtitleParser,
+ parseVTTCues,
+ useEqualizer,
+ useFocusTrap,
+ useAutoplayDetection,
+ useABLoop,
+ useShareableTimestamp,
+ usePauseAd,
+ useRewardedAd,
+ useSyncPlayback,
+ formatTimestamp,
+ parseTimestamp,
type UseAudioOptions,
type UseAudioReturn,
type UseVideoOptions,
@@ -179,10 +228,50 @@ export {
type UsePlaylistOptions,
type UsePlaylistReturn,
type UseKeyboardControlsOptions,
+ type UseGesturesOptions,
+ type UseAutoplayDetectionOptions,
+ type AutoplayPolicy,
+ type ABLoopState,
+ type ABLoopControls,
+ type UseABLoopOptions,
+ type UseABLoopReturn,
+ type UseShareableTimestampOptions,
+ type UseShareableTimestampReturn,
+ type UseFocusTrapOptions,
+ type UseFocusTrapReturn,
+ type UseSyncPlaybackOptions,
+ type UseSyncPlaybackReturn,
+ type UseSubtitleParserOptions,
+ type UseSubtitleParserReturn,
+ type SubtitleCue,
} from './hooks';
+// Equalizer
+export {
+ DEFAULT_BANDS,
+ EQUALIZER_PRESETS,
+} from './types/equalizer';
+export type {
+ EqualizerBand,
+ EqualizerPreset,
+ UseEqualizerOptions,
+ UseEqualizerReturn,
+} from './types/equalizer';
+
+// Subtitle styling
+export {
+ DEFAULT_SUBTITLE_STYLE,
+ SUBTITLE_PRESETS,
+} from './types/subtitleStyling';
+export type {
+ SubtitleStyle,
+ SubtitleStylePreset,
+ UseSubtitleStylingOptions,
+ UseSubtitleStylingReturn,
+} from './types/subtitleStyling';
+
// Services
-export { TrackingService, AdService } from './services';
+export { TrackingService, AdService, WebSocketSyncTransport } from './services';
// Types
export type {
@@ -240,6 +329,49 @@ export type {
createStatItem,
formatStatNumber,
formatStatDate,
+ // Sleep timer types
+ SleepTimerPreset,
+ SleepTimerConfig,
+ SleepTimerState,
+ SleepTimerControls,
+ UseSleepTimerOptions,
+ UseSleepTimerReturn,
+ DEFAULT_SLEEP_TIMER_PRESETS,
+ // Resume types
+ ResumeConfig,
+ ResumeData,
+ UseResumePositionReturn,
+ // Playlist persistence types
+ PlaylistPersistenceConfig,
+ PlaylistPersistenceData,
+ UsePlaylistPersistenceReturn,
+ // Media types
+ MediaState,
+ MediaControls as MediaControlsInterface,
+ UseMediaOptions,
+ UseMediaReturn,
+ // Playback history types
+ PlaybackHistoryEntry,
+ PlaybackHistoryConfig,
+ UsePlaybackHistoryReturn,
+ // Sync types
+ SyncEventType,
+ SyncEvent,
+ SyncEventData,
+ SyncPeer,
+ SyncConnectionState,
+ SyncRoomInfo,
+ SyncTransport,
+ // Pause ad types
+ PauseAd as PauseAdType,
+ PauseAdState,
+ UsePauseAdOptions,
+ UsePauseAdReturn,
+ // Rewarded ad types
+ RewardedAd as RewardedAdType,
+ RewardedAdState,
+ UseRewardedAdOptions,
+ UseRewardedAdReturn,
} from './types';
// Labels utilities
@@ -286,6 +418,8 @@ export type {
// Utilities
export { formatTime, formatDuration, parseTime, calculatePercentage, cn } from './utils';
+export { parseVTT, findCueAtTime, generateSpriteCues, type ThumbnailConfig, type ThumbnailCue } from './utils/thumbnails';
+export { ThumbnailPreview, type ThumbnailPreviewProps } from './components/controls/ProgressBar/ThumbnailPreview';
// Ad Event Bus (for external ad control)
export {
diff --git a/src/services/AdService.test.ts b/src/services/AdService.test.ts
new file mode 100644
index 0000000..590d100
--- /dev/null
+++ b/src/services/AdService.test.ts
@@ -0,0 +1,563 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { AdService } from './AdService';
+import { createMockAd, createMockAdBreak } from '@/test/helpers';
+import type { AdConfig, AdBreak } from '@/types/ads';
+
+function createAdConfig(overrides: Partial = {}): AdConfig {
+ return {
+ enabled: true,
+ skipAllowed: true,
+ defaultSkipAfter: 5,
+ adBreaks: [],
+ ...overrides,
+ };
+}
+
+describe('AdService', () => {
+ let service: AdService;
+
+ // ─── Construction ───────────────────────────────────────────────────
+
+ describe('constructor', () => {
+ it('creates an instance with the provided config', () => {
+ const config = createAdConfig();
+ service = new AdService(config);
+ expect(service).toBeInstanceOf(AdService);
+ });
+
+ it('creates an instance with minimal config', () => {
+ service = new AdService({ enabled: false });
+ expect(service).toBeInstanceOf(AdService);
+ });
+
+ it('creates an instance with ad breaks', () => {
+ const config = createAdConfig({
+ adBreaks: [createMockAdBreak()],
+ });
+ service = new AdService(config);
+ expect(service).toBeInstanceOf(AdService);
+ });
+ });
+
+ // ─── getAdBreaksForPosition ─────────────────────────────────────────
+
+ describe('getAdBreaksForPosition', () => {
+ it('returns pre-roll ad breaks', () => {
+ const preRoll = createMockAdBreak({ id: 'pre-1', position: 'pre-roll' });
+ const midRoll = createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 60 });
+ service = new AdService(createAdConfig({ adBreaks: [preRoll, midRoll] }));
+
+ const result = service.getAdBreaksForPosition('pre-roll');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('pre-1');
+ });
+
+ it('returns mid-roll ad breaks', () => {
+ const midRoll = createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 60 });
+ service = new AdService(createAdConfig({ adBreaks: [midRoll] }));
+
+ const result = service.getAdBreaksForPosition('mid-roll');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('mid-1');
+ });
+
+ it('returns post-roll ad breaks', () => {
+ const postRoll = createMockAdBreak({ id: 'post-1', position: 'post-roll' });
+ service = new AdService(createAdConfig({ adBreaks: [postRoll] }));
+
+ const result = service.getAdBreaksForPosition('post-roll');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('post-1');
+ });
+
+ it('returns multiple ad breaks for the same position', () => {
+ const preRoll1 = createMockAdBreak({ id: 'pre-1', position: 'pre-roll' });
+ const preRoll2 = createMockAdBreak({ id: 'pre-2', position: 'pre-roll' });
+ service = new AdService(createAdConfig({ adBreaks: [preRoll1, preRoll2] }));
+
+ const result = service.getAdBreaksForPosition('pre-roll');
+ expect(result).toHaveLength(2);
+ });
+
+ it('returns empty array when no ad breaks match the position', () => {
+ const preRoll = createMockAdBreak({ id: 'pre-1', position: 'pre-roll' });
+ service = new AdService(createAdConfig({ adBreaks: [preRoll] }));
+
+ const result = service.getAdBreaksForPosition('post-roll');
+ expect(result).toHaveLength(0);
+ });
+
+ it('returns empty array when ads are disabled', () => {
+ const preRoll = createMockAdBreak({ id: 'pre-1', position: 'pre-roll' });
+ service = new AdService(createAdConfig({ enabled: false, adBreaks: [preRoll] }));
+
+ const result = service.getAdBreaksForPosition('pre-roll');
+ expect(result).toHaveLength(0);
+ });
+
+ it('returns empty array when adBreaks is undefined', () => {
+ service = new AdService(createAdConfig({ adBreaks: undefined }));
+
+ const result = service.getAdBreaksForPosition('pre-roll');
+ expect(result).toHaveLength(0);
+ });
+
+ it('returns empty array when adBreaks is empty', () => {
+ service = new AdService(createAdConfig({ adBreaks: [] }));
+
+ const result = service.getAdBreaksForPosition('pre-roll');
+ expect(result).toHaveLength(0);
+ });
+ });
+
+ // ─── getMidRollAdBreaksAtTime ───────────────────────────────────────
+
+ describe('getMidRollAdBreaksAtTime', () => {
+ let midRoll: AdBreak;
+
+ beforeEach(() => {
+ midRoll = createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 60 });
+ service = new AdService(createAdConfig({ adBreaks: [midRoll] }));
+ });
+
+ it('returns mid-roll ad break when currentTime matches triggerTime exactly', () => {
+ const result = service.getMidRollAdBreaksAtTime(60);
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('mid-1');
+ });
+
+ it('returns mid-roll ad break when currentTime is within 1s after triggerTime', () => {
+ const result = service.getMidRollAdBreaksAtTime(60.5);
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('mid-1');
+ });
+
+ it('returns mid-roll ad break at the boundary (just under 1s)', () => {
+ const result = service.getMidRollAdBreaksAtTime(60.99);
+ expect(result).toHaveLength(1);
+ });
+
+ it('does not return mid-roll ad break when currentTime is at or beyond 1s after triggerTime', () => {
+ const result = service.getMidRollAdBreaksAtTime(61);
+ expect(result).toHaveLength(0);
+ });
+
+ it('does not return mid-roll ad break when currentTime is before triggerTime', () => {
+ const result = service.getMidRollAdBreaksAtTime(59.9);
+ expect(result).toHaveLength(0);
+ });
+
+ it('does not return mid-roll ad break without triggerTime', () => {
+ const noTrigger = createMockAdBreak({ id: 'mid-2', position: 'mid-roll' });
+ service = new AdService(createAdConfig({ adBreaks: [noTrigger] }));
+
+ const result = service.getMidRollAdBreaksAtTime(0);
+ expect(result).toHaveLength(0);
+ });
+
+ it('does not return already played mid-roll ad breaks', () => {
+ service.markAdBreakPlayed('mid-1');
+ const result = service.getMidRollAdBreaksAtTime(60);
+ expect(result).toHaveLength(0);
+ });
+
+ it('excludes non-mid-roll ad breaks', () => {
+ const preRoll = createMockAdBreak({ id: 'pre-1', position: 'pre-roll' });
+ service = new AdService(createAdConfig({ adBreaks: [preRoll, midRoll] }));
+
+ const result = service.getMidRollAdBreaksAtTime(0);
+ expect(result).toHaveLength(0);
+ });
+
+ it('returns empty array when ads are disabled', () => {
+ service = new AdService(createAdConfig({ enabled: false, adBreaks: [midRoll] }));
+
+ const result = service.getMidRollAdBreaksAtTime(60);
+ expect(result).toHaveLength(0);
+ });
+
+ it('returns empty array when adBreaks is undefined', () => {
+ service = new AdService(createAdConfig({ adBreaks: undefined }));
+
+ const result = service.getMidRollAdBreaksAtTime(60);
+ expect(result).toHaveLength(0);
+ });
+
+ it('returns multiple mid-roll ad breaks at the same trigger time', () => {
+ const midRoll2 = createMockAdBreak({ id: 'mid-2', position: 'mid-roll', triggerTime: 60 });
+ service = new AdService(createAdConfig({ adBreaks: [midRoll, midRoll2] }));
+
+ const result = service.getMidRollAdBreaksAtTime(60);
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ // ─── markAdBreakPlayed / resetPlayedAdBreaks ────────────────────────
+
+ describe('markAdBreakPlayed', () => {
+ it('marks an ad break as played so it is excluded from getMidRollAdBreaksAtTime', () => {
+ const midRoll = createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 60 });
+ service = new AdService(createAdConfig({ adBreaks: [midRoll] }));
+
+ service.markAdBreakPlayed('mid-1');
+ const result = service.getMidRollAdBreaksAtTime(60);
+ expect(result).toHaveLength(0);
+ });
+
+ it('does not affect other ad breaks when marking one as played', () => {
+ const midRoll1 = createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 60 });
+ const midRoll2 = createMockAdBreak({ id: 'mid-2', position: 'mid-roll', triggerTime: 120 });
+ service = new AdService(createAdConfig({ adBreaks: [midRoll1, midRoll2] }));
+
+ service.markAdBreakPlayed('mid-1');
+
+ expect(service.getMidRollAdBreaksAtTime(60)).toHaveLength(0);
+ expect(service.getMidRollAdBreaksAtTime(120)).toHaveLength(1);
+ });
+
+ it('handles marking the same ad break multiple times gracefully', () => {
+ const midRoll = createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 60 });
+ service = new AdService(createAdConfig({ adBreaks: [midRoll] }));
+
+ service.markAdBreakPlayed('mid-1');
+ service.markAdBreakPlayed('mid-1');
+
+ const result = service.getMidRollAdBreaksAtTime(60);
+ expect(result).toHaveLength(0);
+ });
+
+ it('handles marking a non-existent ad break id without error', () => {
+ service = new AdService(createAdConfig());
+ expect(() => service.markAdBreakPlayed('nonexistent')).not.toThrow();
+ });
+ });
+
+ describe('resetPlayedAdBreaks', () => {
+ it('resets played status so ad breaks are returned again', () => {
+ const midRoll = createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 60 });
+ service = new AdService(createAdConfig({ adBreaks: [midRoll] }));
+
+ service.markAdBreakPlayed('mid-1');
+ expect(service.getMidRollAdBreaksAtTime(60)).toHaveLength(0);
+
+ service.resetPlayedAdBreaks();
+ expect(service.getMidRollAdBreaksAtTime(60)).toHaveLength(1);
+ });
+
+ it('resets all played ad breaks at once', () => {
+ const midRoll1 = createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 60 });
+ const midRoll2 = createMockAdBreak({ id: 'mid-2', position: 'mid-roll', triggerTime: 120 });
+ service = new AdService(createAdConfig({ adBreaks: [midRoll1, midRoll2] }));
+
+ service.markAdBreakPlayed('mid-1');
+ service.markAdBreakPlayed('mid-2');
+ service.resetPlayedAdBreaks();
+
+ expect(service.getMidRollAdBreaksAtTime(60)).toHaveLength(1);
+ expect(service.getMidRollAdBreaksAtTime(120)).toHaveLength(1);
+ });
+
+ it('does nothing when no ad breaks have been played', () => {
+ service = new AdService(createAdConfig());
+ expect(() => service.resetPlayedAdBreaks()).not.toThrow();
+ });
+ });
+
+ // ─── trackAdEvent ──────────────────────────────────────────────────
+
+ describe('trackAdEvent', () => {
+ let fetchSpy: ReturnType;
+
+ beforeEach(() => {
+ fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response());
+ });
+
+ afterEach(() => {
+ fetchSpy.mockRestore();
+ });
+
+ it('fires a GET request to the tracking URL for the given event type', async () => {
+ const ad = createMockAd({
+ trackingUrls: { impression: 'https://track.example.com/impression' },
+ });
+ service = new AdService(createAdConfig());
+
+ await service.trackAdEvent(ad, 'impression');
+
+ expect(fetchSpy).toHaveBeenCalledWith('https://track.example.com/impression', {
+ method: 'GET',
+ mode: 'no-cors',
+ });
+ });
+
+ it('fires tracking for the start event type', async () => {
+ const ad = createMockAd({
+ trackingUrls: { start: 'https://track.example.com/start' },
+ });
+ service = new AdService(createAdConfig());
+
+ await service.trackAdEvent(ad, 'start');
+
+ expect(fetchSpy).toHaveBeenCalledWith('https://track.example.com/start', {
+ method: 'GET',
+ mode: 'no-cors',
+ });
+ });
+
+ it('fires tracking for the complete event type', async () => {
+ const ad = createMockAd({
+ trackingUrls: { complete: 'https://track.example.com/complete' },
+ });
+ service = new AdService(createAdConfig());
+
+ await service.trackAdEvent(ad, 'complete');
+
+ expect(fetchSpy).toHaveBeenCalledWith('https://track.example.com/complete', {
+ method: 'GET',
+ mode: 'no-cors',
+ });
+ });
+
+ it('does not fire fetch when tracking URL is missing for event type', async () => {
+ const ad = createMockAd({ trackingUrls: {} });
+ service = new AdService(createAdConfig());
+
+ await service.trackAdEvent(ad, 'impression');
+
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not fire fetch when trackingUrls is undefined', async () => {
+ const ad = createMockAd({ trackingUrls: undefined });
+ service = new AdService(createAdConfig());
+
+ await service.trackAdEvent(ad, 'impression');
+
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('logs an error when fetch fails', async () => {
+ fetchSpy.mockRejectedValueOnce(new Error('Network error'));
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const ad = createMockAd({
+ trackingUrls: { impression: 'https://track.example.com/impression' },
+ });
+ service = new AdService(createAdConfig());
+
+ await service.trackAdEvent(ad, 'impression');
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to track ad impression:',
+ expect.any(Error),
+ );
+ consoleSpy.mockRestore();
+ });
+
+ it('fires tracking for skip event', async () => {
+ const ad = createMockAd({
+ trackingUrls: { skip: 'https://track.example.com/skip' },
+ });
+ service = new AdService(createAdConfig());
+
+ await service.trackAdEvent(ad, 'skip');
+
+ expect(fetchSpy).toHaveBeenCalledWith('https://track.example.com/skip', {
+ method: 'GET',
+ mode: 'no-cors',
+ });
+ });
+
+ it('fires tracking for quartile events', async () => {
+ const ad = createMockAd({
+ trackingUrls: {
+ firstQuartile: 'https://track.example.com/q1',
+ midpoint: 'https://track.example.com/mid',
+ thirdQuartile: 'https://track.example.com/q3',
+ },
+ });
+ service = new AdService(createAdConfig());
+
+ await service.trackAdEvent(ad, 'firstQuartile');
+ expect(fetchSpy).toHaveBeenCalledWith('https://track.example.com/q1', expect.any(Object));
+
+ await service.trackAdEvent(ad, 'midpoint');
+ expect(fetchSpy).toHaveBeenCalledWith('https://track.example.com/mid', expect.any(Object));
+
+ await service.trackAdEvent(ad, 'thirdQuartile');
+ expect(fetchSpy).toHaveBeenCalledWith('https://track.example.com/q3', expect.any(Object));
+ });
+ });
+
+ // ─── isSkipAllowed ─────────────────────────────────────────────────
+
+ describe('isSkipAllowed', () => {
+ it('returns true when config skipAllowed is true and ad has skipAfterSeconds', () => {
+ service = new AdService(createAdConfig({ skipAllowed: true }));
+ const ad = createMockAd({ skipAfterSeconds: 5 });
+
+ expect(service.isSkipAllowed(ad)).toBe(true);
+ });
+
+ it('returns false when config skipAllowed is false', () => {
+ service = new AdService(createAdConfig({ skipAllowed: false }));
+ const ad = createMockAd({ skipAfterSeconds: 5 });
+
+ expect(service.isSkipAllowed(ad)).toBe(false);
+ });
+
+ it('returns false when ad skipAfterSeconds is null', () => {
+ service = new AdService(createAdConfig({ skipAllowed: true }));
+ const ad = createMockAd({ skipAfterSeconds: null });
+
+ expect(service.isSkipAllowed(ad)).toBe(false);
+ });
+
+ it('returns false when ad skipAfterSeconds is undefined', () => {
+ service = new AdService(createAdConfig({ skipAllowed: true }));
+ const ad = createMockAd({ skipAfterSeconds: undefined });
+
+ expect(service.isSkipAllowed(ad)).toBe(false);
+ });
+
+ it('returns true when skipAfterSeconds is 0', () => {
+ service = new AdService(createAdConfig({ skipAllowed: true }));
+ const ad = createMockAd({ skipAfterSeconds: 0 });
+
+ expect(service.isSkipAllowed(ad)).toBe(true);
+ });
+
+ it('returns false when config skipAllowed is undefined', () => {
+ service = new AdService(createAdConfig({ skipAllowed: undefined }));
+ const ad = createMockAd({ skipAfterSeconds: 5 });
+
+ expect(service.isSkipAllowed(ad)).toBe(false);
+ });
+ });
+
+ // ─── getSkipDelay ──────────────────────────────────────────────────
+
+ describe('getSkipDelay', () => {
+ it('returns ad skipAfterSeconds when set', () => {
+ service = new AdService(createAdConfig({ skipAllowed: true, defaultSkipAfter: 10 }));
+ const ad = createMockAd({ skipAfterSeconds: 3 });
+
+ expect(service.getSkipDelay(ad)).toBe(3);
+ });
+
+ it('returns config defaultSkipAfter when ad skipAfterSeconds is 0', () => {
+ service = new AdService(createAdConfig({ skipAllowed: true, defaultSkipAfter: 10 }));
+ const ad = createMockAd({ skipAfterSeconds: 0 });
+
+ // skipAfterSeconds is 0 which is falsy, so ?? falls through to defaultSkipAfter
+ expect(service.getSkipDelay(ad)).toBe(0);
+ });
+
+ it('returns null when skip is not allowed (skipAllowed false)', () => {
+ service = new AdService(createAdConfig({ skipAllowed: false }));
+ const ad = createMockAd({ skipAfterSeconds: 5 });
+
+ expect(service.getSkipDelay(ad)).toBeNull();
+ });
+
+ it('returns null when skip is not allowed (skipAfterSeconds null)', () => {
+ service = new AdService(createAdConfig({ skipAllowed: true }));
+ const ad = createMockAd({ skipAfterSeconds: null });
+
+ expect(service.getSkipDelay(ad)).toBeNull();
+ });
+
+ it('returns null when skip is not allowed (skipAfterSeconds undefined)', () => {
+ service = new AdService(createAdConfig({ skipAllowed: true }));
+ const ad = createMockAd({ skipAfterSeconds: undefined });
+
+ expect(service.getSkipDelay(ad)).toBeNull();
+ });
+
+ it('returns 5 as fallback when both ad skipAfterSeconds and defaultSkipAfter are undefined', () => {
+ service = new AdService(createAdConfig({ skipAllowed: true, defaultSkipAfter: undefined }));
+ // We need an ad where isSkipAllowed returns true but skipAfterSeconds is nullish.
+ // However, isSkipAllowed requires skipAfterSeconds to be non-null/non-undefined.
+ // So this path (fallback to 5) is only reachable if skipAfterSeconds is defined
+ // but resolves falsy via ??. Let's test with a value that passes isSkipAllowed.
+ // Actually, since isSkipAllowed checks for non-null and non-undefined,
+ // and getSkipDelay uses ??, the fallback chain only applies when skipAfterSeconds
+ // is a valid number. With skipAfterSeconds set to a number, ?? won't fall through.
+ // The fallback to 5 is technically unreachable in the current implementation,
+ // but let's still verify the contract with a valid scenario.
+ const ad = createMockAd({ skipAfterSeconds: 7 });
+ expect(service.getSkipDelay(ad)).toBe(7);
+ });
+
+ it('returns the defaultSkipAfter from config when ad has no specific value', () => {
+ // Since isSkipAllowed requires skipAfterSeconds !== null && !== undefined,
+ // the defaultSkipAfter is only used when skipAfterSeconds is falsy but defined (0).
+ // With skipAfterSeconds = 0, ?? does not fall through (0 is not nullish).
+ service = new AdService(createAdConfig({ skipAllowed: true, defaultSkipAfter: 8 }));
+ const ad = createMockAd({ skipAfterSeconds: 0 });
+ // 0 ?? 8 = 0 (0 is not null/undefined)
+ expect(service.getSkipDelay(ad)).toBe(0);
+ });
+ });
+
+ // ─── Edge Cases ────────────────────────────────────────────────────
+
+ describe('edge cases', () => {
+ it('handles config with no adBreaks array', () => {
+ service = new AdService({ enabled: true });
+
+ expect(service.getAdBreaksForPosition('pre-roll')).toEqual([]);
+ expect(service.getMidRollAdBreaksAtTime(0)).toEqual([]);
+ });
+
+ it('handles mark and reset cycle multiple times', () => {
+ const midRoll = createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 60 });
+ service = new AdService(createAdConfig({ adBreaks: [midRoll] }));
+
+ // First cycle
+ service.markAdBreakPlayed('mid-1');
+ expect(service.getMidRollAdBreaksAtTime(60)).toHaveLength(0);
+ service.resetPlayedAdBreaks();
+ expect(service.getMidRollAdBreaksAtTime(60)).toHaveLength(1);
+
+ // Second cycle
+ service.markAdBreakPlayed('mid-1');
+ expect(service.getMidRollAdBreaksAtTime(60)).toHaveLength(0);
+ service.resetPlayedAdBreaks();
+ expect(service.getMidRollAdBreaksAtTime(60)).toHaveLength(1);
+ });
+
+ it('getAdBreaksForPosition does not filter by played status', () => {
+ // Note: getAdBreaksForPosition does NOT check playedAdBreaks (only getMidRollAdBreaksAtTime does)
+ const preRoll = createMockAdBreak({ id: 'pre-1', position: 'pre-roll' });
+ service = new AdService(createAdConfig({ adBreaks: [preRoll] }));
+
+ service.markAdBreakPlayed('pre-1');
+ const result = service.getAdBreaksForPosition('pre-roll');
+ expect(result).toHaveLength(1);
+ });
+
+ it('handles ad with empty trackingUrls object', async () => {
+ const localFetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response());
+ const ad = createMockAd({ trackingUrls: {} });
+ service = new AdService(createAdConfig());
+
+ await service.trackAdEvent(ad, 'impression');
+ expect(localFetchSpy).not.toHaveBeenCalled();
+ localFetchSpy.mockRestore();
+ });
+
+ it('handles multiple ad breaks at different trigger times', () => {
+ const breaks = [
+ createMockAdBreak({ id: 'mid-1', position: 'mid-roll', triggerTime: 30 }),
+ createMockAdBreak({ id: 'mid-2', position: 'mid-roll', triggerTime: 60 }),
+ createMockAdBreak({ id: 'mid-3', position: 'mid-roll', triggerTime: 90 }),
+ ];
+ service = new AdService(createAdConfig({ adBreaks: breaks }));
+
+ expect(service.getMidRollAdBreaksAtTime(30)).toHaveLength(1);
+ expect(service.getMidRollAdBreaksAtTime(60)).toHaveLength(1);
+ expect(service.getMidRollAdBreaksAtTime(90)).toHaveLength(1);
+ expect(service.getMidRollAdBreaksAtTime(45)).toHaveLength(0);
+ });
+ });
+});
diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts
new file mode 100644
index 0000000..fb2f8ad
--- /dev/null
+++ b/src/services/SyncService.ts
@@ -0,0 +1,142 @@
+import type { SyncTransport, SyncEvent, SyncConnectionState } from '@/types/sync';
+
+/**
+ * Default WebSocket-based sync transport.
+ * Expects a WebSocket server that relays messages between room peers.
+ *
+ * Protocol:
+ * - Client sends: { action: 'join', roomId, peerId } to join a room
+ * - Client sends: { action: 'leave' } to leave
+ * - Client sends: { action: 'broadcast', event: SyncEvent } to broadcast to room
+ * - Server relays broadcast events to all other peers in the room
+ */
+export class WebSocketSyncTransport implements SyncTransport {
+ private ws: WebSocket | null = null;
+ private serverUrl: string;
+ private messageCallback: ((event: SyncEvent) => void) | null = null;
+ private connectionCallback: ((state: SyncConnectionState) => void) | null = null;
+ private state: SyncConnectionState = 'disconnected';
+ private reconnectAttempts = 0;
+ private maxReconnectAttempts = 5;
+ private reconnectTimeout: ReturnType | null = null;
+ private roomId: string | null = null;
+ private peerId: string | null = null;
+
+ constructor(serverUrl: string) {
+ this.serverUrl = serverUrl;
+ }
+
+ async connect(roomId: string, peerId: string): Promise {
+ this.roomId = roomId;
+ this.peerId = peerId;
+ this.reconnectAttempts = 0;
+
+ return new Promise((resolve, reject) => {
+ try {
+ this.setState('connecting');
+ this.ws = new WebSocket(this.serverUrl);
+
+ this.ws.onopen = () => {
+ this.setState('connected');
+ this.reconnectAttempts = 0;
+ // Join the room
+ this.ws?.send(JSON.stringify({
+ action: 'join',
+ roomId,
+ peerId,
+ }));
+ resolve();
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.event && this.messageCallback) {
+ this.messageCallback(data.event as SyncEvent);
+ }
+ } catch {
+ // Invalid message - ignore
+ }
+ };
+
+ this.ws.onclose = () => {
+ this.setState('disconnected');
+ this.attemptReconnect();
+ };
+
+ this.ws.onerror = () => {
+ this.setState('error');
+ reject(new Error('WebSocket connection failed'));
+ };
+ } catch (err) {
+ this.setState('error');
+ reject(err);
+ }
+ });
+ }
+
+ disconnect(): void {
+ if (this.reconnectTimeout) {
+ clearTimeout(this.reconnectTimeout);
+ this.reconnectTimeout = null;
+ }
+ this.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnect
+
+ if (this.ws) {
+ try {
+ this.ws.send(JSON.stringify({ action: 'leave' }));
+ } catch {
+ // Ignore
+ }
+ this.ws.close();
+ this.ws = null;
+ }
+
+ this.roomId = null;
+ this.peerId = null;
+ this.setState('disconnected');
+ }
+
+ send(event: SyncEvent): void {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify({
+ action: 'broadcast',
+ event,
+ }));
+ }
+ }
+
+ onMessage(callback: (event: SyncEvent) => void): void {
+ this.messageCallback = callback;
+ }
+
+ onConnectionChange(callback: (state: SyncConnectionState) => void): void {
+ this.connectionCallback = callback;
+ }
+
+ getConnectionState(): SyncConnectionState {
+ return this.state;
+ }
+
+ private setState(state: SyncConnectionState): void {
+ this.state = state;
+ this.connectionCallback?.(state);
+ }
+
+ private attemptReconnect(): void {
+ if (this.reconnectAttempts >= this.maxReconnectAttempts || !this.roomId || !this.peerId) {
+ return;
+ }
+
+ this.reconnectAttempts++;
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts - 1), 30000);
+
+ this.reconnectTimeout = setTimeout(() => {
+ if (this.roomId && this.peerId) {
+ this.connect(this.roomId, this.peerId).catch(() => {
+ // Reconnect failed, will retry via onclose
+ });
+ }
+ }, delay);
+ }
+}
diff --git a/src/services/TrackingService.test.ts b/src/services/TrackingService.test.ts
new file mode 100644
index 0000000..7db856c
--- /dev/null
+++ b/src/services/TrackingService.test.ts
@@ -0,0 +1,746 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { TrackingService } from './TrackingService';
+import { createMockTrackingConfig } from '@/test/helpers';
+import type { TrackingEvent, TrackingEventData } from '@/types/tracking';
+
+function defaultEventData(
+ overrides: Partial> = {},
+): Omit {
+ return {
+ currentTime: 10,
+ duration: 100,
+ ...overrides,
+ };
+}
+
+describe('TrackingService', () => {
+ let service: TrackingService;
+ let fetchSpy: ReturnType;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response());
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ fetchSpy.mockRestore();
+ });
+
+ // ─── Construction ───────────────────────────────────────────────────
+
+ describe('constructor', () => {
+ it('creates an instance with the provided config', () => {
+ service = new TrackingService(createMockTrackingConfig());
+ expect(service).toBeInstanceOf(TrackingService);
+ });
+
+ it('creates an instance with minimal config', () => {
+ service = new TrackingService({ enabled: false });
+ expect(service).toBeInstanceOf(TrackingService);
+ });
+
+ it('starts a batch timer when batchEvents and batchInterval are set', () => {
+ const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 5000 }),
+ );
+
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 5000);
+ setIntervalSpy.mockRestore();
+
+ // Clean up to avoid leaking timer
+ service.destroy();
+ });
+
+ it('does not start a batch timer when batchEvents is false', () => {
+ const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
+ service = new TrackingService(createMockTrackingConfig({ batchEvents: false }));
+
+ expect(setIntervalSpy).not.toHaveBeenCalled();
+ setIntervalSpy.mockRestore();
+ });
+ });
+
+ // ─── track ─────────────────────────────────────────────────────────
+
+ describe('track', () => {
+ it('sends an event immediately when batch mode is off', () => {
+ service = new TrackingService(createMockTrackingConfig());
+ service.track('play', defaultEventData());
+
+ // session_start + play = 2 fetch calls
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ const body = JSON.parse(fetchSpy.mock.calls[1][1]!.body as string);
+ expect(body.events).toHaveLength(1);
+ expect(body.events[0].type).toBe('play');
+ });
+
+ it('includes timestamp in the event', () => {
+ vi.setSystemTime(new Date('2026-01-15T10:00:00Z'));
+ service = new TrackingService(createMockTrackingConfig());
+ service.track('play', defaultEventData());
+
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string);
+ expect(body.events[0].timestamp).toBe(Date.now());
+ });
+
+ it('includes sessionId in event data', () => {
+ service = new TrackingService(createMockTrackingConfig());
+ service.track('play', defaultEventData());
+
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string);
+ expect(body.events[0].data.sessionId).toBeDefined();
+ expect(typeof body.events[0].data.sessionId).toBe('string');
+ });
+
+ it('includes sessionId at the top level of the request body', () => {
+ service = new TrackingService(createMockTrackingConfig());
+ service.track('play', defaultEventData());
+
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string);
+ expect(body.sessionId).toBeDefined();
+ });
+
+ it('includes event data fields in the tracked event', () => {
+ service = new TrackingService(createMockTrackingConfig());
+ service.track('play', defaultEventData({ currentTime: 42, duration: 200 }));
+
+ const body = JSON.parse(fetchSpy.mock.calls[1][1]!.body as string);
+ expect(body.events[0].data.currentTime).toBe(42);
+ expect(body.events[0].data.duration).toBe(200);
+ });
+
+ it('sends POST request with correct headers', () => {
+ service = new TrackingService(createMockTrackingConfig());
+ service.track('play', defaultEventData());
+
+ expect(fetchSpy).toHaveBeenCalledWith(
+ 'https://example.com/track',
+ expect.objectContaining({
+ method: 'POST',
+ headers: expect.objectContaining({
+ 'Content-Type': 'application/json',
+ }),
+ }),
+ );
+ });
+
+ it('includes custom headers from config', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ headers: { Authorization: 'Bearer test-token' } }),
+ );
+ service.track('play', defaultEventData());
+
+ expect(fetchSpy).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer test-token',
+ 'Content-Type': 'application/json',
+ }),
+ }),
+ );
+ });
+
+ it('does not send events when tracking is disabled', () => {
+ service = new TrackingService(createMockTrackingConfig({ enabled: false }));
+ service.track('play', defaultEventData());
+
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not send events when no endpoint is configured', () => {
+ service = new TrackingService(createMockTrackingConfig({ endpoint: undefined }));
+ service.track('play', defaultEventData());
+
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('logs an error when fetch fails', async () => {
+ fetchSpy.mockRejectedValue(new Error('Network error'));
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ service = new TrackingService(createMockTrackingConfig());
+
+ service.track('play', defaultEventData());
+ await vi.runAllTimersAsync();
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to send tracking events after retries:',
+ expect.any(Error),
+ );
+ consoleSpy.mockRestore();
+ warnSpy.mockRestore();
+ });
+ });
+
+ // ─── Event Type Filtering ──────────────────────────────────────────
+
+ describe('event type filtering', () => {
+ it('filters out disabled event types', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({
+ events: { play: true, pause: false, seek: true, complete: true, progress: true },
+ }),
+ );
+
+ // session_start was sent at construction = 1 fetch call
+ const callsAfterConstruct = fetchSpy.mock.calls.length;
+
+ service.track('pause', defaultEventData());
+ expect(fetchSpy).toHaveBeenCalledTimes(callsAfterConstruct); // no new call
+
+ service.track('play', defaultEventData());
+ expect(fetchSpy).toHaveBeenCalledTimes(callsAfterConstruct + 1);
+ });
+
+ it('sends events when events config is not provided (no filtering)', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ events: undefined }),
+ );
+
+ // session_start + play = 2
+ service.track('play', defaultEventData());
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('converts snake_case event types to camelCase for config lookup', () => {
+ // chapter_change -> chapterChange
+ service = new TrackingService(
+ createMockTrackingConfig({
+ events: { chapterChange: false, play: true, pause: true, seek: true, complete: true, progress: true },
+ }),
+ );
+
+ const callsAfterConstruct = fetchSpy.mock.calls.length;
+
+ service.track('chapter_change', defaultEventData());
+ // chapter_change is disabled, so no new fetch call
+ expect(fetchSpy).toHaveBeenCalledTimes(callsAfterConstruct);
+ });
+
+ it('allows track_change events when trackChange is enabled', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({
+ events: { trackChange: true, play: true, pause: true, seek: true, complete: true, progress: true },
+ }),
+ );
+
+ // session_start + track_change = 2
+ service.track('track_change', defaultEventData());
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('blocks ad_start when adStart is disabled', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({
+ events: { adStart: false, play: true, pause: true, seek: true, complete: true, progress: true },
+ }),
+ );
+
+ const callsAfterConstruct = fetchSpy.mock.calls.length;
+
+ service.track('ad_start', defaultEventData());
+ // ad_start is disabled, so no new fetch call
+ expect(fetchSpy).toHaveBeenCalledTimes(callsAfterConstruct);
+ });
+ });
+
+ // ─── Disabled State ────────────────────────────────────────────────
+
+ describe('disabled state', () => {
+ it('does not track when initially disabled', () => {
+ service = new TrackingService(createMockTrackingConfig({ enabled: false }));
+ service.track('play', defaultEventData());
+
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('stops tracking after setEnabled(false)', () => {
+ service = new TrackingService(createMockTrackingConfig());
+ // session_start was already sent at construction
+ const callsAfterConstruct = fetchSpy.mock.calls.length;
+
+ service.setEnabled(false);
+ service.track('play', defaultEventData());
+
+ // No new fetch calls after disabling
+ expect(fetchSpy).toHaveBeenCalledTimes(callsAfterConstruct);
+ });
+
+ it('resumes tracking after setEnabled(true)', () => {
+ service = new TrackingService(createMockTrackingConfig({ enabled: false }));
+ service.setEnabled(true);
+ service.track('play', defaultEventData());
+
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // ─── Batch Mode ────────────────────────────────────────────────────
+
+ describe('batch mode', () => {
+ it('queues events instead of sending immediately', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 5000, batchSize: 10 }),
+ );
+
+ service.track('play', defaultEventData());
+ service.track('pause', defaultEventData());
+
+ expect(fetchSpy).not.toHaveBeenCalled();
+
+ service.destroy();
+ });
+
+ it('flushes the queue when batchSize is reached', () => {
+ // batchSize=4 to account for session_start being queued at construction
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 60000, batchSize: 4 }),
+ );
+
+ // Queue: [session_start, play, pause] = 3, not yet at batchSize 4
+ service.track('play', defaultEventData());
+ service.track('pause', defaultEventData());
+ expect(fetchSpy).not.toHaveBeenCalled();
+
+ // Queue: [session_start, play, pause, seek] = 4, reaches batchSize -> flush
+ service.track('seek', defaultEventData());
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string);
+ expect(body.events).toHaveLength(4);
+
+ service.destroy();
+ });
+
+ it('defaults batchSize to 10 when not specified', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 60000 }),
+ );
+
+ // session_start is already queued (1 event), so 8 more to reach 9
+ for (let i = 0; i < 8; i++) {
+ service.track('play', defaultEventData());
+ }
+ expect(fetchSpy).not.toHaveBeenCalled();
+
+ // 10th event triggers flush
+ service.track('play', defaultEventData());
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ service.destroy();
+ });
+
+ it('flushes on batch timer interval', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 5000, batchSize: 100 }),
+ );
+
+ service.track('play', defaultEventData());
+ service.track('pause', defaultEventData());
+
+ vi.advanceTimersByTime(5000);
+
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string);
+ // session_start + play + pause = 3 events
+ expect(body.events).toHaveLength(3);
+
+ service.destroy();
+ });
+
+ it('does not send when queue is empty on timer flush', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 5000 }),
+ );
+
+ // First timer flush sends the queued session_start
+ vi.advanceTimersByTime(5000);
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ // Second timer flush: queue is now truly empty
+ vi.advanceTimersByTime(5000);
+ expect(fetchSpy).toHaveBeenCalledTimes(1); // no additional call
+
+ service.destroy();
+ });
+ });
+
+ // ─── flush ─────────────────────────────────────────────────────────
+
+ describe('flush', () => {
+ it('sends all queued events to the endpoint', async () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 60000, batchSize: 100 }),
+ );
+
+ service.track('play', defaultEventData());
+ service.track('pause', defaultEventData());
+
+ await service.flush();
+
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string);
+ // session_start + play + pause = 3 events
+ expect(body.events).toHaveLength(3);
+ expect(body.events[0].type).toBe('session_start');
+ expect(body.events[1].type).toBe('play');
+ expect(body.events[2].type).toBe('pause');
+
+ service.destroy();
+ });
+
+ it('clears the queue after flushing', async () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 60000, batchSize: 100 }),
+ );
+
+ service.track('play', defaultEventData());
+ await service.flush();
+
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ // Second flush should not send anything
+ await service.flush();
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ service.destroy();
+ });
+
+ it('does nothing when queue is empty', async () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 60000 }),
+ );
+
+ // First flush sends the queued session_start
+ await service.flush();
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ // Second flush: queue is now truly empty
+ await service.flush();
+ expect(fetchSpy).toHaveBeenCalledTimes(1); // no additional call
+
+ service.destroy();
+ });
+ });
+
+ // ─── trackProgress ────────────────────────────────────────────────
+
+ describe('trackProgress', () => {
+ it('fires milestone at 25%', () => {
+ service = new TrackingService(createMockTrackingConfig());
+
+ service.trackProgress(25, 100);
+
+ // session_start + progress = 2
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ const body = JSON.parse(fetchSpy.mock.calls[1][1]!.body as string);
+ expect(body.events[0].type).toBe('progress');
+ expect(body.events[0].data.percentage).toBe(25);
+ });
+
+ it('fires milestone at 50%', () => {
+ service = new TrackingService(createMockTrackingConfig());
+
+ service.trackProgress(50, 100);
+
+ const calls = fetchSpy.mock.calls;
+ // session_start + 25% + 50% milestones = 3
+ expect(calls.length).toBe(3);
+ });
+
+ it('fires milestone at 75%', () => {
+ service = new TrackingService(createMockTrackingConfig());
+
+ service.trackProgress(75, 100);
+
+ // session_start + 25% + 50% + 75% = 4
+ expect(fetchSpy).toHaveBeenCalledTimes(4);
+ });
+
+ it('fires each milestone only once', () => {
+ service = new TrackingService(createMockTrackingConfig());
+
+ // session_start + 25% progress = 2
+ service.trackProgress(25, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+
+ service.trackProgress(26, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(2); // No new call
+
+ service.trackProgress(30, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(2); // Still no new call
+ });
+
+ it('fires next milestone when progress advances', () => {
+ service = new TrackingService(createMockTrackingConfig());
+
+ // session_start + 25% = 2
+ service.trackProgress(25, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+
+ // + 50% = 3
+ service.trackProgress(50, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
+ });
+
+ it('does not fire when percentage is below the first interval', () => {
+ service = new TrackingService(createMockTrackingConfig());
+
+ // session_start was sent at construction
+ const callsAfterConstruct = fetchSpy.mock.calls.length;
+
+ service.trackProgress(10, 100);
+ // No progress milestone fired
+ expect(fetchSpy).toHaveBeenCalledTimes(callsAfterConstruct);
+ });
+
+ it('uses custom progressIntervals from config', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ progressIntervals: [10, 50, 90] }),
+ );
+
+ // session_start + 10% = 2
+ service.trackProgress(10, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+
+ service.trackProgress(25, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(2); // 25 is not a milestone
+
+ // + 50% = 3
+ service.trackProgress(50, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
+ });
+
+ it('does not track progress when disabled', () => {
+ service = new TrackingService(createMockTrackingConfig({ enabled: false }));
+
+ service.trackProgress(50, 100);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not track progress when duration is 0', () => {
+ service = new TrackingService(createMockTrackingConfig());
+
+ // session_start was sent at construction
+ const callsAfterConstruct = fetchSpy.mock.calls.length;
+
+ service.trackProgress(10, 0);
+ // No progress event tracked
+ expect(fetchSpy).toHaveBeenCalledTimes(callsAfterConstruct);
+ });
+
+ it('includes currentTime and duration in progress event data', () => {
+ service = new TrackingService(createMockTrackingConfig());
+
+ service.trackProgress(75, 300);
+
+ // Index 1 is the first progress event (index 0 is session_start)
+ const body = JSON.parse(fetchSpy.mock.calls[1][1]!.body as string);
+ expect(body.events[0].data.currentTime).toBe(75);
+ expect(body.events[0].data.duration).toBe(300);
+ });
+
+ it('resets milestones so they can fire again', () => {
+ service = new TrackingService(createMockTrackingConfig());
+
+ // session_start + 25% = 2
+ service.trackProgress(25, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+
+ service.resetProgress();
+
+ // + 25% again = 3
+ service.trackProgress(25, 100);
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ // ─── setSessionId ──────────────────────────────────────────────────
+
+ describe('setSessionId', () => {
+ it('updates the session ID used in subsequent events', () => {
+ service = new TrackingService(createMockTrackingConfig());
+ service.setSessionId('custom-session-123');
+
+ service.track('play', defaultEventData());
+
+ // Index 1 is the play event (index 0 is session_start with the old ID)
+ const body = JSON.parse(fetchSpy.mock.calls[1][1]!.body as string);
+ expect(body.events[0].data.sessionId).toBe('custom-session-123');
+ expect(body.sessionId).toBe('custom-session-123');
+ });
+
+ it('uses auto-generated session ID before setSessionId is called', () => {
+ service = new TrackingService(createMockTrackingConfig());
+ service.track('play', defaultEventData());
+
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string);
+ expect(body.sessionId).toBeDefined();
+ expect(body.sessionId).toMatch(/^\d+-[a-z0-9]+$/);
+ });
+ });
+
+ // ─── transformEvent ────────────────────────────────────────────────
+
+ describe('transformEvent', () => {
+ it('applies transformEvent callback to events before sending', () => {
+ const transformEvent = vi.fn((event: TrackingEvent) => ({
+ ...event,
+ data: { ...event.data, metadata: { custom: true } },
+ }));
+
+ service = new TrackingService(createMockTrackingConfig({ transformEvent }));
+ service.track('play', defaultEventData());
+
+ // Called for session_start + play = 2
+ expect(transformEvent).toHaveBeenCalledTimes(2);
+ const body = JSON.parse(fetchSpy.mock.calls[1][1]!.body as string);
+ expect(body.events[0].data.metadata).toEqual({ custom: true });
+ });
+
+ it('drops the event when transformEvent returns null', () => {
+ const transformEvent = vi.fn(() => null);
+
+ service = new TrackingService(createMockTrackingConfig({ transformEvent }));
+ service.track('play', defaultEventData());
+
+ // Called for session_start + play = 2 (both dropped)
+ expect(transformEvent).toHaveBeenCalledTimes(2);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('receives the fully constructed event with type and timestamp', () => {
+ const transformEvent = vi.fn((event: TrackingEvent) => event);
+
+ service = new TrackingService(createMockTrackingConfig({ transformEvent }));
+ service.track('play', defaultEventData());
+
+ // calls[0] is session_start, calls[1] is play
+ const receivedEvent = transformEvent.mock.calls[1][0];
+ expect(receivedEvent.type).toBe('play');
+ expect(receivedEvent.timestamp).toBeDefined();
+ expect(receivedEvent.data.sessionId).toBeDefined();
+ });
+ });
+
+ // ─── onTrack callback ─────────────────────────────────────────────
+
+ describe('onTrack callback', () => {
+ it('calls onTrack for each tracked event', () => {
+ const onTrack = vi.fn();
+ service = new TrackingService(createMockTrackingConfig({ onTrack }));
+
+ service.track('play', defaultEventData());
+
+ // session_start + play = 2
+ expect(onTrack).toHaveBeenCalledTimes(2);
+ expect(onTrack).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'play' }),
+ );
+ });
+
+ it('does not call onTrack when tracking is disabled', () => {
+ const onTrack = vi.fn();
+ service = new TrackingService(createMockTrackingConfig({ enabled: false, onTrack }));
+
+ service.track('play', defaultEventData());
+ expect(onTrack).not.toHaveBeenCalled();
+ });
+
+ it('does not call onTrack when event type is filtered out', () => {
+ const onTrack = vi.fn();
+ service = new TrackingService(
+ createMockTrackingConfig({
+ onTrack,
+ events: { play: false, pause: true, seek: true, complete: true, progress: true },
+ }),
+ );
+
+ // session_start triggers onTrack once at construction
+ const callsAfterConstruct = onTrack.mock.calls.length;
+
+ service.track('play', defaultEventData());
+ // play is filtered out, so no additional call
+ expect(onTrack).toHaveBeenCalledTimes(callsAfterConstruct);
+ });
+
+ it('calls onTrack even when transformEvent modifies the event', () => {
+ const onTrack = vi.fn();
+ const transformEvent = (event: TrackingEvent) => ({
+ ...event,
+ data: { ...event.data, metadata: { transformed: true } },
+ });
+
+ service = new TrackingService(createMockTrackingConfig({ onTrack, transformEvent }));
+ service.track('play', defaultEventData());
+
+ // session_start + play = 2
+ expect(onTrack).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not call onTrack when transformEvent returns null', () => {
+ const onTrack = vi.fn();
+ const transformEvent = () => null;
+
+ service = new TrackingService(createMockTrackingConfig({ onTrack, transformEvent }));
+ service.track('play', defaultEventData());
+
+ expect(onTrack).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── destroy ───────────────────────────────────────────────────────
+
+ describe('destroy', () => {
+ it('clears the batch timer', () => {
+ const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 5000 }),
+ );
+
+ service.destroy();
+
+ expect(clearIntervalSpy).toHaveBeenCalled();
+ clearIntervalSpy.mockRestore();
+ });
+
+ it('flushes remaining events on destroy', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 60000, batchSize: 100 }),
+ );
+
+ service.track('play', defaultEventData());
+ service.track('pause', defaultEventData());
+
+ service.destroy();
+
+ // destroy sends session_end via sendBeacon or flush
+ // Queue contains: session_start + play + pause + session_end = 4
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string);
+ expect(body.events).toHaveLength(4);
+ });
+
+ it('does not error when destroying a service without batch timer', () => {
+ service = new TrackingService(createMockTrackingConfig({ batchEvents: false }));
+ expect(() => service.destroy()).not.toThrow();
+ });
+
+ it('sends session events on destroy even without explicit tracking', () => {
+ service = new TrackingService(
+ createMockTrackingConfig({ batchEvents: true, batchInterval: 5000 }),
+ );
+
+ // destroy flushes session_start (queued at construction) + session_end
+ service.destroy();
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string);
+ expect(body.events).toHaveLength(2);
+ expect(body.events[0].type).toBe('session_start');
+ expect(body.events[1].type).toBe('session_end');
+ });
+ });
+});
diff --git a/src/services/TrackingService.ts b/src/services/TrackingService.ts
index 5f5bd14..8fbdd97 100644
--- a/src/services/TrackingService.ts
+++ b/src/services/TrackingService.ts
@@ -1,19 +1,65 @@
import type { TrackingConfig, TrackingEvent, TrackingEventType, TrackingEventData } from '@/types/tracking';
+const OFFLINE_QUEUE_KEY = 'fairu_tracking_queue';
+const DEFAULT_MAX_RETRIES = 3;
+const DEFAULT_REQUEST_TIMEOUT = 5000;
+const DEFAULT_HEARTBEAT_INTERVAL = 30000;
+const DEFAULT_OFFLINE_QUEUE_MAX_SIZE = 100;
+
export class TrackingService {
private config: TrackingConfig;
private sessionId: string;
private eventQueue: TrackingEvent[] = [];
private batchTimer: ReturnType | null = null;
private progressMilestones: Set = new Set();
+ private heartbeatTimer: ReturnType | null = null;
+ private lastKnownPosition: number = 0;
+ private lastKnownDuration: number = 0;
+ private isOnline: boolean = true;
+
+ private readonly boundBeforeUnload: () => void;
+ private readonly boundPageHide: (e: PageTransitionEvent) => void;
+ private readonly boundOnline: () => void;
+ private readonly boundOffline: () => void;
constructor(config: TrackingConfig) {
this.config = config;
this.sessionId = this.generateSessionId();
+ // Bind event handlers
+ this.boundBeforeUnload = this.handleUnload.bind(this);
+ this.boundPageHide = this.handlePageHide.bind(this);
+ this.boundOnline = this.handleOnline.bind(this);
+ this.boundOffline = this.handleOffline.bind(this);
+
+ // Set up batch timer
if (config.batchEvents && config.batchInterval) {
this.startBatchTimer();
}
+
+ // Set up page unload listeners
+ if (typeof window !== 'undefined') {
+ window.addEventListener('beforeunload', this.boundBeforeUnload);
+ window.addEventListener('pagehide', this.boundPageHide);
+ }
+
+ // Set up online/offline detection
+ if (config.offlineQueue && typeof navigator !== 'undefined') {
+ this.isOnline = navigator.onLine;
+ window.addEventListener('online', this.boundOnline);
+ window.addEventListener('offline', this.boundOffline);
+ }
+
+ // Emit session_start
+ this.track('session_start', {
+ currentTime: 0,
+ duration: 0,
+ });
+
+ // Flush any events stored offline from a previous session
+ if (config.offlineQueue && this.isOnline) {
+ this.flushOfflineQueue();
+ }
}
private generateSessionId(): string {
@@ -26,6 +72,154 @@ export class TrackingService {
}, this.config.batchInterval);
}
+ /**
+ * Handle beforeunload: flush remaining events via sendBeacon
+ */
+ private handleUnload(): void {
+ this.flushWithBeacon();
+ }
+
+ /**
+ * Handle pagehide: flush remaining events via sendBeacon
+ */
+ private handlePageHide(e: PageTransitionEvent): void {
+ if (e.persisted) return; // Page is being cached (bfcache), not truly unloading
+ this.flushWithBeacon();
+ }
+
+ /**
+ * Flush events using navigator.sendBeacon (for page unload scenarios)
+ */
+ private flushWithBeacon(): void {
+ const events = [...this.eventQueue];
+ this.eventQueue = [];
+
+ if (events.length === 0 && !this.config.endpoint) return;
+
+ // Add session_end event
+ events.push({
+ type: 'session_end',
+ timestamp: Date.now(),
+ data: {
+ currentTime: this.lastKnownPosition,
+ duration: this.lastKnownDuration,
+ sessionId: this.sessionId,
+ },
+ });
+
+ if (!this.config.endpoint) return;
+
+ const payload = JSON.stringify({
+ events,
+ sessionId: this.sessionId,
+ });
+
+ if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
+ const blob = new Blob([payload], { type: 'application/json' });
+ navigator.sendBeacon(this.config.endpoint, blob);
+ }
+ }
+
+ /**
+ * Handle coming back online
+ */
+ private handleOnline(): void {
+ this.isOnline = true;
+ this.flushOfflineQueue();
+ }
+
+ /**
+ * Handle going offline
+ */
+ private handleOffline(): void {
+ this.isOnline = false;
+ }
+
+ /**
+ * Store events in localStorage for offline persistence
+ */
+ private storeOffline(events: TrackingEvent[]): void {
+ if (typeof localStorage === 'undefined') return;
+
+ try {
+ const maxSize = this.config.offlineQueueMaxSize ?? DEFAULT_OFFLINE_QUEUE_MAX_SIZE;
+ const existing = this.getOfflineQueue();
+ const combined = [...existing, ...events];
+
+ // Trim to max size, keeping the newest events
+ const trimmed = combined.slice(-maxSize);
+
+ localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(trimmed));
+ } catch {
+ console.warn('Failed to store tracking events offline');
+ }
+ }
+
+ /**
+ * Retrieve events from localStorage
+ */
+ private getOfflineQueue(): TrackingEvent[] {
+ if (typeof localStorage === 'undefined') return [];
+
+ try {
+ const stored = localStorage.getItem(OFFLINE_QUEUE_KEY);
+ if (!stored) return [];
+ return JSON.parse(stored) as TrackingEvent[];
+ } catch {
+ return [];
+ }
+ }
+
+ /**
+ * Clear the offline queue
+ */
+ private clearOfflineQueue(): void {
+ if (typeof localStorage === 'undefined') return;
+
+ try {
+ localStorage.removeItem(OFFLINE_QUEUE_KEY);
+ } catch {
+ // Silently ignore
+ }
+ }
+
+ /**
+ * Flush events stored in localStorage
+ */
+ private async flushOfflineQueue(): Promise {
+ const offlineEvents = this.getOfflineQueue();
+ if (offlineEvents.length === 0) return;
+
+ this.clearOfflineQueue();
+ await this.send(offlineEvents);
+ }
+
+ /**
+ * Start heartbeat interval
+ */
+ private startHeartbeat(): void {
+ if (this.heartbeatTimer) return; // Already running
+
+ const interval = this.config.heartbeat ?? DEFAULT_HEARTBEAT_INTERVAL;
+
+ this.heartbeatTimer = setInterval(() => {
+ this.track('heartbeat', {
+ currentTime: this.lastKnownPosition,
+ duration: this.lastKnownDuration,
+ });
+ }, interval);
+ }
+
+ /**
+ * Stop heartbeat interval
+ */
+ private stopHeartbeat(): void {
+ if (this.heartbeatTimer) {
+ clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = null;
+ }
+ }
+
/**
* Track an event
*/
@@ -34,7 +228,7 @@ export class TrackingService {
// Check if event type is enabled
const eventKey = type.replace(/_([a-z])/g, (_, l) => l.toUpperCase()) as keyof NonNullable;
- if (this.config.events && !this.config.events[eventKey]) return;
+ if (this.config.events && this.config.events[eventKey] === false) return;
const event: TrackingEvent = {
type,
@@ -45,6 +239,14 @@ export class TrackingService {
},
};
+ // Update last known position for heartbeat/unload events
+ if (data.currentTime !== undefined) {
+ this.lastKnownPosition = data.currentTime;
+ }
+ if (data.duration !== undefined) {
+ this.lastKnownDuration = data.duration;
+ }
+
// Transform event if transformer provided
if (this.config.transformEvent) {
const transformed = this.config.transformEvent(event);
@@ -55,6 +257,15 @@ export class TrackingService {
// Call onTrack callback
this.config.onTrack?.(event);
+ // Manage heartbeat lifecycle based on play/pause events
+ if (this.config.heartbeat) {
+ if (type === 'play') {
+ this.startHeartbeat();
+ } else if (type === 'pause') {
+ this.stopHeartbeat();
+ }
+ }
+
if (this.config.batchEvents) {
this.eventQueue.push(event);
if (this.eventQueue.length >= (this.config.batchSize || 10)) {
@@ -105,28 +316,75 @@ export class TrackingService {
}
/**
- * Send events to endpoint
+ * Send events to endpoint with retry logic, timeout, and offline support
*/
private async send(events: TrackingEvent[]): Promise {
if (!this.config.endpoint || events.length === 0) return;
- try {
- await fetch(this.config.endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...this.config.headers,
- },
- body: JSON.stringify({
- events,
- sessionId: this.sessionId,
- }),
- });
- } catch (error) {
- console.error('Failed to send tracking events:', error);
+ // If offline and offline queue is enabled, store for later
+ if (this.config.offlineQueue && !this.isOnline) {
+ this.storeOffline(events);
+ return;
+ }
+
+ const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
+ const timeout = this.config.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
+
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ try {
+ await fetch(this.config.endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...this.config.headers,
+ },
+ body: JSON.stringify({
+ events,
+ sessionId: this.sessionId,
+ }),
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+ return; // Success, exit retry loop
+ } catch (error) {
+ clearTimeout(timeoutId);
+ throw error;
+ }
+ } catch (error) {
+ if (attempt < maxRetries) {
+ // Exponential backoff: 1s, 2s, 4s
+ const delay = Math.pow(2, attempt) * 1000;
+ await this.sleep(delay);
+ } else {
+ // Final failure
+ console.error('Failed to send tracking events after retries:', error);
+
+ if (this.config.batchEvents) {
+ // Store failed events back into the queue
+ this.eventQueue.push(...events);
+ } else if (this.config.offlineQueue) {
+ // Store in offline queue as fallback
+ this.storeOffline(events);
+ } else {
+ console.warn('Tracking events dropped after all retry attempts');
+ }
+ }
+ }
}
}
+ /**
+ * Sleep utility for retry backoff
+ */
+ private sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
/**
* Set tracking enabled state
*/
@@ -145,9 +403,55 @@ export class TrackingService {
* Destroy service
*/
destroy() {
+ // Stop heartbeat
+ this.stopHeartbeat();
+
+ // Clear batch timer
if (this.batchTimer) {
clearInterval(this.batchTimer);
+ this.batchTimer = null;
+ }
+
+ // Emit session_end event
+ if (this.config.enabled) {
+ const sessionEndEvent: TrackingEvent = {
+ type: 'session_end',
+ timestamp: Date.now(),
+ data: {
+ currentTime: this.lastKnownPosition,
+ duration: this.lastKnownDuration,
+ sessionId: this.sessionId,
+ },
+ };
+
+ // Try to send via beacon if available, otherwise add to queue and flush
+ if (this.config.endpoint && typeof navigator !== 'undefined' && navigator.sendBeacon) {
+ const allEvents = [...this.eventQueue, sessionEndEvent];
+ this.eventQueue = [];
+
+ const payload = JSON.stringify({
+ events: allEvents,
+ sessionId: this.sessionId,
+ });
+ const blob = new Blob([payload], { type: 'application/json' });
+ navigator.sendBeacon(this.config.endpoint, blob);
+ } else {
+ this.eventQueue.push(sessionEndEvent);
+ this.flush();
+ }
+ } else {
+ this.flush();
+ }
+
+ // Remove event listeners
+ if (typeof window !== 'undefined') {
+ window.removeEventListener('beforeunload', this.boundBeforeUnload);
+ window.removeEventListener('pagehide', this.boundPageHide);
+ }
+
+ if (this.config.offlineQueue && typeof window !== 'undefined') {
+ window.removeEventListener('online', this.boundOnline);
+ window.removeEventListener('offline', this.boundOffline);
}
- this.flush();
}
}
diff --git a/src/services/index.ts b/src/services/index.ts
index ce15ced..e45b400 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -1,2 +1,3 @@
export { TrackingService } from './TrackingService';
export { AdService } from './AdService';
+export { WebSocketSyncTransport } from './SyncService';
diff --git a/src/test/helpers.tsx b/src/test/helpers.tsx
new file mode 100644
index 0000000..6b13772
--- /dev/null
+++ b/src/test/helpers.tsx
@@ -0,0 +1,258 @@
+import React, { type ReactNode } from 'react';
+import { render, type RenderOptions } from '@testing-library/react';
+import { PlayerProvider } from '@/context/PlayerContext';
+import { TrackingProvider } from '@/context/TrackingContext';
+import { LabelsProvider } from '@/context/LabelsContext';
+import type { Track, PlayerConfig } from '@/types/player';
+import type { TrackingConfig } from '@/types/tracking';
+import type { VideoTrack, OverlayAd, InfoCard } from '@/types/video';
+import type { Ad, AdBreak } from '@/types/ads';
+
+// ─── Mock Data Factories ─────────────────────────────────────────────
+
+export function createMockTrack(overrides: Partial = {}): Track {
+ return {
+ id: 'track-1',
+ src: 'https://example.com/audio.mp3',
+ title: 'Test Track',
+ artist: 'Test Artist',
+ album: 'Test Album',
+ artwork: 'https://example.com/cover.jpg',
+ duration: 180,
+ ...overrides,
+ };
+}
+
+export function createMockVideoTrack(overrides: Partial = {}): VideoTrack {
+ return {
+ id: 'video-1',
+ src: 'https://example.com/video.mp4',
+ title: 'Test Video',
+ artist: 'Test Creator',
+ poster: 'https://example.com/poster.jpg',
+ duration: 300,
+ ...overrides,
+ };
+}
+
+export function createMockPlaylist(count = 3): Track[] {
+ return Array.from({ length: count }, (_, i) =>
+ createMockTrack({
+ id: `track-${i + 1}`,
+ title: `Track ${i + 1}`,
+ artist: `Artist ${i + 1}`,
+ src: `https://example.com/track-${i + 1}.mp3`,
+ })
+ );
+}
+
+export function createMockVideoPlaylist(count = 3): VideoTrack[] {
+ return Array.from({ length: count }, (_, i) =>
+ createMockVideoTrack({
+ id: `video-${i + 1}`,
+ title: `Video ${i + 1}`,
+ src: `https://example.com/video-${i + 1}.mp4`,
+ })
+ );
+}
+
+export function createMockAd(overrides: Partial = {}): Ad {
+ return {
+ id: 'ad-1',
+ src: 'https://example.com/ad.mp4',
+ duration: 15,
+ skipAfterSeconds: 5,
+ clickThroughUrl: 'https://example.com/click',
+ ...overrides,
+ };
+}
+
+export function createMockAdBreak(overrides: Partial = {}): AdBreak {
+ return {
+ id: 'break-1',
+ position: 'pre-roll',
+ ads: [createMockAd()],
+ played: false,
+ ...overrides,
+ };
+}
+
+export function createMockOverlayAd(overrides: Partial = {}): OverlayAd {
+ return {
+ id: 'overlay-1',
+ imageUrl: 'https://example.com/banner.jpg',
+ clickThroughUrl: 'https://example.com/click',
+ displayAt: 10,
+ duration: 15,
+ position: 'bottom',
+ closeable: true,
+ ...overrides,
+ };
+}
+
+export function createMockInfoCard(overrides: Partial = {}): InfoCard {
+ return {
+ id: 'card-1',
+ type: 'product',
+ title: 'Test Product',
+ description: 'A test product description',
+ thumbnail: 'https://example.com/thumb.jpg',
+ url: 'https://example.com/product',
+ displayAt: 20,
+ duration: 10,
+ position: 'top-right',
+ ...overrides,
+ };
+}
+
+export function createMockChapters() {
+ return [
+ { id: 'ch-1', title: 'Intro', startTime: 0, endTime: 30 },
+ { id: 'ch-2', title: 'Main Content', startTime: 30, endTime: 120 },
+ { id: 'ch-3', title: 'Outro', startTime: 120, endTime: 180 },
+ ];
+}
+
+export function createMockMarkers() {
+ return [
+ { id: 'm-1', time: 15, title: 'Highlight 1' },
+ { id: 'm-2', time: 60, title: 'Highlight 2' },
+ { id: 'm-3', time: 120, title: 'Highlight 3' },
+ ];
+}
+
+// ─── Mock Video Element ──────────────────────────────────────────────
+
+export function createMockVideoElement(overrides: Partial = {}): HTMLVideoElement {
+ const el = document.createElement('video');
+
+ // Add common properties
+ Object.defineProperty(el, 'duration', { writable: true, value: overrides.duration ?? 300 });
+ Object.defineProperty(el, 'currentTime', { writable: true, value: overrides.currentTime ?? 0 });
+ Object.defineProperty(el, 'volume', { writable: true, value: overrides.volume ?? 1 });
+ Object.defineProperty(el, 'muted', { writable: true, value: overrides.muted ?? false });
+ Object.defineProperty(el, 'paused', { writable: true, value: overrides.paused ?? true });
+ Object.defineProperty(el, 'playbackRate', { writable: true, value: overrides.playbackRate ?? 1 });
+ Object.defineProperty(el, 'readyState', { writable: true, value: overrides.readyState ?? 4 });
+
+ return el;
+}
+
+// ─── Mock HLS Instance ──────────────────────────────────────────────
+
+export function createMockHlsInstance(): Record {
+ return {
+ loadSource: vi.fn(),
+ attachMedia: vi.fn(),
+ detachMedia: vi.fn(),
+ destroy: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ levels: [
+ { height: 360, width: 640, bitrate: 800000, name: '360p' },
+ { height: 720, width: 1280, bitrate: 2500000, name: '720p' },
+ { height: 1080, width: 1920, bitrate: 5000000, name: '1080p' },
+ ],
+ currentLevel: -1,
+ nextLevel: -1,
+ autoLevelEnabled: true,
+ subtitleTracks: [],
+ subtitleTrack: -1,
+ };
+}
+
+// ─── Config Factories ────────────────────────────────────────────────
+
+export function createMockPlayerConfig(overrides: Partial = {}): PlayerConfig {
+ return {
+ track: createMockTrack(),
+ volume: 1,
+ muted: false,
+ autoPlay: false,
+ skipForwardSeconds: 30,
+ skipBackwardSeconds: 10,
+ repeat: 'none',
+ shuffle: false,
+ ...overrides,
+ };
+}
+
+export function createMockTrackingConfig(overrides: Partial = {}): TrackingConfig {
+ return {
+ enabled: true,
+ endpoint: 'https://example.com/track',
+ events: {
+ play: true,
+ pause: true,
+ seek: true,
+ complete: true,
+ progress: true,
+ chapterChange: true,
+ trackChange: true,
+ adStart: true,
+ adComplete: true,
+ adSkip: true,
+ error: true,
+ },
+ progressIntervals: [25, 50, 75],
+ batchEvents: false,
+ ...overrides,
+ };
+}
+
+// ─── Render Helpers ──────────────────────────────────────────────────
+
+interface RenderWithProvidersOptions extends Omit {
+ playerConfig?: Partial;
+ trackingConfig?: Partial;
+ labels?: Record;
+}
+
+export function renderWithProviders(
+ ui: React.ReactElement,
+ options: RenderWithProvidersOptions = {}
+) {
+ const { playerConfig, trackingConfig, labels, ...renderOptions } = options;
+
+ const config = createMockPlayerConfig(playerConfig);
+
+ function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+
+ return render(ui, { wrapper: Wrapper, ...renderOptions });
+}
+
+// ─── Event Helpers ───────────────────────────────────────────────────
+
+export function fireMediaEvent(element: HTMLMediaElement, event: string, detail?: Record) {
+ const evt = new Event(event, { bubbles: true });
+ if (detail) {
+ Object.assign(evt, detail);
+ }
+ element.dispatchEvent(evt);
+}
+
+export function simulateTimeUpdate(element: HTMLMediaElement, time: number) {
+ Object.defineProperty(element, 'currentTime', { writable: true, value: time });
+ fireMediaEvent(element, 'timeupdate');
+}
+
+export function simulateLoadedMetadata(element: HTMLMediaElement, duration: number) {
+ Object.defineProperty(element, 'duration', { writable: true, value: duration });
+ fireMediaEvent(element, 'loadedmetadata');
+}
+
+// ─── Async Helpers ───────────────────────────────────────────────────
+
+export function waitForNextTick() {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
diff --git a/src/test/setup.ts b/src/test/setup.ts
index e716abf..aafa79f 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -28,6 +28,17 @@ Object.defineProperty(window, 'matchMedia', {
disconnect() {}
};
+// Mock IntersectionObserver
+(globalThis as typeof globalThis & { IntersectionObserver: typeof IntersectionObserver }).IntersectionObserver = class IntersectionObserver {
+ readonly root = null;
+ readonly rootMargin = '0px';
+ readonly thresholds = [0];
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ takeRecords() { return []; }
+} as unknown as typeof IntersectionObserver;
+
// Mock HTMLMediaElement methods
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
configurable: true,
@@ -46,3 +57,55 @@ Object.defineProperty(HTMLMediaElement.prototype, 'load', {
writable: true,
value: () => {},
});
+
+// Mock Fullscreen API
+Object.defineProperty(document, 'fullscreenElement', {
+ writable: true,
+ value: null,
+});
+
+Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', {
+ configurable: true,
+ writable: true,
+ value: () => Promise.resolve(),
+});
+
+Object.defineProperty(document, 'exitFullscreen', {
+ configurable: true,
+ writable: true,
+ value: () => Promise.resolve(),
+});
+
+// Mock Picture-in-Picture API
+Object.defineProperty(document, 'pictureInPictureElement', {
+ writable: true,
+ value: null,
+});
+
+Object.defineProperty(HTMLVideoElement.prototype, 'requestPictureInPicture', {
+ configurable: true,
+ writable: true,
+ value: () => Promise.resolve(document.createElement('div')),
+});
+
+Object.defineProperty(document, 'exitPictureInPicture', {
+ configurable: true,
+ writable: true,
+ value: () => Promise.resolve(),
+});
+
+Object.defineProperty(document, 'pictureInPictureEnabled', {
+ writable: true,
+ value: true,
+});
+
+// Mock navigator.mediaSession
+Object.defineProperty(navigator, 'mediaSession', {
+ writable: true,
+ value: {
+ metadata: null,
+ playbackState: 'none',
+ setActionHandler: vi.fn(),
+ setPositionState: vi.fn(),
+ },
+});
diff --git a/src/types/abloop.ts b/src/types/abloop.ts
new file mode 100644
index 0000000..d195848
--- /dev/null
+++ b/src/types/abloop.ts
@@ -0,0 +1 @@
+export type { ABLoopState, ABLoopControls, UseABLoopOptions, UseABLoopReturn } from '@/hooks/useABLoop';
diff --git a/src/types/equalizer.ts b/src/types/equalizer.ts
new file mode 100644
index 0000000..cffd239
--- /dev/null
+++ b/src/types/equalizer.ts
@@ -0,0 +1,67 @@
+export interface EqualizerBand {
+ /** Frequency in Hz */
+ frequency: number;
+ /** Gain in dB (-12 to +12) */
+ gain: number;
+ /** Filter type */
+ type: BiquadFilterType;
+ /** Q factor (quality) */
+ Q: number;
+}
+
+export interface EqualizerPreset {
+ name: string;
+ label: string;
+ bands: number[]; // Gain values for each band, in order
+}
+
+export const DEFAULT_BANDS: EqualizerBand[] = [
+ { frequency: 60, gain: 0, type: 'lowshelf', Q: 1 },
+ { frequency: 230, gain: 0, type: 'peaking', Q: 1 },
+ { frequency: 910, gain: 0, type: 'peaking', Q: 1 },
+ { frequency: 4000, gain: 0, type: 'peaking', Q: 1 },
+ { frequency: 14000, gain: 0, type: 'highshelf', Q: 1 },
+];
+
+export const EQUALIZER_PRESETS: EqualizerPreset[] = [
+ { name: 'flat', label: 'Flat', bands: [0, 0, 0, 0, 0] },
+ { name: 'podcast', label: 'Podcast', bands: [-2, 1, 4, 3, 1] },
+ { name: 'music', label: 'Music', bands: [3, 1, 0, 2, 4] },
+ { name: 'bass-boost', label: 'Bass Boost', bands: [6, 4, 0, 0, 0] },
+ { name: 'treble-boost', label: 'Treble Boost', bands: [0, 0, 0, 4, 6] },
+ { name: 'voice-boost', label: 'Voice Boost', bands: [-2, 0, 5, 4, 0] },
+];
+
+export interface UseEqualizerOptions {
+ /** Ref to the audio or video element */
+ mediaRef: React.RefObject;
+ /** Whether the equalizer is enabled. Default: false */
+ enabled?: boolean;
+ /** Initial preset name. Default: 'flat' */
+ initialPreset?: string;
+ /** Whether to persist settings to localStorage. Default: true */
+ persist?: boolean;
+ /** localStorage key. Default: 'fairu_equalizer' */
+ storageKey?: string;
+}
+
+export interface UseEqualizerReturn {
+ /** Current band settings */
+ bands: EqualizerBand[];
+ /** Set gain for a specific band by index */
+ setBandGain: (index: number, gain: number) => void;
+ /** Apply a preset by name */
+ applyPreset: (presetName: string) => void;
+ /** Reset all bands to flat */
+ reset: () => void;
+ /** Whether the equalizer is connected */
+ isConnected: boolean;
+ /** Whether the equalizer is enabled */
+ enabled: boolean;
+ /** Toggle enabled state */
+ setEnabled: (enabled: boolean) => void;
+ /** Available presets */
+ presets: EqualizerPreset[];
+ /** Current preset name (null if custom) */
+ currentPreset: string | null;
+}
diff --git a/src/types/history.ts b/src/types/history.ts
new file mode 100644
index 0000000..9c32dfc
--- /dev/null
+++ b/src/types/history.ts
@@ -0,0 +1,52 @@
+export interface PlaybackHistoryEntry {
+ /** Track ID */
+ trackId: string;
+ /** Track title (for display without needing to look up track) */
+ title?: string;
+ /** Track artist */
+ artist?: string;
+ /** Track artwork URL */
+ artwork?: string;
+ /** Last playback position in seconds */
+ lastPosition: number;
+ /** Track duration in seconds */
+ duration: number;
+ /** Progress as percentage 0-100 */
+ progress: number;
+ /** Whether the track was completed (>= 95%) */
+ completed: boolean;
+ /** Timestamp of last play */
+ lastPlayedAt: number;
+ /** Number of times played */
+ playCount: number;
+}
+
+export interface PlaybackHistoryConfig {
+ /** Whether history tracking is enabled. Default: true */
+ enabled?: boolean;
+ /** Maximum number of entries to keep. Default: 100 */
+ maxEntries?: number;
+ /** Number of days before entries expire. Default: 90 */
+ expiryDays?: number;
+ /** localStorage key. Default: 'fairu_history' */
+ storageKey?: string;
+}
+
+export interface UsePlaybackHistoryReturn {
+ /** Get all history entries, sorted by lastPlayedAt descending */
+ getHistory: () => PlaybackHistoryEntry[];
+ /** Get entries that are in progress (not completed) */
+ getResumeList: () => PlaybackHistoryEntry[];
+ /** Record/update a playback entry */
+ recordPlay: (entry: Omit) => void;
+ /** Check if a track has been played before */
+ isPlayed: (trackId: string) => boolean;
+ /** Get a specific entry by track ID */
+ getEntry: (trackId: string) => PlaybackHistoryEntry | null;
+ /** Remove a specific entry */
+ removeEntry: (trackId: string) => void;
+ /** Clear all history */
+ clearHistory: () => void;
+ /** Number of entries in history */
+ count: number;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 84b91e8..6606394 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -9,3 +9,13 @@ export * from './stats';
export * from './logo';
export * from './podcast';
export * from './markers';
+export * from './sleepTimer';
+export * from './resume';
+export * from './abloop';
+export * from './playlistPersistence';
+export * from './history';
+export * from './subtitleStyling';
+export * from './equalizer';
+export * from './sync';
+export * from './pauseAd';
+export * from './rewardedAd';
diff --git a/src/types/media.ts b/src/types/media.ts
index e649288..b8c2260 100644
--- a/src/types/media.ts
+++ b/src/types/media.ts
@@ -20,6 +20,8 @@ export interface MediaState {
volume: number;
playbackRate: number;
error: Error | null;
+ retryCount: number;
+ isRetrying: boolean;
}
/**
@@ -37,6 +39,7 @@ export interface MediaControls {
setVolume: (volume: number) => void;
toggleMute: () => void;
setPlaybackRate: (rate: number) => void;
+ retry: () => Promise;
}
/**
@@ -85,4 +88,6 @@ export const initialMediaState: MediaState = {
volume: 1,
playbackRate: 1,
error: null,
+ retryCount: 0,
+ isRetrying: false,
};
diff --git a/src/types/pauseAd.ts b/src/types/pauseAd.ts
new file mode 100644
index 0000000..dd518f5
--- /dev/null
+++ b/src/types/pauseAd.ts
@@ -0,0 +1,54 @@
+export interface PauseAd {
+ /** Unique identifier */
+ id: string;
+ /** Image URL for the ad */
+ imageUrl: string;
+ /** Click-through URL (optional) */
+ clickThroughUrl?: string;
+ /** Alt text for accessibility */
+ altText?: string;
+ /** Title text overlaid on the image (optional) */
+ title?: string;
+ /** Description text (optional) */
+ description?: string;
+ /** Minimum pause duration in seconds before showing the ad. Default: 0 (immediate) */
+ minPauseDuration?: number;
+ /** Tracking URLs */
+ trackingUrls?: {
+ impression?: string;
+ click?: string;
+ close?: string;
+ };
+}
+
+export interface PauseAdState {
+ /** Whether a pause ad is currently visible */
+ isVisible: boolean;
+ /** The currently displayed pause ad */
+ currentAd: PauseAd | null;
+ /** How long the video has been paused (seconds) */
+ pauseDuration: number;
+}
+
+export interface UsePauseAdOptions {
+ /** The pause ad to display */
+ ad?: PauseAd;
+ /** Whether the video is currently paused */
+ isPaused: boolean;
+ /** Whether the video is playing (needed to distinguish initial state from paused) */
+ isPlaying: boolean;
+ /** Whether pause ads are enabled. Default: true */
+ enabled?: boolean;
+ /** Callback when the ad is shown */
+ onShow?: (ad: PauseAd) => void;
+ /** Callback when the ad is hidden (user resumed) */
+ onHide?: (ad: PauseAd) => void;
+ /** Callback when the ad is clicked */
+ onClick?: (ad: PauseAd) => void;
+}
+
+export interface UsePauseAdReturn {
+ state: PauseAdState;
+ /** Dismiss the ad manually */
+ dismiss: () => void;
+}
diff --git a/src/types/playlistPersistence.ts b/src/types/playlistPersistence.ts
new file mode 100644
index 0000000..ceb33fc
--- /dev/null
+++ b/src/types/playlistPersistence.ts
@@ -0,0 +1,36 @@
+export interface PlaylistPersistenceConfig {
+ /** Unique key to identify this playlist. Used as localStorage key suffix. */
+ playlistId: string;
+ /** Whether persistence is enabled. Default: true */
+ enabled?: boolean;
+ /** How many days before the saved data expires. Default: 30 */
+ expiryDays?: number;
+ /** Debounce interval in ms for saving changes. Default: 1000 */
+ saveDebounce?: number;
+}
+
+export interface PlaylistPersistenceData {
+ /** The playlist ID */
+ playlistId: string;
+ /** Current track index */
+ currentIndex: number;
+ /** Whether shuffle is enabled */
+ shuffle: boolean;
+ /** Repeat mode */
+ repeat: 'none' | 'one' | 'all';
+ /** Ordered track IDs (to detect if playlist changed) */
+ trackIds: string[];
+ /** Timestamp when this was saved */
+ timestamp: number;
+}
+
+export interface UsePlaylistPersistenceReturn {
+ /** Restore saved playlist state. Returns the saved data or null. */
+ restore: () => PlaylistPersistenceData | null;
+ /** Save the current playlist state */
+ save: (data: Omit) => void;
+ /** Clear saved state for this playlist */
+ clear: () => void;
+ /** Whether there is saved state available */
+ hasSavedState: boolean;
+}
diff --git a/src/types/resume.ts b/src/types/resume.ts
new file mode 100644
index 0000000..74a1b3b
--- /dev/null
+++ b/src/types/resume.ts
@@ -0,0 +1,49 @@
+/**
+ * Resume position persistence types
+ */
+
+/**
+ * Configuration for the useResumePosition hook
+ */
+export interface ResumeConfig {
+ /** Unique identifier for the media track */
+ trackId: string;
+ /** Reference to the media element to seek on resume */
+ mediaRef: React.RefObject;
+ /** Whether resume functionality is enabled (default: true) */
+ enabled?: boolean;
+ /** Minimum seconds played before saving position (default: 10) */
+ threshold?: number;
+ /** How often to save position in milliseconds (default: 5000) */
+ saveInterval?: number;
+ /** Auto-expire old entries after this many days (default: 30) */
+ expiryDays?: number;
+ /** Callback when resuming from a saved position */
+ onResume?: (position: number) => void;
+}
+
+/**
+ * Data structure stored in localStorage for resume position
+ */
+export interface ResumeData {
+ /** Saved playback position in seconds */
+ position: number;
+ /** Timestamp when the position was saved (ms since epoch) */
+ timestamp: number;
+ /** Total duration of the media in seconds */
+ duration: number;
+ /** Unique identifier for the media track */
+ trackId: string;
+}
+
+/**
+ * Return type for useResumePosition hook
+ */
+export interface UseResumePositionReturn {
+ /** The saved position in seconds, or null if none */
+ savedPosition: number | null;
+ /** Clear the saved position for the current track */
+ clearPosition: () => void;
+ /** Whether a saved position exists for the current track */
+ hasSavedPosition: boolean;
+}
diff --git a/src/types/rewardedAd.ts b/src/types/rewardedAd.ts
new file mode 100644
index 0000000..612f173
--- /dev/null
+++ b/src/types/rewardedAd.ts
@@ -0,0 +1,66 @@
+export interface RewardedAd {
+ /** Unique identifier */
+ id: string;
+ /** Video ad source URL */
+ src: string;
+ /** Ad duration in seconds */
+ duration: number;
+ /** Ad title (e.g., "Watch to unlock premium episode") */
+ title?: string;
+ /** Description of the reward */
+ rewardDescription?: string;
+ /** Poster image */
+ poster?: string;
+ /** Click-through URL */
+ clickThroughUrl?: string;
+ /** Tracking URLs */
+ trackingUrls?: {
+ impression?: string;
+ start?: string;
+ complete?: string;
+ click?: string;
+ quartile25?: string;
+ quartile50?: string;
+ quartile75?: string;
+ };
+}
+
+export interface RewardedAdState {
+ /** Whether the rewarded ad UI is showing */
+ isShowing: boolean;
+ /** Whether the ad video is currently playing */
+ isPlaying: boolean;
+ /** Whether the ad has been completed and reward earned */
+ isRewarded: boolean;
+ /** Current playback progress in seconds */
+ progress: number;
+ /** Ad duration */
+ duration: number;
+ /** Progress percentage 0-100 */
+ percentage: number;
+ /** The current ad being displayed */
+ currentAd: RewardedAd | null;
+}
+
+export interface UseRewardedAdOptions {
+ /** The rewarded ad configuration */
+ ad?: RewardedAd;
+ /** Called when the user earns the reward (ad completed) */
+ onReward?: (ad: RewardedAd) => void;
+ /** Called when the ad starts playing */
+ onStart?: (ad: RewardedAd) => void;
+ /** Called when the user closes without completing */
+ onClose?: (ad: RewardedAd, completed: boolean) => void;
+ /** Called on error */
+ onError?: (error: Error, ad: RewardedAd) => void;
+}
+
+export interface UseRewardedAdReturn {
+ state: RewardedAdState;
+ /** Show the rewarded ad prompt */
+ show: () => void;
+ /** Close/dismiss the rewarded ad */
+ close: () => void;
+ /** Whether an ad is available */
+ isAvailable: boolean;
+}
diff --git a/src/types/sleepTimer.ts b/src/types/sleepTimer.ts
new file mode 100644
index 0000000..fa82f3f
--- /dev/null
+++ b/src/types/sleepTimer.ts
@@ -0,0 +1,76 @@
+/**
+ * Sleep timer types
+ */
+
+/** Preset duration option for the sleep timer */
+export interface SleepTimerPreset {
+ /** Duration in minutes, or 'endOfTrack' for end of current track */
+ value: number | 'endOfTrack';
+ /** Display label */
+ label: string;
+}
+
+/** Configuration for the sleep timer */
+export interface SleepTimerConfig {
+ /** Preset duration options (in minutes) */
+ presets?: SleepTimerPreset[];
+ /** Whether to fade out volume in the last 30 seconds before pausing */
+ fadeOut?: boolean;
+ /** Duration of the fade out in seconds (default: 30) */
+ fadeOutDuration?: number;
+}
+
+/** State of the sleep timer */
+export interface SleepTimerState {
+ /** Whether the timer is currently active */
+ isActive: boolean;
+ /** Remaining time in seconds */
+ remainingTime: number;
+ /** The selected duration in minutes, or 'endOfTrack' */
+ selectedDuration: number | 'endOfTrack' | null;
+ /** Whether the timer is currently fading out volume */
+ isFadingOut: boolean;
+}
+
+/** Controls for the sleep timer */
+export interface SleepTimerControls {
+ /** Start the timer with a duration in minutes, or 'endOfTrack' */
+ startTimer: (duration: number | 'endOfTrack') => void;
+ /** Stop/cancel the timer */
+ stopTimer: () => void;
+ /** Extend the timer by additional minutes */
+ extendTimer: (minutes: number) => void;
+}
+
+/** Options for the useSleepTimer hook */
+export interface UseSleepTimerOptions {
+ /** Reference to the media element to pause when timer ends */
+ mediaRef: React.RefObject;
+ /** Callback when the timer ends */
+ onTimerEnd?: () => void;
+ /** Whether to fade out volume in the last 30 seconds */
+ fadeOut?: boolean;
+ /** Duration of the fade out in seconds (default: 30) */
+ fadeOutDuration?: number;
+ /** Current time of the media (needed for 'endOfTrack' mode) */
+ currentTime?: number;
+ /** Duration of the current track (needed for 'endOfTrack' mode) */
+ duration?: number;
+}
+
+/** Return type for the useSleepTimer hook */
+export interface UseSleepTimerReturn {
+ state: SleepTimerState;
+ controls: SleepTimerControls;
+}
+
+/** Default preset durations */
+export const DEFAULT_SLEEP_TIMER_PRESETS: SleepTimerPreset[] = [
+ { value: 5, label: '5 min' },
+ { value: 10, label: '10 min' },
+ { value: 15, label: '15 min' },
+ { value: 30, label: '30 min' },
+ { value: 45, label: '45 min' },
+ { value: 60, label: '60 min' },
+ { value: 'endOfTrack', label: 'End of track' },
+];
diff --git a/src/types/subtitleStyling.ts b/src/types/subtitleStyling.ts
new file mode 100644
index 0000000..f1d5369
--- /dev/null
+++ b/src/types/subtitleStyling.ts
@@ -0,0 +1,103 @@
+export interface SubtitleStyle {
+ /** Font size in pixels. Default: 16 */
+ fontSize: number;
+ /** Font family. Default: 'inherit' */
+ fontFamily: string;
+ /** Text color. Default: '#ffffff' */
+ textColor: string;
+ /** Background color (without opacity). Default: '#000000' */
+ backgroundColor: string;
+ /** Background opacity 0-1. Default: 0.75 */
+ backgroundOpacity: number;
+ /** Position: 'top' or 'bottom'. Default: 'bottom' */
+ position: 'top' | 'bottom';
+ /** Text shadow for readability. Default: 'none' */
+ textShadow: string;
+}
+
+export interface SubtitleStylePreset {
+ name: string;
+ label: string;
+ style: SubtitleStyle;
+}
+
+export const DEFAULT_SUBTITLE_STYLE: SubtitleStyle = {
+ fontSize: 16,
+ fontFamily: 'inherit',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ backgroundOpacity: 0.75,
+ position: 'bottom',
+ textShadow: 'none',
+};
+
+export const SUBTITLE_PRESETS: SubtitleStylePreset[] = [
+ {
+ name: 'default',
+ label: 'Default',
+ style: { ...DEFAULT_SUBTITLE_STYLE },
+ },
+ {
+ name: 'high-contrast',
+ label: 'High Contrast',
+ style: {
+ fontSize: 18,
+ fontFamily: 'inherit',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ backgroundOpacity: 1,
+ position: 'bottom',
+ textShadow: '2px 2px 4px rgba(0,0,0,0.9)',
+ },
+ },
+ {
+ name: 'yellow-on-black',
+ label: 'Yellow on Black',
+ style: {
+ fontSize: 18,
+ fontFamily: 'inherit',
+ textColor: '#ffff00',
+ backgroundColor: '#000000',
+ backgroundOpacity: 0.85,
+ position: 'bottom',
+ textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
+ },
+ },
+ {
+ name: 'transparent',
+ label: 'No Background',
+ style: {
+ fontSize: 18,
+ fontFamily: 'inherit',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ backgroundOpacity: 0,
+ position: 'bottom',
+ textShadow: '2px 2px 4px rgba(0,0,0,0.9), -1px -1px 2px rgba(0,0,0,0.5)',
+ },
+ },
+];
+
+export interface UseSubtitleStylingOptions {
+ /** Initial style overrides */
+ initialStyle?: Partial;
+ /** Whether to persist to localStorage. Default: true */
+ persist?: boolean;
+ /** localStorage key. Default: 'fairu_subtitle_style' */
+ storageKey?: string;
+}
+
+export interface UseSubtitleStylingReturn {
+ /** Current subtitle style */
+ style: SubtitleStyle;
+ /** Update one or more style properties */
+ updateStyle: (updates: Partial) => void;
+ /** Apply a preset */
+ applyPreset: (presetName: string) => void;
+ /** Reset to default */
+ resetStyle: () => void;
+ /** CSS properties object to apply to the subtitle container */
+ cssProperties: React.CSSProperties;
+ /** Available presets */
+ presets: SubtitleStylePreset[];
+}
diff --git a/src/types/sync.ts b/src/types/sync.ts
new file mode 100644
index 0000000..3729ad7
--- /dev/null
+++ b/src/types/sync.ts
@@ -0,0 +1,99 @@
+/**
+ * Sync event types sent between peers
+ */
+export type SyncEventType = 'play' | 'pause' | 'seek' | 'playbackRate' | 'join' | 'leave' | 'state';
+
+export interface SyncEvent {
+ type: SyncEventType;
+ /** Timestamp when this event was created (for latency compensation) */
+ timestamp: number;
+ /** The peer who originated this event */
+ peerId: string;
+ /** Event payload */
+ data: SyncEventData;
+}
+
+export type SyncEventData =
+ | { type: 'play'; currentTime: number }
+ | { type: 'pause'; currentTime: number }
+ | { type: 'seek'; currentTime: number }
+ | { type: 'playbackRate'; rate: number }
+ | { type: 'join'; peerId: string; isLeader: boolean }
+ | { type: 'leave'; peerId: string }
+ | { type: 'state'; currentTime: number; isPlaying: boolean; playbackRate: number };
+
+export interface SyncPeer {
+ id: string;
+ isLeader: boolean;
+ joinedAt: number;
+}
+
+export type SyncConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
+
+export interface SyncRoomInfo {
+ roomId: string;
+ peers: SyncPeer[];
+ leaderId: string | null;
+}
+
+/**
+ * Transport interface — users can implement their own transport
+ * (WebSocket, WebRTC, Firebase, etc.)
+ */
+export interface SyncTransport {
+ /** Connect to a room */
+ connect(roomId: string, peerId: string): Promise;
+ /** Disconnect from the room */
+ disconnect(): void;
+ /** Send a sync event to all peers in the room */
+ send(event: SyncEvent): void;
+ /** Register a listener for incoming sync events */
+ onMessage(callback: (event: SyncEvent) => void): void;
+ /** Register a listener for connection state changes */
+ onConnectionChange(callback: (state: SyncConnectionState) => void): void;
+ /** Get current connection state */
+ getConnectionState(): SyncConnectionState;
+}
+
+export interface UseSyncPlaybackOptions {
+ /** Transport implementation. If not provided, uses default WebSocket transport. */
+ transport?: SyncTransport;
+ /** WebSocket server URL (only used if no custom transport is provided) */
+ serverUrl?: string;
+ /** Whether this peer should be the leader. Default: false (auto-detect) */
+ isLeader?: boolean;
+ /** Callbacks */
+ onPeerJoin?: (peer: SyncPeer) => void;
+ onPeerLeave?: (peerId: string) => void;
+ onConnectionChange?: (state: SyncConnectionState) => void;
+ onError?: (error: Error) => void;
+}
+
+export interface UseSyncPlaybackReturn {
+ /** Create a new room and return the room ID */
+ createRoom: () => Promise;
+ /** Join an existing room */
+ joinRoom: (roomId: string) => Promise;
+ /** Leave the current room */
+ leaveRoom: () => void;
+ /** Send a play event to all peers */
+ syncPlay: (currentTime: number) => void;
+ /** Send a pause event to all peers */
+ syncPause: (currentTime: number) => void;
+ /** Send a seek event to all peers */
+ syncSeek: (currentTime: number) => void;
+ /** Send a playback rate change to all peers */
+ syncPlaybackRate: (rate: number) => void;
+ /** Request full state from the leader */
+ requestState: () => void;
+ /** Current connection state */
+ connectionState: SyncConnectionState;
+ /** Current room info */
+ room: SyncRoomInfo | null;
+ /** This peer's ID */
+ peerId: string;
+ /** Whether this peer is the leader */
+ isLeader: boolean;
+ /** List of peers in the room */
+ peers: SyncPeer[];
+}
diff --git a/src/types/tracking.ts b/src/types/tracking.ts
index 36ee0c6..dfddcf9 100644
--- a/src/types/tracking.ts
+++ b/src/types/tracking.ts
@@ -14,7 +14,10 @@ export type TrackingEventType =
| 'tab_hidden'
| 'tab_visible'
| 'return_ad_triggered'
- | 'error';
+ | 'error'
+ | 'heartbeat'
+ | 'session_start'
+ | 'session_end';
export interface TrackingEventData {
currentTime: number;
@@ -55,6 +58,9 @@ export interface TrackingEventsConfig {
tabVisible?: boolean;
returnAdTriggered?: boolean;
error?: boolean;
+ heartbeat?: boolean;
+ sessionStart?: boolean;
+ sessionEnd?: boolean;
}
export interface TrackingConfig {
@@ -68,6 +74,16 @@ export interface TrackingConfig {
headers?: Record;
transformEvent?: (event: TrackingEvent) => TrackingEvent | null;
onTrack?: (event: TrackingEvent) => void;
+ /** Heartbeat interval in ms. Disabled by default. Set to enable (e.g. 30000). */
+ heartbeat?: number;
+ /** Max retry attempts for failed send requests (default: 3) */
+ maxRetries?: number;
+ /** Request timeout in ms (default: 5000) */
+ requestTimeout?: number;
+ /** Enable offline queue with localStorage (default: false) */
+ offlineQueue?: boolean;
+ /** Max number of events stored in offline queue (default: 100) */
+ offlineQueueMaxSize?: number;
}
export interface TrackingContextValue {
diff --git a/src/types/video.ts b/src/types/video.ts
index 2a807b8..05b3e25 100644
--- a/src/types/video.ts
+++ b/src/types/video.ts
@@ -457,6 +457,8 @@ export const initialVideoState: VideoState = {
volume: 1,
playbackRate: 1,
error: null,
+ retryCount: 0,
+ isRetrying: false,
isFullscreen: false,
isPictureInPicture: false,
isCasting: false,
diff --git a/src/utils/PlayerEventBus.test.ts b/src/utils/PlayerEventBus.test.ts
new file mode 100644
index 0000000..94317df
--- /dev/null
+++ b/src/utils/PlayerEventBus.test.ts
@@ -0,0 +1,491 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ createPlayerEventBus,
+ getGlobalPlayerEventBus,
+ resetGlobalPlayerEventBus,
+ type PlayerEventBus,
+} from './PlayerEventBus';
+
+describe('PlayerEventBus', () => {
+ let eventBus: PlayerEventBus;
+
+ beforeEach(() => {
+ eventBus = createPlayerEventBus();
+ });
+
+ describe('createPlayerEventBus', () => {
+ it('creates a new event bus instance', () => {
+ expect(eventBus).toBeDefined();
+ expect(eventBus.emit).toBeDefined();
+ expect(eventBus.on).toBeDefined();
+ expect(eventBus.once).toBeDefined();
+ expect(eventBus.off).toBeDefined();
+ expect(eventBus.removeAllListeners).toBeDefined();
+ });
+
+ it('creates independent instances', () => {
+ const eventBus2 = createPlayerEventBus();
+ const listener = vi.fn();
+
+ eventBus.on('enterPictureInPicture', listener);
+ eventBus2.emit('enterPictureInPicture');
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('creates instances that do not share listeners', () => {
+ const bus1 = createPlayerEventBus();
+ const bus2 = createPlayerEventBus();
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+
+ bus1.on('castStart', listener1);
+ bus2.on('castStart', listener2);
+
+ bus1.emit('castStart');
+
+ expect(listener1).toHaveBeenCalledTimes(1);
+ expect(listener2).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('emit and on - void events', () => {
+ it('emits enterPictureInPicture event', () => {
+ const listener = vi.fn();
+ eventBus.on('enterPictureInPicture', listener);
+
+ eventBus.emit('enterPictureInPicture');
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits exitPictureInPicture event', () => {
+ const listener = vi.fn();
+ eventBus.on('exitPictureInPicture', listener);
+
+ eventBus.emit('exitPictureInPicture');
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits castStart event', () => {
+ const listener = vi.fn();
+ eventBus.on('castStart', listener);
+
+ eventBus.emit('castStart');
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits castStop event', () => {
+ const listener = vi.fn();
+ eventBus.on('castStop', listener);
+
+ eventBus.emit('castStop');
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('passes undefined payload for void events', () => {
+ const listener = vi.fn();
+ eventBus.on('enterPictureInPicture', listener);
+
+ eventBus.emit('enterPictureInPicture');
+
+ expect(listener).toHaveBeenCalledWith(undefined);
+ });
+ });
+
+ describe('emit and on - payload events', () => {
+ it('emits tabHidden event with timestamp payload', () => {
+ const listener = vi.fn();
+ eventBus.on('tabHidden', listener);
+
+ eventBus.emit('tabHidden', { timestamp: 1000 });
+
+ expect(listener).toHaveBeenCalledWith({ timestamp: 1000 });
+ });
+
+ it('emits tabVisible event with timestamp and hiddenDuration payload', () => {
+ const listener = vi.fn();
+ eventBus.on('tabVisible', listener);
+
+ eventBus.emit('tabVisible', { timestamp: 2000, hiddenDuration: 1000 });
+
+ expect(listener).toHaveBeenCalledWith({ timestamp: 2000, hiddenDuration: 1000 });
+ });
+
+ it('emits triggerReturnAd event with hiddenDuration payload', () => {
+ const listener = vi.fn();
+ eventBus.on('triggerReturnAd', listener);
+
+ eventBus.emit('triggerReturnAd', { hiddenDuration: 5000 });
+
+ expect(listener).toHaveBeenCalledWith({ hiddenDuration: 5000 });
+ });
+ });
+
+ describe('multiple listeners', () => {
+ it('supports multiple listeners for the same event', () => {
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+ const listener3 = vi.fn();
+
+ eventBus.on('enterPictureInPicture', listener1);
+ eventBus.on('enterPictureInPicture', listener2);
+ eventBus.on('enterPictureInPicture', listener3);
+
+ eventBus.emit('enterPictureInPicture');
+
+ expect(listener1).toHaveBeenCalledTimes(1);
+ expect(listener2).toHaveBeenCalledTimes(1);
+ expect(listener3).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call listeners for different events', () => {
+ const pipListener = vi.fn();
+ const castListener = vi.fn();
+
+ eventBus.on('enterPictureInPicture', pipListener);
+ eventBus.on('castStart', castListener);
+
+ eventBus.emit('enterPictureInPicture');
+
+ expect(pipListener).toHaveBeenCalledTimes(1);
+ expect(castListener).not.toHaveBeenCalled();
+ });
+
+ it('calls each listener with the correct payload', () => {
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+
+ eventBus.on('tabHidden', listener1);
+ eventBus.on('tabHidden', listener2);
+
+ eventBus.emit('tabHidden', { timestamp: 42 });
+
+ expect(listener1).toHaveBeenCalledWith({ timestamp: 42 });
+ expect(listener2).toHaveBeenCalledWith({ timestamp: 42 });
+ });
+ });
+
+ describe('emit with no listeners', () => {
+ it('does not throw when emitting with no listeners registered', () => {
+ expect(() => {
+ eventBus.emit('enterPictureInPicture');
+ }).not.toThrow();
+ });
+
+ it('does not throw when emitting payload event with no listeners', () => {
+ expect(() => {
+ eventBus.emit('tabHidden', { timestamp: 100 });
+ }).not.toThrow();
+ });
+ });
+
+ describe('on return value (unsubscribe)', () => {
+ it('returns an unsubscribe function', () => {
+ const listener = vi.fn();
+ const unsubscribe = eventBus.on('enterPictureInPicture', listener);
+
+ expect(typeof unsubscribe).toBe('function');
+ });
+
+ it('unsubscribes when unsubscribe function is called', () => {
+ const listener = vi.fn();
+ const unsubscribe = eventBus.on('enterPictureInPicture', listener);
+
+ unsubscribe();
+ eventBus.emit('enterPictureInPicture');
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('only unsubscribes the specific listener', () => {
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+
+ const unsubscribe1 = eventBus.on('castStart', listener1);
+ eventBus.on('castStart', listener2);
+
+ unsubscribe1();
+ eventBus.emit('castStart');
+
+ expect(listener1).not.toHaveBeenCalled();
+ expect(listener2).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles double unsubscribe gracefully', () => {
+ const listener = vi.fn();
+ const unsubscribe = eventBus.on('castStop', listener);
+
+ unsubscribe();
+ expect(() => unsubscribe()).not.toThrow();
+ });
+ });
+
+ describe('once', () => {
+ it('calls listener only once for void events', () => {
+ const listener = vi.fn();
+ eventBus.once('enterPictureInPicture', listener);
+
+ eventBus.emit('enterPictureInPicture');
+ eventBus.emit('enterPictureInPicture');
+ eventBus.emit('enterPictureInPicture');
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls listener only once for payload events', () => {
+ const listener = vi.fn();
+ eventBus.once('tabHidden', listener);
+
+ eventBus.emit('tabHidden', { timestamp: 1 });
+ eventBus.emit('tabHidden', { timestamp: 2 });
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith({ timestamp: 1 });
+ });
+
+ it('returns an unsubscribe function', () => {
+ const listener = vi.fn();
+ const unsubscribe = eventBus.once('exitPictureInPicture', listener);
+
+ unsubscribe();
+ eventBus.emit('exitPictureInPicture');
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('does not affect other listeners on the same event', () => {
+ const onceListener = vi.fn();
+ const regularListener = vi.fn();
+
+ eventBus.once('castStart', onceListener);
+ eventBus.on('castStart', regularListener);
+
+ eventBus.emit('castStart');
+ eventBus.emit('castStart');
+
+ expect(onceListener).toHaveBeenCalledTimes(1);
+ expect(regularListener).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('off', () => {
+ it('removes a specific listener', () => {
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+
+ eventBus.on('tabHidden', listener1);
+ eventBus.on('tabHidden', listener2);
+
+ eventBus.off('tabHidden', listener1);
+ eventBus.emit('tabHidden', { timestamp: 99 });
+
+ expect(listener1).not.toHaveBeenCalled();
+ expect(listener2).toHaveBeenCalledWith({ timestamp: 99 });
+ });
+
+ it('handles removing non-existent listener gracefully', () => {
+ const listener = vi.fn();
+
+ expect(() => {
+ eventBus.off('enterPictureInPicture', listener);
+ }).not.toThrow();
+ });
+
+ it('handles off on event type with no listeners registered', () => {
+ const listener = vi.fn();
+
+ expect(() => {
+ eventBus.off('castStop', listener);
+ }).not.toThrow();
+ });
+
+ it('does not affect other events when removing a listener', () => {
+ const listener = vi.fn();
+
+ eventBus.on('enterPictureInPicture', listener);
+ eventBus.on('exitPictureInPicture', listener);
+
+ eventBus.off('enterPictureInPicture', listener);
+
+ eventBus.emit('enterPictureInPicture');
+ eventBus.emit('exitPictureInPicture');
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('removeAllListeners', () => {
+ it('removes all listeners for all events', () => {
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+ const listener3 = vi.fn();
+
+ eventBus.on('enterPictureInPicture', listener1);
+ eventBus.on('tabHidden', listener2);
+ eventBus.on('triggerReturnAd', listener3);
+
+ eventBus.removeAllListeners();
+
+ eventBus.emit('enterPictureInPicture');
+ eventBus.emit('tabHidden', { timestamp: 1 });
+ eventBus.emit('triggerReturnAd', { hiddenDuration: 5 });
+
+ expect(listener1).not.toHaveBeenCalled();
+ expect(listener2).not.toHaveBeenCalled();
+ expect(listener3).not.toHaveBeenCalled();
+ });
+
+ it('allows adding new listeners after removeAllListeners', () => {
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+
+ eventBus.on('castStart', listener1);
+ eventBus.removeAllListeners();
+
+ eventBus.on('castStart', listener2);
+ eventBus.emit('castStart');
+
+ expect(listener1).not.toHaveBeenCalled();
+ expect(listener2).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not throw when called on empty bus', () => {
+ expect(() => {
+ eventBus.removeAllListeners();
+ }).not.toThrow();
+ });
+ });
+
+ describe('error handling in listeners', () => {
+ it('catches errors in listeners and continues execution', () => {
+ const errorListener = vi.fn(() => {
+ throw new Error('Listener error');
+ });
+ const goodListener = vi.fn();
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ eventBus.on('enterPictureInPicture', errorListener);
+ eventBus.on('enterPictureInPicture', goodListener);
+
+ eventBus.emit('enterPictureInPicture');
+
+ expect(errorListener).toHaveBeenCalled();
+ expect(goodListener).toHaveBeenCalled();
+ expect(consoleSpy).toHaveBeenCalled();
+
+ consoleSpy.mockRestore();
+ });
+
+ it('logs error with event name in the message', () => {
+ const errorListener = vi.fn(() => {
+ throw new Error('Test error');
+ });
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ eventBus.on('tabHidden', errorListener);
+ eventBus.emit('tabHidden', { timestamp: 1 });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('tabHidden'),
+ expect.any(Error)
+ );
+
+ consoleSpy.mockRestore();
+ });
+
+ it('continues calling remaining listeners after an error', () => {
+ const listeners = [vi.fn(), vi.fn(() => { throw new Error('fail'); }), vi.fn()];
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ listeners.forEach((l) => eventBus.on('castStart', l));
+ eventBus.emit('castStart');
+
+ expect(listeners[0]).toHaveBeenCalledTimes(1);
+ expect(listeners[1]).toHaveBeenCalledTimes(1);
+ expect(listeners[2]).toHaveBeenCalledTimes(1);
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('global event bus', () => {
+ beforeEach(() => {
+ resetGlobalPlayerEventBus();
+ });
+
+ it('getGlobalPlayerEventBus returns a PlayerEventBus instance', () => {
+ const global = getGlobalPlayerEventBus();
+
+ expect(global).toBeDefined();
+ expect(global.emit).toBeDefined();
+ expect(global.on).toBeDefined();
+ expect(global.off).toBeDefined();
+ expect(global.once).toBeDefined();
+ expect(global.removeAllListeners).toBeDefined();
+ });
+
+ it('getGlobalPlayerEventBus returns the same instance on multiple calls', () => {
+ const global1 = getGlobalPlayerEventBus();
+ const global2 = getGlobalPlayerEventBus();
+
+ expect(global1).toBe(global2);
+ });
+
+ it('global event bus is functional', () => {
+ const global = getGlobalPlayerEventBus();
+ const listener = vi.fn();
+
+ global.on('castStart', listener);
+ global.emit('castStart');
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('resetGlobalPlayerEventBus clears listeners and creates new instance', () => {
+ const global1 = getGlobalPlayerEventBus();
+ const listener = vi.fn();
+ global1.on('enterPictureInPicture', listener);
+
+ resetGlobalPlayerEventBus();
+
+ const global2 = getGlobalPlayerEventBus();
+ global2.emit('enterPictureInPicture');
+
+ expect(listener).not.toHaveBeenCalled();
+ expect(global1).not.toBe(global2);
+ });
+
+ it('resetGlobalPlayerEventBus does not throw when called without prior get', () => {
+ expect(() => {
+ resetGlobalPlayerEventBus();
+ }).not.toThrow();
+ });
+
+ it('resetGlobalPlayerEventBus can be called multiple times safely', () => {
+ getGlobalPlayerEventBus();
+
+ expect(() => {
+ resetGlobalPlayerEventBus();
+ resetGlobalPlayerEventBus();
+ }).not.toThrow();
+ });
+ });
+
+ describe('type safety (runtime verification)', () => {
+ it('accepts correct payload types for all events', () => {
+ eventBus.emit('enterPictureInPicture');
+ eventBus.emit('exitPictureInPicture');
+ eventBus.emit('castStart');
+ eventBus.emit('castStop');
+ eventBus.emit('tabHidden', { timestamp: 123 });
+ eventBus.emit('tabVisible', { timestamp: 456, hiddenDuration: 333 });
+ eventBus.emit('triggerReturnAd', { hiddenDuration: 999 });
+
+ expect(true).toBe(true);
+ });
+ });
+});
diff --git a/src/utils/fairu.test.ts b/src/utils/fairu.test.ts
new file mode 100644
index 0000000..db3fbbf
--- /dev/null
+++ b/src/utils/fairu.test.ts
@@ -0,0 +1,565 @@
+import { describe, it, expect } from 'vitest';
+import {
+ FAIRU_FILES_BASE_URL,
+ FAIRU_DEFAULT_COVER_WIDTH,
+ FAIRU_DEFAULT_COVER_HEIGHT,
+ getFairuAudioUrl,
+ getFairuVideoUrl,
+ getFairuHlsUrl,
+ getFairuCoverUrl,
+ getFairuThumbnailUrl,
+ createTrackFromFairu,
+ createVideoTrackFromFairu,
+ createPlaylistFromFairu,
+ createVideoPlaylistFromFairu,
+ secondsToFairuTimestamp,
+ createFairuMarkers,
+} from './fairu';
+
+const TEST_UUID = '123e4567-e89b-12d3-a456-426614174000';
+const CUSTOM_BASE = 'https://custom.cdn.example.com';
+
+describe('constants', () => {
+ it('FAIRU_FILES_BASE_URL is the expected value', () => {
+ expect(FAIRU_FILES_BASE_URL).toBe('https://files.fairu.app');
+ });
+
+ it('FAIRU_DEFAULT_COVER_WIDTH is 400', () => {
+ expect(FAIRU_DEFAULT_COVER_WIDTH).toBe(400);
+ });
+
+ it('FAIRU_DEFAULT_COVER_HEIGHT is 400', () => {
+ expect(FAIRU_DEFAULT_COVER_HEIGHT).toBe(400);
+ });
+});
+
+describe('getFairuAudioUrl', () => {
+ it('returns the correct audio URL with default base', () => {
+ const url = getFairuAudioUrl(TEST_UUID);
+ expect(url).toBe(`https://files.fairu.app/${TEST_UUID}/audio.mp3`);
+ });
+
+ it('uses a custom base URL when provided', () => {
+ const url = getFairuAudioUrl(TEST_UUID, { baseUrl: CUSTOM_BASE });
+ expect(url).toBe(`${CUSTOM_BASE}/${TEST_UUID}/audio.mp3`);
+ });
+
+ it('works with a simple uuid string', () => {
+ const url = getFairuAudioUrl('abc-123');
+ expect(url).toBe('https://files.fairu.app/abc-123/audio.mp3');
+ });
+});
+
+describe('getFairuVideoUrl', () => {
+ it('returns the correct video URL without version', () => {
+ const url = getFairuVideoUrl(TEST_UUID);
+ expect(url).toBe(`https://files.fairu.app/${TEST_UUID}/video.mp4`);
+ });
+
+ it('appends version=low query parameter', () => {
+ const url = getFairuVideoUrl(TEST_UUID, { version: 'low' });
+ expect(url).toBe(`https://files.fairu.app/${TEST_UUID}/video.mp4?version=low`);
+ });
+
+ it('appends version=medium query parameter', () => {
+ const url = getFairuVideoUrl(TEST_UUID, { version: 'medium' });
+ expect(url).toBe(`https://files.fairu.app/${TEST_UUID}/video.mp4?version=medium`);
+ });
+
+ it('appends version=high query parameter', () => {
+ const url = getFairuVideoUrl(TEST_UUID, { version: 'high' });
+ expect(url).toBe(`https://files.fairu.app/${TEST_UUID}/video.mp4?version=high`);
+ });
+
+ it('uses a custom base URL when provided', () => {
+ const url = getFairuVideoUrl(TEST_UUID, { baseUrl: CUSTOM_BASE });
+ expect(url).toBe(`${CUSTOM_BASE}/${TEST_UUID}/video.mp4`);
+ });
+
+ it('uses a custom base URL and version together', () => {
+ const url = getFairuVideoUrl(TEST_UUID, { baseUrl: CUSTOM_BASE, version: 'high' });
+ expect(url).toBe(`${CUSTOM_BASE}/${TEST_UUID}/video.mp4?version=high`);
+ });
+});
+
+describe('getFairuHlsUrl', () => {
+ it('returns the correct HLS URL', () => {
+ const url = getFairuHlsUrl(TEST_UUID, 'my-tenant');
+ expect(url).toBe(`https://files.fairu.app/hls/my-tenant/${TEST_UUID}/master.m3u8`);
+ });
+
+ it('uses a custom base URL when provided', () => {
+ const url = getFairuHlsUrl(TEST_UUID, 'tenant-x', { baseUrl: CUSTOM_BASE });
+ expect(url).toBe(`${CUSTOM_BASE}/hls/tenant-x/${TEST_UUID}/master.m3u8`);
+ });
+
+ it('handles different tenant values', () => {
+ const url = getFairuHlsUrl(TEST_UUID, 'org-123');
+ expect(url).toBe(`https://files.fairu.app/hls/org-123/${TEST_UUID}/master.m3u8`);
+ });
+});
+
+describe('getFairuCoverUrl', () => {
+ it('returns URL with default 400x400 dimensions', () => {
+ const url = getFairuCoverUrl(TEST_UUID);
+ expect(url).toContain(`/${TEST_UUID}/cover.jpg?`);
+ expect(url).toContain('width=400');
+ expect(url).toContain('height=400');
+ });
+
+ it('uses custom width and height', () => {
+ const url = getFairuCoverUrl(TEST_UUID, { width: 800, height: 600 });
+ expect(url).toContain('width=800');
+ expect(url).toContain('height=600');
+ });
+
+ it('includes format parameter when specified', () => {
+ const url = getFairuCoverUrl(TEST_UUID, { format: 'webp' });
+ expect(url).toContain('format=webp');
+ });
+
+ it('includes quality parameter when specified', () => {
+ const url = getFairuCoverUrl(TEST_UUID, { quality: 85 });
+ expect(url).toContain('quality=85');
+ });
+
+ it('includes fit parameter when specified', () => {
+ const url = getFairuCoverUrl(TEST_UUID, { fit: 'contain' });
+ expect(url).toContain('fit=contain');
+ });
+
+ it('includes focal parameter when specified', () => {
+ const url = getFairuCoverUrl(TEST_UUID, { focal: '50-30-1.5' });
+ expect(url).toContain('focal=50-30-1.5');
+ });
+
+ it('includes all optional parameters together', () => {
+ const url = getFairuCoverUrl(TEST_UUID, {
+ width: 1200,
+ height: 675,
+ format: 'png',
+ quality: 90,
+ fit: 'cover',
+ focal: '25-75-2',
+ });
+ expect(url).toContain('width=1200');
+ expect(url).toContain('height=675');
+ expect(url).toContain('format=png');
+ expect(url).toContain('quality=90');
+ expect(url).toContain('fit=cover');
+ expect(url).toContain('focal=25-75-2');
+ });
+
+ it('uses a custom base URL when provided', () => {
+ const url = getFairuCoverUrl(TEST_UUID, { baseUrl: CUSTOM_BASE });
+ expect(url).toContain(`${CUSTOM_BASE}/${TEST_UUID}/cover.jpg?`);
+ });
+
+ it('does not include format when not specified', () => {
+ const url = getFairuCoverUrl(TEST_UUID);
+ expect(url).not.toContain('format=');
+ });
+
+ it('does not include quality when not specified', () => {
+ const url = getFairuCoverUrl(TEST_UUID);
+ expect(url).not.toContain('quality=');
+ });
+
+ it('includes quality=0 when explicitly set to 0', () => {
+ const url = getFairuCoverUrl(TEST_UUID, { quality: 0 });
+ expect(url).toContain('quality=0');
+ });
+});
+
+describe('getFairuThumbnailUrl', () => {
+ it('returns URL with timestamp parameter', () => {
+ const url = getFairuThumbnailUrl(TEST_UUID, '00:00:30.000');
+ expect(url).toContain(`/${TEST_UUID}/thumbnail.jpg?`);
+ expect(url).toContain('timestamp=00%3A00%3A30.000');
+ });
+
+ it('includes optional width', () => {
+ const url = getFairuThumbnailUrl(TEST_UUID, '00:01:00.000', { width: 320 });
+ expect(url).toContain('width=320');
+ });
+
+ it('includes optional height', () => {
+ const url = getFairuThumbnailUrl(TEST_UUID, '00:01:00.000', { height: 180 });
+ expect(url).toContain('height=180');
+ });
+
+ it('includes optional format', () => {
+ const url = getFairuThumbnailUrl(TEST_UUID, '00:01:00.000', { format: 'webp' });
+ expect(url).toContain('format=webp');
+ });
+
+ it('includes optional quality', () => {
+ const url = getFairuThumbnailUrl(TEST_UUID, '00:01:00.000', { quality: 75 });
+ expect(url).toContain('quality=75');
+ });
+
+ it('does not include width when not specified', () => {
+ const url = getFairuThumbnailUrl(TEST_UUID, '00:00:00.000');
+ expect(url).not.toContain('width=');
+ });
+
+ it('does not include height when not specified', () => {
+ const url = getFairuThumbnailUrl(TEST_UUID, '00:00:00.000');
+ expect(url).not.toContain('height=');
+ });
+
+ it('uses custom base URL', () => {
+ const url = getFairuThumbnailUrl(TEST_UUID, '00:00:05.000', { baseUrl: CUSTOM_BASE });
+ expect(url).toContain(`${CUSTOM_BASE}/${TEST_UUID}/thumbnail.jpg?`);
+ });
+
+ it('includes all optional params together', () => {
+ const url = getFairuThumbnailUrl(TEST_UUID, '00:02:15.500', {
+ width: 640,
+ height: 360,
+ format: 'jpg',
+ quality: 80,
+ });
+ expect(url).toContain('width=640');
+ expect(url).toContain('height=360');
+ expect(url).toContain('format=jpg');
+ expect(url).toContain('quality=80');
+ });
+});
+
+describe('secondsToFairuTimestamp', () => {
+ it('converts 0 seconds', () => {
+ expect(secondsToFairuTimestamp(0)).toBe('00:00:00.000');
+ });
+
+ it('converts fractional seconds', () => {
+ expect(secondsToFairuTimestamp(0.5)).toBe('00:00:00.500');
+ });
+
+ it('converts whole seconds', () => {
+ expect(secondsToFairuTimestamp(5)).toBe('00:00:05.000');
+ });
+
+ it('converts to minutes', () => {
+ expect(secondsToFairuTimestamp(90)).toBe('00:01:30.000');
+ });
+
+ it('converts the documented example (90.5 seconds)', () => {
+ expect(secondsToFairuTimestamp(90.5)).toBe('00:01:30.500');
+ });
+
+ it('converts to hours', () => {
+ expect(secondsToFairuTimestamp(3600)).toBe('01:00:00.000');
+ });
+
+ it('converts complex time', () => {
+ expect(secondsToFairuTimestamp(3661.123)).toBe('01:01:01.123');
+ });
+
+ it('pads hours correctly', () => {
+ expect(secondsToFairuTimestamp(36000)).toBe('10:00:00.000');
+ });
+
+ it('handles millisecond precision', () => {
+ expect(secondsToFairuTimestamp(1.001)).toBe('00:00:01.001');
+ });
+
+ it('handles 59 seconds', () => {
+ expect(secondsToFairuTimestamp(59)).toBe('00:00:59.000');
+ });
+
+ it('handles 59 minutes 59 seconds', () => {
+ expect(secondsToFairuTimestamp(3599)).toBe('00:59:59.000');
+ });
+});
+
+describe('createTrackFromFairu', () => {
+ it('creates a track with minimal config', () => {
+ const track = createTrackFromFairu({ uuid: TEST_UUID });
+
+ expect(track.id).toBe(TEST_UUID);
+ expect(track.src).toBe(`https://files.fairu.app/${TEST_UUID}/audio.mp3`);
+ expect(track.title).toBe('Untitled');
+ expect(track.artwork).toContain(`/${TEST_UUID}/cover.jpg`);
+ });
+
+ it('uses provided title', () => {
+ const track = createTrackFromFairu({ uuid: TEST_UUID, title: 'My Song' });
+ expect(track.title).toBe('My Song');
+ });
+
+ it('includes artist when provided', () => {
+ const track = createTrackFromFairu({ uuid: TEST_UUID, artist: 'Artist Name' });
+ expect(track.artist).toBe('Artist Name');
+ });
+
+ it('includes album when provided', () => {
+ const track = createTrackFromFairu({ uuid: TEST_UUID, album: 'Album Name' });
+ expect(track.album).toBe('Album Name');
+ });
+
+ it('includes duration when provided', () => {
+ const track = createTrackFromFairu({ uuid: TEST_UUID, duration: 180 });
+ expect(track.duration).toBe(180);
+ });
+
+ it('does not include artist when not provided', () => {
+ const track = createTrackFromFairu({ uuid: TEST_UUID });
+ expect(track.artist).toBeUndefined();
+ });
+
+ it('does not include album when not provided', () => {
+ const track = createTrackFromFairu({ uuid: TEST_UUID });
+ expect(track.album).toBeUndefined();
+ });
+
+ it('does not include duration when not provided', () => {
+ const track = createTrackFromFairu({ uuid: TEST_UUID });
+ expect(track.duration).toBeUndefined();
+ });
+
+ it('uses custom base URL for src', () => {
+ const track = createTrackFromFairu({ uuid: TEST_UUID }, { baseUrl: CUSTOM_BASE });
+ expect(track.src).toBe(`${CUSTOM_BASE}/${TEST_UUID}/audio.mp3`);
+ });
+
+ it('applies coverOptions to artwork URL', () => {
+ const track = createTrackFromFairu({
+ uuid: TEST_UUID,
+ coverOptions: { width: 800, height: 800, format: 'webp' },
+ });
+ expect(track.artwork).toContain('width=800');
+ expect(track.artwork).toContain('height=800');
+ expect(track.artwork).toContain('format=webp');
+ });
+
+ it('creates a complete track with all fields', () => {
+ const track = createTrackFromFairu({
+ uuid: TEST_UUID,
+ title: 'Full Track',
+ artist: 'Full Artist',
+ album: 'Full Album',
+ duration: 300,
+ });
+
+ expect(track).toEqual({
+ id: TEST_UUID,
+ src: `https://files.fairu.app/${TEST_UUID}/audio.mp3`,
+ title: 'Full Track',
+ artist: 'Full Artist',
+ album: 'Full Album',
+ artwork: expect.stringContaining(`/${TEST_UUID}/cover.jpg`),
+ duration: 300,
+ });
+ });
+});
+
+describe('createVideoTrackFromFairu', () => {
+ it('creates a video track with minimal config', () => {
+ const track = createVideoTrackFromFairu({ uuid: TEST_UUID });
+
+ expect(track.id).toBe(TEST_UUID);
+ expect(track.src).toBe(`https://files.fairu.app/${TEST_UUID}/video.mp4`);
+ expect(track.title).toBe('Untitled');
+ expect(track.poster).toContain(`/${TEST_UUID}/cover.jpg`);
+ });
+
+ it('uses provided title', () => {
+ const track = createVideoTrackFromFairu({ uuid: TEST_UUID, title: 'My Video' });
+ expect(track.title).toBe('My Video');
+ });
+
+ it('includes artist when provided', () => {
+ const track = createVideoTrackFromFairu({ uuid: TEST_UUID, artist: 'Director' });
+ expect(track.artist).toBe('Director');
+ });
+
+ it('includes duration when provided', () => {
+ const track = createVideoTrackFromFairu({ uuid: TEST_UUID, duration: 600 });
+ expect(track.duration).toBe(600);
+ });
+
+ it('appends version to video URL when provided', () => {
+ const track = createVideoTrackFromFairu({ uuid: TEST_UUID, version: 'high' });
+ expect(track.src).toContain('version=high');
+ });
+
+ it('uses posterOptions for poster URL when provided', () => {
+ const track = createVideoTrackFromFairu({
+ uuid: TEST_UUID,
+ posterOptions: { width: 1920, height: 1080 },
+ });
+ expect(track.poster).toContain('width=1920');
+ expect(track.poster).toContain('height=1080');
+ });
+
+ it('falls back to coverOptions for poster URL when posterOptions not provided', () => {
+ const track = createVideoTrackFromFairu({
+ uuid: TEST_UUID,
+ coverOptions: { width: 640, height: 360 },
+ });
+ expect(track.poster).toContain('width=640');
+ expect(track.poster).toContain('height=360');
+ });
+
+ it('uses custom base URL for src', () => {
+ const track = createVideoTrackFromFairu({ uuid: TEST_UUID }, { baseUrl: CUSTOM_BASE });
+ expect(track.src).toBe(`${CUSTOM_BASE}/${TEST_UUID}/video.mp4`);
+ });
+});
+
+describe('createPlaylistFromFairu', () => {
+ it('returns empty array for empty input', () => {
+ const playlist = createPlaylistFromFairu([]);
+ expect(playlist).toEqual([]);
+ });
+
+ it('converts a single track', () => {
+ const playlist = createPlaylistFromFairu([{ uuid: 'uuid-1', title: 'Track 1' }]);
+ expect(playlist).toHaveLength(1);
+ expect(playlist[0].id).toBe('uuid-1');
+ expect(playlist[0].title).toBe('Track 1');
+ });
+
+ it('converts multiple tracks', () => {
+ const playlist = createPlaylistFromFairu([
+ { uuid: 'uuid-1', title: 'Track 1' },
+ { uuid: 'uuid-2', title: 'Track 2' },
+ { uuid: 'uuid-3', title: 'Track 3' },
+ ]);
+ expect(playlist).toHaveLength(3);
+ expect(playlist[0].id).toBe('uuid-1');
+ expect(playlist[1].id).toBe('uuid-2');
+ expect(playlist[2].id).toBe('uuid-3');
+ });
+
+ it('applies shared options to all tracks', () => {
+ const playlist = createPlaylistFromFairu(
+ [{ uuid: 'uuid-1' }, { uuid: 'uuid-2' }],
+ { baseUrl: CUSTOM_BASE }
+ );
+ expect(playlist[0].src).toContain(CUSTOM_BASE);
+ expect(playlist[1].src).toContain(CUSTOM_BASE);
+ });
+});
+
+describe('createVideoPlaylistFromFairu', () => {
+ it('returns empty array for empty input', () => {
+ const playlist = createVideoPlaylistFromFairu([]);
+ expect(playlist).toEqual([]);
+ });
+
+ it('converts a single video track', () => {
+ const playlist = createVideoPlaylistFromFairu([{ uuid: 'uuid-1', title: 'Video 1' }]);
+ expect(playlist).toHaveLength(1);
+ expect(playlist[0].src).toContain('video.mp4');
+ });
+
+ it('converts multiple video tracks', () => {
+ const playlist = createVideoPlaylistFromFairu([
+ { uuid: 'uuid-1', title: 'Video 1', version: 'low' },
+ { uuid: 'uuid-2', title: 'Video 2', version: 'high' },
+ ]);
+ expect(playlist).toHaveLength(2);
+ expect(playlist[0].src).toContain('version=low');
+ expect(playlist[1].src).toContain('version=high');
+ });
+
+ it('applies shared options to all video tracks', () => {
+ const playlist = createVideoPlaylistFromFairu(
+ [{ uuid: 'uuid-1' }, { uuid: 'uuid-2' }],
+ { baseUrl: CUSTOM_BASE }
+ );
+ expect(playlist[0].src).toContain(CUSTOM_BASE);
+ expect(playlist[1].src).toContain(CUSTOM_BASE);
+ });
+});
+
+describe('createFairuMarkers', () => {
+ it('adds previewImage to markers without one', () => {
+ const markers = createFairuMarkers(TEST_UUID, [
+ { id: '1', time: 30, title: 'Intro' },
+ ]);
+
+ expect(markers[0].previewImage).toBeDefined();
+ expect(markers[0].previewImage).toContain(`/${TEST_UUID}/thumbnail.jpg`);
+ });
+
+ it('preserves existing previewImage on markers', () => {
+ const existingImage = 'https://example.com/custom-thumb.jpg';
+ const markers = createFairuMarkers(TEST_UUID, [
+ { id: '1', time: 30, title: 'Intro', previewImage: existingImage },
+ ]);
+
+ expect(markers[0].previewImage).toBe(existingImage);
+ });
+
+ it('uses default thumbnail options (160x90 webp quality 80)', () => {
+ const markers = createFairuMarkers(TEST_UUID, [
+ { id: '1', time: 10 },
+ ]);
+
+ expect(markers[0].previewImage).toContain('width=160');
+ expect(markers[0].previewImage).toContain('height=90');
+ expect(markers[0].previewImage).toContain('format=webp');
+ expect(markers[0].previewImage).toContain('quality=80');
+ });
+
+ it('allows overriding default thumbnail options', () => {
+ const markers = createFairuMarkers(
+ TEST_UUID,
+ [{ id: '1', time: 10 }],
+ { width: 320, height: 180, format: 'jpg', quality: 95 }
+ );
+
+ expect(markers[0].previewImage).toContain('width=320');
+ expect(markers[0].previewImage).toContain('height=180');
+ expect(markers[0].previewImage).toContain('format=jpg');
+ expect(markers[0].previewImage).toContain('quality=95');
+ });
+
+ it('converts marker time to correct timestamp in URL', () => {
+ const markers = createFairuMarkers(TEST_UUID, [
+ { id: '1', time: 90 },
+ ]);
+
+ // 90 seconds = 00:01:30.000, URL-encoded colon is %3A
+ expect(markers[0].previewImage).toContain('timestamp=00%3A01%3A30.000');
+ });
+
+ it('handles multiple markers with mixed previewImage presence', () => {
+ const markers = createFairuMarkers(TEST_UUID, [
+ { id: '1', time: 0, title: 'Start' },
+ { id: '2', time: 30, title: 'Middle', previewImage: 'https://custom.com/thumb.jpg' },
+ { id: '3', time: 60, title: 'End' },
+ ]);
+
+ expect(markers[0].previewImage).toContain('thumbnail.jpg');
+ expect(markers[1].previewImage).toBe('https://custom.com/thumb.jpg');
+ expect(markers[2].previewImage).toContain('thumbnail.jpg');
+ });
+
+ it('returns empty array for empty markers input', () => {
+ const markers = createFairuMarkers(TEST_UUID, []);
+ expect(markers).toEqual([]);
+ });
+
+ it('preserves all original marker properties', () => {
+ const markers = createFairuMarkers(TEST_UUID, [
+ { id: 'marker-1', time: 15, title: 'Chapter 1', color: '#ff0000' },
+ ]);
+
+ expect(markers[0].id).toBe('marker-1');
+ expect(markers[0].time).toBe(15);
+ expect(markers[0].title).toBe('Chapter 1');
+ expect(markers[0].color).toBe('#ff0000');
+ });
+
+ it('does not mutate the original markers array', () => {
+ const original = [{ id: '1', time: 10 }];
+ const result = createFairuMarkers(TEST_UUID, original);
+
+ expect(result).not.toBe(original);
+ expect(original[0]).not.toHaveProperty('previewImage');
+ });
+});
diff --git a/src/utils/index.ts b/src/utils/index.ts
index b38c8ea..95fc2ae 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -44,3 +44,4 @@ export {
type FairuTrack,
type FairuVideoTrack,
} from './fairu';
+export { parseVTT, findCueAtTime, generateSpriteCues, type ThumbnailConfig, type ThumbnailCue } from './thumbnails';
diff --git a/src/utils/thumbnails.ts b/src/utils/thumbnails.ts
new file mode 100644
index 0000000..7de0d72
--- /dev/null
+++ b/src/utils/thumbnails.ts
@@ -0,0 +1,156 @@
+/**
+ * A single thumbnail cue from a VTT file
+ */
+export interface ThumbnailCue {
+ startTime: number;
+ endTime: number;
+ /** URL of the thumbnail image (or sprite sheet) */
+ url: string;
+ /** If sprite sheet: x position */
+ x?: number;
+ /** If sprite sheet: y position */
+ y?: number;
+ /** If sprite sheet: width of this thumbnail */
+ width?: number;
+ /** If sprite sheet: height of this thumbnail */
+ height?: number;
+}
+
+/**
+ * Configuration for thumbnail previews
+ */
+export interface ThumbnailConfig {
+ /** URL to a WebVTT file containing thumbnail cues */
+ vttUrl?: string;
+ /** URL to a sprite sheet image (alternative to VTT) */
+ spriteUrl?: string;
+ /** Number of columns in the sprite sheet */
+ spriteColumns?: number;
+ /** Number of rows in the sprite sheet */
+ spriteRows?: number;
+ /** Width of each thumbnail in the sprite */
+ thumbWidth?: number;
+ /** Height of each thumbnail in the sprite */
+ thumbHeight?: number;
+ /** Interval between thumbnails in seconds (for sprite sheets without VTT) */
+ interval?: number;
+ /** Total duration of the video (needed for sprite calculation without VTT) */
+ duration?: number;
+}
+
+/**
+ * Parse a VTT timestamp "HH:MM:SS.mmm" or "MM:SS.mmm" to seconds
+ */
+function parseVTTTime(timeStr: string): number {
+ const parts = timeStr.trim().split(':');
+ if (parts.length === 3) {
+ return parseFloat(parts[0]) * 3600 + parseFloat(parts[1]) * 60 + parseFloat(parts[2]);
+ }
+ if (parts.length === 2) {
+ return parseFloat(parts[0]) * 60 + parseFloat(parts[1]);
+ }
+ return parseFloat(parts[0]);
+}
+
+/**
+ * Parse spatial media fragment from URL hash (#xywh=x,y,w,h)
+ */
+function parseSpatialFragment(url: string): { baseUrl: string; x?: number; y?: number; width?: number; height?: number } {
+ const hashIndex = url.indexOf('#xywh=');
+ if (hashIndex === -1) {
+ return { baseUrl: url };
+ }
+
+ const baseUrl = url.substring(0, hashIndex);
+ const fragment = url.substring(hashIndex + 6);
+ const [x, y, w, h] = fragment.split(',').map(Number);
+
+ return { baseUrl, x, y, width: w, height: h };
+}
+
+/**
+ * Parse a WebVTT thumbnail file content into ThumbnailCue array
+ */
+export function parseVTT(vttContent: string): ThumbnailCue[] {
+ const cues: ThumbnailCue[] = [];
+ const lines = vttContent.trim().split('\n');
+
+ let i = 0;
+ // Skip WEBVTT header
+ while (i < lines.length && !lines[i].includes('-->')) {
+ i++;
+ }
+
+ while (i < lines.length) {
+ const line = lines[i].trim();
+
+ // Look for timestamp line "00:00:00.000 --> 00:00:05.000"
+ if (line.includes('-->')) {
+ const [startStr, endStr] = line.split('-->').map(s => s.trim());
+ const startTime = parseVTTTime(startStr);
+ const endTime = parseVTTTime(endStr);
+
+ // Next line should be the URL (possibly with spatial fragment)
+ i++;
+ if (i < lines.length) {
+ const urlLine = lines[i].trim();
+ if (urlLine) {
+ const { baseUrl, x, y, width, height } = parseSpatialFragment(urlLine);
+ cues.push({ startTime, endTime, url: baseUrl, x, y, width, height });
+ }
+ }
+ }
+ i++;
+ }
+
+ return cues;
+}
+
+/**
+ * Find the thumbnail cue for a given time
+ */
+export function findCueAtTime(cues: ThumbnailCue[], time: number): ThumbnailCue | null {
+ for (const cue of cues) {
+ if (time >= cue.startTime && time < cue.endTime) {
+ return cue;
+ }
+ }
+ return null;
+}
+
+/**
+ * Generate thumbnail cues from a sprite sheet configuration (no VTT needed)
+ */
+export function generateSpriteCues(config: {
+ spriteUrl: string;
+ columns: number;
+ rows: number;
+ thumbWidth: number;
+ thumbHeight: number;
+ interval: number;
+ duration: number;
+}): ThumbnailCue[] {
+ const { spriteUrl, columns, rows, thumbWidth, thumbHeight, interval, duration } = config;
+ const cues: ThumbnailCue[] = [];
+ const totalThumbs = columns * rows;
+
+ for (let i = 0; i < totalThumbs; i++) {
+ const startTime = i * interval;
+ if (startTime >= duration) break;
+ const endTime = Math.min((i + 1) * interval, duration);
+ const col = i % columns;
+ const row = Math.floor(i / columns);
+
+ cues.push({
+ startTime,
+ endTime,
+ url: spriteUrl,
+ x: col * thumbWidth,
+ y: row * thumbHeight,
+ width: thumbWidth,
+ height: thumbHeight,
+ });
+ }
+
+ return cues;
+}
diff --git a/vite.config.ts b/vite.config.ts
index f203881..5d7e42b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -139,12 +139,15 @@ export default defineConfig(({ mode }) => {
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
- reporter: ['text', 'json', 'html'],
+ reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/**/*.stories.tsx',
'src/**/*.d.ts',
'src/test/',
+ 'src/types/',
+ 'src/**/index.ts',
+ 'src/examples/',
],
},
},