AudioMessageRender.vue 14 KB

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