Commit f1c15699 authored by liujiangnan's avatar liujiangnan

feat: 点读功能

parent b5a849da
# 绘本阅读器
基于JSON数据实现的绘本阅读器系统,支持音频同步播放、页面切换、文本高亮等功能。
一个交互式的绘本阅读器,支持音频同步播放和点读功能。
## 功能特性
1. **分页展示图片** - 每一页显示一张图片,图片按照页面尺寸适配
2. **页面控制** - 左下角浮动上一页/下一页按钮
3. **音频播放控制** - 播放/暂停按钮,浮动在左下角
4. **音频页面同步** - 音频播放到哪一页就显示对应页面
5. **智能跳转** - 手动切换页面时,如果音频正在播放则跳转到对应页面的开始时间
6. **蓝色矩形框** - 当音频读到某个矩形框时显示,过了就消失
7. **单词高亮** - 当音频读到某个单词时显示黄色背景高亮,切换页时高亮消失
8. **循环播放** - 音频播放结束后自动回到第一页重新开始
## 技术栈
- Vue 2
- Element UI 2.12
- jQuery 1.12.3
- 原生HTML5 Audio API
## 数据结构
JSON数据包含以下字段:
- `audio_url`: 音频文件地址
- `pages`: 页面数组,每个页面包含:
- `image_url`: 图片地址
- `page`: 页面编号
- `start`: 页面开始时间(秒)
- `end`: 页面结束时间(秒)
- `groups`: 句子组数组,包含文本位置和时间信息
### 基本功能
- 绘本页面展示
- 音频同步播放
- 自动页面切换
- 实时高亮显示当前朗读内容
### 点读功能
- 点击右上角的"点读"按钮进入点读模式
- 在点读模式下,当前页面的所有句子都会以蓝色矩形框显示
- **只显示有完整时间信息的句子**(开始时间和结束时间都不为空)
- **点击任意矩形框播放整个句子**:一个句子可能由多个矩形框组成,点击任意矩形框都会播放整个句子的音频
- **鼠标悬停高亮整个句子**:鼠标悬停在任意矩形框上时,同一句子的所有矩形框都会高亮显示
- **连续点击同一个句子时会被忽略**,确保当前句子播放完整
- **播放整个句子的时间范围**:使用句子的原始开始时间和结束时间,不进行额外的时间调整
- **点读模式下不会因为音频播放时间而自动跳页**,用户可以自由浏览不同页面
- 再次点击"退出点读"按钮可以退出点读模式
### 控制按钮
- 上一页/下一页:手动切换页面
- 播放/暂停:控制音频播放
## 使用方法
1. 启动本地服务器:
```bash
cd play
python3 -m http.server 8000
```
1. 打开 `index.html` 文件
2. 等待页面加载完成
3. 点击播放按钮开始自动播放
4. 点击右上角的"点读"按钮进入点读模式
5. 在点读模式下点击任意蓝色矩形框播放对应句子
2. 打开浏览器访问:`http://localhost:8000`
## 技术实现
3. 使用控制按钮:
- 点击播放按钮开始播放音频
- 点击暂停按钮暂停播放
- 点击上一页/下一页按钮切换页面
- 观察文本高亮和矩形框显示效果
## 文件结构
- `index.html` - 主页面模板
- `index.css` - 样式文件
- `index.js` - Vue应用逻辑
- `defaultData_1.json` - 示例数据文件
- `schema.json` - 数据格式定义
## 注意事项
- 确保音频文件和图片文件可以正常访问
- 数据文件需要符合schema.json定义的格式
- 浏览器需要支持HTML5 Audio API
- 使用 Vue.js 进行状态管理
- 使用 HTML5 Audio API 进行音频控制
- 响应式设计,支持不同屏幕尺寸
- 健壮的时间计算算法,确保音频播放的准确性
......@@ -160,3 +160,73 @@ body {
text-align: center;
z-index: 200;
}
/* 点读按钮样式 */
.point-read-btn {
position: absolute;
top: 20px;
right: 20px;
z-index: 100;
padding: 10px 20px;
border: none;
border-radius: 25px;
background-color: rgba(255, 255, 255, 0.9);
color: #333;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.point-read-btn:hover {
background-color: rgba(255, 255, 255, 1);
transform: scale(1.05);
}
.point-read-btn.active {
background-color: rgba(76, 175, 80, 0.9);
color: white;
}
.point-read-btn.active:hover {
background-color: rgba(76, 175, 80, 1);
}
/* 点读模式下的可点击矩形框 */
.highlight-rect.clickable {
pointer-events: auto;
cursor: pointer;
border: 3px solid #007bff;
background-color: rgba(0, 123, 255, 0.1);
transition: all 0.2s ease;
}
.highlight-rect.clickable:hover {
background-color: rgba(0, 123, 255, 0.3);
border-color: #0056b3;
transform: scale(1.02);
}
/* 句子高亮状态 */
.highlight-rect.clickable.highlighted {
background-color: rgba(255, 193, 7, 0.4);
border-color: #ffc107;
border-width: 4px;
transform: scale(1.05);
box-shadow: 0 0 10px rgba(255, 193, 7, 0.6);
}
/* 调试信息样式 */
.debug-info {
position: absolute;
top: 80px;
right: 20px;
z-index: 100;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 5px;
font-size: 12px;
font-family: monospace;
}
......@@ -26,6 +26,15 @@
<div v-else class="book-container">
<!-- 点读按钮 -->
<button
class="point-read-btn"
:class="{ active: isPointReadMode }"
@click="togglePointReadMode"
title="点读模式"
>
{{ isPointReadMode ? '退出点读' : '点读' }}
</button>
<!-- 图片容器 -->
<div class="image-container">
......@@ -43,7 +52,14 @@
v-for="(rect, index) in activeRects"
:key="'rect-' + index"
class="highlight-rect"
:class="{
'clickable': isPointReadMode,
'highlighted': rect._highlighted
}"
:style="getRectStyle(rect)"
@click="isPointReadMode ? playRectAudio(rect, index) : null"
@mouseenter="isPointReadMode ? highlightSentence(rect) : null"
@mouseleave="isPointReadMode ? clearSentenceHighlight() : null"
></div>
<!-- 单词高亮 -->
......
......@@ -32,6 +32,8 @@ function initializeApp(data) {
error: null,
isPlaying: false,
currentPageIndex: 0,
isPointReadMode: false,
currentPlayingRect: null,
// 音频相关
audio: null,
......@@ -119,7 +121,11 @@ function initializeApp(data) {
this.currentTime = this.audio.currentTime;
this.updateHighlights();
this.updatePageByTime();
// 在点读模式下不更新页面
if (!this.isPointReadMode) {
this.updatePageByTime();
}
// 每5秒输出一次调试信息
if (Math.floor(this.currentTime) % 5 === 0 && this.currentTime > 0) {
......@@ -130,17 +136,31 @@ function initializeApp(data) {
// 音频结束
onAudioEnded() {
this.isPlaying = false;
this.currentPageIndex = 0;
this.currentTime = 0;
this.audio.currentTime = 0;
this.clearHighlights();
this.loadCurrentPage();
this.currentPlayingRect = null;
// 清除定时器
if (this.audioTimer) {
clearTimeout(this.audioTimer);
this.audioTimer = null;
}
// 在点读模式下不重置页面
if (!this.isPointReadMode) {
this.currentPageIndex = 0;
this.currentTime = 0;
this.audio.currentTime = 0;
this.clearHighlights();
this.loadCurrentPage();
}
},
// 根据时间更新页面
updatePageByTime() {
if (!this.audio || !this.pages.length) return;
// 如果处于点读模式,不自动跳页
if (this.isPointReadMode) return;
const currentTime = this.audio.currentTime;
// 找到当前时间对应的页面
......@@ -161,6 +181,9 @@ function initializeApp(data) {
updateHighlights() {
if (!this.currentPage || !this.imageLoaded) return;
// 如果处于点读模式,不更新高亮
if (this.isPointReadMode) return;
const currentTime = this.audio.currentTime;
const newRects = [];
const newWords = [];
......@@ -169,6 +192,12 @@ function initializeApp(data) {
if (currentTime >= this.currentPage.start && currentTime <= this.currentPage.end) {
// 遍历当前页面的所有组
this.currentPage.groups.forEach(group => {
// 检查句子是否有有效的时间信息
if (group.start === null || group.start === undefined ||
group.end === null || group.end === undefined) {
return; // 跳过没有时间信息的句子
}
// 检查句子时间
if (currentTime >= group.start && currentTime <= group.end) {
// 遍历句子中的文本组
......@@ -214,6 +243,13 @@ function initializeApp(data) {
console.log('加载页面:', this.currentPageIndex, this.currentPage);
this.clearHighlights();
this.imageLoaded = false;
// 如果处于点读模式,重新显示所有矩形框
if (this.isPointReadMode) {
this.$nextTick(() => {
this.showAllRects();
});
}
},
// 图片加载完成
......@@ -224,8 +260,12 @@ function initializeApp(data) {
this.imageLoaded = true;
console.log('图片加载完成:', this.imageWidth, this.imageHeight);
// 如果处于点读模式,显示所有矩形框
if (this.isPointReadMode) {
this.showAllRects();
}
// 如果音频正在播放,立即更新高亮
if (this.isPlaying) {
else if (this.isPlaying) {
this.updateHighlights();
}
},
......@@ -314,10 +354,13 @@ function initializeApp(data) {
// 如果音频暂停在当前页面,继续播放
if (this.isPlaying) return;
// 如果音频不在当前页面时间范围内,跳转到当前页面开始时间
if (this.currentTime < this.currentPageStartTime || this.currentTime > this.currentPageEndTime) {
console.log('跳转到页面开始时间:', this.currentPageStartTime);
this.audio.currentTime = this.currentPageStartTime;
// 在点读模式下不自动跳转时间
if (!this.isPointReadMode) {
// 如果音频不在当前页面时间范围内,跳转到当前页面开始时间
if (this.currentTime < this.currentPageStartTime || this.currentTime > this.currentPageEndTime) {
console.log('跳转到页面开始时间:', this.currentPageStartTime);
this.audio.currentTime = this.currentPageStartTime;
}
}
this.audio.play().then(() => {
......@@ -335,6 +378,13 @@ function initializeApp(data) {
this.audio.pause();
this.isPlaying = false;
this.currentPlayingRect = null;
// 清除定时器
if (this.audioTimer) {
clearTimeout(this.audioTimer);
this.audioTimer = null;
}
},
// 上一页
......@@ -343,8 +393,8 @@ function initializeApp(data) {
this.currentPageIndex--;
this.loadCurrentPage();
// 如果音频正在播放,跳转到新页面的开始时间
if (this.isPlaying) {
// 在点读模式下不跳转音频时间
if (this.isPlaying && !this.isPointReadMode) {
this.audio.currentTime = this.currentPageStartTime;
}
}
......@@ -356,8 +406,8 @@ function initializeApp(data) {
this.currentPageIndex++;
this.loadCurrentPage();
// 如果音频正在播放,跳转到新页面的开始时间
if (this.isPlaying) {
// 在点读模式下不跳转音频时间
if (this.isPlaying && !this.isPointReadMode) {
this.audio.currentTime = this.currentPageStartTime;
}
}
......@@ -369,6 +419,245 @@ function initializeApp(data) {
this.$forceUpdate();
},
// 切换点读模式
togglePointReadMode() {
this.isPointReadMode = !this.isPointReadMode;
console.log('切换点读模式:', this.isPointReadMode ? '开启' : '关闭');
console.log('当前页面:', this.currentPage);
console.log('图片是否加载:', this.imageLoaded);
if (this.isPointReadMode) {
// 进入点读模式,显示所有矩形框
this.showAllRects();
// 暂停当前播放
if (this.isPlaying) {
this.pauseAudio();
}
} else {
// 退出点读模式,清除所有矩形框
this.clearHighlights();
}
console.log('点读模式:', this.isPointReadMode ? '开启' : '关闭');
},
// 显示当前页面所有矩形框
showAllRects() {
console.log('showAllRects 被调用');
console.log('当前页面:', this.currentPage);
console.log('图片是否加载:', this.imageLoaded);
if (!this.currentPage || !this.imageLoaded) {
console.log('条件不满足,退出 showAllRects');
return;
}
const allRects = [];
// 遍历当前页面的所有组
this.currentPage.groups.forEach((group, groupIndex) => {
console.log(`处理组 ${groupIndex}:`, group);
// 检查句子是否有有效的时间信息
if (group.start === null || group.start === undefined ||
group.end === null || group.end === undefined) {
console.log(`组 ${groupIndex} 缺少时间信息,跳过:`, group);
return;
}
// 遍历句子中的文本组
group.group.forEach((textGroup, textIndex) => {
console.log(`处理文本组 ${textIndex}:`, textGroup);
// 添加文本组的矩形框
if (textGroup.rect) {
// 为每个矩形框添加额外的数据属性,包括句子信息
const rectWithData = {
...textGroup.rect,
_start: group.start,
_end: group.end,
_text: textGroup.text,
_groupIndex: groupIndex, // 添加组索引,用于识别同一句子的矩形框
_sentence: group.sentence // 添加句子文本
};
allRects.push(rectWithData);
console.log('添加矩形框:', rectWithData);
} else {
console.log('文本组没有矩形框:', textGroup);
}
});
});
this.activeRects = allRects;
console.log('显示所有矩形框:', allRects.length, '个');
console.log('activeRects 已更新:', this.activeRects);
// 强制更新视图
this.$forceUpdate();
},
// 播放矩形框对应的音频
playRectAudio(rectData, index) {
if (!this.audio || !rectData) return;
// 获取句子信息(开始时间和结束时间)
const sentenceStart = rectData._start;
const sentenceEnd = rectData._end;
const groupIndex = rectData._groupIndex;
console.log('播放句子音频:', {
index,
sentenceStart,
sentenceEnd,
duration: sentenceEnd - sentenceStart,
sentence: rectData._sentence,
groupIndex: groupIndex
});
// 如果正在播放同一个句子,则忽略点击
if (this.isPlaying && this.currentPlayingRect &&
this.currentPlayingRect._groupIndex === groupIndex) {
console.log('正在播放同一个句子,忽略点击');
return;
}
// 清除之前的定时器
if (this.audioTimer) {
clearTimeout(this.audioTimer);
this.audioTimer = null;
}
// 设置音频时间并播放整个句子
this.audio.currentTime = sentenceStart;
this.audio.play().then(() => {
this.isPlaying = true;
this.currentPlayingRect = rectData;
console.log('开始播放句子音频,当前时间:', this.audio.currentTime);
// 设置定时器在句子结束时间停止播放
this.audioTimer = setTimeout(() => {
if (this.audio && this.isPlaying) {
console.log('句子播放结束,当前时间:', this.audio.currentTime);
this.audio.pause();
this.isPlaying = false;
this.currentPlayingRect = null;
}
}, (sentenceEnd - sentenceStart) * 1000);
}).catch(error => {
console.error('播放失败:', error);
this.error = '播放失败';
});
},
// 计算开始时间(健壮性处理)
calculateStartTime(rectData, index) {
const currentStart = rectData._start;
const currentEnd = rectData._end;
// 获取当前页面的所有矩形框数据
const allRects = this.getAllRectsData();
// 找到当前矩形框在数组中的位置
const currentIndex = allRects.findIndex(item =>
item._start === currentStart && item._end === currentEnd
);
if (currentIndex === -1) {
// 如果找不到,使用原始开始时间
return currentStart;
}
// 获取上一个矩形框的结束时间
const prevRect = currentIndex > 0 ? allRects[currentIndex - 1] : null;
const prevEnd = prevRect ? prevRect._end : currentStart;
// 开始时间往前提100毫秒
let startTime = currentStart - 0.1;
// 如果开始时间小于上一句的结束时间,则改为往前提50毫秒
if (startTime < prevEnd) {
startTime = currentStart - 0.05;
// 如果还小于上一句的结束时间,则直接取上一句的结束时间
if (startTime < prevEnd) {
startTime = prevEnd;
}
}
return startTime;
},
// 计算结束时间(直接使用句子结束时间)
calculateEndTime(rectData, index) {
// 直接返回句子的原始结束时间
return rectData._end;
},
// 高亮整个句子
highlightSentence(rectData) {
if (!rectData || !this.isPointReadMode) return;
const groupIndex = rectData._groupIndex;
console.log('高亮句子,组索引:', groupIndex);
// 找到同一句子的所有矩形框并高亮
this.activeRects.forEach(rect => {
if (rect._groupIndex === groupIndex) {
rect._highlighted = true;
}
});
// 强制更新视图
this.$forceUpdate();
},
// 清除句子高亮
clearSentenceHighlight() {
if (!this.isPointReadMode) return;
// 清除所有高亮状态
this.activeRects.forEach(rect => {
rect._highlighted = false;
});
// 强制更新视图
this.$forceUpdate();
},
// 获取当前页面所有矩形框的数据
getAllRectsData() {
if (!this.currentPage) return [];
const allRects = [];
// 遍历当前页面的所有组
this.currentPage.groups.forEach(group => {
// 检查句子是否有有效的时间信息
if (group.start === null || group.start === undefined ||
group.end === null || group.end === undefined) {
return; // 跳过没有时间信息的句子
}
// 遍历句子中的文本组
group.group.forEach(textGroup => {
// 添加文本组的矩形框数据
if (textGroup.rect) {
allRects.push({
...textGroup.rect,
_start: group.start,
_end: group.end,
_text: textGroup.text
});
}
});
});
return allRects;
},
// 清理资源
cleanup() {
if (this.audio) {
......@@ -377,7 +666,7 @@ function initializeApp(data) {
}
if (this.audioTimer) {
clearInterval(this.audioTimer);
clearTimeout(this.audioTimer);
this.audioTimer = null;
}
......@@ -385,6 +674,8 @@ function initializeApp(data) {
clearInterval(this.highlightTimer);
this.highlightTimer = null;
}
this.currentPlayingRect = null;
}
}
});
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment