那你现在看看代码,有没有照着你总结的最终方案修改,然后看还有没有需要优化的建议,以下是代码 首先是java代码:package com.hghg.rbbj;
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.List;
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 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) {
// 注入高亮工具函数
injectHighlightHelper();
// 延迟采集文本节点
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() {
loadJsFromAssets("collect_text_nodes.js"); // 确保这个JS存在assets中
}
private void loadJsFromAssets(String fileName) {
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);
mWebView.evaluateJavascript(jsCode, null);
} catch (Exception e) {
Log.e(TAG, "加载JS失败: " + fileName, e);
Toast.makeText(this, "脚本加载失败", Toast.LENGTH_SHORT).show();
}
}
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;
}
StringBuilder js = new StringBuilder();
js.append("(function() {");
js.append(" var oldMarks = document.querySelectorAll('mark.highlight');")
.append(" oldMarks.forEach(function(m) {")
.append(" var p = m.parentNode;")
.append(" p.replaceChild(document.createTextNode(m.textContent), m);")
.append(" p.normalize();")
.append(" });");
for (HighlightRange range : ranges) {
String escapedText = escapeJsString(range.node.text.substring(range.startInNode, range.endInNode));
js.append(String.format(
"createHighlightAtNodeByText('%s', %d, %d);",
escapedText, range.startInNode, range.endInNode
));
}
js.append(" setTimeout(function(){")
.append(" var h = document.querySelector('mark.highlight');")
.append(" if(h)h.scrollIntoView({behavior:'smooth',block:'center'});")
.append(" }, 50);");
js.append("})();");
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");
}
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;
NodeInfo(String text, int start) {
this.text = text;
this.start = start;
}
}
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");
nodeInfoList.add(new NodeInfo(text, start));
}
Log.d(TAG, "✅ 成功解析 " + nodeInfoList.size() + " 个文本节点");
} catch (Exception e) {
Log.e(TAG, "解析 nodeInfo 失败", e);
Toast.makeText(MainActivity.this, "结构解析失败", 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 = '';
function traverse(node) {
if (node.nodeType === Node.TEXT_NODE) {
var text = node.textContent;
if (text && text.trim().length > 0) {
textNodes.push({
text: text,
start: fullText.length
});
fullText += text;
}
} 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 iframeWin = iframes[i].contentWindow;
var iframeDoc = iframes[i].contentDocument || iframeWin.document;
if (iframeDoc && iframeDoc.body) {
traverse(iframeDoc.body);
}
} catch (e) {
console.warn("无法访问 iframe (跨域?):", iframes[i].src);
}
}
// 回调 Java
try {
if (typeof window.TextNodeInterface !== 'undefined') {
window.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);
})();