电子书馆增加听书功能

This commit is contained in:
mtvpls
2026-05-04 14:50:28 +08:00
parent 038abff091
commit da7b01bc35
11 changed files with 1290 additions and 54 deletions
+1
View File
@@ -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",
+80 -36
View File
@@ -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([email protected])
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/[email protected]':
resolution: {integrity: sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@ast-grep/[email protected]':
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/[email protected]':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/[email protected]':
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/[email protected]':
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/[email protected]':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
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/[email protected]':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -2227,28 +2210,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/[email protected]':
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/[email protected]':
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/[email protected]':
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/[email protected]':
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/[email protected]':
resolution: {integrity: sha512-CCKEk+H+8c0WGe/8n1E20n85Tq4Pv+HNAbjP1KfUXW+01aCWSMjU56ChNrM2tvHnXicfm7QRNoZyfY8cWh7jLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]':
resolution: {integrity: sha512-VlfwJ/HCskPmQi8R0JuAFndySKVFX7yPhE658o27cjSDWWbXVtGkSbwaxstii7Q+3Rz87ZXN+HLnb1kd4R9Img==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-kuO92hTRyGy0Ts3Nsqll0rfO8eFsEJe9dGQGktkQnZ2hrJrDVN0y419dMgKy/gB2S2o7F2dpWhpfQOBehZPwVA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]':
resolution: {integrity: sha512-PXAebvNL4sYfCqi8LdY4qyFRacrRoiPZLo3NoUmiTxm7MPtYYR8CNtBGNokqDmMuZIQIecRaD/jbmFAIDz7DxQ==}
@@ -3364,49 +3339,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/[email protected]':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/[email protected]':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/[email protected]':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -3877,6 +3844,9 @@ packages:
resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
[email protected]:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -4290,6 +4260,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
[email protected]:
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
[email protected]:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -4540,6 +4513,10 @@ packages:
engines: {node: '>=16'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-53Zk6UH3+3g844APjuhtHb2KTTdRLY6fZtEZsSIF3Kz3tYEpsjwQedCtaz8qTaxYdqFOMoFyCGNOHm2PxuX2Jw==}
engines: {node: ^18.17 || ^20.9 || >=22, npm: '>=9'}
[email protected]:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -4984,6 +4961,15 @@ packages:
[email protected]:
resolution: {integrity: sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==}
[email protected]:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
[email protected]:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -5537,6 +5523,11 @@ packages:
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
engines: {node: '>=16'}
[email protected]:
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
peerDependencies:
ws: '*'
[email protected]:
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'}
[email protected]:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
@@ -7965,6 +7960,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==}
[email protected]:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
@@ -8263,6 +8262,9 @@ packages:
resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
engines: {node: '>= 6.0'}
[email protected]:
resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==}
[email protected]:
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
@@ -13475,6 +13477,14 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
[email protected]: {}
[email protected](@babel/[email protected]):
@@ -13910,6 +13920,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
[email protected]:
dependencies:
path-key: 3.1.1
@@ -14146,6 +14162,22 @@ snapshots:
signal-exit: 4.0.2
time-span: 4.0.0
[email protected]([email protected]):
dependencies:
axios: 1.16.0
cross-fetch: 4.1.0
https-proxy-agent: 7.0.6
isomorphic-ws: 5.0.0([email protected]([email protected]))
uuid: 11.1.1
ws: 8.18.3([email protected])
xml-escape: 1.1.0
transitivePeerDependencies:
- bufferutil
- debug
- encoding
- supports-color
- utf-8-validate
[email protected]: {}
[email protected]:
@@ -14830,6 +14862,8 @@ snapshots:
es6-promise: 4.2.8
webworkify-webpack: 2.1.5
[email protected]: {}
[email protected]:
dependencies:
is-callable: 1.2.7
@@ -15408,6 +15442,10 @@ snapshots:
[email protected]: {}
[email protected]([email protected]([email protected])):
dependencies:
ws: 8.18.3([email protected])
[email protected]: {}
[email protected]:
@@ -17279,6 +17317,8 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
[email protected]: {}
[email protected]:
dependencies:
punycode: 2.3.1
@@ -18529,6 +18569,8 @@ snapshots:
is-typed-array: 1.1.15
which-typed-array: 1.1.19
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -18969,6 +19011,8 @@ snapshots:
dependencies:
os-paths: 4.4.0
[email protected]: {}
[email protected]: {}
[email protected]:
+3 -3
View File
@@ -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,
+52
View File
@@ -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 });
}
}
+31
View File
@@ -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 });
}
}
+766 -5
View File
@@ -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<ReaderTheme, { bodyBg: string; bodyColor: string; panelBg: string }> = {
light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' },
@@ -91,6 +120,27 @@ const THEME_STYLES: Record<ReaderTheme, { bodyBg: string; bodyColor: string; pan
dark: { bodyBg: '#111827', bodyColor: '#e5e7eb', panelBg: '#030712' },
};
function loadTtsSettings(): TtsSettings {
if (typeof window === 'undefined') return DEFAULT_TTS_SETTINGS;
try {
const raw = localStorage.getItem(TTS_SETTINGS_STORAGE_KEY);
if (!raw) return DEFAULT_TTS_SETTINGS;
return { ...DEFAULT_TTS_SETTINGS, ...(JSON.parse(raw) as Partial<TtsSettings>) };
} 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<void>((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<ReaderSettings>(DEFAULT_SETTINGS);
const [ttsSettings, setTtsSettings] = useState<TtsSettings>(DEFAULT_TTS_SETTINGS);
const [tocItems, setTocItems] = useState<TocItem[]>([]);
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<BookTtsVoice[]>([]);
const [ttsAvailable, setTtsAvailable] = useState(false);
const [ttsStatus, setTtsStatus] = useState<TtsStatus>('idle');
const [ttsError, setTtsError] = useState('');
const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]);
const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0);
const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<number | null>(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<HTMLDivElement | null>(null);
const tocScrollRef = useRef<HTMLDivElement | null>(null);
const tocItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
@@ -258,9 +424,22 @@ export default function BookReadPage() {
const lastChapterRef = useRef('');
const locationsReadyRef = useRef(false);
const tocItemsRef = useRef<TocItem[]>([]);
const audioRef = useRef<HTMLAudioElement | null>(null);
const ttsChunkAudioUrlRef = useRef<Record<number, string>>({});
const ttsChunkBlobCacheRef = useRef<Record<number, { url: string; text: string }>>({});
const ttsChunksRef = useRef<TtsChunk[]>([]);
const ttsSettingsRef = useRef<TtsSettings>(DEFAULT_TTS_SETTINGS);
const ttsCurrentChunkIndexRef = useRef(0);
const ttsCurrentChapterHrefRef = useRef('');
const ttsCurrentChapterTitleRef = useRef('');
const ttsStatusRef = useRef<TtsStatus>('idle');
const ttsSeekingRef = useRef(false);
const ttsPrefetchedFromChunkRef = useRef<number | null>(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 <div className='p-4 text-sm text-red-500'>{error}</div>;
if (!manifest) return <div className='p-4 text-sm text-gray-500'>...</div>;
@@ -831,6 +1348,250 @@ export default function BookReadPage() {
document.body
) : null}
{manifest.format === 'epub' && ttsBarVisible ? (
<>
<div className='absolute inset-x-0 bottom-3 z-20 mx-auto w-[min(94vw,34rem)]'>
<div className='overflow-hidden rounded-3xl border border-gray-200 bg-white/95 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/95'>
<div className='px-2 pt-2'>
<input
type='range'
min={0}
max={Math.max(ttsDuration, 0)}
step={0.1}
value={Math.min(ttsSeekValue, Math.max(ttsDuration, 0))}
disabled={!ttsAvailable || ttsDuration <= 0}
onPointerDown={() => 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'
/>
</div>
<div className='px-3 py-2.5'>
<div className='flex items-center gap-2'>
<button
type='button'
onClick={() => void toggleTtsPlayback()}
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-600 text-white disabled:opacity-50'
>
{ttsLoadingChunkIndex !== null ? <Loader2 className='h-4 w-4 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-4 w-4' /> : <Play className='h-4 w-4' />}
</button>
<div className='min-w-0 flex-1'>
<div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>
{ttsCurrentChapterTitle || currentTocLabel || currentChapter || '语音朗读'}
</div>
<div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'>
<span className='truncate'>
{!ttsAvailable
? '服务异常'
: ttsLoadingChunkIndex !== null
? '生成语音中...'
: ttsStatus === 'playing'
? '正在播放'
: ttsStatus === 'paused'
? '已暂停'
: '待播放'}
</span>
{ttsChunks.length > 0 ? <span>{ttsCurrentChunkIndex + 1}/{ttsChunks.length}</span> : null}
</div>
</div>
<button
type='button'
onClick={() => setTtsPanelOpen((prev) => !prev)}
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200'
>
<ChevronUp className={`h-4 w-4 transition-transform ${ttsPanelOpen ? 'rotate-180' : ''}`} />
</button>
</div>
<div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'>
<span>{selectedVoice?.displayName || '默认音色'}</span>
<span>{Math.floor(displayedTtsTime)}s / {Math.floor(ttsDuration || 0)}s</span>
</div>
</div>
</div>
</div>
{ttsPanelOpen ? (
<div className='absolute inset-x-0 bottom-20 z-30 mx-auto w-[min(94vw,34rem)]'>
<div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'>
<div className='mb-3 flex items-center justify-between'>
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
<Headphones className='h-4 w-4 text-sky-500' />
</div>
<button
type='button'
onClick={() => setTtsPanelOpen(false)}
className='flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-900 dark:text-gray-300'
>
<X className='h-4 w-4' />
</button>
</div>
<div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'>
<span className='truncate'>{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}</span>
<span className='ml-2 shrink-0'>{Math.round(ttsChunkPercent)}%</span>
</div>
<div className='mb-4 flex items-center justify-center gap-3'>
<button
type='button'
onClick={() => {
const next = Math.max(0, ttsCurrentChunkIndex - 1);
if (ttsChunks[next]) void playTtsChunk(next);
}}
disabled={ttsCurrentChunkIndex <= 0 || ttsChunks.length === 0}
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40'
>
<SkipBack className='h-5 w-5' />
</button>
<button
type='button'
onClick={() => void toggleTtsPlayback()}
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
className='flex h-14 w-14 items-center justify-center rounded-full bg-sky-600 text-white shadow-lg disabled:opacity-50'
>
{ttsLoadingChunkIndex !== null ? <Loader2 className='h-5 w-5 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-5 w-5' /> : <Play className='h-5 w-5' />}
</button>
<button
type='button'
onClick={() => {
const next = ttsCurrentChunkIndex + 1;
if (ttsChunks[next]) void playTtsChunk(next);
}}
disabled={ttsCurrentChunkIndex >= ttsChunks.length - 1 || ttsChunks.length === 0}
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40'
>
<SkipForward className='h-5 w-5' />
</button>
<button
type='button'
onClick={() => stopTts(true)}
disabled={ttsStatus === 'idle' && ttsChunks.length === 0}
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40'
>
<Square className='h-4 w-4' />
</button>
</div>
<div className='space-y-4'>
<div>
<div className='mb-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
<Waves className='h-3.5 w-3.5' />
<span></span>
</div>
<select
value={ttsSettings.voice}
onChange={(e) => {
stopTts(true);
setTtsSettings((prev) => ({ ...prev, voice: e.target.value }));
}}
className='w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'
>
{ttsVoices.map((voice) => (
<option key={voice.shortName} value={voice.shortName}>
{voice.displayName || voice.shortName}
</option>
))}
</select>
</div>
<label className='block'>
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<span className='flex items-center gap-2'><Gauge className='h-3.5 w-3.5' /></span>
<span>{ttsSettings.rate}</span>
</div>
<input
type='range'
min={0}
max={TTS_RATE_STEPS.length - 1}
step={1}
value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))}
onChange={(e) => {
stopTts(true);
const nextValue = TTS_RATE_STEPS[Number(e.target.value)] ?? 0;
setTtsSettings((prev) => ({ ...prev, rate: formatSignedValue(nextValue, '%') }));
}}
className='w-full'
/>
</label>
<label className='block'>
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<span className='flex items-center gap-2'><Waves className='h-3.5 w-3.5' /></span>
<span>{ttsSettings.pitch}</span>
</div>
<input
type='range'
min={0}
max={TTS_PITCH_STEPS.length - 1}
step={1}
value={Math.max(0, TTS_PITCH_STEPS.indexOf(ttsPitchValue))}
onChange={(e) => {
stopTts(true);
const nextValue = TTS_PITCH_STEPS[Number(e.target.value)] ?? 0;
setTtsSettings((prev) => ({ ...prev, pitch: formatSignedValue(nextValue, 'Hz') }));
}}
className='w-full'
/>
</label>
<label className='block'>
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<span className='flex items-center gap-2'><Volume2 className='h-3.5 w-3.5' /></span>
<span>{ttsSettings.volume}</span>
</div>
<input
type='range'
min={0}
max={TTS_VOLUME_STEPS.length - 1}
step={1}
value={Math.max(0, TTS_VOLUME_STEPS.indexOf(ttsVolumeValue))}
onChange={(e) => {
stopTts(true);
const nextValue = TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0;
setTtsSettings((prev) => ({ ...prev, volume: formatSignedValue(nextValue, '%') }));
}}
className='w-full'
/>
</label>
</div>
{ttsError ? <div className='mt-3 text-xs text-red-500'>{ttsError}</div> : null}
</div>
</div>
) : null}
</>
) : null}
{ready && !tocOpen && !settingsOpen ? (
<>
+53 -10
View File
@@ -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<ReadHeaderPayload | null>(null);
const [readMenuOpen, setReadMenuOpen] = useState(false);
const readMenuRef = useRef<HTMLDivElement | null>(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 })
>
<List className='h-5 w-5' />
</button>
<button
type='button'
onClick={() => window.dispatchEvent(new CustomEvent('books-read-toggle-settings'))}
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
aria-label='设置'
>
<Settings2 className='h-5 w-5' />
</button>
<div className='relative' ref={readMenuRef}>
<button
type='button'
onClick={() => setReadMenuOpen((prev) => !prev)}
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
aria-label='更多'
>
<MoreVertical className='h-5 w-5' />
</button>
{readMenuOpen ? (
<div className='absolute right-0 top-12 z-50 min-w-[9rem] overflow-hidden rounded-2xl border border-gray-200 bg-white py-1 shadow-xl dark:border-gray-800 dark:bg-gray-950'>
<button
type='button'
onClick={() => {
setReadMenuOpen(false);
window.dispatchEvent(new CustomEvent('books-read-toggle-settings'));
}}
className='flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
>
<Settings2 className='h-4 w-4' />
</button>
<button
type='button'
onClick={() => {
setReadMenuOpen(false);
window.dispatchEvent(new CustomEvent('books-read-toggle-tts'));
}}
className='flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
>
<Headphones className='h-4 w-4' />
</button>
</div>
) : null}
</div>
</div>
) : (
<nav className='hidden items-center gap-2 md:flex'>
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { BookTtsProgress } from './book.types';
import { generateStorageKey } from './db.client';
const BOOK_TTS_PROGRESS_KEY = 'moontv_book_tts_progress';
function readAll(): Record<string, BookTtsProgress> {
if (typeof window === 'undefined') return {};
try {
const raw = localStorage.getItem(BOOK_TTS_PROGRESS_KEY);
return raw ? JSON.parse(raw) as Record<string, BookTtsProgress> : {};
} catch {
return {};
}
}
function writeAll(data: Record<string, BookTtsProgress>) {
if (typeof window === 'undefined') return;
localStorage.setItem(BOOK_TTS_PROGRESS_KEY, JSON.stringify(data));
}
export function getBookTtsProgress(sourceId: string, bookId: string): BookTtsProgress | null {
return readAll()[generateStorageKey(sourceId, bookId)] || null;
}
export function saveBookTtsProgress(progress: BookTtsProgress) {
const data = readAll();
data[generateStorageKey(progress.sourceId, progress.bookId)] = progress;
writeAll(data);
}
export function deleteBookTtsProgress(sourceId: string, bookId: string) {
const data = readAll();
delete data[generateStorageKey(sourceId, bookId)];
writeAll(data);
}
+227
View File
@@ -0,0 +1,227 @@
import { createHash } from 'crypto';
import { BookTtsBoundary, BookTtsVoice } from './book.types';
import { getConfig } from './config';
type RawVoice = {
Name?: string;
ShortName?: string;
Locale?: string;
Gender?: string;
FriendlyName?: string;
DisplayName?: string;
};
type RawBoundary = {
offset?: number;
duration?: number;
text?: string;
word?: string;
};
type TtsSynthesizeResult = {
audioBuffer: Buffer;
mimeType: string;
boundaries: BookTtsBoundary[];
};
type EdgeTtsSynthesizer = {
synthesize: () => Promise<{
audio?: unknown;
mimeType?: string;
subtitle?: RawBoundary[] | { words?: RawBoundary[] };
subtitles?: RawBoundary[];
}>;
};
type EdgeTtsVoicesManagerInstance = {
getVoices?: () => Promise<RawVoice[]>;
voices?: RawVoice[];
};
type EdgeTtsConstructor = new (
text: string,
voice: string,
options?: {
rate?: string;
pitch?: string;
volume?: string;
}
) => EdgeTtsSynthesizer;
export type BookTtsRuntimeConfig = {
enabled: boolean;
provider: 'edge-tts-universal';
defaultVoice: string;
defaultRate: string;
defaultPitch: string;
defaultVolume: string;
maxCharsPerChunk: number;
prefetchChunks: number;
cacheEnabled: boolean;
cacheTtl: number;
maxTextLengthPerRequest: number;
};
type EdgeTtsModule = {
EdgeTTS?: EdgeTtsConstructor;
VoicesManager?: {
create?: () => Promise<EdgeTtsVoicesManagerInstance>;
new (): EdgeTtsVoicesManagerInstance;
};
default?: {
EdgeTTS?: EdgeTtsConstructor;
VoicesManager?: {
create?: () => Promise<EdgeTtsVoicesManagerInstance>;
new (): EdgeTtsVoicesManagerInstance;
};
};
};
function resolveEdgeTtsModule(): EdgeTtsModule {
try {
// eslint-disable-next-line no-eval
return eval('require')('edge-tts-universal') as EdgeTtsModule;
} catch (error) {
throw new Error(`未安装 edge-tts-universal,请先执行 pnpm add edge-tts-universal。${(error as Error).message}`);
}
}
function normalizeVoice(item: RawVoice): BookTtsVoice {
const shortName = item.ShortName || item.Name || '';
const displayName = item.DisplayName || item.FriendlyName || shortName;
return {
name: item.Name || shortName,
shortName,
locale: item.Locale || '',
gender: item.Gender || undefined,
displayName,
};
}
function normalizeBoundary(item: RawBoundary): BookTtsBoundary {
return {
offset: Number(item.offset || 0),
duration: Number(item.duration || 0),
text: item.text || item.word || '',
};
}
function isArrayBufferLike(audio: unknown): audio is { arrayBuffer: () => Promise<ArrayBuffer> } {
return !!audio && typeof audio === 'object' && typeof (audio as { arrayBuffer?: unknown }).arrayBuffer === 'function';
}
async function toBuffer(audio: unknown): Promise<Buffer> {
if (Buffer.isBuffer(audio)) return audio;
if (audio instanceof Uint8Array) return Buffer.from(audio);
if (audio instanceof ArrayBuffer) return Buffer.from(audio);
if (isArrayBufferLike(audio)) return Buffer.from(await audio.arrayBuffer());
if (typeof audio === 'string') return Buffer.from(audio, 'base64');
throw new Error('无法识别 edge-tts-universal 返回的音频格式');
}
function buildCacheKey(input: {
sourceId: string;
bookId: string;
chapterHref: string;
voice: string;
rate: string;
pitch: string;
volume: string;
text: string;
}) {
return createHash('sha256').update(JSON.stringify(input)).digest('hex');
}
export async function getBookTtsConfig(): Promise<BookTtsRuntimeConfig> {
await getConfig();
return {
enabled: true,
provider: 'edge-tts-universal',
defaultVoice: 'zh-CN-XiaoxiaoNeural',
defaultRate: '+0%',
defaultPitch: '+0Hz',
defaultVolume: '+0%',
maxCharsPerChunk: 1200,
prefetchChunks: 1,
cacheEnabled: false,
cacheTtl: 0,
maxTextLengthPerRequest: 2000,
};
}
export async function listBookTtsVoices(): Promise<BookTtsVoice[]> {
const mod = resolveEdgeTtsModule();
const VoicesManager = mod.VoicesManager || mod.default?.VoicesManager;
if (!VoicesManager) throw new Error('edge-tts-universal 未导出 VoicesManager');
let voices: RawVoice[] = [];
if (typeof VoicesManager.create === 'function') {
const manager = await VoicesManager.create();
voices = (await manager.getVoices?.()) || manager.voices || [];
} else {
const manager = new VoicesManager();
voices = (await manager.getVoices?.()) || manager.voices || [];
}
return voices
.map(normalizeVoice)
.filter((item) => !!item.shortName)
.sort((a, b) => {
const zhA = a.locale.startsWith('zh') ? 0 : 1;
const zhB = b.locale.startsWith('zh') ? 0 : 1;
return zhA - zhB || a.locale.localeCompare(b.locale) || a.shortName.localeCompare(b.shortName);
});
}
export async function synthesizeBookTts(input: {
sourceId: string;
bookId: string;
chapterHref: string;
text: string;
voice: string;
rate: string;
pitch: string;
volume: string;
}): Promise<TtsSynthesizeResult & { cacheKey: string; cacheHit: boolean }> {
const config = await getBookTtsConfig();
const normalizedText = input.text.trim();
if (!normalizedText) throw new Error('缺少朗读文本');
if (normalizedText.length > config.maxTextLengthPerRequest) {
throw new Error(`单次朗读文本过长,最多 ${config.maxTextLengthPerRequest} 个字符`);
}
const cacheKey = buildCacheKey({
...input,
text: normalizedText,
});
const mod = resolveEdgeTtsModule();
const EdgeTTS = mod.EdgeTTS || mod.default?.EdgeTTS;
if (!EdgeTTS) throw new Error('edge-tts-universal 未导出 EdgeTTS');
const tts = new EdgeTTS(
normalizedText,
input.voice || config.defaultVoice,
{
rate: input.rate || config.defaultRate,
pitch: input.pitch || config.defaultPitch,
volume: input.volume || config.defaultVolume,
}
);
const result = await tts.synthesize();
const payload: TtsSynthesizeResult = {
audioBuffer: await toBuffer(result?.audio),
mimeType: result?.mimeType || 'audio/mpeg',
boundaries: Array.isArray(result?.subtitle)
? result.subtitle.map(normalizeBoundary)
: Array.isArray(result?.subtitle?.words)
? result.subtitle?.words?.map(normalizeBoundary) || []
: Array.isArray(result?.subtitles)
? result.subtitles.map(normalizeBoundary)
: [],
};
return { ...payload, cacheKey, cacheHit: false };
}
+28
View File
@@ -128,6 +128,34 @@ export interface BookReadRecord {
saveTime: number;
}
export interface BookTtsVoice {
name: string;
shortName: string;
locale: string;
gender?: string;
displayName: string;
}
export interface BookTtsBoundary {
offset: number;
duration: number;
text: string;
}
export interface BookTtsProgress {
sourceId: string;
bookId: string;
chapterHref: string;
chapterTitle?: string;
chunkIndex: number;
charOffset: number;
voice: string;
rate: string;
pitch: string;
volume: string;
saveTime: number;
}
export interface BookReadManifest {
book: BookDetail;
format: 'epub' | 'pdf';
+12
View File
@@ -810,6 +810,18 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
adminConfig.MusicConfig.ProxyEnabled = true;
}
if (!adminConfig.OPDSConfig) {
adminConfig.OPDSConfig = {
Enabled: false,
Sources: [],
CacheTTL: 10 * 60 * 1000,
};
} else {
if (adminConfig.OPDSConfig.CacheTTL === undefined) {
adminConfig.OPDSConfig.CacheTTL = 10 * 60 * 1000;
}
}
return adminConfig;
}