实现音频组件的音频播放
This commit is contained in:
@@ -29,7 +29,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="discovery-panel" :class="{ 'is-collapsed': isDiscoveryPanelCollapsed }">
|
<view class="discovery-panel" :class="{ 'is-collapsed': isDiscoveryPanelCollapsed }">
|
||||||
<Discovery @content-scroll="handleDiscoveryContentScroll" />
|
<Discovery :active="tabIndex === 0" @content-scroll="handleDiscoveryContentScroll" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
<view class="audio-visual">
|
<view class="audio-visual">
|
||||||
<image v-if="normalizedItem.coverUrl" class="audio-cover" :src="normalizedItem.coverUrl" mode="aspectFill" />
|
<image v-if="normalizedItem.coverUrl" class="audio-cover" :src="normalizedItem.coverUrl" mode="aspectFill" />
|
||||||
<view v-else class="audio-cover audio-cover-fallback" />
|
<view v-else class="audio-cover audio-cover-fallback" />
|
||||||
<view class="audio-play">
|
<view class="audio-play" @tap.stop="handlePlaybackToggle">
|
||||||
<view class="audio-play-triangle" />
|
<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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -13,39 +17,32 @@
|
|||||||
<text class="audio-subtitle block w-full truncate">{{ normalizedItem.subTitle }}</text>
|
<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-row w-full min-w-0">
|
||||||
<view class="audio-progress-track" />
|
<view class="audio-progress-track">
|
||||||
<text class="audio-time block w-[90px] truncate">{{ normalizedItem.duration }}</text>
|
<view class="audio-progress-fill" :style="{ width: `${progressPercent}%` }" />
|
||||||
|
</view>
|
||||||
|
<text class="audio-time block w-[90px] truncate">{{ playbackTimeText }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed, onUnmounted, ref, watch } from "vue";
|
||||||
|
import { onPageHide } from "@dcloudio/uni-app";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["didSelectItem"]);
|
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 normalizedItem = computed(() => {
|
||||||
const source = props.item && typeof props.item === "object" ? props.item : {};
|
const source = props.item && typeof props.item === "object" ? props.item : {};
|
||||||
const resource = source.resource || {};
|
const resource = source.resource || {};
|
||||||
@@ -61,10 +58,169 @@ const normalizedItem = computed(() => {
|
|||||||
url: payload.url || "",
|
url: payload.url || "",
|
||||||
mimeType: payload.mimeType || "",
|
mimeType: payload.mimeType || "",
|
||||||
durationMs: payload.durationMs,
|
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 = () => {
|
const handleSelect = () => {
|
||||||
emit("didSelectItem", normalizedItem.value.raw);
|
emit("didSelectItem", normalizedItem.value.raw);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<DiscoveryPhotoToolCard v-if="discoveryPhotoToolData" :item="discoveryPhotoToolData"
|
<DiscoveryPhotoToolCard v-if="discoveryPhotoToolData" :item="discoveryPhotoToolData"
|
||||||
@didSelectItem="handleToolCardClick" @didSelectOption="handleToolOptionClick" />
|
@didSelectItem="handleToolCardClick" @didSelectOption="handleToolOptionClick" />
|
||||||
<DiscoveryAudioToolCard v-if="discoveryAudioToolData" :item="discoveryAudioToolData"
|
<DiscoveryAudioToolCard v-if="discoveryAudioToolData" :item="discoveryAudioToolData"
|
||||||
@didSelectItem="handleToolCardClick" />
|
:active="props.active" @didSelectItem="handleToolCardClick" />
|
||||||
<DiscoveryGuideMapToolCard v-if="discoveryGuideMapToolData" :item="discoveryGuideMapToolData" />
|
<DiscoveryGuideMapToolCard v-if="discoveryGuideMapToolData" :item="discoveryGuideMapToolData" />
|
||||||
<DiscoveryGoodsCard v-if="discoveryGoodsData.length > 0" :list="discoveryGoodsData" />
|
<DiscoveryGoodsCard v-if="discoveryGoodsData.length > 0" :list="discoveryGoodsData" />
|
||||||
|
|
||||||
@@ -61,6 +61,13 @@ import { homeTabsData, getNearbyTags, homeTabContentData, homeQuickQuestionData,
|
|||||||
import { useAppStore, useLocationStore } from "@/store";
|
import { useAppStore, useLocationStore } from "@/store";
|
||||||
import { JumpType } from "../../model/ChatModel";
|
import { JumpType } from "../../model/ChatModel";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const locationStore = useLocationStore();
|
const locationStore = useLocationStore();
|
||||||
/// 从个渠道获取如二维码
|
/// 从个渠道获取如二维码
|
||||||
|
|||||||
Reference in New Issue
Block a user