实现音频组件的音频播放

This commit is contained in:
2026-08-07 15:57:28 +08:00
parent fe1b3f5e02
commit 0ae1b15007
3 changed files with 185 additions and 22 deletions

View File

@@ -29,7 +29,7 @@
</view>
<view class="discovery-panel" :class="{ 'is-collapsed': isDiscoveryPanelCollapsed }">
<Discovery @content-scroll="handleDiscoveryContentScroll" />
<Discovery :active="tabIndex === 0" @content-scroll="handleDiscoveryContentScroll" />
</view>
</view>

View File

@@ -3,8 +3,12 @@
<view class="audio-visual">
<image v-if="normalizedItem.coverUrl" class="audio-cover" :src="normalizedItem.coverUrl" mode="aspectFill" />
<view v-else class="audio-cover audio-cover-fallback" />
<view class="audio-play">
<view class="audio-play-triangle" />
<view class="audio-play" @tap.stop="handlePlaybackToggle">
<view v-if="isPlaying" class="flex items-center gap-[6px]">
<view class="h-[20px] w-[5px] rounded-[2px] bg-white" />
<view class="h-[20px] w-[5px] rounded-[2px] bg-white" />
</view>
<view v-else class="audio-play-triangle" />
</view>
</view>
@@ -13,39 +17,32 @@
<text class="audio-subtitle block w-full truncate">{{ normalizedItem.subTitle }}</text>
<view class="audio-progress-row w-full min-w-0">
<view class="audio-progress-track" />
<text class="audio-time block w-[90px] truncate">{{ normalizedItem.duration }}</text>
<view class="audio-progress-track">
<view class="audio-progress-fill" :style="{ width: `${progressPercent}%` }" />
</view>
<text class="audio-time block w-[90px] truncate">{{ playbackTimeText }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
import { computed, onUnmounted, ref, watch } from "vue";
import { onPageHide } from "@dcloudio/uni-app";
const props = defineProps({
item: {
type: Object,
default: () => ({}),
},
active: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["didSelectItem"]);
const formatDuration = (durationMs) => {
if (durationMs === null || durationMs === undefined || durationMs === "") {
return "";
}
const value = Number(durationMs);
if (!Number.isFinite(value) || value < 0) return "";
const totalSeconds = Math.floor(value / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
};
const normalizedItem = computed(() => {
const source = props.item && typeof props.item === "object" ? props.item : {};
const resource = source.resource || {};
@@ -61,10 +58,169 @@ const normalizedItem = computed(() => {
url: payload.url || "",
mimeType: payload.mimeType || "",
durationMs: payload.durationMs,
duration: formatDuration(payload.durationMs),
};
});
const isPlaying = ref(false);
const currentTimeSeconds = ref(0);
const audioDurationSeconds = ref(0);
let audioContext = null;
const formatSeconds = (secondsValue) => {
const value = Number(secondsValue);
if (!Number.isFinite(value) || value < 0) return "";
const totalSeconds = Math.floor(value);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
};
const metadataDurationSeconds = computed(() => {
const durationMs = Number(normalizedItem.value.durationMs);
return Number.isFinite(durationMs) && durationMs > 0 ? durationMs / 1000 : 0;
});
const effectiveDurationSeconds = computed(
() => audioDurationSeconds.value || metadataDurationSeconds.value,
);
const progressPercent = computed(() => {
if (!effectiveDurationSeconds.value) return 0;
const progress = (currentTimeSeconds.value / effectiveDurationSeconds.value) * 100;
return Math.min(Math.max(progress, 0), 100);
});
const playbackTimeText = computed(() => {
if (!effectiveDurationSeconds.value && !currentTimeSeconds.value) return "";
const currentTimeText = formatSeconds(currentTimeSeconds.value);
const durationText = formatSeconds(effectiveDurationSeconds.value);
return durationText ? `${currentTimeText} / ${durationText}` : currentTimeText;
});
const resetPlaybackState = () => {
isPlaying.value = false;
currentTimeSeconds.value = 0;
audioDurationSeconds.value = 0;
};
const destroyAudioContext = () => {
if (!audioContext) return;
const context = audioContext;
audioContext = null;
context.pause();
context.destroy();
};
const stopAndResetPlayback = () => {
destroyAudioContext();
resetPlaybackState();
};
const createAudioContext = () => {
const url = normalizedItem.value.url;
console.log("Creating audio context for URL:", url);
if (!url) return null;
const context = uni.createInnerAudioContext();
audioContext = context;
context.autoplay = false;
context.src = url;
context.onPlay(() => {
if (audioContext === context) isPlaying.value = true;
});
context.onPause(() => {
if (audioContext === context) isPlaying.value = false;
});
context.onCanplay(() => {
if (audioContext !== context) return;
const duration = Number(context.duration);
if (Number.isFinite(duration) && duration > 0) {
audioDurationSeconds.value = duration;
}
});
context.onTimeUpdate(() => {
if (audioContext !== context) return;
currentTimeSeconds.value = Number(context.currentTime) || 0;
const duration = Number(context.duration);
if (Number.isFinite(duration) && duration > 0) {
audioDurationSeconds.value = duration;
}
});
context.onEnded(() => {
if (audioContext !== context) return;
isPlaying.value = false;
currentTimeSeconds.value = 0;
});
context.onStop(() => {
if (audioContext !== context) return;
isPlaying.value = false;
currentTimeSeconds.value = 0;
});
context.onError(() => {
if (audioContext !== context) return;
stopAndResetPlayback();
uni.showToast({
title: "音频播放失败",
icon: "none",
});
});
return context;
};
const handlePlaybackToggle = () => {
if (!normalizedItem.value.url) return;
const context = audioContext || createAudioContext();
if (!context) return;
if (isPlaying.value) {
isPlaying.value = false;
context.pause();
} else {
isPlaying.value = true;
context.play();
}
};
watch(
() => props.active,
(active) => {
if (!active) stopAndResetPlayback();
},
);
watch(
() => normalizedItem.value.url,
() => {
stopAndResetPlayback();
},
);
onPageHide(() => {
stopAndResetPlayback();
});
onUnmounted(() => {
stopAndResetPlayback();
});
const handleSelect = () => {
emit("didSelectItem", normalizedItem.value.raw);
};

View File

@@ -30,7 +30,7 @@
<DiscoveryPhotoToolCard v-if="discoveryPhotoToolData" :item="discoveryPhotoToolData"
@didSelectItem="handleToolCardClick" @didSelectOption="handleToolOptionClick" />
<DiscoveryAudioToolCard v-if="discoveryAudioToolData" :item="discoveryAudioToolData"
@didSelectItem="handleToolCardClick" />
:active="props.active" @didSelectItem="handleToolCardClick" />
<DiscoveryGuideMapToolCard v-if="discoveryGuideMapToolData" :item="discoveryGuideMapToolData" />
<DiscoveryGoodsCard v-if="discoveryGoodsData.length > 0" :list="discoveryGoodsData" />
@@ -61,6 +61,13 @@ import { homeTabsData, getNearbyTags, homeTabContentData, homeQuickQuestionData,
import { useAppStore, useLocationStore } from "@/store";
import { JumpType } from "../../model/ChatModel";
const props = defineProps({
active: {
type: Boolean,
default: true,
},
});
const appStore = useAppStore();
const locationStore = useLocationStore();
/// 从个渠道获取如二维码