电子书馆增加听书功能
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+80
-36
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user