| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612 |
- <template>
- <view
- @click="playAudio"
- class="audio_message_container bg_container"
- :class="[isSender ? 'audio_message_container_self' : '']"
- style="position: relative; overflow: visible;">
- <view class="msg_arrow" :class="isSender ? 'msg_arrow_right' : 'msg_arrow_left'"></view>
- <view class="cricleplay" :class="[isSender ? 'cricleplay_self' : '']">
- <view class="play-icon-small" :class="[isSender ? 'play-icon-small-self' : '']"></view>
- <view class="play-icon-middle" :class="[isSender ? 'play-icon-middle-self' : '']" :style="{ opacity: (playing && animateStep < 2) ? 0 : 1 }"></view>
- <view class="play-icon-large" :class="[isSender ? 'play-icon-large-self' : '']" :style="{ opacity: (playing && animateStep < 3) ? 0 : 1 }"></view>
- </view>
- <text class="audio_duration">{{ message.soundElem.duration }}''</text>
- <view v-if="showUnreadIndicator" class="audio_unread_dot"></view>
- </view>
- </template>
- <script>
- // 音频播放单例管理
- let _audioContext = null;
- let _currentVM = null;
- function getAudioContext() {
- if (!_audioContext) {
- _audioContext = uni.createInnerAudioContext();
- // #ifdef APP-PLUS
- // 强制使用扬声器播放,忽略静音开关
- if (typeof uni.setInnerAudioOption === 'function') {
- uni.setInnerAudioOption({
- obeyMuteSwitch: false,
- speakerOn: true
- });
- }
- // #endif
- _audioContext.onPlay(() => {
- if (_currentVM) {
- _currentVM.playing = true;
- _currentVM.paused = false;
- // Animation is started immediately on click to reduce perceived latency
- if (_currentVM.animateStep === 3) { // Only start if not already running
- _currentVM.startAnimate();
- }
- _currentVM.startSafetyTimer();
- }
- });
-
- _audioContext.onPause(() => {
- if (_currentVM) {
- _currentVM.playing = false;
- _currentVM.paused = true;
- _currentVM.stopAnimate();
- _currentVM.stopSafetyTimer();
- }
- });
-
- _audioContext.onStop(() => {
- if (_currentVM) {
- _currentVM.playing = false;
- _currentVM.paused = false;
- _currentVM.stopAnimate();
- _currentVM.stopSafetyTimer();
- }
- });
-
- _audioContext.onEnded(() => {
- if (_currentVM) {
- _currentVM.playing = false;
- _currentVM.paused = false;
- _currentVM.stopAnimate();
- _currentVM.stopSafetyTimer();
- }
- });
- _audioContext.onTimeUpdate(() => {
- if (_currentVM && _currentVM.playing) {
- _currentVM.handleTimeUpdate();
- }
- });
-
- _audioContext.onError((err) => {
- console.error('Audio play error:', err);
- // 添加错误提示,方便用户感知
- uni.showToast({
- title: '播放失败',
- icon: 'none'
- });
- if (_currentVM) {
- _currentVM.playing = false;
- _currentVM.paused = false;
- _currentVM.stopAnimate();
- _currentVM.stopSafetyTimer();
- }
- });
- }
- return _audioContext;
- }
- export default {
- name: "AudioMessageRender",
- props: {
- isSender: Boolean,
- message: Object,
- },
- data() {
- return {
- playing: false,
- paused: false,
- hasRead: false,
- animateStep: 3,
- animateTimer: null,
- safetyTimer: null,
- };
- },
- mounted() {
- this.checkReadState();
- },
- watch: {
- message: {
- handler() {
- this.checkReadState();
- },
- },
- playing(val) {
- if (val) {
- this.startAnimate();
- this.startSafetyTimer();
- } else {
- this.stopAnimate();
- this.stopSafetyTimer();
- }
- },
- },
- beforeDestroy() {
- this.stopAnimate();
- this.stopSafetyTimer();
- // 如果当前组件正在播放,销毁前停止播放
- if (_currentVM === this) {
- const ctx = getAudioContext();
- if (ctx) {
- ctx.stop();
- }
- _currentVM = null;
- }
- },
- computed: {
- showUnreadIndicator() {
- return !this.isSender && !this.hasRead && Boolean(this.message?.clientMsgID);
- },
- },
- methods: {
- getStorageKey() {
- const userID = this.$store?.getters?.storeCurrentUserID || "";
- return `${userID || "GLOBAL"}_AudioMessageReadIds`;
- },
- loadReadIds() {
- const key = this.getStorageKey();
- const stored = uni.getStorageSync(key);
- const readIds = Array.isArray(stored) ? stored : [];
- return { key, readIds };
- },
- checkReadState() {
- const clientMsgID = this.message?.clientMsgID;
- if (!clientMsgID) {
- this.hasRead = true;
- return;
- }
- const { readIds } = this.loadReadIds();
- this.hasRead = readIds.includes(clientMsgID);
- },
- markAsRead() {
- const clientMsgID = this.message?.clientMsgID;
- if (this.hasRead || !clientMsgID) {
- return;
- }
- const { key, readIds } = this.loadReadIds();
- if (readIds.includes(clientMsgID)) {
- this.hasRead = true;
- return;
- }
- uni.setStorageSync(key, [...readIds, clientMsgID]);
- this.hasRead = true;
- },
- // 异步检查文件是否存在
- checkFileExist(path) {
- return new Promise((resolve) => {
- if (!path) {
- resolve(false);
- return;
- }
- // #ifdef APP-PLUS
- // 增加平台路径格式校验,防止跨平台同步的数据(如 Android 路径同步到 iOS)导致误判
- const platform = uni.getSystemInfoSync().platform;
- if (platform === 'ios') {
- // Android 特有路径特征
- if (path.includes('/storage/') || path.includes('/sdcard/') || path.includes('/Android/data/')) {
- console.log('Detected Android path on iOS, ignoring:', path);
- resolve(false);
- return;
- }
- }
-
- plus.io.resolveLocalFileSystemURL(path, () => {
- resolve(true);
- }, () => {
- // 尝试加上 file:// 前缀再试一次
- if (!path.startsWith('file://') && path.startsWith('/')) {
- plus.io.resolveLocalFileSystemURL('file://' + path, () => {
- resolve(true);
- }, () => {
- resolve(false);
- });
- } else {
- resolve(false);
- }
- });
- // #endif
-
- // #ifndef APP-PLUS
- // H5 或其他平台暂不支持本地文件检查,直接返回 true 尝试播放,或 false
- resolve(true);
- // #endif
- });
- },
-
- async playAudio() {
- const ctx = getAudioContext();
-
- // 如果点击的是当前正在播放/暂停的音频
- if (_currentVM === this) {
- if (this.playing) {
- ctx.pause();
- this.playing = false;
- this.paused = true;
- } else {
- ctx.play();
- this.playing = true;
- this.paused = false;
- this.markAsRead();
- }
- return;
- }
- // 如果有其他音频正在播放,先停止它
- if (_currentVM) {
- _currentVM.playing = false;
- _currentVM.paused = false;
- _currentVM.stopAnimate();
- }
-
- // 切换到当前音频
- _currentVM = this;
-
- const sourceUrl = this.message.soundElem.sourceUrl;
- const soundPath = this.message.soundElem.soundPath;
- console.log("qxj sourceUrl:"+sourceUrl+" soundPath:"+soundPath);
- // 1. 尝试播放本地文件 (通常是自己发送的消息)
- if (soundPath) {
- const exists = await this.checkFileExist(soundPath);
- if (exists) {
- this.playLocalFile(ctx, soundPath);
- return;
- }
- }
- // 2. 尝试播放缓存文件
- const cachedKey = 'AUDIO_CACHE_' + sourceUrl;
- const cachedPath = uni.getStorageSync(cachedKey);
- if (cachedPath) {
- const exists = await this.checkFileExist(cachedPath);
- if (exists) {
- this.playLocalFile(ctx, cachedPath);
- return;
- } else {
- // 缓存文件不存在(可能被系统清理),清除缓存记录
- uni.removeStorageSync(cachedKey);
- }
- }
- // 3. 优先尝试流式播放(减少等待时间),同时后台下载缓存
- if (sourceUrl) {
- // 立即播放远程地址
- ctx.src = sourceUrl;
- ctx.play();
- this.playing = true;
- this.paused = false;
- this.startAnimate(); // 立即开始动画,减少用户感知的延迟
- this.markAsRead();
-
- // 后台静默下载缓存
- this.downloadFileSilently(sourceUrl);
- } else {
- uni.showToast({ title: '无效的音频地址', icon: 'none' });
- this.playing = false;
- _currentVM = null;
- }
- },
-
- // 修复路径问题:确保本地路径有正确的前缀
- fixPath(path) {
- if (!path) return '';
- if (path.startsWith('http')) return path;
- // #ifdef APP-PLUS
- let fixedPath = path;
- // 处理 _doc/ 开头的相对路径
- if (fixedPath.startsWith('_doc/')) {
- fixedPath = plus.io.convertLocalFileSystemURL(fixedPath);
- }
- // 处理绝对路径
- if (fixedPath.startsWith('/')) {
- if (!fixedPath.startsWith('file://')) {
- fixedPath = 'file://' + fixedPath;
- }
- }
- return fixedPath;
- // #endif
- return path;
- },
-
- playLocalFile(ctx, path) {
- const fixedPath = this.fixPath(path);
- // 避免重复赋值 src 导致重新加载
- if (ctx.src !== fixedPath) {
- ctx.src = fixedPath;
- }
- ctx.play();
- // 立即更新状态,确保UI响应
- this.playing = true;
- this.paused = false;
- this.startAnimate(); // 立即开始动画
- this.markAsRead();
- },
- downloadFileSilently(url) {
- // 检查是否已有缓存或正在下载
- if (uni.getStorageSync('AUDIO_CACHE_' + url)) return;
-
- uni.downloadFile({
- url: url,
- success: (res) => {
- if (res.statusCode === 200) {
- uni.setStorageSync('AUDIO_CACHE_' + url, res.tempFilePath);
- }
- },
- fail: (err) => {
- console.error('Silent audio download failed:', err);
- }
- });
- },
- // 保留旧方法作为备用(如果需要)
- downloadAndPlay(ctx, url) {
- uni.downloadFile({
- url: url,
- success: (res) => {
- // 如果用户已经切换到其他音频,丢弃本次下载结果
- if (_currentVM !== this) return;
- if (res.statusCode === 200) {
- const tempFilePath = res.tempFilePath;
- // 缓存路径
- uni.setStorageSync('AUDIO_CACHE_' + url, tempFilePath);
- // 播放
- ctx.src = tempFilePath;
- ctx.play();
-
- // 立即更新状态
- this.playing = true;
- this.paused = false;
- this.markAsRead();
- } else {
- console.error('Audio download failed:', res);
- uni.showToast({ title: '音频加载失败', icon: 'none' });
- this.playing = false;
- _currentVM = null;
- }
- },
- fail: (err) => {
- // 如果用户已经切换到其他音频,忽略错误
- if (_currentVM !== this) return;
- console.error('Audio download error:', err);
- uni.showToast({ title: '音频下载出错', icon: 'none' });
- this.playing = false;
- _currentVM = null;
- }
- });
- },
- startAnimate() {
- this.stopAnimate();
- this.animateStep = 1;
- this.animateTimer = setInterval(() => {
- this.animateStep++;
- if (this.animateStep > 3) {
- this.animateStep = 1;
- }
- }, 500);
- },
- stopAnimate() {
- if (this.animateTimer) {
- clearInterval(this.animateTimer);
- this.animateTimer = null;
- }
- this.animateStep = 3;
- // Force update by using $nextTick if available or simple assignment
- // In nvue/weex, sometimes direct assignment doesn't trigger UI update if value is same type but not detected change?
- // But here 3 should trigger opacity 1.
- // Let's verify template logic: :style="{ opacity: animateStep >= 2 ? 1 : 0 }"
- // If animateStep is 3, opacity is 1.
- },
- handleTimeUpdate() {
- const ctx = getAudioContext();
- if (!ctx) return;
-
- const duration = this.message.soundElem.duration;
- // Check if playback exceeded duration (with small tolerance)
- if (duration && ctx.currentTime >= duration + 0.5) {
- this.forceStop();
- return;
- }
-
- // Reset safety timer based on remaining time + buffer
- this.resetSafetyTimer(duration - ctx.currentTime);
- },
-
- startSafetyTimer() {
- const duration = this.message.soundElem.duration || 60;
- // Initial safe buffer: duration + 5s (to account for buffering)
- this.resetSafetyTimer(duration + 5);
- },
-
- stopSafetyTimer() {
- if (this.safetyTimer) {
- clearTimeout(this.safetyTimer);
- this.safetyTimer = null;
- }
- },
-
- resetSafetyTimer(seconds) {
- this.stopSafetyTimer();
- const ms = Math.max(1000, seconds * 1000 + 1000); // Minimum 1s, Buffer 1s
- this.safetyTimer = setTimeout(() => {
- this.forceStop();
- }, ms);
- },
-
- forceStop() {
- this.playing = false;
- this.paused = false;
- this.stopAnimate();
- this.stopSafetyTimer();
-
- if (_currentVM === this) {
- const ctx = getAudioContext();
- if (ctx) ctx.stop();
- _currentVM = null;
- }
- },
- },
- };
- </script>
- <style lang="scss" scoped>
- .bg_container {
- padding: 16rpx 24rpx;
- border-radius: 0rpx 12rpx 12rpx 12rpx;
- background-color: #ffffff;
- }
- .audio_wrapper {
- flex-direction: row;
- align-items: center;
- }
- .audio_wrapper_self {
- flex-direction: row-reverse;
- }
- .audio_message_container {
- position: relative;
- display: flex;
- flex-direction: row;
- align-items: center;
- padding: 16rpx 24rpx;
- border-radius: 0rpx 12rpx 12rpx 12rpx;
- border-width: 1px;
- border-style: solid;
- border-color: #E8EAEF;
- min-width: 140rpx;
- background-color: #ffffff;
- /* z-index: 1; */
- }
- .audio_message_container_self {
- flex-direction: row-reverse;
- border-radius: 12rpx 0 12rpx 12rpx;
- background-color: #95EC69 !important;
- border-color: #95EC69;
- }
- .audio_duration {
- font-size: 18px;
- color: #333333;
- }
- .msg_arrow {
- width: 0;
- height: 0;
- border-top: 12rpx solid transparent;
- border-bottom: 12rpx solid transparent;
- position: absolute;
- top: 22rpx;
- }
- .msg_arrow_left {
- left: -14rpx;
- border-right: 16rpx solid #ffffff;
- }
- .msg_arrow_right {
- right: -14rpx;
- border-left: 16rpx solid #95EC69;
- }
- .cricleplay {
- display: flex;
- flex-direction: row;
- align-items: center;
- margin-right: 12rpx;
- flex-shrink: 0;
- }
- .cricleplay_self {
- margin-right: 0;
- margin-left: 12rpx;
- flex-direction: row-reverse;
- }
- .play-icon-small {
- width: 7px;
- height: 5px;
- flex-shrink: 0;
- border-style: solid;
- border-width: 1px;
- border-radius: 50%;
- border-color: transparent;
- border-right-color: #3870e4;
- }
- .play-icon-small-self {
- border-color: transparent;
- border-left-color: #000000;
- border-right-color: transparent;
- }
- .play-icon-middle {
- width: 10px;
- height: 10px;
- flex-shrink: 0;
- border-style: solid;
- border-width: 1px;
- border-radius: 50%;
- border-color: transparent;
- border-right-color: #3870e4;
- margin-left: -5px;
- opacity: 1;
- }
- .play-icon-middle-self {
- border-color: transparent;
- border-left-color: #000000;
- border-right-color: transparent;
- margin-left: 0;
- margin-right: -5px;
- }
- .play-icon-large {
- width: 20px;
- height: 20px;
- flex-shrink: 0;
- border-style: solid;
- border-width: 1px;
- border-radius: 50%;
- border-color: transparent;
- border-right-color: #3870e4;
- margin-left: -15px;
- opacity: 1;
- }
- .play-icon-large-self {
- border-color: transparent;
- border-left-color: #000000;
- border-right-color: transparent;
- margin-left: 0;
- margin-right: -15px;
- }
- /* Animations in NVUE are limited, using opacity might work but @keyframes are not fully supported in all nvue modes.
- Ideally should use bindingx or weex animation module.
- For now, we remove complex animation or keep it simple. */
- .audio_unread_dot {
- width: 14rpx;
- height: 14rpx;
- border-radius: 50%;
- background-color: #FF4D4F;
- margin-left: 10rpx;
- }
- </style>
|