player.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. <template>
  2. <view class="container">
  3. <!-- 顶部导航栏 -->
  4. <view class="header">
  5. <view class="back-btn" @click="goBack">
  6. <text class="back-icon">↓</text>
  7. </view>
  8. <view class="share-btn" @click="handleShare">
  9. <text class="share-icon">↗</text>
  10. </view>
  11. </view>
  12. <!-- 书籍信息 -->
  13. <view class="book-info-section">
  14. <image class="book-cover" :src="bookInfo.image" mode="aspectFill"></image>
  15. <text class="book-title">{{ bookInfo.title }}</text>
  16. <text class="chapter-title" v-if="chapterInfo.title">{{ chapterInfo.title }}</text>
  17. <text class="book-author">{{ bookInfo.author }}</text>
  18. <button class="add-to-shelf-btn" @click="handleAddToShelf">加入书架</button>
  19. </view>
  20. <!-- 播放控制区域 -->
  21. <view class="player-section">
  22. <!-- 进度条 -->
  23. <view class="progress-section">
  24. <view class="progress-bar-wrapper" @touchstart="handleProgressTouchStart" @touchmove="handleProgressTouchMove" @touchend="handleProgressTouchEnd">
  25. <view class="progress-bar">
  26. <view class="progress-filled" :style="{ width: progressPercent + '%' }"></view>
  27. <view class="progress-dot" :style="{ left: progressPercent + '%' }" v-if="!isDragging"></view>
  28. </view>
  29. </view>
  30. <view class="time-label">
  31. <text class="time-text">{{ currentTime }}/{{ totalTime }}</text>
  32. </view>
  33. </view>
  34. <!-- 控制按钮 -->
  35. <view class="controls-section">
  36. <view class="control-btn" @click="rewind15s">
  37. <text class="control-text">-15s</text>
  38. </view>
  39. <view class="control-btn prev-btn" @click="playPrevious">
  40. <text class="control-icon">⏮</text>
  41. </view>
  42. <view class="control-btn play-btn" @click="togglePlay">
  43. <text class="play-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
  44. </view>
  45. <view class="control-btn next-btn" @click="playNext">
  46. <text class="control-icon">⏭</text>
  47. </view>
  48. <view class="control-btn" @click="forward15s">
  49. <text class="control-text">+15s</text>
  50. </view>
  51. </view>
  52. </view>
  53. </view>
  54. </template>
  55. <script>
  56. import { getChapterDetail, playChapter, saveListeningProgress, getListeningProgress, recordListeningHistory } from '../../utils/api.js'
  57. export default {
  58. data() {
  59. return {
  60. audiobookId: null,
  61. chapterId: null,
  62. bookInfo: {
  63. id: null,
  64. title: '',
  65. author: '',
  66. image: ''
  67. },
  68. chapterInfo: {
  69. id: null,
  70. title: '',
  71. audioUrl: '',
  72. duration: 0
  73. },
  74. isPlaying: false,
  75. currentTime: '00:00',
  76. currentSeconds: 0,
  77. totalTime: '00:00',
  78. totalSeconds: 0,
  79. progressPercent: 0,
  80. audioContext: null,
  81. playTimer: null,
  82. progressSaveTimer: null,
  83. userInfo: null,
  84. isDragging: false
  85. }
  86. },
  87. onLoad(options) {
  88. // 从路由参数获取信息
  89. if (options.audiobookId) {
  90. this.audiobookId = parseInt(options.audiobookId)
  91. } else if (options.bookId) {
  92. this.audiobookId = parseInt(options.bookId)
  93. }
  94. if (options.title) {
  95. this.bookInfo.title = decodeURIComponent(options.title)
  96. }
  97. if (options.image) {
  98. this.bookInfo.image = decodeURIComponent(options.image)
  99. }
  100. if (options.author) {
  101. this.bookInfo.author = decodeURIComponent(options.author)
  102. }
  103. if (options.chapterId) {
  104. this.chapterId = parseInt(options.chapterId)
  105. }
  106. if (options.audioUrl) {
  107. this.chapterInfo.audioUrl = decodeURIComponent(options.audioUrl)
  108. }
  109. // 加载用户信息
  110. this.loadUserInfo()
  111. // 初始化播放器
  112. if (this.chapterId) {
  113. this.initPlayer()
  114. }
  115. },
  116. onUnload() {
  117. // 页面卸载时保存进度并停止播放
  118. this.saveProgress()
  119. if (this.audioContext) {
  120. this.audioContext.destroy()
  121. }
  122. if (this.playTimer) {
  123. clearInterval(this.playTimer)
  124. }
  125. if (this.progressSaveTimer) {
  126. clearInterval(this.progressSaveTimer)
  127. }
  128. },
  129. methods: {
  130. loadUserInfo() {
  131. try {
  132. const userInfo = uni.getStorageSync('userInfo')
  133. const isLogin = uni.getStorageSync('isLogin')
  134. if (userInfo && userInfo.id && isLogin) {
  135. this.userInfo = userInfo
  136. }
  137. } catch (e) {
  138. console.error('获取用户信息失败:', e)
  139. }
  140. },
  141. async initPlayer() {
  142. try {
  143. // 加载章节详情
  144. if (this.chapterId) {
  145. const res = await getChapterDetail(this.chapterId)
  146. if (res && res.code === 200 && res.data) {
  147. this.chapterInfo = {
  148. id: res.data.id,
  149. title: res.data.title || '',
  150. audioUrl: res.data.audioUrl || this.chapterInfo.audioUrl,
  151. duration: res.data.duration || 0
  152. }
  153. this.totalSeconds = this.chapterInfo.duration
  154. this.totalTime = this.formatTime(this.totalSeconds)
  155. }
  156. }
  157. // 加载播放进度
  158. if (this.userInfo && this.userInfo.id && this.chapterId) {
  159. const progressRes = await getListeningProgress(this.userInfo.id, this.chapterId)
  160. if (progressRes && progressRes.code === 200 && progressRes.data) {
  161. const progress = progressRes.data
  162. this.currentSeconds = progress.currentPosition || 0
  163. this.currentTime = this.formatTime(this.currentSeconds)
  164. this.progressPercent = this.totalSeconds > 0 ? (this.currentSeconds / this.totalSeconds) * 100 : 0
  165. }
  166. }
  167. // 创建音频上下文
  168. this.audioContext = uni.createInnerAudioContext()
  169. this.audioContext.src = this.chapterInfo.audioUrl
  170. this.audioContext.startTime = this.currentSeconds
  171. // 监听播放事件
  172. this.audioContext.onPlay(() => {
  173. this.isPlaying = true
  174. this.startProgressTimer()
  175. })
  176. this.audioContext.onPause(() => {
  177. this.isPlaying = false
  178. this.stopProgressTimer()
  179. })
  180. this.audioContext.onEnded(() => {
  181. this.isPlaying = false
  182. this.stopProgressTimer()
  183. // 播放结束,自动播放下一章
  184. // this.playNext()
  185. })
  186. this.audioContext.onTimeUpdate(() => {
  187. if (!this.isDragging) {
  188. this.currentSeconds = Math.floor(this.audioContext.currentTime)
  189. this.currentTime = this.formatTime(this.currentSeconds)
  190. if (this.totalSeconds > 0) {
  191. this.progressPercent = (this.currentSeconds / this.totalSeconds) * 100
  192. }
  193. }
  194. })
  195. // 记录播放(增加播放次数)
  196. if (this.audiobookId && this.chapterId) {
  197. try {
  198. await playChapter(this.audiobookId, this.chapterId)
  199. } catch (e) {
  200. console.error('记录播放失败:', e)
  201. }
  202. }
  203. // 记录听书历史
  204. if (this.userInfo && this.userInfo.id && this.audiobookId && this.chapterId) {
  205. try {
  206. await recordListeningHistory(this.userInfo.id, this.audiobookId, this.chapterId)
  207. } catch (e) {
  208. console.error('记录听书历史失败:', e)
  209. }
  210. }
  211. // 启动进度保存定时器(每30秒保存一次)
  212. this.progressSaveTimer = setInterval(() => {
  213. this.saveProgress()
  214. }, 30000)
  215. } catch (e) {
  216. console.error('初始化播放器失败:', e)
  217. uni.showToast({
  218. title: '加载失败,请重试',
  219. icon: 'none'
  220. })
  221. }
  222. },
  223. goBack() {
  224. this.saveProgress()
  225. uni.navigateBack()
  226. },
  227. handleShare() {
  228. uni.showToast({
  229. title: '分享',
  230. icon: 'none'
  231. })
  232. },
  233. handleAddToShelf() {
  234. uni.showToast({
  235. title: '已加入书架',
  236. icon: 'success'
  237. })
  238. },
  239. togglePlay() {
  240. if (!this.audioContext) {
  241. uni.showToast({
  242. title: '播放器未初始化',
  243. icon: 'none'
  244. })
  245. return
  246. }
  247. if (this.isPlaying) {
  248. this.audioContext.pause()
  249. } else {
  250. this.audioContext.play()
  251. }
  252. },
  253. startProgressTimer() {
  254. // 进度更新已通过audioContext.onTimeUpdate处理
  255. },
  256. stopProgressTimer() {
  257. // 进度更新已通过audioContext.onTimeUpdate处理
  258. },
  259. playPrevious() {
  260. uni.showToast({
  261. title: '上一章功能待实现',
  262. icon: 'none'
  263. })
  264. },
  265. playNext() {
  266. uni.showToast({
  267. title: '下一章功能待实现',
  268. icon: 'none'
  269. })
  270. },
  271. rewind15s() {
  272. if (this.audioContext) {
  273. const newTime = Math.max(0, this.currentSeconds - 15)
  274. this.audioContext.seek(newTime)
  275. this.currentSeconds = newTime
  276. this.currentTime = this.formatTime(newTime)
  277. if (this.totalSeconds > 0) {
  278. this.progressPercent = (newTime / this.totalSeconds) * 100
  279. }
  280. }
  281. },
  282. forward15s() {
  283. if (this.audioContext) {
  284. const newTime = Math.min(this.totalSeconds, this.currentSeconds + 15)
  285. this.audioContext.seek(newTime)
  286. this.currentSeconds = newTime
  287. this.currentTime = this.formatTime(newTime)
  288. if (this.totalSeconds > 0) {
  289. this.progressPercent = (newTime / this.totalSeconds) * 100
  290. }
  291. }
  292. },
  293. handleProgressTouchStart(e) {
  294. this.isDragging = true
  295. },
  296. handleProgressTouchMove(e) {
  297. // 拖动进度条的处理
  298. // 这里需要获取进度条的宽度和触摸位置
  299. },
  300. async handleProgressTouchEnd(e) {
  301. this.isDragging = false
  302. // 结束拖动,跳转到指定位置
  303. // 这里需要计算拖动的百分比,然后设置播放位置
  304. if (this.audioContext && this.totalSeconds > 0) {
  305. // 这里应该根据触摸位置计算新的播放时间
  306. // 暂时使用progressPercent
  307. const newTime = Math.floor((this.progressPercent / 100) * this.totalSeconds)
  308. this.audioContext.seek(newTime)
  309. this.currentSeconds = newTime
  310. this.currentTime = this.formatTime(newTime)
  311. await this.saveProgress()
  312. }
  313. },
  314. async saveProgress() {
  315. if (this.userInfo && this.userInfo.id && this.audiobookId && this.chapterId) {
  316. try {
  317. await saveListeningProgress(
  318. this.userInfo.id,
  319. this.audiobookId,
  320. this.chapterId,
  321. this.currentSeconds,
  322. this.totalSeconds
  323. )
  324. } catch (e) {
  325. console.error('保存进度失败:', e)
  326. }
  327. }
  328. },
  329. formatTime(seconds) {
  330. if (!seconds || seconds < 0) {
  331. return '00:00'
  332. }
  333. const hours = Math.floor(seconds / 3600)
  334. const mins = Math.floor((seconds % 3600) / 60)
  335. const secs = seconds % 60
  336. if (hours > 0) {
  337. return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
  338. } else {
  339. return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
  340. }
  341. }
  342. }
  343. }
  344. </script>
  345. <style scoped>
  346. .container {
  347. width: 100%;
  348. height: 100vh;
  349. background-color: #F5F5F5;
  350. display: flex;
  351. flex-direction: column;
  352. align-items: center;
  353. justify-content: space-between;
  354. padding: 40rpx 30rpx 80rpx;
  355. padding-top: calc(30px + 40rpx);
  356. box-sizing: border-box;
  357. box-sizing: border-box;
  358. }
  359. .header {
  360. width: 100%;
  361. display: flex;
  362. align-items: center;
  363. justify-content: space-between;
  364. padding: 20rpx 0;
  365. }
  366. .back-btn {
  367. width: 60rpx;
  368. height: 60rpx;
  369. display: flex;
  370. align-items: center;
  371. justify-content: center;
  372. }
  373. .back-icon {
  374. font-size: 36rpx;
  375. color: #666666;
  376. }
  377. .share-btn {
  378. width: 60rpx;
  379. height: 60rpx;
  380. display: flex;
  381. align-items: center;
  382. justify-content: center;
  383. }
  384. .share-icon {
  385. font-size: 36rpx;
  386. color: #666666;
  387. }
  388. .book-info-section {
  389. display: flex;
  390. flex-direction: column;
  391. align-items: center;
  392. flex: 1;
  393. justify-content: center;
  394. }
  395. .book-cover {
  396. width: 400rpx;
  397. height: 560rpx;
  398. border-radius: 8rpx;
  399. margin-bottom: 40rpx;
  400. box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.15);
  401. background-color: #F5F5F5;
  402. }
  403. .book-title {
  404. font-size: 40rpx;
  405. font-weight: bold;
  406. color: #333333;
  407. margin-bottom: 20rpx;
  408. text-align: center;
  409. }
  410. .book-author {
  411. font-size: 28rpx;
  412. color: #999999;
  413. margin-bottom: 20rpx;
  414. text-align: center;
  415. }
  416. .chapter-title {
  417. font-size: 26rpx;
  418. color: #666666;
  419. margin-bottom: 20rpx;
  420. text-align: center;
  421. }
  422. .add-to-shelf-btn {
  423. width: 300rpx;
  424. height: 80rpx;
  425. background-color: #F5F5F5;
  426. color: #333333;
  427. font-size: 28rpx;
  428. border: none;
  429. border-radius: 40rpx;
  430. display: flex;
  431. align-items: center;
  432. justify-content: center;
  433. }
  434. .add-to-shelf-btn::after {
  435. border: none;
  436. }
  437. .player-section {
  438. width: 100%;
  439. padding-bottom: env(safe-area-inset-bottom);
  440. }
  441. .progress-section {
  442. margin-bottom: 60rpx;
  443. width: 100%;
  444. }
  445. .progress-bar-wrapper {
  446. width: 100%;
  447. height: 60rpx;
  448. display: flex;
  449. align-items: center;
  450. position: relative;
  451. margin-bottom: 20rpx;
  452. }
  453. .time-label {
  454. display: flex;
  455. justify-content: center;
  456. }
  457. .time-text {
  458. background-color: #4FC3F7;
  459. color: #FFFFFF;
  460. font-size: 24rpx;
  461. padding: 8rpx 20rpx;
  462. border-radius: 20rpx;
  463. }
  464. .progress-bar {
  465. width: 100%;
  466. height: 4rpx;
  467. background-color: #E0E0E0;
  468. border-radius: 2rpx;
  469. position: relative;
  470. }
  471. .progress-filled {
  472. height: 100%;
  473. background-color: #4FC3F7;
  474. border-radius: 2rpx;
  475. transition: width 0.3s ease;
  476. }
  477. .progress-dot {
  478. position: absolute;
  479. top: 50%;
  480. transform: translate(-50%, -50%);
  481. width: 24rpx;
  482. height: 24rpx;
  483. background-color: #4FC3F7;
  484. border-radius: 50%;
  485. box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.2);
  486. }
  487. .controls-section {
  488. display: flex;
  489. justify-content: space-around;
  490. align-items: center;
  491. padding: 0 40rpx;
  492. }
  493. .control-btn {
  494. display: flex;
  495. align-items: center;
  496. justify-content: center;
  497. }
  498. .control-text {
  499. font-size: 28rpx;
  500. color: #999999;
  501. }
  502. .control-icon {
  503. font-size: 40rpx;
  504. color: #333333;
  505. }
  506. .play-btn {
  507. width: 120rpx;
  508. height: 120rpx;
  509. background-color: #4FC3F7;
  510. border-radius: 50%;
  511. box-shadow: 0 4rpx 12rpx rgba(79, 195, 247, 0.4);
  512. }
  513. .play-icon {
  514. font-size: 60rpx;
  515. color: #FFFFFF;
  516. margin-left: 6rpx;
  517. }
  518. </style>