Browse Source

播放音频

dev_des
zhangminghao 1 month ago
parent
commit
973f48eb2e
  1. 8
      subPackages/other/index.vue
  2. 787
      subPackages/other/playNovel.vue

8
subPackages/other/index.vue

@ -8,7 +8,7 @@
</view> -->
<BackButton />
<!-- 封面图 -->
<image class="cover-image" :src="showImg('/uploads/20250918/478322390dfe8befd6fb30643e1b5cb1.png')"
<image class="cover-image" :src="showImg('/uploads/20250919/c4c909e3c543ddb738e56462fd047c59.png')"
mode="aspectFill"></image>
<!-- 小说信息 -->
@ -97,7 +97,7 @@
align-items: center;
justify-content: center;
padding: 20rpx;
background-color: #F6EBD4;
background-color: #EAD2AE;
min-height: 100vh;
}
@ -233,8 +233,8 @@
}
.read-btn {
border-color: #e64340;
color: #e64340;
border-color: #895A4E;
color: #895A4E;
}
.play-btn {

787
subPackages/other/playNovel.vue

@ -1,448 +1,543 @@
<template>
<view class="novel-player-container">
<!-- 1. 小说封面图区域适配多端屏幕比例 -->
<view class="novel-cover-wrapper">
<!-- 封面图加载态+失败备用 -->
<view class="player-container">
<!-- 小说封面和标题区域 -->
<view class="novel-info">
<image
class="novel-cover"
:class="{ 'cover-loading': isCoverLoading }"
:src="showImg('/uploads/20250918/478322390dfe8befd6fb30643e1b5cb1.png')"
mode="widthFix"
@load="isCoverLoading = false"
@error="handleCoverError"
lazy-load
class="novel-cover"
src="https://picsum.photos/300/400"
mode="widthFix"
alt="小说封面图"
></image>
<!-- 封面加载失败备用视图 -->
<view class="cover-fallback" v-if="isCoverError">
<uni-icons type="image" size="40" color="#999"></uni-icons>
<text class="fallback-text">封面加载失败</text>
</view>
<view class="novel-title">{{ currentNovel.title }}</view>
<view class="chapter-title">{{ currentChapter.title }}</view>
</view>
<!-- 2. 小说信息区域 -->
<view class="novel-info">
<text class="novel-title">园界修真传</text>
<text class="novel-chapter">第1章考研失败是有原因的</text>
<!-- 进度条区域 -->
<view class="progress-container">
<text class="time">{{ formatTime(currentTime) }}</text>
<view class="progress-bar">
<view
class="progress-track"
@click="changeProgress"
>
<view
class="progress-played"
:style="{ width: progress + '%' }"
></view>
<view
class="progress-thumb"
:style="{ left: progress + '%' }"
@touchstart="startDrag"
@touchmove="dragProgress"
@touchend="endDrag"
></view>
</view>
</view>
<text class="time">{{ formatTime(duration) }}</text>
</view>
<!-- 3. 音频核心控制区域 -->
<view class="audio-player">
<!-- Uniapp 音频组件替代原生audio支持多端 -->
<uni-audio
ref="audioRef"
:src="audioUrl"
:preload="preloadMode"
:initial-time="0"
@play="updatePlayStatus(true)"
@pause="updatePlayStatus(false)"
@ended="handleAudioEnd"
@timeupdate="updateProgress"
@error="handleAudioError"
hidden
></uni-audio>
<!-- 播放/暂停按钮 -->
<!-- 控制按钮区域 -->
<view class="controls">
<button
class="play-btn"
:disabled="isAudioLoading"
@click="togglePlayPause"
hover-class="play-btn-hover"
class="control-btn prev-btn"
@click="prevChapter"
:disabled="currentChapterIndex <= 0"
>
<uni-icons
:type="isPlaying ? 'pause' : 'play'"
size="24"
color="#fff"
></uni-icons>
<i class="iconfont icon-prev"></i>
<text>上一章</text>
</button>
<!-- 进度条控制支持点击+拖动 -->
<view class="progress-container" @click="handleProgressClick">
<!-- 已播放进度 -->
<view
class="progress-played"
:style="{ width: `${progressPercent}%` }"
></view>
<!-- 进度滑块 -->
<view
class="progress-thumb"
:style="{ left: `${progressPercent}%` }"
@touchstart="startDragProgress"
@touchmove="onDragProgress"
@touchend="endDragProgress"
></view>
</view>
<!-- 时间显示已播放/总时长 -->
<view class="time-display">
<text>{{ formatTime(currentTime) }}</text>
<text class="time-split">/</text>
<text>{{ formatTime(totalTime) }}</text>
</view>
<!-- 音量控制 -->
<view class="volume-control">
<uni-icons
:type="isMuted ? 'volume-off' : 'volume-up'"
size="18"
color="#666"
@click="toggleMute"
></uni-icons>
<slider
class="volume-slider"
min="0"
max="100"
:value="currentVolume"
@change="adjustVolume"
activeColor="#32c5ff"
></slider>
</view>
<!-- 清晰度选择模拟多音质切换 -->
<view class="quality-select">
<text class="quality-label">清晰度</text>
<picker
class="quality-picker"
:value="selectedQuality"
:range="qualityOptions"
:range-key="'label'"
@change="switchAudioQuality"
>
<text>{{ qualityOptions.find(item => item.value === selectedQuality).label }}</text>
<uni-icons type="down" size="14" color="#666" class="picker-icon"></uni-icons>
</picker>
<button
class="control-btn play-btn"
@click="togglePlay"
>
<i class="iconfont" :class="isPlaying ? 'icon-pause' : 'icon-play'"></i>
</button>
<button
class="control-btn next-btn"
@click="nextChapter"
:disabled="currentChapterIndex >= novelData.chapters.length - 1"
>
<text>下一章</text>
<i class="iconfont icon-next"></i>
</button>
</view>
<!-- 章节列表弹窗 -->
<view class="chapter-modal" v-if="showChapterList">
<view class="modal-content">
<view class="modal-header">
<text>章节列表</text>
<button class="close-btn" @click="showChapterList = false">
<i class="iconfont icon-close"></i>
</button>
</view>
<scroll-view class="chapter-list" scroll-y>
<view
class="chapter-item"
v-for="(chapter, index) in novelData.chapters"
:key="index"
@click="switchChapter(index)"
:class="{ active: index === currentChapterIndex }"
>
<text>{{ chapter.title }}</text>
</view>
</scroll-view>
</view>
</view>
<!-- 章节列表按钮 -->
<button class="chapter-list-btn" @click="showChapterList = true">
<i class="iconfont icon-list"></i>
</button>
</view>
</template>
<script>
export default {
name: 'PlayNovel',
data() {
return {
//
coverUrl: '/static/image.png', // Uniapp static
isCoverLoading: true, //
isCoverError: false, //
//
audioUrl: 'https://des.js-dyyj.com/data/2025/09/05/9875a62d-14ef-481e-b88f-19c061478ce6.MP3', // /CDN
preloadMode: 'auto', // auto/metadata/none
isAudioLoading: false, //
isPlaying: false, //
currentTime: 0, //
totalTime: 0, //
progressPercent: 0, // 0-100
currentVolume: 80, // 0-100
isMuted: false, //
isDragging: false, //
//
qualityOptions: [
{ label: '标准', value: 'low' },
{ label: '高清', value: 'medium' },
{ label: '无损', value: 'high' }
],
selectedQuality: 'medium' //
//
novelData: {
title: "三体",
chapters: [
{ id: 1, title: "第1章 科学边界", audioUrl: "https://des.js-dyyj.com/data/2025/09/04/fbc13519-cfe5-4088-89b2-59f138bc23cb.MP3" },
{ id: 2, title: "第2章 射手和农场主", audioUrl: "https://des.js-dyyj.com/data/2025/09/04/fbc13519-cfe5-4088-89b2-59f138bc23cb.MP3" },
{ id: 3, title: "第3章 疯狂年代", audioUrl: "https://des.js-dyyj.com/data/2025/09/04/fbc13519-cfe5-4088-89b2-59f138bc23cb.MP3" },
{ id: 4, title: "第4章 叶文洁", audioUrl: "https://des.js-dyyj.com/data/2025/09/04/fbc13519-cfe5-4088-89b2-59f138bc23cb.MP3" },
{ id: 5, title: "第5章 宇宙闪烁", audioUrl: "https://des.js-dyyj.com/data/2025/09/04/fbc13519-cfe5-4088-89b2-59f138bc23cb.MP3" }
]
},
currentChapterIndex: 0,
isPlaying: false,
currentTime: 0,
duration: 0,
progress: 0,
audioContext: null,
isDragging: false,
showChapterList: false
};
},
onReady() {
// Uniapp
this.audioRef = this.$refs.audioRef;
//
this.audioRef.setVolume(this.currentVolume / 100);
//
this.switchAudioQuality({ detail: { value: 'medium' } });
computed: {
currentNovel() {
return this.novelData;
},
currentChapter() {
return this.novelData.chapters[this.currentChapterIndex];
}
},
onLoad() {
//
this.audioContext = uni.createInnerAudioContext();
this.audioContext.autoplay = false;
//
this.setAudioSource();
//
this.audioContext.onPlay(() => {
this.isPlaying = true;
});
this.audioContext.onPause(() => {
this.isPlaying = false;
});
this.audioContext.onTimeUpdate(() => {
if (!this.isDragging) {
this.currentTime = this.audioContext.currentTime;
this.duration = this.audioContext.duration;
this.progress = (this.currentTime / this.duration) * 100;
}
});
//
this.audioContext.onEnded(() => {
this.nextChapter();
});
//
this.audioContext.onCanplay(() => {
this.duration = this.audioContext.duration;
});
},
onUnload() {
//
if (this.audioRef) {
this.audioRef.pause();
}
//
this.audioContext.destroy();
},
methods: {
// ---------------------- ----------------------
handleCoverError() {
this.isCoverLoading = false;
this.isCoverError = true;
//
setAudioSource() {
const wasPlaying = this.isPlaying;
this.audioContext.src = this.currentChapter.audioUrl;
//
if (wasPlaying) {
this.audioContext.play();
}
},
// ---------------------- ----------------------
// /
togglePlayPause() {
// /
togglePlay() {
if (this.isPlaying) {
this.audioRef.pause();
this.audioContext.pause();
} else {
this.isAudioLoading = true;
this.audioRef.play().catch(err => {
console.error('播放失败:', err);
this.isAudioLoading = false;
uni.showToast({ title: '音频播放失败', icon: 'none' });
});
this.audioContext.play();
}
},
//
updatePlayStatus(isPlaying) {
this.isPlaying = isPlaying;
this.isAudioLoading = false;
},
//
handleAudioEnd() {
this.isPlaying = false;
this.currentTime = 0;
this.progressPercent = 0;
uni.showToast({ title: '本章播放完毕', icon: 'none' });
//
},
//
handleAudioError() {
this.isAudioLoading = false;
uni.showToast({ title: '音频加载失败', icon: 'none' });
//
prevChapter() {
if (this.currentChapterIndex > 0) {
this.currentChapterIndex--;
this.setAudioSource();
this.audioContext.play();
}
},
// ---------------------- ----------------------
//
updateProgress() {
if (!this.isDragging) {
this.currentTime = this.audioRef.currentTime;
this.totalTime = this.audioRef.duration || 0;
this.progressPercent = (this.currentTime / this.totalTime) * 100 || 0;
//
nextChapter() {
if (this.currentChapterIndex < this.novelData.chapters.length - 1) {
this.currentChapterIndex++;
this.setAudioSource();
this.audioContext.play();
}
},
//
handleProgressClick(e) {
const containerWidth = e.currentTarget.offsetWidth;
const clickLeft = e.touches[0].clientX - e.currentTarget.offsetLeft;
const percent = (clickLeft / containerWidth) * 100;
this.setProgress(percent);
//
switchChapter(index) {
this.currentChapterIndex = index;
this.setAudioSource();
this.audioContext.play();
this.showChapterList = false;
},
// -> mm:ss
formatTime(seconds) {
if (isNaN(seconds)) return "00:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
},
//
startDragProgress() {
startDrag() {
this.isDragging = true;
},
//
onDragProgress(e) {
//
dragProgress(e) {
if (!this.isDragging) return;
const containerWidth = e.currentTarget.offsetWidth;
const dragLeft = e.touches[0].clientX - e.currentTarget.offsetLeft;
let percent = (dragLeft / containerWidth) * 100;
// 0-100
percent = Math.max(0, Math.min(100, percent));
this.progressPercent = percent;
//
const progressBarWidth = e.currentTarget.offsetWidth;
const touchX = e.touches[0].clientX - e.currentTarget.offsetLeft;
//
let percent = (touchX / progressBarWidth) * 100;
percent = Math.max(0, Math.min(100, percent)); // 0-100
this.progress = percent;
this.currentTime = (percent / 100) * this.duration;
},
//
endDragProgress() {
endDrag() {
if (!this.isDragging) return;
this.isDragging = false;
this.setProgress(this.progressPercent);
},
//
setProgress(percent) {
const targetTime = (percent / 100) * this.totalTime;
this.audioRef.seek(targetTime);
this.currentTime = targetTime;
this.progressPercent = percent;
},
// ---------------------- ----------------------
//
toggleMute() {
this.isMuted = !this.isMuted;
this.audioRef.setMuted(this.isMuted);
},
//
adjustVolume(e) {
this.currentVolume = e.detail.value;
this.audioRef.setVolume(this.currentVolume / 100);
//
if (this.isMuted && this.currentVolume > 0) {
this.isMuted = false;
this.audioRef.setMuted(false);
}
},
// ---------------------- ----------------------
switchAudioQuality(e) {
const quality = e.detail.value;
this.selectedQuality = quality;
//
const audioMap = {
low: '/static/audio/chapter1-low.mp3',
medium: '/static/audio/chapter1-medium.mp3',
high: '/static/audio/chapter1-high.mp3'
};
this.audioUrl = audioMap[quality];
//
//
this.audioContext.seek(this.currentTime);
//
if (this.isPlaying) {
this.audioRef.play();
this.audioContext.play();
}
},
// ---------------------- ----------------------
// : 8:16
formatTime(seconds) {
if (!seconds) return '00:00';
const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60);
// 1 01
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
//
changeProgress(e) {
if (this.isDragging) return;
//
const progressBarWidth = e.currentTarget.offsetWidth;
const touchX = e.touches ? e.touches[0].clientX : e.clientX;
const clickX = touchX - e.currentTarget.offsetLeft;
//
let percent = (clickX / progressBarWidth) * 100;
percent = Math.max(0, Math.min(100, percent)); // 0-100
this.progress = percent;
this.currentTime = (percent / 100) * this.duration;
this.audioContext.seek(this.currentTime);
}
}
};
</script>
<style scoped>
/* 容器整体样式 */
.novel-player-container {
padding: 20rpx;
background: #595959;
.player-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
min-height: 100vh;
}
/* 封面图样式 */
.novel-cover-wrapper {
width: 100%;
border-radius: 20rpx;
overflow: hidden;
margin-bottom: 30rpx;
position: relative;
}
.novel-cover {
width: 100%;
background-color: #f5f5f5;
padding: 20rpx;
box-sizing: border-box;
}
.cover-loading {
opacity: 0.5;
}
.cover-fallback {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.novel-info {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #f5f5f5;
}
.fallback-text {
margin-top: 20rpx;
font-size: 24rpx;
color: #999;
margin-top: 60rpx;
width: 100%;
}
/* 小说信息样式 */
.novel-info {
.novel-cover {
width: 240rpx;
height: 320rpx;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
margin-bottom: 30rpx;
text-align: center;
}
.novel-title {
font-size: 36rpx;
font-weight: bold;
color: #fff;
color: #333;
margin-bottom: 10rpx;
display: block;
text-align: center;
}
.novel-chapter {
.chapter-title {
font-size: 28rpx;
color: #fff;
display: block;
color: #666;
margin-bottom: 60rpx;
text-align: center;
max-width: 80%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 音频播放器样式 */
.audio-player {
.progress-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 25rpx;
align-items: center;
justify-content: space-between;
padding: 0 20rpx;
box-sizing: border-box;
margin-bottom: 80rpx;
}
/* 播放按钮样式 */
.play-btn {
.time {
font-size: 24rpx;
color: #999;
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #32c5ff;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 10rpx;
}
.play-btn-hover {
background-color: #28a4e0;
.progress-bar {
flex: 1;
padding: 0 20rpx;
}
/* 进度条样式 */
.progress-container {
width: 100%;
height: 12rpx;
background-color: #eee;
border-radius: 6rpx;
.progress-track {
height: 8rpx;
background-color: #e0e0e0;
border-radius: 4rpx;
position: relative;
touch-action: none; /* 防止移动端默认触摸行为 */
}
.progress-played {
height: 100%;
background-color: #32c5ff;
border-radius: 6rpx;
background-color: #007aff;
border-radius: 4rpx;
position: absolute;
left: 0;
top: 0;
}
.progress-thumb {
width: 24rpx;
height: 24rpx;
background-color: #007aff;
border-radius: 50%;
background-color: #32c5ff;
position: absolute;
top: 50%;
transform: translateY(-50%);
margin-left: -12rpx;
box-shadow: 0 0 10rpx rgba(50, 197, 255, 0.5);
transform: translate(-50%, -50%);
box-shadow: 0 2rpx 8rpx rgba(0, 122, 255, 0.5);
}
/* 时间显示样式 */
.time-display {
font-size: 24rpx;
color: #999;
.controls {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-around;
width: 100%;
margin-bottom: 120rpx;
}
.time-split {
margin: 0 10rpx;
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
margin: 0 20rpx;
}
.control-btn::after {
border: none;
}
/* 音量控制样式 */
.volume-control {
.prev-btn, .next-btn {
color: #666;
font-size: 24rpx;
}
.play-btn {
width: 120rpx;
height: 120rpx;
background-color: #007aff;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
gap: 15rpx;
justify-content: center;
}
.volume-slider {
flex: 1;
height: 8rpx;
.iconfont {
font-size: 48rpx;
}
.play-btn .iconfont {
font-size: 56rpx;
}
/* 清晰度选择样式 */
.quality-select {
.prev-btn .iconfont, .next-btn .iconfont {
margin: 8rpx 0;
}
.chapter-list-btn {
position: fixed;
bottom: 40rpx;
right: 40rpx;
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #007aff;
color: white;
display: flex;
align-items: center;
gap: 15rpx;
font-size: 26rpx;
color: #666;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 122, 255, 0.3);
border: none;
}
.quality-picker {
.chapter-list-btn::after {
border: none;
}
.chapter-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: flex-end;
z-index: 999;
}
.modal-content {
width: 70%;
max-width: 500rpx;
height: 100%;
background-color: white;
display: flex;
flex-direction: column;
}
.modal-header {
height: 80rpx;
display: flex;
align-items: center;
gap: 8rpx;
color: #32c5ff;
justify-content: space-between;
padding: 0 30rpx;
border-bottom: 1rpx solid #eee;
font-size: 30rpx;
font-weight: bold;
}
.close-btn {
background: none;
border: none;
color: #999;
font-size: 36rpx;
}
.picker-icon {
margin-top: 4rpx;
.close-btn::after {
border: none;
}
.chapter-list {
flex: 1;
padding: 20rpx 0;
}
.chapter-item {
padding: 24rpx 30rpx;
font-size: 28rpx;
color: #333;
border-bottom: 1rpx solid #f5f5f5;
}
.chapter-item.active {
background-color: #f0f7ff;
color: #007aff;
}
/* 图标字体样式 */
@font-face {
font-family: 'iconfont';
src: url('//at.alicdn.com/t/font_2493751_2x4w6q4k59l.ttf') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-play:before {
content: "\e61c";
}
.icon-pause:before {
content: "\e61d";
}
.icon-prev:before {
content: "\e61e";
}
.icon-next:before {
content: "\e61f";
}
.icon-list:before {
content: "\e620";
}
.icon-close:before {
content: "\e621";
}
</style>
</style>

Loading…
Cancel
Save