Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
A
AI_BOOK_TOOL
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
template admin
AI_BOOK_TOOL
Commits
f1c15699
Commit
f1c15699
authored
Aug 01, 2025
by
liujiangnan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 点读功能
parent
b5a849da
Changes
4
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
424 additions
and
68 deletions
+424
-68
README.md
play/README.md
+31
-52
index.css
play/index.css
+70
-0
index.html
play/index.html
+16
-0
index.js
play/index.js
+307
-16
No files found.
play/README.md
View file @
f1c15699
# 绘本阅读器
基于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 进行音频控制
-
响应式设计,支持不同屏幕尺寸
-
健壮的时间计算算法,确保音频播放的准确性
play/index.css
View file @
f1c15699
...
...
@@ -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
;
}
play/index.html
View file @
f1c15699
...
...
@@ -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>
<!-- 单词高亮 -->
...
...
play/index.js
View file @
f1c15699
...
...
@@ -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
)
{
clear
Interval
(
this
.
audioTimer
);
clear
Timeout
(
this
.
audioTimer
);
this
.
audioTimer
=
null
;
}
...
...
@@ -385,6 +674,8 @@ function initializeApp(data) {
clearInterval
(
this
.
highlightTimer
);
this
.
highlightTimer
=
null
;
}
this
.
currentPlayingRect
=
null
;
}
}
});
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment