Former-commit-id: 279a92966786748029a3d7542a9b75877a401c74
This commit is contained in:
troyeguo
2023-05-20 19:31:04 +08:00
parent 4725001f2e
commit 3f77693ef9
22 changed files with 478 additions and 339 deletions

291
edge-tts.js Normal file
View File

@@ -0,0 +1,291 @@
const { randomBytes } = require("crypto");
const { WebSocket } = require("ws");
const FORMAT_CONTENT_TYPE = new Map([
["raw-16khz-16bit-mono-pcm", "audio/basic"],
["raw-48khz-16bit-mono-pcm", "audio/basic"],
["raw-8khz-8bit-mono-mulaw", "audio/basic"],
["raw-8khz-8bit-mono-alaw", "audio/basic"],
["raw-16khz-16bit-mono-truesilk", "audio/SILK"],
["raw-24khz-16bit-mono-truesilk", "audio/SILK"],
["riff-16khz-16bit-mono-pcm", "audio/x-wav"],
["riff-24khz-16bit-mono-pcm", "audio/x-wav"],
["riff-48khz-16bit-mono-pcm", "audio/x-wav"],
["riff-8khz-8bit-mono-mulaw", "audio/x-wav"],
["riff-8khz-8bit-mono-alaw", "audio/x-wav"],
["audio-16khz-32kbitrate-mono-mp3", "audio/mpeg"],
["audio-16khz-64kbitrate-mono-mp3", "audio/mpeg"],
["audio-16khz-128kbitrate-mono-mp3", "audio/mpeg"],
["audio-24khz-48kbitrate-mono-mp3", "audio/mpeg"],
["audio-24khz-96kbitrate-mono-mp3", "audio/mpeg"],
["audio-24khz-160kbitrate-mono-mp3", "audio/mpeg"],
["audio-48khz-96kbitrate-mono-mp3", "audio/mpeg"],
["audio-48khz-192kbitrate-mono-mp3", "audio/mpeg"],
["webm-16khz-16bit-mono-opus", "audio/webm; codec=opus"],
["webm-24khz-16bit-mono-opus", "audio/webm; codec=opus"],
["ogg-16khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=16000"],
["ogg-24khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=24000"],
["ogg-48khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=48000"],
]);
class Service {
ws = null;
executorMap;
bufferMap;
timer = null;
constructor() {
this.executorMap = new Map();
this.bufferMap = new Map();
}
async connect() {
const connectionId = randomBytes(16).toString("hex").toLowerCase();
let url = `wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&ConnectionId=${connectionId}`;
console.log(url);
let ws = new WebSocket(url, {
host: "speech.platform.bing.com",
origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36 Edg/103.0.1264.44",
},
});
console.log(ws);
return new Promise((resolve, reject) => {
ws.on("open", () => {
resolve(ws);
});
ws.on("close", (code, reason) => {
// 服务器会自动断开空闲超过30秒的连接
this.ws = null;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
for (let [key, value] of this.executorMap) {
value.reject(`连接已关闭: ${reason} ${code}`);
}
this.executorMap.clear();
this.bufferMap.clear();
console.info(`连接已关闭: ${reason} ${code}`);
});
ws.on("message", (message, isBinary) => {
let pattern = /X-RequestId:(?<id>[a-z|0-9]*)/;
if (!isBinary) {
console.debug("收到文本消息:%s", message);
let data = message.toString();
if (data.includes("Path:turn.start")) {
// 开始传输
let matches = data.match(pattern);
let requestId = matches.groups.id;
console.debug(`开始传输:${requestId}……`);
this.bufferMap.set(requestId, Buffer.from([]));
} else if (data.includes("Path:turn.end")) {
// 结束传输
let matches = data.match(pattern);
let requestId = matches.groups.id;
let executor = this.executorMap.get(requestId);
if (executor) {
this.executorMap.delete(matches.groups.id);
let result = this.bufferMap.get(requestId);
executor.resolve(result);
console.debug(`传输完成:${requestId}……`);
} else {
console.debug(`请求已被丢弃:${requestId}`);
}
}
} else if (isBinary) {
let separator = "Path:audio\r\n";
let data = message;
let contentIndex = data.indexOf(separator) + separator.length;
let headers = data.slice(2, contentIndex).toString();
let matches = headers.match(pattern);
let requestId = matches.groups.id;
let content = data.slice(contentIndex);
console.debug(
`收到音频片段:${requestId} Length: ${content.length}\n${headers}`
);
let buffer = this.bufferMap.get(requestId);
if (buffer) {
buffer = Buffer.concat([buffer, content]);
this.bufferMap.set(requestId, buffer);
} else {
console.debug(`请求已被丢弃:${requestId}`);
}
}
});
ws.on("error", (error) => {
console.error(`连接失败: ${error}`);
reject(`连接失败: ${error}`);
});
ws.on("ping", (data) => {
console.debug("ping %s", data);
});
ws.on("pong", (data) => {
console.debug("pong %s", data);
});
});
}
async convert(ssml, format) {
if (this.ws == null || this.ws.readyState != WebSocket.OPEN) {
console.info("准备连接服务器……");
let connection = await this.connect();
this.ws = connection;
console.info("连接成功!");
}
const requestId = randomBytes(16).toString("hex").toLowerCase();
let result = new Promise((resolve, reject) => {
// 等待服务器返回后这个方法才会返回结果
this.executorMap.set(requestId, {
resolve,
reject,
});
// 发送配置消息
let configData = {
context: {
synthesis: {
audio: {
metadataoptions: {
sentenceBoundaryEnabled: "false",
wordBoundaryEnabled: "false",
},
outputFormat: format,
},
},
},
};
let configMessage =
`X-Timestamp:${Date()}\r\n` +
"Content-Type:application/json; charset=utf-8\r\n" +
"Path:speech.config\r\n\r\n" +
JSON.stringify(configData);
console.info(`开始转换:${requestId}……`);
console.debug(`准备发送配置请求:${requestId}\n`, configMessage);
this.ws.send(configMessage, (configError) => {
if (configError) {
console.error(`配置请求发送失败:${requestId}\n`, configError);
}
// 发送SSML消息
let ssmlMessage =
`X-Timestamp:${Date()}\r\n` +
`X-RequestId:${requestId}\r\n` +
`Content-Type:application/ssml+xml\r\n` +
`Path:ssml\r\n\r\n` +
ssml;
console.debug(`准备发送SSML消息${requestId}\n`, ssmlMessage);
this.ws.send(ssmlMessage, (ssmlError) => {
if (ssmlError) {
console.error(`SSML消息发送失败${requestId}\n`, ssmlError);
}
});
});
});
// 收到请求,清除超时定时器
if (this.timer) {
console.debug("收到新的请求,清除超时定时器");
clearTimeout(this.timer);
}
// 设置定时器超过10秒没有收到请求主动断开连接
console.debug("创建新的超时定时器");
this.timer = setTimeout(() => {
if (this.ws && this.ws.readyState == WebSocket.OPEN) {
console.debug("已经 10 秒没有请求,主动关闭连接");
this.ws.close(1000);
this.timer = null;
}
}, 10000);
let data = await Promise.race([
result,
new Promise((resolve, reject) => {
// 如果超过 20 秒没有返回结果,则清除请求并返回超时
setTimeout(() => {
this.executorMap.delete(requestId);
this.bufferMap.delete(requestId);
reject("转换超时");
}, 10000);
}),
]);
console.info(`转换完成:${requestId}`);
console.info(`剩余 ${this.executorMap.size} 个任务`);
return data;
}
}
const service = new Service();
const retry = async function (fn, times, errorFn, failedMessage) {
let reason = {
message: failedMessage ?? "多次尝试后失败",
errors: [],
};
for (let i = 0; i < times; i++) {
try {
return await fn();
} catch (error) {
if (errorFn) {
errorFn(i, error);
}
reason.errors.push(error);
}
}
throw reason;
};
const ra = async (text) => {
console.debug(`请求正文:${text}`);
try {
let format = "webm-24khz-16bit-mono-opus";
if (Array.isArray(format)) {
throw `无效的音频格式:${format}`;
}
if (!FORMAT_CONTENT_TYPE.has(format)) {
throw `无效的音频格式:${format}`;
}
let ssml = text;
if (ssml == null) {
throw `转换参数无效`;
}
let result = await retry(
async () => {
let result = await service.convert(ssml, format);
return result;
},
3,
(index, error) => {
console.warn(`${index}次转换失败:${error}`);
},
"服务器多次尝试后转换失败"
);
return result;
// response.sendDate = true;
// response
// .status(200)
// .setHeader("Content-Type", FORMAT_CONTENT_TYPE.get(format));
// response.end(result);
} catch (error) {
console.error(`发生错误, ${error.message}`);
// response.status(503).json(error);
}
};
// ra(
// `<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"> <voice name="zh-CN-XiaoxiaoNeural"><prosody rate="0%" pitch="0%">如果喜欢这个项目的话请点个 Star 吧。</prosody ></voice > </speak >`
// );
module.exports = { ra };

291
main.js
View File

@@ -13,8 +13,7 @@ const Store = require("electron-store");
const store = new Store();
const fs = require("fs");
const configDir = app.getPath("userData");
const { randomBytes } = require("crypto");
const { WebSocket } = require("ws");
const { ra } = require("./edge-tts");
const dirPath = path.join(configDir, "uploads");
let mainWin;
let readerWindow;
@@ -251,291 +250,3 @@ app.on("window-all-closed", () => {
app.on("open-file", (e, pathToFile) => {
filePath = pathToFile;
});
const FORMAT_CONTENT_TYPE = new Map([
["raw-16khz-16bit-mono-pcm", "audio/basic"],
["raw-48khz-16bit-mono-pcm", "audio/basic"],
["raw-8khz-8bit-mono-mulaw", "audio/basic"],
["raw-8khz-8bit-mono-alaw", "audio/basic"],
["raw-16khz-16bit-mono-truesilk", "audio/SILK"],
["raw-24khz-16bit-mono-truesilk", "audio/SILK"],
["riff-16khz-16bit-mono-pcm", "audio/x-wav"],
["riff-24khz-16bit-mono-pcm", "audio/x-wav"],
["riff-48khz-16bit-mono-pcm", "audio/x-wav"],
["riff-8khz-8bit-mono-mulaw", "audio/x-wav"],
["riff-8khz-8bit-mono-alaw", "audio/x-wav"],
["audio-16khz-32kbitrate-mono-mp3", "audio/mpeg"],
["audio-16khz-64kbitrate-mono-mp3", "audio/mpeg"],
["audio-16khz-128kbitrate-mono-mp3", "audio/mpeg"],
["audio-24khz-48kbitrate-mono-mp3", "audio/mpeg"],
["audio-24khz-96kbitrate-mono-mp3", "audio/mpeg"],
["audio-24khz-160kbitrate-mono-mp3", "audio/mpeg"],
["audio-48khz-96kbitrate-mono-mp3", "audio/mpeg"],
["audio-48khz-192kbitrate-mono-mp3", "audio/mpeg"],
["webm-16khz-16bit-mono-opus", "audio/webm; codec=opus"],
["webm-24khz-16bit-mono-opus", "audio/webm; codec=opus"],
["ogg-16khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=16000"],
["ogg-24khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=24000"],
["ogg-48khz-16bit-mono-opus", "audio/ogg; codecs=opus; rate=48000"],
]);
class Service {
ws = null;
executorMap;
bufferMap;
timer = null;
constructor() {
this.executorMap = new Map();
this.bufferMap = new Map();
}
async connect() {
const connectionId = randomBytes(16).toString("hex").toLowerCase();
let url = `wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&ConnectionId=${connectionId}`;
console.log(url);
let ws = new WebSocket(url, {
host: "speech.platform.bing.com",
origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36 Edg/103.0.1264.44",
},
});
console.log(ws);
return new Promise((resolve, reject) => {
ws.on("open", () => {
resolve(ws);
});
ws.on("close", (code, reason) => {
// 服务器会自动断开空闲超过30秒的连接
this.ws = null;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
for (let [key, value] of this.executorMap) {
value.reject(`连接已关闭: ${reason} ${code}`);
}
this.executorMap.clear();
this.bufferMap.clear();
console.info(`连接已关闭: ${reason} ${code}`);
});
ws.on("message", (message, isBinary) => {
let pattern = /X-RequestId:(?<id>[a-z|0-9]*)/;
if (!isBinary) {
console.debug("收到文本消息:%s", message);
let data = message.toString();
if (data.includes("Path:turn.start")) {
// 开始传输
let matches = data.match(pattern);
let requestId = matches.groups.id;
console.debug(`开始传输:${requestId}……`);
this.bufferMap.set(requestId, Buffer.from([]));
} else if (data.includes("Path:turn.end")) {
// 结束传输
let matches = data.match(pattern);
let requestId = matches.groups.id;
let executor = this.executorMap.get(requestId);
if (executor) {
this.executorMap.delete(matches.groups.id);
let result = this.bufferMap.get(requestId);
executor.resolve(result);
console.debug(`传输完成:${requestId}……`);
} else {
console.debug(`请求已被丢弃:${requestId}`);
}
}
} else if (isBinary) {
let separator = "Path:audio\r\n";
let data = message;
let contentIndex = data.indexOf(separator) + separator.length;
let headers = data.slice(2, contentIndex).toString();
let matches = headers.match(pattern);
let requestId = matches.groups.id;
let content = data.slice(contentIndex);
console.debug(
`收到音频片段:${requestId} Length: ${content.length}\n${headers}`
);
let buffer = this.bufferMap.get(requestId);
if (buffer) {
buffer = Buffer.concat([buffer, content]);
this.bufferMap.set(requestId, buffer);
} else {
console.debug(`请求已被丢弃:${requestId}`);
}
}
});
ws.on("error", (error) => {
console.error(`连接失败: ${error}`);
reject(`连接失败: ${error}`);
});
ws.on("ping", (data) => {
console.debug("ping %s", data);
});
ws.on("pong", (data) => {
console.debug("pong %s", data);
});
});
}
async convert(ssml, format) {
if (this.ws == null || this.ws.readyState != WebSocket.OPEN) {
console.info("准备连接服务器……");
let connection = await this.connect();
this.ws = connection;
console.info("连接成功!");
}
const requestId = randomBytes(16).toString("hex").toLowerCase();
let result = new Promise((resolve, reject) => {
// 等待服务器返回后这个方法才会返回结果
this.executorMap.set(requestId, {
resolve,
reject,
});
// 发送配置消息
let configData = {
context: {
synthesis: {
audio: {
metadataoptions: {
sentenceBoundaryEnabled: "false",
wordBoundaryEnabled: "false",
},
outputFormat: format,
},
},
},
};
let configMessage =
`X-Timestamp:${Date()}\r\n` +
"Content-Type:application/json; charset=utf-8\r\n" +
"Path:speech.config\r\n\r\n" +
JSON.stringify(configData);
console.info(`开始转换:${requestId}……`);
console.debug(`准备发送配置请求:${requestId}\n`, configMessage);
this.ws.send(configMessage, (configError) => {
if (configError) {
console.error(`配置请求发送失败:${requestId}\n`, configError);
}
// 发送SSML消息
let ssmlMessage =
`X-Timestamp:${Date()}\r\n` +
`X-RequestId:${requestId}\r\n` +
`Content-Type:application/ssml+xml\r\n` +
`Path:ssml\r\n\r\n` +
ssml;
console.debug(`准备发送SSML消息${requestId}\n`, ssmlMessage);
this.ws.send(ssmlMessage, (ssmlError) => {
if (ssmlError) {
console.error(`SSML消息发送失败${requestId}\n`, ssmlError);
}
});
});
});
// 收到请求,清除超时定时器
if (this.timer) {
console.debug("收到新的请求,清除超时定时器");
clearTimeout(this.timer);
}
// 设置定时器超过10秒没有收到请求主动断开连接
console.debug("创建新的超时定时器");
this.timer = setTimeout(() => {
if (this.ws && this.ws.readyState == WebSocket.OPEN) {
console.debug("已经 10 秒没有请求,主动关闭连接");
this.ws.close(1000);
this.timer = null;
}
}, 10000);
let data = await Promise.race([
result,
new Promise((resolve, reject) => {
// 如果超过 20 秒没有返回结果,则清除请求并返回超时
setTimeout(() => {
this.executorMap.delete(requestId);
this.bufferMap.delete(requestId);
reject("转换超时");
}, 10000);
}),
]);
console.info(`转换完成:${requestId}`);
console.info(`剩余 ${this.executorMap.size} 个任务`);
return data;
}
}
const service = new Service();
const retry = async function (fn, times, errorFn, failedMessage) {
let reason = {
message: failedMessage ?? "多次尝试后失败",
errors: [],
};
for (let i = 0; i < times; i++) {
try {
return await fn();
} catch (error) {
if (errorFn) {
errorFn(i, error);
}
reason.errors.push(error);
}
}
throw reason;
};
const ra = async (text) => {
console.debug(`请求正文:${text}`);
try {
let format = "webm-24khz-16bit-mono-opus";
if (Array.isArray(format)) {
throw `无效的音频格式:${format}`;
}
if (!FORMAT_CONTENT_TYPE.has(format)) {
throw `无效的音频格式:${format}`;
}
let ssml = text;
if (ssml == null) {
throw `转换参数无效`;
}
let result = await retry(
async () => {
let result = await service.convert(ssml, format);
return result;
},
3,
(index, error) => {
console.warn(`${index}次转换失败:${error}`);
},
"服务器多次尝试后转换失败"
);
return result;
// response.sendDate = true;
// response
// .status(200)
// .setHeader("Content-Type", FORMAT_CONTENT_TYPE.get(format));
// response.end(result);
} catch (error) {
console.error(`发生错误, ${error.message}`);
// response.status(503).json(error);
}
};
// ra(
// `<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"> <voice name="zh-CN-XiaoxiaoNeural"><prosody rate="0%" pitch="0%">如果喜欢这个项目的话请点个 Star 吧。</prosody ></voice > </speak >`
// );

View File

File diff suppressed because one or more lines are too long

View File

@@ -90,6 +90,7 @@
"Descend": "降序",
"Ascend": "升序",
"Sort by Name": "按书名",
"Turn on Bionic Reading": "开启仿生阅读",
"Token": "凭证",
"Book not exist": "书籍不存在",
"Please authorize your account, and fill the following box with the token": "登录授权后,您将获得一串代码,请将代码填入以下输入框即可完成绑定",

View File

@@ -103,6 +103,7 @@
"Open url with built-in browser": "Open url with built-in browser",
"Light Mode": "Light Mode",
"Night Mode": "Night Mode",
"Turn on Bionic Reading": "Turn on Bionic Reading",
"Follow OS": "Follow OS",
"Use first page as PDF cover": "Use first page as PDF cover",
"You may see this error when the book you're importing is not supported by Koodo Reader, try converting it with Calibre": "You may see this error when the book you're importing is not supported by Koodo Reader, try converting it with Calibre",

View File

@@ -28,6 +28,7 @@ class DeleteIcon extends React.Component<DeleteIconProps, DeleteIconStates> {
deleteItems.forEach((item: any, index: number) => {
if (this.props.mode === "tags") {
item === this.props.tagName && TagUtil.clear(item);
this.handleDeleteTagFromNote(item);
return;
}
if (item.key === this.props.itemKey) {
@@ -56,6 +57,17 @@ class DeleteIcon extends React.Component<DeleteIconProps, DeleteIconStates> {
}
});
};
handleDeleteTagFromNote = (tagName: string) => {
let noteList = this.props.notes.map((item) => {
return {
...item,
tag: item.tag.filter((subitem) => subitem !== tagName),
};
});
localforage.setItem("notes", noteList).then(() => {
this.props.handleFetchNotes();
});
};
handleDeletePopup = (isOpenDelete: boolean) => {
this.setState({ isOpenDelete });
if (!isOpenDelete) {

View File

@@ -20,6 +20,7 @@ class SettingSwitch extends React.Component<
isShadow: StorageUtil.getReaderConfig("isShadow") === "yes",
isItalic: StorageUtil.getReaderConfig("isItalic") === "yes",
isInvert: StorageUtil.getReaderConfig("isInvert") === "yes",
isBionic: StorageUtil.getReaderConfig("isBionic") === "yes",
isHideBackground:
StorageUtil.getReaderConfig("isHideBackground") === "yes",
isHideFooter: StorageUtil.getReaderConfig("isHideFooter") === "yes",
@@ -99,6 +100,9 @@ class SettingSwitch extends React.Component<
case "isInvert":
this._handleChange("isInvert");
break;
case "isBionic":
this._handleChange("isBionic");
break;
case "isHideFooter":
this.handleChange("isHideFooter");
break;

View File

@@ -18,6 +18,7 @@ export interface SettingSwitchState {
isUnderline: boolean;
isItalic: boolean;
isInvert: boolean;
isBionic: boolean;
isHideHeader: boolean;
isHidePageButton: boolean;
isHideMenuButton: boolean;

View File

@@ -1,7 +1,7 @@
.header-search-box {
width: 150px;
height: 38px;
border-radius: 19px;
border-radius: 22px;
border-style: none;
outline: none;
padding-left: 20px;

View File

@@ -103,4 +103,4 @@ class SelectBook extends React.Component<BookListProps, BookListState> {
}
}
export default withRouter(SelectBook);
export default withRouter(SelectBook as any);

View File

@@ -125,27 +125,44 @@ class TextToSpeech extends React.Component<
}
};
async handleRead() {
let text = this.state.nodeList[this.state.nodeIndex];
let currentText = this.state.nodeList[this.state.nodeIndex];
let style = "background: #f3a6a68c";
this.props.htmlBook.rendition.highlightNode(text, style);
text = text
this.props.htmlBook.rendition.highlightNode(currentText, style);
currentText = currentText
.replace(/\s\s/g, "")
.replace(/\r/g, "")
.replace(/\n/g, "")
.replace(/\t/g, "")
.replace(/\f/g, "");
let nextText = "";
if (this.state.nodeIndex < this.state.nodeList.length - 1) {
nextText = this.state.nodeList[this.state.nodeIndex + 1];
nextText = nextText
.replace(/\s\s/g, "")
.replace(/\r/g, "")
.replace(/\n/g, "")
.replace(/\t/g, "")
.replace(/\f/g, "");
}
await this.handleSpeech(
text,
currentText,
nextText,
StorageUtil.getReaderConfig("voiceIndex") || 0,
StorageUtil.getReaderConfig("voiceSpeed") || 1
);
this.setState({ nodeIndex: this.state.nodeIndex + 1 });
}
handleSpeech = async (text: string, voiceIndex: number, speed: number) => {
handleSpeech = async (
currentText: string,
nextText: string,
voiceIndex: number,
speed: number
) => {
if (voiceIndex > this.state.nativeVoices.length) {
let edgeVoice =
this.state.edgeVoices[voiceIndex - this.state.nativeVoices.length];
await EdgeUtil.readAloud(text, edgeVoice.ShortName);
await EdgeUtil.readAloud(currentText, nextText, edgeVoice.ShortName);
let player = EdgeUtil.getPlayer();
player.onended = async (event) => {
@@ -157,7 +174,7 @@ class TextToSpeech extends React.Component<
};
} else {
var msg = new SpeechSynthesisUtterance();
msg.text = text;
msg.text = currentText;
msg.voice = window.speechSynthesis.getVoices()[voiceIndex];
msg.rate = speed;
window.speechSynthesis.speak(msg);

View File

@@ -168,6 +168,10 @@ export const readerSettingList = [
title: "Invert color",
propName: "isInvert",
},
{
title: "Turn on Bionic Reading",
propName: "isBionic",
},
{
title: "Hide footer",
propName: "isHideFooter",

View File

@@ -21,6 +21,7 @@ import { removeExtraQuestionMark } from "../../utils/commonUtil";
import CFI from "epub-cfi-resolver";
import mhtml2html from "mhtml2html";
import rtfToHTML from "@iarna/rtf-to-html";
import { binicReadingProcess } from "../../utils/serviceUtils/bionicUtil";
declare var window: any;
let lock = false; //prevent from clicking too fasts
@@ -184,6 +185,7 @@ class Viewer extends React.Component<ViewerProps, ViewerState> {
});
StyleUtil.addDefaultCss();
tsTransform();
binicReadingProcess();
rendition.setStyle(
StyleUtil.getCustomCss(
true,
@@ -277,6 +279,7 @@ class Viewer extends React.Component<ViewerProps, ViewerState> {
this.handleContentScroll(chapter, bookLocation.chapterHref);
StyleUtil.addDefaultCss();
tsTransform();
binicReadingProcess();
this.handleBindGesture();
lock = true;
setTimeout(function () {
@@ -294,7 +297,9 @@ class Viewer extends React.Component<ViewerProps, ViewerState> {
let contentList = contentBody.getElementsByTagName("a");
let targetContent = Array.from(contentList).filter((item, index) => {
item.setAttribute("style", "");
return item.textContent === chapter && index === chapterIndex;
return (
item.textContent === chapter && Math.abs(index - chapterIndex) <= 1
);
});
if (targetContent.length > 0) {
contentBody.scrollTo(0, targetContent[0].offsetTop);

View File

@@ -108,7 +108,7 @@ class BookList extends React.Component<BookListProps, BookListState> {
//返回排序后的图书index
SortUtil.sortBooks(this.props.books, this.props.bookSortCode) || []
);
if (this.props.mode === "shelf" && books.length === 0) {
if (books.length === 0) {
return (
<div
style={{
@@ -183,7 +183,9 @@ class BookList extends React.Component<BookListProps, BookListState> {
<>
<ViewMode />
<SelectBook />
{!this.props.isSelectBook && <ShelfSelector />}
<div style={this.props.isSelectBook ? { display: "none" } : {}}>
<ShelfSelector />
</div>
<div
className="book-list-container-parent"
style={

View File

@@ -5,7 +5,6 @@ import { NavListProps, NavListState } from "./interface";
import DeleteIcon from "../../../components/deleteIcon";
import toast from "react-hot-toast";
import CFI from "epub-cfi-resolver";
import StorageUtil from "../../../utils/serviceUtils/storageUtil";
class NavList extends React.Component<NavListProps, NavListState> {
constructor(props: NavListProps) {
super(props);
@@ -15,6 +14,7 @@ class NavList extends React.Component<NavListProps, NavListState> {
}
//跳转到图书的指定位置
async handleJump(cfi: string) {
//书签跳转
if (!cfi) {
toast(this.props.t("Wrong bookmark"));
return;
@@ -59,9 +59,7 @@ class NavList extends React.Component<NavListProps, NavListState> {
cfi: bookLocation.cfi,
})
);
let style =
"background: " +
(StorageUtil.getReaderConfig("backgroundColor") || "#f3a6a68c");
let style = "background: #f3a6a68c";
this.props.htmlBook.rendition.highlightNode(bookLocation.text, style);
}
}

View File

@@ -105,10 +105,7 @@ class NavigationPanel extends React.Component<
cfi: bookLocation.cfi,
})
);
let style =
"background: " +
(StorageUtil.getReaderConfig("backgroundColor") ||
"#f3a6a68c");
let style = "background: #f3a6a68c";
this.props.htmlBook.rendition.highlightNode(
bookLocation.text,
style

View File

@@ -89,7 +89,11 @@ class Sidebar extends React.Component<SidebarProps, SidebarState> {
? `icon-${item.icon} active-icon`
: `icon-${item.icon}`
}
style={this.props.isCollapsed ? { marginLeft: "-25px" } : {}}
style={
this.props.isCollapsed
? { position: "fixed", marginLeft: "-6px" }
: {}
}
></span>
</div>

View File

@@ -21,11 +21,6 @@ import LoadingDialog from "../../components/dialogs/loadingDialog";
import TipDialog from "../../components/dialogs/TipDialog";
import { Toaster } from "react-hot-toast";
//判断是否为触控设备
const is_touch_device = () => {
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
};
class Manager extends React.Component<ManagerProps, ManagerState> {
timer!: NodeJS.Timeout;
constructor(props: ManagerProps) {
@@ -74,9 +69,6 @@ class Manager extends React.Component<ManagerProps, ManagerState> {
this.props.handleFetchList();
}
componentDidMount() {
if (is_touch_device() && !StorageUtil.getReaderConfig("isTouch")) {
StorageUtil.setReaderConfig("isTouch", "yes");
}
this.props.handleReadingState(false);
}
@@ -85,7 +77,7 @@ class Manager extends React.Component<ManagerProps, ManagerState> {
};
render() {
let { books } = this.props;
if (isMobile) {
if (isMobile && document.location.href.indexOf("192.168") === -1) {
return (
<>
<p className="waring-title">

View File

@@ -27,7 +27,7 @@ export const handleLinkJump = async (event: any, rendition: any = {}) => {
}
if (href.indexOf("/#") === -1) {
let chapterInfo = rendition.resolveChapter(href);
rendition.goToChapter(
await rendition.goToChapter(
chapterInfo.index,
chapterInfo.href,
chapterInfo.title
@@ -38,7 +38,7 @@ export const handleLinkJump = async (event: any, rendition: any = {}) => {
await rendition.goToNode(doc.body.querySelector("#" + id) || doc.body);
} else if (href && rendition.resolveChapter(href)) {
let chapterInfo = rendition.resolveChapter(href);
rendition.goToChapter(
await rendition.goToChapter(
chapterInfo.index,
chapterInfo.href,
chapterInfo.title

View File

@@ -0,0 +1,90 @@
import { getIframeDoc } from "./docUtil";
import StorageUtil from "./storageUtil";
/* Insert one Node after another Node */
const insertAfter = (newNode, existingNode) => {
if (existingNode.nextSibling !== undefined)
existingNode.parentNode.insertBefore(newNode, existingNode.nextSibling);
else existingNode.parentNode.appendChild(newNode);
};
/* process all children of a Node*/
const HalfBold = (parentElement) => {
/* iterating through all children of the parent*/
for (var i = 0; parentElement.childNodes[i] !== undefined; i++) {
/* if the child is a text element*/
if (
parentElement.childNodes[i].nodeName === "#text" &&
parentElement.childNodes[i].textContent.trim().length !== 0
) {
var recentNode = parentElement.childNodes[i];
var newNodeCount = 0;
/* add bold and non-bold elements*/
parentElement.childNodes[i].textContent
.split(/(\s+|\S+)/)
// eslint-disable-next-line
.forEach((word) => {
if (word.length === 0) return;
var trimmedWordLength = word.trim().length;
if (trimmedWordLength === 0) {
let textNode = document.createTextNode(word);
insertAfter(textNode, recentNode);
newNodeCount++;
recentNode = textNode;
return;
}
var length = Math.floor(trimmedWordLength / 2);
if (length === 0) length = 1;
const bold = document.createElement("b");
bold.innerHTML = word.slice(0, length);
insertAfter(bold, recentNode);
newNodeCount++;
recentNode = bold;
if (word.length === 1) return;
let textNode = document.createTextNode(word.slice(length));
insertAfter(textNode, recentNode);
newNodeCount++;
recentNode = textNode;
});
/* and remove the original text element*/
parentElement.removeChild(parentElement.childNodes[i]);
i += newNodeCount - 1;
}
}
};
/* a way to stop from processing certain nodes*/
var ignoreTags = {
B: true,
META: true,
LINK: true,
SCRIPT: true,
STYLE: true,
};
export const processDocumentBody = (element) => {
/* we check all Nodes in the body*/
if (element === null) return;
if (element.body === undefined) return;
var collection = element.body.getElementsByTagName("*");
for (var i = 0; collection[i] !== undefined; i++) {
if (ignoreTags[collection[i].nodeName]) continue;
if (collection[i].nodeType !== 1) continue;
if (collection[i].nodeName === "IFRAME") {
processDocumentBody(collection[i].contentDocument);
} else {
if (collection[i].childNodes.length === 0) continue;
HalfBold(collection[i]);
}
}
};
export const binicReadingProcess = () => {
let doc = getIframeDoc();
if (!doc) return;
if (StorageUtil.getReaderConfig("isBionic") === "yes") {
processDocumentBody(doc);
}
};

View File

@@ -2,20 +2,32 @@ import { voiceList } from "../../constants/voiceList";
class EdgeUtil {
static player: AudioBufferSourceNode;
//`<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"> <voice name="zh-CN-XiaoxiaoNeural"> <prosody rate="0%" pitch="0%">如果喜欢这个项目的话请点个 Star 吧。</prosody ></voice > </speak >`
static async readAloud(text: string, voiceName: string) {
let audioBuffer = await window
.require("electron")
.ipcRenderer.invoke("edge-tts", {
text: this.createSSML(text, voiceName),
static currentAudioBuffer: any;
static nextAudioBuffer: any;
static async readAloud(
currentText: string,
nextText: string,
voiceName: string
) {
let audioBuffer =
this.nextAudioBuffer ||
(await window.require("electron").ipcRenderer.invoke("edge-tts", {
text: this.createSSML(currentText, voiceName),
format: "",
});
}));
let ctx = new AudioContext();
let audio = await ctx.decodeAudioData(this.toArrayBuffer(audioBuffer));
this.player = ctx.createBufferSource();
this.player.buffer = audio;
this.player.connect(ctx.destination);
this.player.start(ctx.currentTime);
this.nextAudioBuffer =
nextText &&
(await window.require("electron").ipcRenderer.invoke("edge-tts", {
text: this.createSSML(nextText, voiceName),
format: "",
}));
}
static pauseAudio() {
if (this.player && this.player.stop) {

View File

@@ -21,17 +21,14 @@ export const getSelection = () => {
};
let lock = false; //prevent from clicking too fasts
const arrowKeys = async (rendition: any, keyCode: number, event: any) => {
if (
document.querySelector(".editor-box") ||
document.querySelector(".navigation-search-title")
) {
if (document.querySelector(".editor-box")) {
return;
}
if (keyCode === 37 || keyCode === 38) {
event.preventDefault();
await rendition.prev();
}
if (keyCode === 39 || keyCode === 40 || keyCode === 32) {
if (keyCode === 39 || keyCode === 40) {
event.preventDefault();
await rendition.next();
}