From da7b01bc35f9465b42d75214603222e4f800bb23 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 4 May 2026 14:50:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=B5=E5=AD=90=E4=B9=A6=E9=A6=86=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=90=AC=E4=B9=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 116 +++- src/app/admin/page.tsx | 6 +- src/app/api/books/tts/synthesize/route.ts | 52 ++ src/app/api/books/tts/voices/route.ts | 31 + src/app/books/read/page.tsx | 771 +++++++++++++++++++++- src/components/books/BooksLayout.tsx | 63 +- src/lib/book-tts-progress.client.ts | 37 ++ src/lib/book-tts.ts | 227 +++++++ src/lib/book.types.ts | 28 + src/lib/config.ts | 12 + 11 files changed, 1290 insertions(+), 54 deletions(-) create mode 100644 src/app/api/books/tts/synthesize/route.ts create mode 100644 src/app/api/books/tts/voices/route.ts create mode 100644 src/lib/book-tts-progress.client.ts create mode 100644 src/lib/book-tts.ts diff --git a/package.json b/package.json index 53d53e5..0ae2a53 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "cheerio": "^1.1.2", "clsx": "^2.0.0", "crypto-js": "^4.2.0", + "edge-tts-universal": "^1.4.0", "flv.js": "^1.6.2", "framer-motion": "^12.18.1", "he": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 907ca5c..e6f3f65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: crypto-js: specifier: ^4.2.0 version: 4.2.0 + edge-tts-universal: + specifier: ^1.4.0 + version: 1.4.0(bufferutil@4.1.0) flv.js: specifier: ^1.6.2 version: 1.6.2 @@ -297,28 +300,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-arm64-musl@0.40.0': resolution: {integrity: sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@ast-grep/napi-linux-x64-gnu@0.40.0': resolution: {integrity: sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-x64-musl@0.40.0': resolution: {integrity: sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@ast-grep/napi-win32-arm64-msvc@0.40.0': resolution: {integrity: sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w==} @@ -1921,105 +1920,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2227,28 +2210,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -2438,28 +2417,24 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.59': resolution: {integrity: sha512-CCKEk+H+8c0WGe/8n1E20n85Tq4Pv+HNAbjP1KfUXW+01aCWSMjU56ChNrM2tvHnXicfm7QRNoZyfY8cWh7jLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.59': resolution: {integrity: sha512-VlfwJ/HCskPmQi8R0JuAFndySKVFX7yPhE658o27cjSDWWbXVtGkSbwaxstii7Q+3Rz87ZXN+HLnb1kd4R9Img==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.59': resolution: {integrity: sha512-kuO92hTRyGy0Ts3Nsqll0rfO8eFsEJe9dGQGktkQnZ2hrJrDVN0y419dMgKy/gB2S2o7F2dpWhpfQOBehZPwVA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.59': resolution: {integrity: sha512-PXAebvNL4sYfCqi8LdY4qyFRacrRoiPZLo3NoUmiTxm7MPtYYR8CNtBGNokqDmMuZIQIecRaD/jbmFAIDz7DxQ==} @@ -3364,49 +3339,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3877,6 +3844,9 @@ packages: resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} engines: {node: '>=4'} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -4290,6 +4260,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4540,6 +4513,10 @@ packages: engines: {node: '>=16'} hasBin: true + edge-tts-universal@1.4.0: + resolution: {integrity: sha512-53Zk6UH3+3g844APjuhtHb2KTTdRLY6fZtEZsSIF3Kz3tYEpsjwQedCtaz8qTaxYdqFOMoFyCGNOHm2PxuX2Jw==} + engines: {node: ^18.17 || ^20.9 || >=22, npm: '>=9'} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -4984,6 +4961,15 @@ packages: flv.js@1.6.2: resolution: {integrity: sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -5537,6 +5523,11 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -6953,6 +6944,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} @@ -7965,6 +7960,10 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -8263,6 +8262,9 @@ packages: resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} engines: {node: '>= 6.0'} + xml-escape@1.1.0: + resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==} + xml-name-validator@3.0.0: resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==} @@ -13475,6 +13477,14 @@ snapshots: axe-core@4.11.0: {} + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} babel-jest@27.5.1(@babel/core@7.28.5): @@ -13910,6 +13920,12 @@ snapshots: create-require@1.1.1: {} + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -14146,6 +14162,22 @@ snapshots: signal-exit: 4.0.2 time-span: 4.0.0 + edge-tts-universal@1.4.0(bufferutil@4.1.0): + dependencies: + axios: 1.16.0 + cross-fetch: 4.1.0 + https-proxy-agent: 7.0.6 + isomorphic-ws: 5.0.0(ws@8.18.3(bufferutil@4.1.0)) + uuid: 11.1.1 + ws: 8.18.3(bufferutil@4.1.0) + xml-escape: 1.1.0 + transitivePeerDependencies: + - bufferutil + - debug + - encoding + - supports-color + - utf-8-validate + ee-first@1.1.1: {} ejs@3.1.10: @@ -14830,6 +14862,8 @@ snapshots: es6-promise: 4.2.8 webworkify-webpack: 2.1.5 + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -15408,6 +15442,10 @@ snapshots: isexe@3.1.1: {} + isomorphic-ws@5.0.0(ws@8.18.3(bufferutil@4.1.0)): + dependencies: + ws: 8.18.3(bufferutil@4.1.0) + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1: @@ -17279,6 +17317,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + psl@1.15.0: dependencies: punycode: 2.3.1 @@ -18529,6 +18569,8 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.19 + uuid@11.1.1: {} + uuid@9.0.1: {} uvu@0.5.6: @@ -18969,6 +19011,8 @@ snapshots: dependencies: os-paths: 4.4.0 + xml-escape@1.1.0: {} + xml-name-validator@3.0.0: {} xml2js@0.6.2: diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1112ca8..e7a8217 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -40,21 +40,21 @@ import { Globe, Mail, Palette, + Plus, Settings, + Trash2, Tv, UserPlus, Users, Video, - Plus, - Trash2, } from 'lucide-react'; import { GripVertical } from 'lucide-react'; import { Fragment, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; -import { BookSource } from '@/lib/book.types'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; +import { BookSource } from '@/lib/book.types'; import { ALL_FEATURE_PERMISSION_KEYS, FEATURE_PERMISSION_OPTIONS, diff --git a/src/app/api/books/tts/synthesize/route.ts b/src/app/api/books/tts/synthesize/route.ts new file mode 100644 index 0000000..4f5a9a8 --- /dev/null +++ b/src/app/api/books/tts/synthesize/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getBookTtsConfig, synthesizeBookTts } from '@/lib/book-tts'; + +import { getAuthorizedBooksUsername } from '../../_utils'; + +export const runtime = 'nodejs'; + +type SynthesizePayload = { + sourceId?: string; + bookId?: string; + chapterHref?: string; + text?: string; + voice?: string; + rate?: string; + pitch?: string; + volume?: string; +}; + +export async function POST(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + try { + const config = await getBookTtsConfig(); + const payload = await request.json() as SynthesizePayload; + if (!payload.sourceId?.trim() || !payload.bookId?.trim() || !payload.chapterHref?.trim()) { + return NextResponse.json({ error: '缺少 sourceId / bookId / chapterHref' }, { status: 400 }); + } + + const result = await synthesizeBookTts({ + sourceId: payload.sourceId.trim(), + bookId: payload.bookId.trim(), + chapterHref: payload.chapterHref.trim(), + text: payload.text?.trim() || '', + voice: payload.voice?.trim() || config.defaultVoice, + rate: payload.rate?.trim() || config.defaultRate, + pitch: payload.pitch?.trim() || config.defaultPitch, + volume: payload.volume?.trim() || config.defaultVolume, + }); + + return NextResponse.json({ + audioBase64: result.audioBuffer.toString('base64'), + mimeType: result.mimeType, + boundaries: result.boundaries, + cacheKey: result.cacheKey, + cacheHit: result.cacheHit, + }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message || '语音合成失败' }, { status: 500 }); + } +} diff --git a/src/app/api/books/tts/voices/route.ts b/src/app/api/books/tts/voices/route.ts new file mode 100644 index 0000000..bfd284a --- /dev/null +++ b/src/app/api/books/tts/voices/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getBookTtsConfig, listBookTtsVoices } from '@/lib/book-tts'; + +import { getAuthorizedBooksUsername } from '../../_utils'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + try { + const config = await getBookTtsConfig(); + const voices = await listBookTtsVoices(); + return NextResponse.json({ + voices, + defaults: { + voice: config.defaultVoice, + rate: config.defaultRate, + pitch: config.defaultPitch, + volume: config.defaultVolume, + maxCharsPerChunk: config.maxCharsPerChunk, + prefetchChunks: config.prefetchChunks, + maxTextLengthPerRequest: config.maxTextLengthPerRequest, + }, + }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message || '获取音色列表失败' }, { status: 500 }); + } +} diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index 0c3b594..286582e 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -1,10 +1,12 @@ 'use client'; -import { BookOpen, List, Moon, Settings2, Sun } from 'lucide-react'; +import { ChevronUp, Gauge, Headphones, Loader2, Moon, Pause, Play, SkipBack, SkipForward, Square, Sun, Volume2, Waves, X } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { saveBookReadRecord } from '@/lib/book.db.client'; +import { BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types'; import { buildBookCacheKey, enforceBookCacheLimit, @@ -13,8 +15,7 @@ import { touchCachedBookFile, } from '@/lib/book-cache.client'; import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client'; -import { saveBookReadRecord } from '@/lib/book.db.client'; -import { BookReadManifest, BookReadRecord } from '@/lib/book.types'; +import { getBookTtsProgress, saveBookTtsProgress } from '@/lib/book-tts-progress.client'; declare global { interface Window { @@ -77,13 +78,41 @@ interface ReaderSettings { theme: ReaderTheme; } +interface TtsChunk { + index: number; + text: string; + start: number; + end: number; +} + +interface TtsSettings { + voice: string; + rate: string; + pitch: string; + volume: string; + autoPlayNext: boolean; +} + +type TtsStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error'; + const SETTINGS_STORAGE_KEY = 'books_epub_reader_settings'; +const TTS_SETTINGS_STORAGE_KEY = 'books_epub_tts_settings'; const DEFAULT_SETTINGS: ReaderSettings = { fontSize: 100, lineHeight: 1.7, theme: 'light', }; +const DEFAULT_TTS_SETTINGS: TtsSettings = { + voice: '', + rate: '+0%', + pitch: '+0Hz', + volume: '+0%', + autoPlayNext: true, +}; const SAVE_INTERVAL_MS = 10000; +const TTS_RATE_STEPS = [-20, -10, 0, 10, 20, 35]; +const TTS_PITCH_STEPS = [-10, 0, 10, 20]; +const TTS_VOLUME_STEPS = [-10, 0, 10, 20]; const THEME_STYLES: Record = { light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' }, @@ -91,6 +120,27 @@ const THEME_STYLES: Record) }; + } catch { + return DEFAULT_TTS_SETTINGS; + } +} + +function parseSignedNumber(value: string, _suffix: '%' | 'Hz') { + const match = value.match(/([+-]?\d+)(?:%|Hz)/); + if (!match) return 0; + return Number(match[1] || 0); +} + +function formatSignedValue(value: number, suffix: '%' | 'Hz') { + return `${value >= 0 ? '+' : ''}${value}${suffix}`; +} + function loadScriptOnce(selector: string, src: string, errorMessage: string) { return new Promise((resolve, reject) => { const existing = document.querySelector(selector) as HTMLScriptElement | null; @@ -223,6 +273,107 @@ function formatBytes(size: number): string { return `${(size / 1024 / 1024).toFixed(1)} MB`; } +function sanitizeTtsText(text: string): string { + return text + .replace(/\u00a0/g, ' ') + .replace(/\r/g, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/[ \t]{2,}/g, ' ') + .trim(); +} + +function chunkTtsText(text: string, maxChars: number): TtsChunk[] { + const normalized = sanitizeTtsText(text); + if (!normalized) return []; + + const paragraphs = normalized.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean); + const chunks: TtsChunk[] = []; + let buffer = ''; + let start = 0; + let cursor = 0; + + const flush = () => { + const value = buffer.trim(); + if (!value) return; + chunks.push({ + index: chunks.length, + text: value, + start, + end: start + value.length, + }); + cursor = start + value.length; + buffer = ''; + start = cursor; + }; + + const appendPiece = (piece: string) => { + const trimmed = piece.trim(); + if (!trimmed) return; + const next = buffer ? `${buffer}\n\n${trimmed}` : trimmed; + if (next.length <= maxChars) { + if (!buffer) start = cursor; + buffer = next; + cursor += trimmed.length; + return; + } + + if (buffer) flush(); + if (trimmed.length <= maxChars) { + buffer = trimmed; + start = cursor; + cursor += trimmed.length; + return; + } + + const sentences = trimmed.split(/(?<=[。!?!?;;])/).map((item) => item.trim()).filter(Boolean); + let local = ''; + let localStart = cursor; + for (const sentence of sentences) { + const maybe = local ? `${local}${sentence}` : sentence; + if (maybe.length <= maxChars) { + if (!local) localStart = cursor; + local = maybe; + cursor += sentence.length; + } else { + if (local) { + chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length }); + local = ''; + } + if (sentence.length <= maxChars) { + local = sentence; + localStart = cursor; + cursor += sentence.length; + } else { + for (let i = 0; i < sentence.length; i += maxChars) { + const part = sentence.slice(i, i + maxChars); + chunks.push({ index: chunks.length, text: part, start: cursor, end: cursor + part.length }); + cursor += part.length; + } + } + } + } + if (local) { + chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length }); + } + }; + + for (const paragraph of paragraphs) { + appendPiece(paragraph); + } + flush(); + return chunks; +} + +function decodeBase64Audio(base64: string, mimeType: string) { + const binary = typeof window === 'undefined' ? '' : window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: mimeType || 'audio/mpeg' }); +} + export default function BookReadPage() { const searchParams = useSearchParams(); const sourceId = searchParams.get('sourceId') || ''; @@ -238,13 +389,28 @@ export default function BookReadPage() { const [settingsOpen, setSettingsOpen] = useState(false); const [tocOpen, setTocOpen] = useState(false); const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [ttsSettings, setTtsSettings] = useState(DEFAULT_TTS_SETTINGS); const [tocItems, setTocItems] = useState([]); const [currentHref, setCurrentHref] = useState(''); const [currentChapter, setCurrentChapter] = useState(''); - const [progressPercent, setProgressPercent] = useState(0); + const [, setProgressPercent] = useState(0); const [restoredMessage, setRestoredMessage] = useState(''); - const [controlsVisible, setControlsVisible] = useState(true); const [pdfBlobUrl, setPdfBlobUrl] = useState(''); + const [ttsVoices, setTtsVoices] = useState([]); + const [ttsAvailable, setTtsAvailable] = useState(false); + const [ttsStatus, setTtsStatus] = useState('idle'); + const [ttsError, setTtsError] = useState(''); + const [ttsChunks, setTtsChunks] = useState([]); + const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0); + const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState(null); + const [ttsCurrentChapterHref, setTtsCurrentChapterHref] = useState(''); + const [ttsCurrentChapterTitle, setTtsCurrentChapterTitle] = useState(''); + const [ttsBarVisible, setTtsBarVisible] = useState(false); + const [ttsPanelOpen, setTtsPanelOpen] = useState(false); + const [ttsCurrentTime, setTtsCurrentTime] = useState(0); + const [ttsDuration, setTtsDuration] = useState(0); + const [ttsSeekValue, setTtsSeekValue] = useState(0); + const [ttsSeeking, setTtsSeeking] = useState(false); const viewerRef = useRef(null); const tocScrollRef = useRef(null); const tocItemRefs = useRef>({}); @@ -258,9 +424,22 @@ export default function BookReadPage() { const lastChapterRef = useRef(''); const locationsReadyRef = useRef(false); const tocItemsRef = useRef([]); + const audioRef = useRef(null); + const ttsChunkAudioUrlRef = useRef>({}); + const ttsChunkBlobCacheRef = useRef>({}); + const ttsChunksRef = useRef([]); + const ttsSettingsRef = useRef(DEFAULT_TTS_SETTINGS); + const ttsCurrentChunkIndexRef = useRef(0); + const ttsCurrentChapterHrefRef = useRef(''); + const ttsCurrentChapterTitleRef = useRef(''); + const ttsStatusRef = useRef('idle'); + const ttsSeekingRef = useRef(false); + const ttsPrefetchedFromChunkRef = useRef(null); + const ttsPrefetchFnRef = useRef<(fromIndex: number) => void>(() => undefined); useEffect(() => { setSettings(loadReaderSettings()); + setTtsSettings(loadTtsSettings()); }, []); useEffect(() => { @@ -269,6 +448,24 @@ export default function BookReadPage() { } }, [settings]); + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem(TTS_SETTINGS_STORAGE_KEY, JSON.stringify(ttsSettings)); + } + ttsSettingsRef.current = ttsSettings; + }, [ttsSettings]); + + useEffect(() => { + ttsStatusRef.current = ttsStatus; + }, [ttsStatus]); + + useEffect(() => { + ttsSeekingRef.current = ttsSeeking; + if (!ttsSeeking) { + setTtsSeekValue(ttsCurrentTime); + } + }, [ttsCurrentTime, ttsSeeking]); + useEffect(() => { const handleToggleSettings = () => { @@ -294,6 +491,25 @@ export default function BookReadPage() { }; }, []); + useEffect(() => { + const handleToggleTts = () => { + setTtsBarVisible((prev) => { + const next = !prev; + if (!next) { + setTtsPanelOpen(false); + } + return next; + }); + setSettingsOpen(false); + setTocOpen(false); + }; + + window.addEventListener('books-read-toggle-tts', handleToggleTts); + return () => { + window.removeEventListener('books-read-toggle-tts', handleToggleTts); + }; + }, []); + useEffect(() => { if (!sourceId || !bookId) return; @@ -321,6 +537,35 @@ export default function BookReadPage() { .catch((err) => setError(err.message || '获取阅读信息失败')); }, [sourceId, bookId, cached]); + useEffect(() => { + if (!manifest || manifest.format !== 'epub') return; + let cancelled = false; + fetch('/api/books/tts/voices') + .then(async (res) => { + const json = await res.json(); + if (!res.ok) throw new Error(json.error || '获取朗读配置失败'); + if (cancelled) return; + setTtsAvailable(true); + setTtsVoices(json.voices || []); + setTtsSettings((prev) => ({ + ...prev, + voice: prev.voice || json.defaults?.voice || '', + rate: prev.rate || json.defaults?.rate || '+0%', + pitch: prev.pitch || json.defaults?.pitch || '+0Hz', + volume: prev.volume || json.defaults?.volume || '+0%', + })); + }) + .catch((err) => { + if (cancelled) return; + setTtsAvailable(false); + setTtsVoices([]); + setTtsError(err.message || '朗读能力不可用'); + }); + return () => { + cancelled = true; + }; + }, [manifest]); + const buildReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string): BookReadRecord | null => { if (!manifest) return null; const locatorValue = location?.start?.cfi || location?.end?.cfi || ''; @@ -424,6 +669,194 @@ export default function BookReadPage() { setSettingsOpen(false); }, [ready]); + const cleanupTtsAudioUrls = useCallback(() => { + Object.values(ttsChunkBlobCacheRef.current).forEach((item) => URL.revokeObjectURL(item.url)); + ttsChunkBlobCacheRef.current = {}; + ttsChunkAudioUrlRef.current = {}; + }, []); + + const stopTts = useCallback((clearQueue = false) => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + } + setTtsCurrentTime(0); + setTtsDuration(0); + setTtsSeekValue(0); + setTtsSeeking(false); + ttsPrefetchedFromChunkRef.current = null; + setTtsLoadingChunkIndex(null); + setTtsStatus('idle'); + if (clearQueue) { + setTtsChunks([]); + ttsChunksRef.current = []; + setTtsCurrentChunkIndex(0); + ttsCurrentChunkIndexRef.current = 0; + setTtsCurrentChapterHref(''); + ttsCurrentChapterHrefRef.current = ''; + setTtsCurrentChapterTitle(''); + ttsCurrentChapterTitleRef.current = ''; + cleanupTtsAudioUrls(); + } + }, [cleanupTtsAudioUrls]); + + const persistTtsProgress = useCallback((chunkIndex?: number) => { + if (!manifest) return; + const chunks = ttsChunksRef.current; + const currentIndex = chunkIndex ?? ttsCurrentChunkIndexRef.current; + const chunk = chunks[currentIndex]; + if (!chunk || !ttsCurrentChapterHrefRef.current || !ttsSettingsRef.current.voice) return; + const progress: BookTtsProgress = { + sourceId: manifest.book.sourceId, + bookId: manifest.book.id, + chapterHref: ttsCurrentChapterHrefRef.current, + chapterTitle: ttsCurrentChapterTitleRef.current || currentChapter, + chunkIndex: currentIndex, + charOffset: chunk.start, + voice: ttsSettingsRef.current.voice, + rate: ttsSettingsRef.current.rate, + pitch: ttsSettingsRef.current.pitch, + volume: ttsSettingsRef.current.volume, + saveTime: Date.now(), + }; + saveBookTtsProgress(progress); + }, [manifest, currentChapter]); + + const getCurrentSpineDocumentText = useCallback(() => { + const iframe = viewerRef.current?.querySelector('iframe'); + const doc = iframe?.contentDocument; + const text = doc?.body?.innerText || doc?.documentElement?.textContent || ''; + return sanitizeTtsText(text); + }, []); + + const fetchTtsChunkAudioUrl = useCallback(async (chunk: TtsChunk, chapterHref: string) => { + const cached = ttsChunkBlobCacheRef.current[chunk.index]; + if (cached?.text === chunk.text) return cached.url; + if (!manifest) throw new Error('书籍信息未准备好'); + const response = await fetch('/api/books/tts/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceId: manifest.book.sourceId, + bookId: manifest.book.id, + chapterHref, + text: chunk.text, + voice: ttsSettingsRef.current.voice, + rate: ttsSettingsRef.current.rate, + pitch: ttsSettingsRef.current.pitch, + volume: ttsSettingsRef.current.volume, + }), + }); + const json = await response.json(); + if (!response.ok) throw new Error(json.error || '朗读音频生成失败'); + const blob = decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg'); + const url = URL.createObjectURL(blob); + if (cached?.url) URL.revokeObjectURL(cached.url); + ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text }; + ttsChunkAudioUrlRef.current[chunk.index] = url; + return url; + }, [manifest]); + + const prefetchTtsChunks = useCallback((fromIndex: number) => { + const chunks = ttsChunksRef.current; + const chapterHref = ttsCurrentChapterHrefRef.current; + if (!ttsSettingsRef.current.autoPlayNext || !chapterHref) return; + if (ttsPrefetchedFromChunkRef.current === fromIndex) return; + const nextIndex = fromIndex + 1; + if (nextIndex >= chunks.length) return; + ttsPrefetchedFromChunkRef.current = fromIndex; + void fetchTtsChunkAudioUrl(chunks[nextIndex], chapterHref).catch(() => undefined); + }, [fetchTtsChunkAudioUrl]); + + useEffect(() => { + ttsPrefetchFnRef.current = prefetchTtsChunks; + }, [prefetchTtsChunks]); + + const playTtsChunk = useCallback(async (index: number) => { + const chunks = ttsChunksRef.current; + const chunk = chunks[index]; + const chapterHref = ttsCurrentChapterHrefRef.current; + if (!chunk || !chapterHref) return; + try { + setTtsError(''); + setTtsLoadingChunkIndex(index); + setTtsStatus('loading'); + const url = await fetchTtsChunkAudioUrl(chunk, chapterHref); + if (!audioRef.current) { + audioRef.current = new Audio(); + } + audioRef.current.src = url; + await audioRef.current.play(); + ttsCurrentChunkIndexRef.current = index; + setTtsCurrentChunkIndex(index); + setTtsStatus('playing'); + setTtsLoadingChunkIndex(null); + persistTtsProgress(index); + } catch (error) { + setTtsStatus('error'); + setTtsLoadingChunkIndex(null); + setTtsError((error as Error).message || '朗读失败'); + } + }, [fetchTtsChunkAudioUrl, persistTtsProgress]); + + const bootstrapTtsForCurrentChapter = useCallback(async (resume = true) => { + if (!manifest || manifest.format !== 'epub') return; + const chapterHref = currentHref || manifest.lastRecord?.chapterHref || ''; + const chapterTitle = findTocLabelByHref(tocItemsRef.current, chapterHref) || currentChapter || manifest.book.title; + if (!chapterHref) { + setTtsError('当前章节尚未定位,稍后再试'); + setTtsStatus('error'); + return; + } + const text = getCurrentSpineDocumentText(); + if (!text) { + setTtsError('当前章节暂未提取到可朗读文本'); + setTtsStatus('error'); + return; + } + cleanupTtsAudioUrls(); + const chunks = chunkTtsText(text, 1200); + if (chunks.length === 0) { + setTtsError('当前章节没有可朗读内容'); + setTtsStatus('error'); + return; + } + const saved = resume ? getBookTtsProgress(manifest.book.sourceId, manifest.book.id) : null; + const startIndex = saved?.chapterHref === chapterHref ? Math.min(saved.chunkIndex, chunks.length - 1) : 0; + setTtsChunks(chunks); + ttsChunksRef.current = chunks; + setTtsCurrentChunkIndex(startIndex); + ttsCurrentChunkIndexRef.current = startIndex; + setTtsCurrentChapterHref(chapterHref); + ttsCurrentChapterHrefRef.current = chapterHref; + setTtsCurrentChapterTitle(chapterTitle); + ttsCurrentChapterTitleRef.current = chapterTitle; + await playTtsChunk(startIndex); + }, [cleanupTtsAudioUrls, currentChapter, currentHref, getCurrentSpineDocumentText, manifest, playTtsChunk]); + + const toggleTtsPlayback = useCallback(async () => { + if (!ttsAvailable) return; + if (ttsStatus === 'playing') { + audioRef.current?.pause(); + setTtsStatus('paused'); + persistTtsProgress(); + return; + } + if (ttsStatus === 'paused' && audioRef.current) { + try { + await audioRef.current.play(); + setTtsStatus('playing'); + } catch (error) { + setTtsStatus('error'); + setTtsError((error as Error).message || '恢复播放失败'); + } + return; + } + await bootstrapTtsForCurrentChapter(true); + }, [bootstrapTtsForCurrentChapter, persistTtsProgress, ttsAvailable, ttsStatus]); + @@ -608,6 +1041,83 @@ export default function BookReadPage() { }; }, [flushPendingReadRecord, persistCurrentProgress]); + useEffect(() => { + if (!audioRef.current) { + audioRef.current = new Audio(); + } + const audio = audioRef.current; + const handleEnded = () => { + setTtsCurrentTime(0); + setTtsSeekValue(0); + const nextIndex = ttsCurrentChunkIndexRef.current + 1; + if (nextIndex < ttsChunksRef.current.length) { + void playTtsChunk(nextIndex); + return; + } + setTtsStatus('idle'); + persistTtsProgress(ttsCurrentChunkIndexRef.current); + }; + const handlePause = () => { + if (!audio.ended && ttsStatusRef.current === 'playing') { + setTtsStatus('paused'); + } + }; + const handleTimeUpdate = () => { + const nextTime = audio.currentTime || 0; + const nextDuration = audio.duration || 0; + setTtsCurrentTime(nextTime); + setTtsDuration(nextDuration); + if (nextDuration > 0 && nextTime / nextDuration >= 0.6) { + ttsPrefetchFnRef.current(ttsCurrentChunkIndexRef.current); + } + if (!ttsSeekingRef.current) { + setTtsSeekValue(nextTime); + } + }; + const handleLoadedMetadata = () => { + const nextDuration = audio.duration || 0; + setTtsDuration(nextDuration); + if (!ttsSeekingRef.current) { + setTtsSeekValue(audio.currentTime || 0); + } + }; + audio.addEventListener('ended', handleEnded); + audio.addEventListener('pause', handlePause); + audio.addEventListener('timeupdate', handleTimeUpdate); + audio.addEventListener('loadedmetadata', handleLoadedMetadata); + return () => { + audio.removeEventListener('ended', handleEnded); + audio.removeEventListener('pause', handlePause); + audio.removeEventListener('timeupdate', handleTimeUpdate); + audio.removeEventListener('loadedmetadata', handleLoadedMetadata); + persistTtsProgress(); + stopTts(true); + }; + }, [persistTtsProgress, playTtsChunk, stopTts]); + + useEffect(() => { + ttsChunksRef.current = ttsChunks; + }, [ttsChunks]); + + useEffect(() => { + ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex; + }, [ttsCurrentChunkIndex]); + + useEffect(() => { + ttsCurrentChapterHrefRef.current = ttsCurrentChapterHref; + }, [ttsCurrentChapterHref]); + + useEffect(() => { + ttsCurrentChapterTitleRef.current = ttsCurrentChapterTitle; + }, [ttsCurrentChapterTitle]); + + useEffect(() => { + if (!ttsCurrentChapterHref || !currentHref) return; + if (!isSameTocTarget(ttsCurrentChapterHref, currentHref)) { + stopTts(true); + } + }, [currentHref, stopTts, ttsCurrentChapterHref]); + useEffect(() => { @@ -702,6 +1212,13 @@ export default function BookReadPage() { }), [currentHref, navigateToTarget]); const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes); + const ttsChunkPercent = ttsChunks.length > 0 ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 : 0; + const selectedVoice = ttsVoices.find((item) => item.shortName === ttsSettings.voice); + const currentChunk = ttsChunks[ttsCurrentChunkIndex]; + const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%'); + const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz'); + const ttsVolumeValue = parseSignedNumber(ttsSettings.volume, '%'); + const displayedTtsTime = ttsSeeking ? ttsSeekValue : ttsCurrentTime; if (error) return
{error}
; if (!manifest) return
准备阅读器中...
; @@ -831,6 +1348,250 @@ export default function BookReadPage() { document.body ) : null} + {manifest.format === 'epub' && ttsBarVisible ? ( + <> +
+
+
+ setTtsSeeking(true)} + onMouseDown={() => setTtsSeeking(true)} + onTouchStart={() => setTtsSeeking(true)} + onChange={(e) => setTtsSeekValue(Number(e.target.value))} + onPointerUp={(e) => { + const nextTime = Number((e.target as HTMLInputElement).value); + if (audioRef.current && Number.isFinite(nextTime)) { + audioRef.current.currentTime = nextTime; + } + setTtsCurrentTime(nextTime); + setTtsSeekValue(nextTime); + setTtsSeeking(false); + }} + onMouseUp={(e) => { + const nextTime = Number((e.target as HTMLInputElement).value); + if (audioRef.current && Number.isFinite(nextTime)) { + audioRef.current.currentTime = nextTime; + } + setTtsCurrentTime(nextTime); + setTtsSeekValue(nextTime); + setTtsSeeking(false); + }} + onTouchEnd={(e) => { + const nextTime = Number((e.target as HTMLInputElement).value); + if (audioRef.current && Number.isFinite(nextTime)) { + audioRef.current.currentTime = nextTime; + } + setTtsCurrentTime(nextTime); + setTtsSeekValue(nextTime); + setTtsSeeking(false); + }} + className='w-full accent-sky-500' + /> +
+
+
+ +
+
+ {ttsCurrentChapterTitle || currentTocLabel || currentChapter || '语音朗读'} +
+
+ + {!ttsAvailable + ? '服务异常' + : ttsLoadingChunkIndex !== null + ? '生成语音中...' + : ttsStatus === 'playing' + ? '正在播放' + : ttsStatus === 'paused' + ? '已暂停' + : '待播放'} + + {ttsChunks.length > 0 ? {ttsCurrentChunkIndex + 1}/{ttsChunks.length} : null} +
+
+ +
+
+ {selectedVoice?.displayName || '默认音色'} + {Math.floor(displayedTtsTime)}s / {Math.floor(ttsDuration || 0)}s +
+
+
+
+ + {ttsPanelOpen ? ( +
+
+
+
+ + 听书控制 +
+ +
+ +
+ {currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'} + {Math.round(ttsChunkPercent)}% +
+ +
+ + + + +
+ +
+
+
+ + 音色 +
+ +
+ + + + + + +
+ + {ttsError ?
{ttsError}
: null} +
+
+ ) : null} + + ) : null} + {ready && !tocOpen && !settingsOpen ? ( <> diff --git a/src/components/books/BooksLayout.tsx b/src/components/books/BooksLayout.tsx index a3c461d..0265981 100644 --- a/src/components/books/BooksLayout.tsx +++ b/src/components/books/BooksLayout.tsx @@ -1,9 +1,9 @@ 'use client'; -import { BookOpen, ChevronLeft, History, Library, List, Search, Settings2 } from 'lucide-react'; +import { BookOpen, ChevronLeft, Headphones, History, Library, List, MoreVertical, Search, Settings2 } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; const tabs = [ { href: '/books', label: '发现', icon: Library }, @@ -32,6 +32,8 @@ export default function BooksLayout({ children }: { children: React.ReactNode }) const searchParams = useSearchParams(); const isRead = pathname === '/books/read'; const [readHeader, setReadHeader] = useState(null); + const [readMenuOpen, setReadMenuOpen] = useState(false); + const readMenuRef = useRef(null); useEffect(() => { if (!isRead) return; @@ -49,6 +51,19 @@ export default function BooksLayout({ children }: { children: React.ReactNode }) if (!isRead) setReadHeader(null); }, [isRead, pathname]); + useEffect(() => { + if (!readMenuOpen) return; + const handleClickOutside = (event: MouseEvent) => { + if (!readMenuRef.current?.contains(event.target as Node)) { + setReadMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [readMenuOpen]); + const meta = useMemo(() => { const base = getStaticMeta(pathname); if (pathname === '/books/detail') { @@ -99,14 +114,42 @@ export default function BooksLayout({ children }: { children: React.ReactNode }) > - +
+ + {readMenuOpen ? ( +
+ + +
+ ) : null} +
) : (