电子书馆增加听书功能
This commit is contained in:
@@ -44,6 +44,7 @@
|
|||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"edge-tts-universal": "^1.4.0",
|
||||||
"flv.js": "^1.6.2",
|
"flv.js": "^1.6.2",
|
||||||
"framer-motion": "^12.18.1",
|
"framer-motion": "^12.18.1",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
|
|||||||
Generated
+80
-36
@@ -65,6 +65,9 @@ importers:
|
|||||||
crypto-js:
|
crypto-js:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
|
edge-tts-universal:
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0([email protected])
|
||||||
flv.js:
|
flv.js:
|
||||||
specifier: ^1.6.2
|
specifier: ^1.6.2
|
||||||
version: 1.6.2
|
version: 1.6.2
|
||||||
@@ -297,28 +300,24 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@ast-grep/[email protected]':
|
'@ast-grep/[email protected]':
|
||||||
resolution: {integrity: sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A==}
|
resolution: {integrity: sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@ast-grep/[email protected]':
|
'@ast-grep/[email protected]':
|
||||||
resolution: {integrity: sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA==}
|
resolution: {integrity: sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@ast-grep/[email protected]':
|
'@ast-grep/[email protected]':
|
||||||
resolution: {integrity: sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ==}
|
resolution: {integrity: sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@ast-grep/[email protected]':
|
'@ast-grep/[email protected]':
|
||||||
resolution: {integrity: sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w==}
|
resolution: {integrity: sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w==}
|
||||||
@@ -1921,105 +1920,89 @@ packages:
|
|||||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
@@ -2227,28 +2210,24 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
|
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
|
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
|
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
|
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
|
||||||
@@ -2438,28 +2417,24 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-CCKEk+H+8c0WGe/8n1E20n85Tq4Pv+HNAbjP1KfUXW+01aCWSMjU56ChNrM2tvHnXicfm7QRNoZyfY8cWh7jLQ==}
|
resolution: {integrity: sha512-CCKEk+H+8c0WGe/8n1E20n85Tq4Pv+HNAbjP1KfUXW+01aCWSMjU56ChNrM2tvHnXicfm7QRNoZyfY8cWh7jLQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-VlfwJ/HCskPmQi8R0JuAFndySKVFX7yPhE658o27cjSDWWbXVtGkSbwaxstii7Q+3Rz87ZXN+HLnb1kd4R9Img==}
|
resolution: {integrity: sha512-VlfwJ/HCskPmQi8R0JuAFndySKVFX7yPhE658o27cjSDWWbXVtGkSbwaxstii7Q+3Rz87ZXN+HLnb1kd4R9Img==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-kuO92hTRyGy0Ts3Nsqll0rfO8eFsEJe9dGQGktkQnZ2hrJrDVN0y419dMgKy/gB2S2o7F2dpWhpfQOBehZPwVA==}
|
resolution: {integrity: sha512-kuO92hTRyGy0Ts3Nsqll0rfO8eFsEJe9dGQGktkQnZ2hrJrDVN0y419dMgKy/gB2S2o7F2dpWhpfQOBehZPwVA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-PXAebvNL4sYfCqi8LdY4qyFRacrRoiPZLo3NoUmiTxm7MPtYYR8CNtBGNokqDmMuZIQIecRaD/jbmFAIDz7DxQ==}
|
resolution: {integrity: sha512-PXAebvNL4sYfCqi8LdY4qyFRacrRoiPZLo3NoUmiTxm7MPtYYR8CNtBGNokqDmMuZIQIecRaD/jbmFAIDz7DxQ==}
|
||||||
@@ -3364,49 +3339,41 @@ packages:
|
|||||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||||
@@ -3877,6 +3844,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==}
|
resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4290,6 +4260,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -4540,6 +4513,10 @@ packages:
|
|||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-53Zk6UH3+3g844APjuhtHb2KTTdRLY6fZtEZsSIF3Kz3tYEpsjwQedCtaz8qTaxYdqFOMoFyCGNOHm2PxuX2Jw==}
|
||||||
|
engines: {node: ^18.17 || ^20.9 || >=22, npm: '>=9'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -4984,6 +4961,15 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5537,6 +5523,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
|
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
|
||||||
|
peerDependencies:
|
||||||
|
ws: '*'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6953,6 +6944,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
|
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
|
||||||
|
|
||||||
@@ -7965,6 +7960,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -8263,6 +8262,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
|
resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
|
||||||
engines: {node: '>= 6.0'}
|
engines: {node: '>= 6.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
|
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
|
||||||
|
|
||||||
@@ -13475,6 +13477,14 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[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]: {}
|
||||||
|
|
||||||
[email protected](@babel/[email protected]):
|
[email protected](@babel/[email protected]):
|
||||||
@@ -13910,6 +13920,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
node-fetch: 2.7.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -14146,6 +14162,22 @@ snapshots:
|
|||||||
signal-exit: 4.0.2
|
signal-exit: 4.0.2
|
||||||
time-span: 4.0.0
|
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]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -14830,6 +14862,8 @@ snapshots:
|
|||||||
es6-promise: 4.2.8
|
es6-promise: 4.2.8
|
||||||
webworkify-webpack: 2.1.5
|
webworkify-webpack: 2.1.5
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
@@ -15408,6 +15442,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected])):
|
||||||
|
dependencies:
|
||||||
|
ws: 8.18.3([email protected])
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -17279,6 +17317,8 @@ snapshots:
|
|||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
ipaddr.js: 1.9.1
|
ipaddr.js: 1.9.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
@@ -18529,6 +18569,8 @@ snapshots:
|
|||||||
is-typed-array: 1.1.15
|
is-typed-array: 1.1.15
|
||||||
which-typed-array: 1.1.19
|
which-typed-array: 1.1.19
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -18969,6 +19011,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
os-paths: 4.4.0
|
os-paths: 4.4.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
|
|||||||
@@ -40,21 +40,21 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Mail,
|
Mail,
|
||||||
Palette,
|
Palette,
|
||||||
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
|
Trash2,
|
||||||
Tv,
|
Tv,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
Video,
|
Video,
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { GripVertical } from 'lucide-react';
|
import { GripVertical } from 'lucide-react';
|
||||||
import { Fragment, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
||||||
import { BookSource } from '@/lib/book.types';
|
|
||||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||||
|
import { BookSource } from '@/lib/book.types';
|
||||||
import {
|
import {
|
||||||
ALL_FEATURE_PERMISSION_KEYS,
|
ALL_FEATURE_PERMISSION_KEYS,
|
||||||
FEATURE_PERMISSION_OPTIONS,
|
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';
|
'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 { useSearchParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { saveBookReadRecord } from '@/lib/book.db.client';
|
||||||
|
import { BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types';
|
||||||
import {
|
import {
|
||||||
buildBookCacheKey,
|
buildBookCacheKey,
|
||||||
enforceBookCacheLimit,
|
enforceBookCacheLimit,
|
||||||
@@ -13,8 +15,7 @@ import {
|
|||||||
touchCachedBookFile,
|
touchCachedBookFile,
|
||||||
} from '@/lib/book-cache.client';
|
} from '@/lib/book-cache.client';
|
||||||
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
||||||
import { saveBookReadRecord } from '@/lib/book.db.client';
|
import { getBookTtsProgress, saveBookTtsProgress } from '@/lib/book-tts-progress.client';
|
||||||
import { BookReadManifest, BookReadRecord } from '@/lib/book.types';
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -77,13 +78,41 @@ interface ReaderSettings {
|
|||||||
theme: ReaderTheme;
|
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 SETTINGS_STORAGE_KEY = 'books_epub_reader_settings';
|
||||||
|
const TTS_SETTINGS_STORAGE_KEY = 'books_epub_tts_settings';
|
||||||
const DEFAULT_SETTINGS: ReaderSettings = {
|
const DEFAULT_SETTINGS: ReaderSettings = {
|
||||||
fontSize: 100,
|
fontSize: 100,
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
};
|
};
|
||||||
|
const DEFAULT_TTS_SETTINGS: TtsSettings = {
|
||||||
|
voice: '',
|
||||||
|
rate: '+0%',
|
||||||
|
pitch: '+0Hz',
|
||||||
|
volume: '+0%',
|
||||||
|
autoPlayNext: true,
|
||||||
|
};
|
||||||
const SAVE_INTERVAL_MS = 10000;
|
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 }> = {
|
const THEME_STYLES: Record<ReaderTheme, { bodyBg: string; bodyColor: string; panelBg: string }> = {
|
||||||
light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' },
|
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' },
|
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) {
|
function loadScriptOnce(selector: string, src: string, errorMessage: string) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const existing = document.querySelector(selector) as HTMLScriptElement | null;
|
const existing = document.querySelector(selector) as HTMLScriptElement | null;
|
||||||
@@ -223,6 +273,107 @@ function formatBytes(size: number): string {
|
|||||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
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() {
|
export default function BookReadPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const sourceId = searchParams.get('sourceId') || '';
|
const sourceId = searchParams.get('sourceId') || '';
|
||||||
@@ -238,13 +389,28 @@ export default function BookReadPage() {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [tocOpen, setTocOpen] = useState(false);
|
const [tocOpen, setTocOpen] = useState(false);
|
||||||
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
|
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
|
||||||
|
const [ttsSettings, setTtsSettings] = useState<TtsSettings>(DEFAULT_TTS_SETTINGS);
|
||||||
const [tocItems, setTocItems] = useState<TocItem[]>([]);
|
const [tocItems, setTocItems] = useState<TocItem[]>([]);
|
||||||
const [currentHref, setCurrentHref] = useState('');
|
const [currentHref, setCurrentHref] = useState('');
|
||||||
const [currentChapter, setCurrentChapter] = useState('');
|
const [currentChapter, setCurrentChapter] = useState('');
|
||||||
const [progressPercent, setProgressPercent] = useState(0);
|
const [, setProgressPercent] = useState(0);
|
||||||
const [restoredMessage, setRestoredMessage] = useState('');
|
const [restoredMessage, setRestoredMessage] = useState('');
|
||||||
const [controlsVisible, setControlsVisible] = useState(true);
|
|
||||||
const [pdfBlobUrl, setPdfBlobUrl] = useState('');
|
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 viewerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const tocScrollRef = useRef<HTMLDivElement | null>(null);
|
const tocScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const tocItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const tocItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
@@ -258,9 +424,22 @@ export default function BookReadPage() {
|
|||||||
const lastChapterRef = useRef('');
|
const lastChapterRef = useRef('');
|
||||||
const locationsReadyRef = useRef(false);
|
const locationsReadyRef = useRef(false);
|
||||||
const tocItemsRef = useRef<TocItem[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
setSettings(loadReaderSettings());
|
setSettings(loadReaderSettings());
|
||||||
|
setTtsSettings(loadTtsSettings());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -269,6 +448,24 @@ export default function BookReadPage() {
|
|||||||
}
|
}
|
||||||
}, [settings]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleToggleSettings = () => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!sourceId || !bookId) return;
|
if (!sourceId || !bookId) return;
|
||||||
@@ -321,6 +537,35 @@ export default function BookReadPage() {
|
|||||||
.catch((err) => setError(err.message || '获取阅读信息失败'));
|
.catch((err) => setError(err.message || '获取阅读信息失败'));
|
||||||
}, [sourceId, bookId, cached]);
|
}, [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 => {
|
const buildReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string): BookReadRecord | null => {
|
||||||
if (!manifest) return null;
|
if (!manifest) return null;
|
||||||
const locatorValue = location?.start?.cfi || location?.end?.cfi || '';
|
const locatorValue = location?.start?.cfi || location?.end?.cfi || '';
|
||||||
@@ -424,6 +669,194 @@ export default function BookReadPage() {
|
|||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
}, [ready]);
|
}, [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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
@@ -702,6 +1212,13 @@ export default function BookReadPage() {
|
|||||||
}), [currentHref, navigateToTarget]);
|
}), [currentHref, navigateToTarget]);
|
||||||
|
|
||||||
const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes);
|
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 (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>;
|
if (!manifest) return <div className='p-4 text-sm text-gray-500'>准备阅读器中...</div>;
|
||||||
@@ -831,6 +1348,250 @@ export default function BookReadPage() {
|
|||||||
document.body
|
document.body
|
||||||
) : null}
|
) : 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 ? (
|
{ready && !tocOpen && !settingsOpen ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'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 Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ href: '/books', label: '发现', icon: Library },
|
{ href: '/books', label: '发现', icon: Library },
|
||||||
@@ -32,6 +32,8 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isRead = pathname === '/books/read';
|
const isRead = pathname === '/books/read';
|
||||||
const [readHeader, setReadHeader] = useState<ReadHeaderPayload | null>(null);
|
const [readHeader, setReadHeader] = useState<ReadHeaderPayload | null>(null);
|
||||||
|
const [readMenuOpen, setReadMenuOpen] = useState(false);
|
||||||
|
const readMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRead) return;
|
if (!isRead) return;
|
||||||
@@ -49,6 +51,19 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
if (!isRead) setReadHeader(null);
|
if (!isRead) setReadHeader(null);
|
||||||
}, [isRead, pathname]);
|
}, [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 meta = useMemo(() => {
|
||||||
const base = getStaticMeta(pathname);
|
const base = getStaticMeta(pathname);
|
||||||
if (pathname === '/books/detail') {
|
if (pathname === '/books/detail') {
|
||||||
@@ -99,14 +114,42 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
>
|
>
|
||||||
<List className='h-5 w-5' />
|
<List className='h-5 w-5' />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className='relative' ref={readMenuRef}>
|
||||||
type='button'
|
<button
|
||||||
onClick={() => window.dispatchEvent(new CustomEvent('books-read-toggle-settings'))}
|
type='button'
|
||||||
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
|
onClick={() => setReadMenuOpen((prev) => !prev)}
|
||||||
aria-label='设置'
|
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>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<nav className='hidden items-center gap-2 md:flex'>
|
<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;
|
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 {
|
export interface BookReadManifest {
|
||||||
book: BookDetail;
|
book: BookDetail;
|
||||||
format: 'epub' | 'pdf';
|
format: 'epub' | 'pdf';
|
||||||
|
|||||||
@@ -810,6 +810,18 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
adminConfig.MusicConfig.ProxyEnabled = true;
|
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;
|
return adminConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user