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

787
subPackages/other/playNovel.vue

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