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}

+ +
+ )} + > + +
+ ), +}; + +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} +

+ )} + + +
+ ); + } +} 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 -
    - )} -
    -

    - {currentTrack.title || 'Untitled'} -

    - {currentTrack.artist && ( -

    - {currentTrack.artist} -

    - )} - {chapterState.currentChapter && ( -

    - Chapter: {chapterState.currentChapter.title} -

    + {/* Track info */} + {currentTrack && ( +
    + {currentTrack.artwork && ( +
    + {/* Artwork glow reflection */} +
    + {currentTrack.title +
    )} +
    +

    + {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: () => ( +
    +
    + Mock Video +
    + +
    + ), +}; + +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 */} +
    + + setText(e.target.value)} + style={{ + width: '100%', + padding: '6px 8px', + borderRadius: '4px', + border: '1px solid #444', + backgroundColor: '#222', + color: '#fff', + fontSize: '14px', + }} + /> +
    + + {/* Mode toggle */} +
    + +
    + {(['overlay', 'below'] as const).map((m) => ( + + ))} +
    +
    + + {/* Preset buttons */} +
    + +
    + {SUBTITLE_PRESETS.map((preset) => ( + + ))} +
    +
    + + {/* 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]) => ( + + ))} +
    +
    + + {/* Style presets */} +
    + {SUBTITLE_PRESETS.map(preset => ( + + ))} +
    + + {/* 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 }) => ( + + ))} +
    +
    + + {/* Style presets */} +
    +
    Style Preset
    +
    + {SUBTITLE_PRESETS.map(preset => ( + + ))} +
    +
    + + {/* 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'} +
    + +
    + + {/* 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 && ( +
    + +
    + )} +
    +
    + ); +} 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! + +
    + +
    + ) : ( + + )} +
    + + {/* Simulate Complete button (always visible when ad is showing) */} + {visible && ( + + )} + + {/* 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 ? ( + + ) : ( + + {remainingSeconds}s remaining + + )} +
    + + {/* Video */} +
    +
    + + {/* Progress bar */} +
    +
    +
    +
    +
    + + {/* Reward description */} + {ad.rewardDescription && ( +

    + {ad.rewardDescription} +

    + )} + + {/* Completed state */} + {isCompleted && ( +
    +
    + + + + Reward earned! +
    +
    + )} +
    + ); +} 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 + +
    + + {/* Presets */} +
    + {presets.map((preset) => ( + + ))} +
    + + {/* 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 */} + +
    + ); +} 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 ( +
    + +
    + + + +
    +
    + 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 ( +
    + + + + +

    + {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 ( + + ); +} 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 ( +
    + + + {isOpen && ( +
    + {isActive && ( + + )} + {presets.map((preset) => ( + + ))} +
    + )} +
    + ); +} 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 */} + + + {/* Settings panel */} + {isOpen && ( +
    +
    Subtitle Style
    + + {/* Presets */} +
    +
    Presets
    +
    + {presets.map((preset) => ( + + ))} +
    +
    + + {/* 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 */} +
    +
    + + {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) => ( + + ))} +
    +
    + + {/* Reset button */} + +
    + )} +
    + ); +} 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() {
    Video thumbnail 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