AudioMessageRender.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. <template>
  2. <view
  3. @click="playAudio"
  4. class="audio_message_container bg_container"
  5. :class="[isSender ? 'audio_message_container_self' : '']"
  6. style="position: relative; overflow: visible;">
  7. <view class="msg_arrow" :class="isSender ? 'msg_arrow_right' : 'msg_arrow_left'"></view>
  8. <view class="cricleplay" :class="[isSender ? 'cricleplay_self' : '']">
  9. <view class="play-icon-small" :class="[isSender ? 'play-icon-small-self' : '']"></view>
  10. <view class="play-icon-middle" :class="[isSender ? 'play-icon-middle-self' : '']" :style="{ opacity: (playing && animateStep < 2) ? 0 : 1 }"></view>
  11. <view class="play-icon-large" :class="[isSender ? 'play-icon-large-self' : '']" :style="{ opacity: (playing && animateStep < 3) ? 0 : 1 }"></view>
  12. </view>
  13. <text class="audio_duration">{{ message.soundElem.duration }}''</text>
  14. <view v-if="showUnreadIndicator" class="audio_unread_dot"></view>
  15. </view>
  16. </template>
  17. <script>
  18. // 音频播放单例管理
  19. let _audioContext = null;
  20. let _currentVM = null;
  21. function getAudioContext() {
  22. if (!_audioContext) {
  23. _audioContext = uni.createInnerAudioContext();
  24. // #ifdef APP-PLUS
  25. // 强制使用扬声器播放,忽略静音开关
  26. if (typeof uni.setInnerAudioOption === 'function') {
  27. uni.setInnerAudioOption({
  28. obeyMuteSwitch: false,
  29. speakerOn: true
  30. });
  31. }
  32. // #endif
  33. _audioContext.onPlay(() => {
  34. if (_currentVM) {
  35. _currentVM.playing = true;
  36. _currentVM.paused = false;
  37. // Animation is started immediately on click to reduce perceived latency
  38. if (_currentVM.animateStep === 3) { // Only start if not already running
  39. _currentVM.startAnimate();
  40. }
  41. _currentVM.startSafetyTimer();
  42. }
  43. });
  44. _audioContext.onPause(() => {
  45. if (_currentVM) {
  46. _currentVM.playing = false;
  47. _currentVM.paused = true;
  48. _currentVM.stopAnimate();
  49. _currentVM.stopSafetyTimer();
  50. }
  51. });
  52. _audioContext.onStop(() => {
  53. if (_currentVM) {
  54. _currentVM.playing = false;
  55. _currentVM.paused = false;
  56. _currentVM.stopAnimate();
  57. _currentVM.stopSafetyTimer();
  58. }
  59. });
  60. _audioContext.onEnded(() => {
  61. if (_currentVM) {
  62. _currentVM.playing = false;
  63. _currentVM.paused = false;
  64. _currentVM.stopAnimate();
  65. _currentVM.stopSafetyTimer();
  66. }
  67. });
  68. _audioContext.onTimeUpdate(() => {
  69. if (_currentVM && _currentVM.playing) {
  70. _currentVM.handleTimeUpdate();
  71. }
  72. });
  73. _audioContext.onError((err) => {
  74. console.error('Audio play error:', err);
  75. // 添加错误提示,方便用户感知
  76. uni.showToast({
  77. title: '播放失败',
  78. icon: 'none'
  79. });
  80. if (_currentVM) {
  81. _currentVM.playing = false;
  82. _currentVM.paused = false;
  83. _currentVM.stopAnimate();
  84. _currentVM.stopSafetyTimer();
  85. }
  86. });
  87. }
  88. return _audioContext;
  89. }
  90. export default {
  91. name: "AudioMessageRender",
  92. props: {
  93. isSender: Boolean,
  94. message: Object,
  95. },
  96. data() {
  97. return {
  98. playing: false,
  99. paused: false,
  100. hasRead: false,
  101. animateStep: 3,
  102. animateTimer: null,
  103. safetyTimer: null,
  104. };
  105. },
  106. mounted() {
  107. this.checkReadState();
  108. },
  109. watch: {
  110. message: {
  111. handler() {
  112. this.checkReadState();
  113. },
  114. },
  115. playing(val) {
  116. if (val) {
  117. this.startAnimate();
  118. this.startSafetyTimer();
  119. } else {
  120. this.stopAnimate();
  121. this.stopSafetyTimer();
  122. }
  123. },
  124. },
  125. beforeDestroy() {
  126. this.stopAnimate();
  127. this.stopSafetyTimer();
  128. // 如果当前组件正在播放,销毁前停止播放
  129. if (_currentVM === this) {
  130. const ctx = getAudioContext();
  131. if (ctx) {
  132. ctx.stop();
  133. }
  134. _currentVM = null;
  135. }
  136. },
  137. computed: {
  138. showUnreadIndicator() {
  139. return !this.isSender && !this.hasRead && Boolean(this.message?.clientMsgID);
  140. },
  141. },
  142. methods: {
  143. getStorageKey() {
  144. const userID = this.$store?.getters?.storeCurrentUserID || "";
  145. return `${userID || "GLOBAL"}_AudioMessageReadIds`;
  146. },
  147. loadReadIds() {
  148. const key = this.getStorageKey();
  149. const stored = uni.getStorageSync(key);
  150. const readIds = Array.isArray(stored) ? stored : [];
  151. return { key, readIds };
  152. },
  153. checkReadState() {
  154. const clientMsgID = this.message?.clientMsgID;
  155. if (!clientMsgID) {
  156. this.hasRead = true;
  157. return;
  158. }
  159. const { readIds } = this.loadReadIds();
  160. this.hasRead = readIds.includes(clientMsgID);
  161. },
  162. markAsRead() {
  163. const clientMsgID = this.message?.clientMsgID;
  164. if (this.hasRead || !clientMsgID) {
  165. return;
  166. }
  167. const { key, readIds } = this.loadReadIds();
  168. if (readIds.includes(clientMsgID)) {
  169. this.hasRead = true;
  170. return;
  171. }
  172. uni.setStorageSync(key, [...readIds, clientMsgID]);
  173. this.hasRead = true;
  174. },
  175. // 异步检查文件是否存在
  176. checkFileExist(path) {
  177. return new Promise((resolve) => {
  178. if (!path) {
  179. resolve(false);
  180. return;
  181. }
  182. // #ifdef APP-PLUS
  183. // 增加平台路径格式校验,防止跨平台同步的数据(如 Android 路径同步到 iOS)导致误判
  184. const platform = uni.getSystemInfoSync().platform;
  185. if (platform === 'ios') {
  186. // Android 特有路径特征
  187. if (path.includes('/storage/') || path.includes('/sdcard/') || path.includes('/Android/data/')) {
  188. console.log('Detected Android path on iOS, ignoring:', path);
  189. resolve(false);
  190. return;
  191. }
  192. }
  193. plus.io.resolveLocalFileSystemURL(path, () => {
  194. resolve(true);
  195. }, () => {
  196. // 尝试加上 file:// 前缀再试一次
  197. if (!path.startsWith('file://') && path.startsWith('/')) {
  198. plus.io.resolveLocalFileSystemURL('file://' + path, () => {
  199. resolve(true);
  200. }, () => {
  201. resolve(false);
  202. });
  203. } else {
  204. resolve(false);
  205. }
  206. });
  207. // #endif
  208. // #ifndef APP-PLUS
  209. // H5 或其他平台暂不支持本地文件检查,直接返回 true 尝试播放,或 false
  210. resolve(true);
  211. // #endif
  212. });
  213. },
  214. async playAudio() {
  215. const ctx = getAudioContext();
  216. // 如果点击的是当前正在播放/暂停的音频
  217. if (_currentVM === this) {
  218. if (this.playing) {
  219. ctx.pause();
  220. this.playing = false;
  221. this.paused = true;
  222. } else {
  223. ctx.play();
  224. this.playing = true;
  225. this.paused = false;
  226. this.markAsRead();
  227. }
  228. return;
  229. }
  230. // 如果有其他音频正在播放,先停止它
  231. if (_currentVM) {
  232. _currentVM.playing = false;
  233. _currentVM.paused = false;
  234. _currentVM.stopAnimate();
  235. }
  236. // 切换到当前音频
  237. _currentVM = this;
  238. const sourceUrl = this.message.soundElem.sourceUrl;
  239. const soundPath = this.message.soundElem.soundPath;
  240. console.log("qxj sourceUrl:"+sourceUrl+" soundPath:"+soundPath);
  241. // 1. 尝试播放本地文件 (通常是自己发送的消息)
  242. if (soundPath) {
  243. const exists = await this.checkFileExist(soundPath);
  244. if (exists) {
  245. this.playLocalFile(ctx, soundPath);
  246. return;
  247. }
  248. }
  249. // 2. 尝试播放缓存文件
  250. const cachedKey = 'AUDIO_CACHE_' + sourceUrl;
  251. const cachedPath = uni.getStorageSync(cachedKey);
  252. if (cachedPath) {
  253. const exists = await this.checkFileExist(cachedPath);
  254. if (exists) {
  255. this.playLocalFile(ctx, cachedPath);
  256. return;
  257. } else {
  258. // 缓存文件不存在(可能被系统清理),清除缓存记录
  259. uni.removeStorageSync(cachedKey);
  260. }
  261. }
  262. // 3. 优先尝试流式播放(减少等待时间),同时后台下载缓存
  263. if (sourceUrl) {
  264. // 立即播放远程地址
  265. ctx.src = sourceUrl;
  266. ctx.play();
  267. this.playing = true;
  268. this.paused = false;
  269. this.startAnimate(); // 立即开始动画,减少用户感知的延迟
  270. this.markAsRead();
  271. // 后台静默下载缓存
  272. this.downloadFileSilently(sourceUrl);
  273. } else {
  274. uni.showToast({ title: '无效的音频地址', icon: 'none' });
  275. this.playing = false;
  276. _currentVM = null;
  277. }
  278. },
  279. // 修复路径问题:确保本地路径有正确的前缀
  280. fixPath(path) {
  281. if (!path) return '';
  282. if (path.startsWith('http')) return path;
  283. // #ifdef APP-PLUS
  284. let fixedPath = path;
  285. // 处理 _doc/ 开头的相对路径
  286. if (fixedPath.startsWith('_doc/')) {
  287. fixedPath = plus.io.convertLocalFileSystemURL(fixedPath);
  288. }
  289. // 处理绝对路径
  290. if (fixedPath.startsWith('/')) {
  291. if (!fixedPath.startsWith('file://')) {
  292. fixedPath = 'file://' + fixedPath;
  293. }
  294. }
  295. return fixedPath;
  296. // #endif
  297. return path;
  298. },
  299. playLocalFile(ctx, path) {
  300. const fixedPath = this.fixPath(path);
  301. // 避免重复赋值 src 导致重新加载
  302. if (ctx.src !== fixedPath) {
  303. ctx.src = fixedPath;
  304. }
  305. ctx.play();
  306. // 立即更新状态,确保UI响应
  307. this.playing = true;
  308. this.paused = false;
  309. this.startAnimate(); // 立即开始动画
  310. this.markAsRead();
  311. },
  312. downloadFileSilently(url) {
  313. // 检查是否已有缓存或正在下载
  314. if (uni.getStorageSync('AUDIO_CACHE_' + url)) return;
  315. uni.downloadFile({
  316. url: url,
  317. success: (res) => {
  318. if (res.statusCode === 200) {
  319. uni.setStorageSync('AUDIO_CACHE_' + url, res.tempFilePath);
  320. }
  321. },
  322. fail: (err) => {
  323. console.error('Silent audio download failed:', err);
  324. }
  325. });
  326. },
  327. // 保留旧方法作为备用(如果需要)
  328. downloadAndPlay(ctx, url) {
  329. uni.downloadFile({
  330. url: url,
  331. success: (res) => {
  332. // 如果用户已经切换到其他音频,丢弃本次下载结果
  333. if (_currentVM !== this) return;
  334. if (res.statusCode === 200) {
  335. const tempFilePath = res.tempFilePath;
  336. // 缓存路径
  337. uni.setStorageSync('AUDIO_CACHE_' + url, tempFilePath);
  338. // 播放
  339. ctx.src = tempFilePath;
  340. ctx.play();
  341. // 立即更新状态
  342. this.playing = true;
  343. this.paused = false;
  344. this.markAsRead();
  345. } else {
  346. console.error('Audio download failed:', res);
  347. uni.showToast({ title: '音频加载失败', icon: 'none' });
  348. this.playing = false;
  349. _currentVM = null;
  350. }
  351. },
  352. fail: (err) => {
  353. // 如果用户已经切换到其他音频,忽略错误
  354. if (_currentVM !== this) return;
  355. console.error('Audio download error:', err);
  356. uni.showToast({ title: '音频下载出错', icon: 'none' });
  357. this.playing = false;
  358. _currentVM = null;
  359. }
  360. });
  361. },
  362. startAnimate() {
  363. this.stopAnimate();
  364. this.animateStep = 1;
  365. this.animateTimer = setInterval(() => {
  366. this.animateStep++;
  367. if (this.animateStep > 3) {
  368. this.animateStep = 1;
  369. }
  370. }, 500);
  371. },
  372. stopAnimate() {
  373. if (this.animateTimer) {
  374. clearInterval(this.animateTimer);
  375. this.animateTimer = null;
  376. }
  377. this.animateStep = 3;
  378. // Force update by using $nextTick if available or simple assignment
  379. // In nvue/weex, sometimes direct assignment doesn't trigger UI update if value is same type but not detected change?
  380. // But here 3 should trigger opacity 1.
  381. // Let's verify template logic: :style="{ opacity: animateStep >= 2 ? 1 : 0 }"
  382. // If animateStep is 3, opacity is 1.
  383. },
  384. handleTimeUpdate() {
  385. const ctx = getAudioContext();
  386. if (!ctx) return;
  387. const duration = this.message.soundElem.duration;
  388. // Check if playback exceeded duration (with small tolerance)
  389. if (duration && ctx.currentTime >= duration + 0.5) {
  390. this.forceStop();
  391. return;
  392. }
  393. // Reset safety timer based on remaining time + buffer
  394. this.resetSafetyTimer(duration - ctx.currentTime);
  395. },
  396. startSafetyTimer() {
  397. const duration = this.message.soundElem.duration || 60;
  398. // Initial safe buffer: duration + 5s (to account for buffering)
  399. this.resetSafetyTimer(duration + 5);
  400. },
  401. stopSafetyTimer() {
  402. if (this.safetyTimer) {
  403. clearTimeout(this.safetyTimer);
  404. this.safetyTimer = null;
  405. }
  406. },
  407. resetSafetyTimer(seconds) {
  408. this.stopSafetyTimer();
  409. const ms = Math.max(1000, seconds * 1000 + 1000); // Minimum 1s, Buffer 1s
  410. this.safetyTimer = setTimeout(() => {
  411. this.forceStop();
  412. }, ms);
  413. },
  414. forceStop() {
  415. this.playing = false;
  416. this.paused = false;
  417. this.stopAnimate();
  418. this.stopSafetyTimer();
  419. if (_currentVM === this) {
  420. const ctx = getAudioContext();
  421. if (ctx) ctx.stop();
  422. _currentVM = null;
  423. }
  424. },
  425. },
  426. };
  427. </script>
  428. <style lang="scss" scoped>
  429. .bg_container {
  430. padding: 16rpx 24rpx;
  431. border-radius: 0rpx 12rpx 12rpx 12rpx;
  432. background-color: #ffffff;
  433. }
  434. .audio_wrapper {
  435. flex-direction: row;
  436. align-items: center;
  437. }
  438. .audio_wrapper_self {
  439. flex-direction: row-reverse;
  440. }
  441. .audio_message_container {
  442. position: relative;
  443. display: flex;
  444. flex-direction: row;
  445. align-items: center;
  446. padding: 16rpx 24rpx;
  447. border-radius: 0rpx 12rpx 12rpx 12rpx;
  448. border-width: 1px;
  449. border-style: solid;
  450. border-color: #E8EAEF;
  451. min-width: 140rpx;
  452. background-color: #ffffff;
  453. /* z-index: 1; */
  454. }
  455. .audio_message_container_self {
  456. flex-direction: row-reverse;
  457. border-radius: 12rpx 0 12rpx 12rpx;
  458. background-color: #95EC69 !important;
  459. border-color: #95EC69;
  460. }
  461. .audio_duration {
  462. font-size: 18px;
  463. color: #333333;
  464. }
  465. .msg_arrow {
  466. width: 0;
  467. height: 0;
  468. border-top: 12rpx solid transparent;
  469. border-bottom: 12rpx solid transparent;
  470. position: absolute;
  471. top: 22rpx;
  472. }
  473. .msg_arrow_left {
  474. left: -14rpx;
  475. border-right: 16rpx solid #ffffff;
  476. }
  477. .msg_arrow_right {
  478. right: -14rpx;
  479. border-left: 16rpx solid #95EC69;
  480. }
  481. .cricleplay {
  482. display: flex;
  483. flex-direction: row;
  484. align-items: center;
  485. margin-right: 12rpx;
  486. flex-shrink: 0;
  487. }
  488. .cricleplay_self {
  489. margin-right: 0;
  490. margin-left: 12rpx;
  491. flex-direction: row-reverse;
  492. }
  493. .play-icon-small {
  494. width: 7px;
  495. height: 5px;
  496. flex-shrink: 0;
  497. border-style: solid;
  498. border-width: 1px;
  499. border-radius: 50%;
  500. border-color: transparent;
  501. border-right-color: #3870e4;
  502. }
  503. .play-icon-small-self {
  504. border-color: transparent;
  505. border-left-color: #000000;
  506. border-right-color: transparent;
  507. }
  508. .play-icon-middle {
  509. width: 10px;
  510. height: 10px;
  511. flex-shrink: 0;
  512. border-style: solid;
  513. border-width: 1px;
  514. border-radius: 50%;
  515. border-color: transparent;
  516. border-right-color: #3870e4;
  517. margin-left: -5px;
  518. opacity: 1;
  519. }
  520. .play-icon-middle-self {
  521. border-color: transparent;
  522. border-left-color: #000000;
  523. border-right-color: transparent;
  524. margin-left: 0;
  525. margin-right: -5px;
  526. }
  527. .play-icon-large {
  528. width: 20px;
  529. height: 20px;
  530. flex-shrink: 0;
  531. border-style: solid;
  532. border-width: 1px;
  533. border-radius: 50%;
  534. border-color: transparent;
  535. border-right-color: #3870e4;
  536. margin-left: -15px;
  537. opacity: 1;
  538. }
  539. .play-icon-large-self {
  540. border-color: transparent;
  541. border-left-color: #000000;
  542. border-right-color: transparent;
  543. margin-left: 0;
  544. margin-right: -15px;
  545. }
  546. /* Animations in NVUE are limited, using opacity might work but @keyframes are not fully supported in all nvue modes.
  547. Ideally should use bindingx or weex animation module.
  548. For now, we remove complex animation or keep it simple. */
  549. .audio_unread_dot {
  550. width: 14rpx;
  551. height: 14rpx;
  552. border-radius: 50%;
  553. background-color: #FF4D4F;
  554. margin-left: 10rpx;
  555. }
  556. </style>