我已照着你说的修改,现在你继续来看下代码,是不是照着你说的完美改动了一下是代码:java类:package com.heffgh.rbgh;
import android.content.SharedPreferences;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.speech.tts.TextToSpeech;
import android.speech.tts.UtteranceProgressListener;
import android.util.Log;
import android.view.View;
import android.webkit.JavascriptInterface;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
import org.json.JSONArray;
import org.json.JSONObject;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
private WebView mWebView;
private TextToSpeech tts;
private boolean isSpeaking = false;
private boolean isPaused = false;
private int currentSentenceIndex = 0;
private int currentCharIndex = 0;
private String[] sentences;
private String fullOriginalText;
private List<NodeInfo> nodeInfoList = new ArrayList<>();
private final Handler handler = new Handler();
private SharedPreferences sharedPreferences;
private boolean isTTSInitialized = false;
private Map<String, String> jsCache = new HashMap<>();
private int[][] sentencePositions; // 在类顶部声明
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
sharedPreferences = getSharedPreferences("TTSPreferences", MODE_PRIVATE);
loadProgress();
initTextToSpeech();
setupWebView();
loadWebContent("file:///android_asset/index.html");
setupButtons();
}
private void setupWebView() {
mWebView = findViewById(R.id.mWebView);
WebSettings settings = mWebView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(true);
settings.setAllowContentAccess(true);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) {
settings.setAllowUniversalAccessFromFileURLs(true);
}
mWebView.addJavascriptInterface(new TextNodeInterface(), "TextNodeInterface");
mWebView.setWebViewClient(new WebViewClient() {
@Override
public void onPageFinished(WebView view, String url) {
// 延迟采集文本节点
handler.postDelayed(() -> collectTextNodes(), 1000);
}
});
}
private void injectHighlightHelper() {
String helperJs = "(function() {" +
"if (window.createHighlightAtNodeByText) return;" + // 防止重复注入
"window.createHighlightAtNodeByText = function(targetText, localStart, localEnd) {" +
" var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);" +
" var node;" +
" while (node = walker.nextNode()) {" +
" if (node.nodeType !== Node.TEXT_NODE || !node.parentNode || node.parentNode.nodeType === Node.ELEMENT_NODE && ['SCRIPT','STYLE'].includes(node.parentNode.tagName)) continue;" +
" if (node.textContent.includes(targetText)) {" +
" var text = node.textContent;" +
" var before = text.substring(0, localStart);" +
" var middle = text.substring(localStart, localEnd);" +
" var after = text.substring(localEnd);" +
" var parent = node.parentNode;" +
" parent.innerHTML = '';" +
" parent.appendChild(document.createTextNode(before));" +
" var mark = document.createElement('mark');" +
" mark.className = 'highlight';" +
" mark.style.backgroundColor = 'yellow';" +
" mark.style.color = 'black';" +
" mark.style.padding = '2px';" +
// 或者添加类名以便 CSS 控制
" mark.classList.add('tts-highlight');"+
" mark.textContent = middle;" +
" parent.appendChild(mark);" +
" parent.appendChild(document.createTextNode(after));" +
" return;" +
" }" +
" }" +
"};" +
"})();";
mWebView.evaluateJavascript(helperJs, null);
}
private void collectTextNodes() {
String jsCode = loadJsFromAssets("collect_text_nodes.js");
if (!jsCode.isEmpty()) {
mWebView.evaluateJavascript(jsCode, null);
}
}
/**
* 从 assets 加载 JS 文件,并缓存结果
* @param fileName assets 目录下的 JS 文件名,如 "collect_text_nodes.js"
* @return JS 脚本内容,失败时返回空字符串
*/
private String loadJsFromAssets(String fileName) {
// 1. 先查缓存
if (jsCache.containsKey(fileName)) {
Log.d(TAG, "✅ 使用缓存 JS: " + fileName);
return jsCache.get(fileName);
}
Log.d(TAG, "🆕 首次加载 JS: " + fileName);
// 2. 缓存没有,就读取文件
try {
InputStream is = getAssets().open(fileName);
byte[] buffer = new byte[is.available()];
is.read(buffer);
is.close();
String jsCode = new String(buffer, StandardCharsets.UTF_8);
// 3. 存入缓存,下次直接用
jsCache.put(fileName, jsCode);
return jsCode;
} catch (Exception e) {
Log.e(TAG, "加载JS失败: " + fileName, e);
Toast.makeText(this, "脚本加载失败", Toast.LENGTH_SHORT).show();
return ""; // 失败返回空字符串
}
}
private void splitSentences(String rawText) {
String filteredText = rawText.replaceAll("[\\r\\n]+", "\n").trim();
List<String> sentenceList = new ArrayList<>();
List<int[]> positionList = new ArrayList<>(); // 存储 [start, end]
String[] paragraphs = filteredText.split("\\n\\s*\\n|\\r\\n\\s*\\r\\n");
Pattern sentenceEnd = Pattern.compile("[。.!??!]");
int globalPos = 0;
for (String para : paragraphs) {
para = para.trim();
if (para.isEmpty()) {
globalPos += 1; // 跳过段落分隔符
continue;
}
int last = 0;
java.util.regex.Matcher matcher = sentenceEnd.matcher(para);
while (matcher.find()) {
String sentence = para.substring(last, matcher.end()).trim();
if (!sentence.isEmpty()) {
int startInFull = globalPos + last;
int endInFull = globalPos + matcher.end();
sentenceList.add(sentence);
positionList.add(new int[]{startInFull, endInFull});
}
last = matcher.end();
}
if (last < para.length()) {
String remaining = para.substring(last).trim();
if (!remaining.isEmpty()) {
int startInFull = globalPos + last;
int endInFull = globalPos + para.length();
sentenceList.add(remaining);
positionList.add(new int[]{startInFull, endInFull});
}
}
globalPos += para.length() + 1; // 包括换行符占位
}
sentences = sentenceList.toArray(new String[0]);
sentencePositions = positionList.toArray(new int[0][]); // 新增字段
Log.d(TAG, "共分割出 " + sentences.length + " 个句子");
}
private void highlightInOriginalText(int globalStart, int globalEnd) {
Log.d(TAG, "高亮范围: [" + globalStart + ", " + globalEnd + ")");
if (fullOriginalText == null || globalStart >= globalEnd || globalEnd > fullOriginalText.length()) {
return;
}
List<HighlightRange> ranges = findRangesInNodes(globalStart, globalEnd);
if (ranges.isEmpty()) {
Log.w(TAG, "未找到匹配节点进行高亮");
return;
}
// 先清除旧高亮
mWebView.evaluateJavascript("removeOldHighlights();", null);
// 构建批量高亮命令
StringBuilder js = new StringBuilder();
js.append("[");
for (int i = 0; i < ranges.size(); i++) {
HighlightRange range = ranges.get(i);
js.append(String.format("{id:'%s',start:%d,end:%d}",
escapeJsString(range.node.id),
range.startInNode,
range.endInNode
));
if (i < ranges.size() - 1) js.append(",");
}
js.append("].forEach(item => { highlightNodeById(item.id, item.start, item.end); });");
// 添加滚动到高亮位置
js.append("setTimeout(function(){")
.append("var h = document.querySelector('mark.highlight');")
.append("if(h)h.scrollIntoView({behavior:'smooth',block:'center'});")
.append("}, 50);");
mWebView.evaluateJavascript(js.toString(), null);
}
private List<HighlightRange> findRangesInNodes(int start, int end) {
List<HighlightRange> result = new ArrayList<>();
for (NodeInfo node : nodeInfoList) {
int ns = node.start;
int ne = node.start + node.text.length();
if (end <= ns || start >= ne) continue;
int ls = Math.max(0, start - ns);
int le = Math.min(node.text.length(), end - ns);
if (ls < le) {
result.add(new HighlightRange(node, ls, le));
}
}
return result;
}
private String escapeJsString(String s) {
return s.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("</script>", "<\\/script>") // 防止 XSS 注入
.replace("<", "\\u003c"); // 更安全显示
}
private void speakFromCurrentPosition() {
if (sentences == null || currentSentenceIndex >= sentences.length) {
stopSpeaking();
return;
}
String sentence = sentences[currentSentenceIndex].trim();
if (sentence.isEmpty()) {
currentSentenceIndex++;
handler.postDelayed(this::speakFromCurrentPosition, 50);
return;
}
// ✅ 直接使用预计算的位置,不再依赖 indexOf
int[] range = sentencePositions[currentSentenceIndex];
int highlightStart = range[0] + currentCharIndex;
int highlightEnd = range[1]; // 注意:end 是闭区间之后的位置
highlightInOriginalText(highlightStart, highlightEnd);
String toSpeak = sentence.substring(currentCharIndex);
Bundle params = new Bundle();
String utteranceId = UUID.randomUUID().toString();
params.putCharSequence(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId);
int status = tts.speak(toSpeak, TextToSpeech.QUEUE_FLUSH, params, utteranceId);
if (status == TextToSpeech.ERROR) {
Log.e(TAG, "TTS 播放错误");
}
}
private void setupButtons() {
Button speakButton = findViewById(R.id.speakButton);
speakButton.setOnClickListener(v -> {
if (isPaused && currentSentenceIndex < sentences.length) {
isPaused = false;
isSpeaking = true;
speakFromCurrentPosition();
} else {
startSpeaking();
}
});
Button pauseButton = findViewById(R.id.pauseButton);
pauseButton.setOnClickListener(v -> {
if (isSpeaking) {
tts.stop();
isSpeaking = false;
isPaused = true;
}
});
Button stopButton = findViewById(R.id.stopButton);
stopButton.setOnClickListener(v -> stopSpeaking());
}
private void startSpeaking() {
if (sentences == null || sentences.length == 0) {
Toast.makeText(this, "尚未加载文本内容", Toast.LENGTH_SHORT).show();
return;
}
isSpeaking = true;
isPaused = false;
currentCharIndex = 0;
speakFromCurrentPosition();
}
private void stopSpeaking() {
if (tts != null) {
tts.stop();
}
isSpeaking = false;
isPaused = false;
currentCharIndex = 0;
saveProgress();
removeHighlight();
}
private void removeHighlight() {
mWebView.evaluateJavascript("(function(){" +
"var ms=document.querySelectorAll('mark.highlight');" +
"ms.forEach(m=>{m.parentNode.replaceChild(document.createTextNode(m.textContent),m);});" +
"})()", null);
}
private void saveProgress() {
SharedPreferences.Editor editor = sharedPreferences.edit();
editor.putInt("currentSentenceIndex", currentSentenceIndex);
editor.putInt("currentCharIndex", currentCharIndex);
editor.apply();
}
private void loadProgress() {
currentSentenceIndex = sharedPreferences.getInt("currentSentenceIndex", 0);
currentCharIndex = sharedPreferences.getInt("currentCharIndex", 0);
}
private void initTextToSpeech() {
tts = new TextToSpeech(this, status -> {
if (status == TextToSpeech.SUCCESS) {
isTTSInitialized = true;
setUtteranceProgressListener();
} else {
Toast.makeText(this, "TTS初始化失败", Toast.LENGTH_SHORT).show();
}
});
}
private void setUtteranceProgressListener() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
tts.setOnUtteranceProgressListener(new UtteranceProgressListener() {
@Override
public void onStart(String utteranceId) {}
@Override
public void onDone(String utteranceId) {
handler.post(() -> {
currentSentenceIndex++;
currentCharIndex = 0;
if (currentSentenceIndex < sentences.length) {
speakFromCurrentPosition();
} else {
isSpeaking = false;
saveProgress();
Toast.makeText(MainActivity.this, "朗读完成", Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void onError(String utteranceId) {}
});
}
}
@Override
protected void onDestroy() {
if (tts != null) {
tts.stop();
tts.shutdown();
}
super.onDestroy();
}
// --- 数据类 ---
public static class NodeInfo {
String text;
int start;
String id; // 新增字段
NodeInfo(String text, int start, String id) {
this.text = text;
this.start = start;
this.id = id;
}
}
public static class HighlightRange {
NodeInfo node;
int startInNode, endInNode;
HighlightRange(NodeInfo node, int start, int end) {
this.node = node;
this.startInNode = start;
this.endInNode = end;
}
}
// --- JS 接口 ---
public class TextNodeInterface {
@JavascriptInterface
public void onTextNodesCollected(String fullText, String nodeInfoJson) {
runOnUiThread(() -> {
if (fullText == null || fullText.trim().isEmpty()) {
Toast.makeText(MainActivity.this, "未提取到有效文本", Toast.LENGTH_SHORT).show();
return;
}
fullOriginalText = fullText;
nodeInfoList.clear();
try {
JSONArray arr = new JSONArray(nodeInfoJson);
for (int i = 0; i < arr.length(); i++) {
JSONObject obj = arr.getJSONObject(i);
String text = obj.getString("text");
int start = obj.getInt("start");
String id = obj.optString("id", ""); // 新增:读取ID,如果没有就是""
nodeInfoList.add(new NodeInfo(text, start, id)); Construct and pass id
}
Log.d(TAG, "✅ Successfully parsed " + nodeInfoList.size() + " text nodes");
} catch (Exception e) {
Log.e(TAG, "Failed to parse nodeInfo", e);
Toast.makeText(MainActivity.this, "Structure parsing failed", Toast.LENGTH_SHORT).show();
return;
}
splitSentences(fullOriginalText);
});
}
}
private void loadWebContent(String url) {
mWebView.loadUrl(url);
}
@Override
public void onBackPressed() {
if (mWebView.canGoBack()) {
mWebView.goBack();
} else {
super.onBackPressed();
}
}} 和下面这个 js 代码:// collect_text_nodes.js
(function() {
var textNodes = [];
var fullText = '';
var nodeIdCounter = 0; // 计数器生成唯一ID
function traverse(node) {
if (node.nodeType === Node.TEXT_NODE) {
var text = node.textContent;
// 只采集有意义的非空文本
if (text && text.trim().length > 0) {
var id = "textnode_" + (nodeIdCounter++); // 生成唯一ID
textNodes.push({
text: text,
start: fullText.length,
id: id // 传给 Java 的关键字段
});
fullText += text;
// 给父元素打标记,方便后续定位这个文本节点
if (!node.parentNode.dataset.nodeIds) {
node.parentNode.dataset.nodeIds = id;
} else {
node.parentNode.dataset.nodeIds += "," + id;
}
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
var tag = node.tagName.toLowerCase();
if (tag !== 'script' && tag !== 'style' && tag !== 'noscript') {
for (var i = 0; i < node.childNodes.length; i++) {
traverse(node.childNodes[i]);
}
}
}
}
// 尝试找主内容区域
var containerSelectors = [
'#content', '.content',
'#main', '.main',
'.article', '.post-body',
'[role="main"]', 'main',
'.entry-content'
];
var container = null;
for (var i = 0; i < containerSelectors.length; i++) {
var el = document.querySelector(containerSelectors[i]);
if (el) {
container = el;
break;
}
}
if (!container) container = document.body;
traverse(container);
// 处理同源 iframe
var iframes = document.getElementsByTagName('iframe');
for (var i = 0; i < iframes.length; i++) {
try {
var iframeDoc = iframes[i].contentDocument || iframes[i].contentWindow.document;
if (iframeDoc && iframeDoc.body) {
traverse(iframeDoc.body);
}
} catch (e) {
console.warn("无法访问 iframe (跨域?):", iframes[i].src);
}
}
// 注入高亮函数到全局
if (typeof window.highlightNodeById !== 'function') {
window.highlightNodeById = function(id, localStart, localEnd) {
// 找到带有此 id 的父元素
var candidates = document.querySelectorAll('[data-node-ids]');
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
if (el.dataset.nodeIds.split(',').includes(id)) {
// 在该元素的所有子节点中找对应的文本节点
for (var j = 0; j < el.childNodes.length; j++) {
var child = el.childNodes[j];
if (child.nodeType === Node.TEXT_NODE) {
// 注意:这里假设 text 是原始提取的那个值
// 实际上可以缓存 map,但简单起见我们只匹配位置
wrapRangeInMark(child, localStart, localEnd);
return true;
}
}
}
}
return false;
};
// 包装某段文本为 <mark>
function wrapRangeInMark(textNode, start, end) {
var parent = textNode.parentNode;
var text = textNode.textContent;
var before = text.substring(0, start);
var middle = text.substring(start, end);
var after = text.substring(end);
var fragment = document.createDocumentFragment();
fragment.appendChild(document.createTextNode(before));
var mark = document.createElement('mark');
mark.className = 'highlight tts-highlight';
mark.style.backgroundColor = 'yellow';
mark.style.color = 'black';
mark.style.padding = '2px';
mark.textContent = middle;
fragment.appendChild(mark);
fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
parent.normalize(); // 合并相邻文本节点
}
// 清除旧高亮
window.removeOldHighlights = function() {
var marks = document.querySelectorAll('mark.highlight');
marks.forEach(function(mark) {
var parent = mark.parentNode;
parent.replaceChild(document.createTextNode(mark.textContent), mark);
parent.normalize();
});
};
}
// 回调 Java
try {
if (typeof TextNodeInterface !== 'undefined') {
TextNodeInterface.onTextNodesCollected(fullText, JSON.stringify(textNodes));
} else {
console.error("❌ 未注册 TextNodeInterface,请检查 addJavascriptInterface");
}
} catch (e) {
console.error("❌ 回传数据失败:", e);
}
console.log("✅ collect_text_nodes.js 执行完成,文本节点数:", textNodes.length, ", 总长度:", fullText.length);
})();