add MusicPlayer
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
<div aria-hidden="true" class="spacer top"></div>
|
||||
</template>
|
||||
<template #default="{ item }">
|
||||
<t-swipe-cell>
|
||||
<t-swipe-cell :disabled="!isSwipeActionVisible(item)" :left="resolveLeftActions(item)">
|
||||
<t-cell
|
||||
class="cell"
|
||||
:title="item.name"
|
||||
@@ -35,17 +35,52 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from "vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import { type ExplorerItem } from "./fileExplorer.shared";
|
||||
import { isBrowserSupportedAudio } from "./fileAudio.shared";
|
||||
|
||||
const props = defineProps<{
|
||||
items: ExplorerItem[];
|
||||
pendingPath?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [item: ExplorerItem];
|
||||
}>();
|
||||
const emit = defineEmits(["open", "queue", "play"]);
|
||||
|
||||
function isSwipeActionVisible(item: ExplorerItem): boolean {
|
||||
return isBrowserSupportedAudio(item);
|
||||
}
|
||||
|
||||
function resolveLeftActions(item: ExplorerItem) {
|
||||
if (!isSwipeActionVisible(item)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const buttonComponent = resolveComponent("t-button");
|
||||
const iconComponent = resolveComponent("t-icon");
|
||||
|
||||
return () => h("div", { class: "actions" }, [
|
||||
h(buttonComponent, {
|
||||
class: "action-btn queue-action",
|
||||
size: "small",
|
||||
shape: "square",
|
||||
theme: "primary",
|
||||
variant: "outline",
|
||||
onClick: () => handleQueue(item)
|
||||
}, {
|
||||
icon: () => h(iconComponent, { name: "queue" })
|
||||
}),
|
||||
h(buttonComponent, {
|
||||
class: "action-btn play-action",
|
||||
size: "small",
|
||||
shape: "square",
|
||||
theme: "primary",
|
||||
onClick: () => handlePlay(item)
|
||||
}, {
|
||||
icon: () => h(iconComponent, { name: "play" })
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
function handleOpen(item: ExplorerItem): void {
|
||||
if (props.pendingPath) {
|
||||
@@ -54,6 +89,22 @@ function handleOpen(item: ExplorerItem): void {
|
||||
|
||||
emit("open", item);
|
||||
}
|
||||
|
||||
function handleQueue(item: ExplorerItem): void {
|
||||
if (props.pendingPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("queue", item);
|
||||
}
|
||||
|
||||
function handlePlay(item: ExplorerItem): void {
|
||||
console.log("play", item);
|
||||
if (props.pendingPath) {
|
||||
return;
|
||||
}
|
||||
emit("play", item);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -113,6 +164,28 @@ function handleOpen(item: ExplorerItem): void {
|
||||
color: var(--app-sub);
|
||||
}
|
||||
|
||||
&:deep(.actions) {
|
||||
gap: .5rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
padding: 0 .75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:deep(.queue-action) {
|
||||
background: rgba(255, 255, 255, .92);
|
||||
}
|
||||
|
||||
&:deep(.play-action) {
|
||||
background: var(--td-brand-color);
|
||||
}
|
||||
|
||||
&:deep(.action-btn) {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.cell {
|
||||
|
||||
&:deep(.t-cell__content) {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
:items="currentItems"
|
||||
:pending-path="pendingFolderPath"
|
||||
@open="openItem"
|
||||
@queue="queueItem"
|
||||
@play="playItem"
|
||||
/>
|
||||
<file-explorer-grid
|
||||
v-else
|
||||
@@ -44,12 +46,14 @@ const props = withDefaults(defineProps<{
|
||||
useRoutePath: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:path": [value: string[]];
|
||||
"open-folder": [value: string[]];
|
||||
"open-file": [value: ExplorerItem];
|
||||
"update:mode": [value: DisplayMode];
|
||||
}>();
|
||||
const emit = defineEmits([
|
||||
"update:path",
|
||||
"open-folder",
|
||||
"open-file",
|
||||
"queue-file",
|
||||
"play-file",
|
||||
"update:mode"
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -220,6 +224,15 @@ async function openItem(item: ExplorerItem): Promise<void> {
|
||||
emit("open-file", item);
|
||||
}
|
||||
|
||||
function queueItem(item: ExplorerItem): void {
|
||||
emit("queue-file", item);
|
||||
}
|
||||
|
||||
function playItem(item: ExplorerItem): void {
|
||||
console.log("play-item", item);
|
||||
emit("play-file", item);
|
||||
}
|
||||
|
||||
async function syncPath(nextSegments: string[]): Promise<void> {
|
||||
if (shouldUseRoutePath.value) {
|
||||
if (!nextSegments.length) {
|
||||
|
||||
42
src/pages/file/fileAudio.shared.ts
Normal file
42
src/pages/file/fileAudio.shared.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ExplorerItem } from "./fileExplorer.shared";
|
||||
|
||||
const audioMimeMap: Record<string, string[]> = {
|
||||
mp3: ["audio/mpeg"],
|
||||
mpeg: ["audio/mpeg"],
|
||||
m4a: ["audio/mp4", "audio/x-m4a"],
|
||||
mp4: ["audio/mp4"],
|
||||
aac: ["audio/aac", "audio/mp4"],
|
||||
ogg: ["audio/ogg", "audio/vorbis"],
|
||||
oga: ["audio/ogg", "audio/vorbis"],
|
||||
wav: ["audio/wav", "audio/wave", "audio/x-wav"],
|
||||
webm: ["audio/webm"],
|
||||
flac: ["audio/flac", "audio/x-flac"]
|
||||
};
|
||||
|
||||
function resolveAudioExtension(item: ExplorerItem): string {
|
||||
const rawExtension = item.extension || item.name.split(".").pop() || "";
|
||||
return rawExtension.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isAudioExplorerItem(item: ExplorerItem): boolean {
|
||||
return item.type === "file" && item.rawType === "AUDIO";
|
||||
}
|
||||
|
||||
export function isBrowserSupportedAudio(item: ExplorerItem): boolean {
|
||||
if (!isAudioExplorerItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extension = resolveAudioExtension(item);
|
||||
const mimeList = audioMimeMap[extension];
|
||||
if (!mimeList?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const audio = document.createElement("audio");
|
||||
return mimeList.some((mime) => !!audio.canPlayType(mime));
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export interface ServerFile {
|
||||
export interface ExplorerItem {
|
||||
name: string;
|
||||
type: FileItemType;
|
||||
extension?: string;
|
||||
size?: string;
|
||||
updatedAt: string;
|
||||
icon: string;
|
||||
@@ -55,6 +56,7 @@ export function mapServerFileToExplorerItem(file: ServerFile): ExplorerItem {
|
||||
return {
|
||||
name: file.name || "未命名",
|
||||
type: isDirectory ? "dir" : "file",
|
||||
extension: file.extension,
|
||||
size: isDirectory ? undefined : formatFileSize(file.size),
|
||||
updatedAt: formatModifiedAt(file.modifiedAt),
|
||||
icon: resolveFileIcon(file, isDirectory),
|
||||
|
||||
278
src/pages/system/MusicPlayerPopup.vue
Normal file
278
src/pages/system/MusicPlayerPopup.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<t-popup
|
||||
:visible="musicPlayerStore.popupVisible"
|
||||
placement="bottom"
|
||||
teleport="body"
|
||||
:duration="460"
|
||||
@visible-change="handleVisibleChange"
|
||||
>
|
||||
<div class="music-popup">
|
||||
<div class="summary">
|
||||
<div class="cover">
|
||||
<img v-if="currentSong.cover" :src="currentSong.cover" alt="封面">
|
||||
<t-icon v-else name="music" />
|
||||
</div>
|
||||
<div class="info">
|
||||
<p class="title" v-text="currentSong.title || currentFile?.name || '未选择歌曲'"></p>
|
||||
<p class="artist" v-text="currentSong.artist || '未知艺术家'"></p>
|
||||
</div>
|
||||
<t-button class="close-btn" variant="text" shape="square" @click="closePopup">
|
||||
关闭
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<t-button variant="text" theme="danger" @click="clearQueue">
|
||||
清空队列
|
||||
</t-button>
|
||||
<t-button variant="text" theme="primary" @click="musicPlayerStore.cyclePlayMode()">
|
||||
<span v-text="musicPlayerStore.playModeLabel"></span>
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<div class="queue">
|
||||
<div class="queue-head">
|
||||
<p class="queue-title">播放队列</p>
|
||||
<p class="queue-count" v-text="`共 ${musicPlayerStore.queue.length} 首`"></p>
|
||||
</div>
|
||||
<div class="queue-list">
|
||||
<button
|
||||
v-for="(item, index) in musicPlayerStore.queue"
|
||||
:key="item.id"
|
||||
:class="{ active: index === musicPlayerStore.currentIndex }"
|
||||
class="queue-item"
|
||||
type="button"
|
||||
@click="playItem(index)"
|
||||
>
|
||||
<div class="queue-main">
|
||||
<p class="queue-name" v-text="item.song.title || item.file.name"></p>
|
||||
<p class="queue-artist" v-text="item.song.artist || '未知艺术家'"></p>
|
||||
</div>
|
||||
<t-button
|
||||
class="queue-remove"
|
||||
variant="text"
|
||||
shape="square"
|
||||
@click.stop="musicPlayerStore.removeFromQueue(item.id)"
|
||||
>
|
||||
移除
|
||||
</t-button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<t-button variant="outline" @click="musicPlayerStore.playPrev">
|
||||
上一首
|
||||
</t-button>
|
||||
<t-button theme="primary" @click="musicPlayerStore.togglePlaying">
|
||||
<span v-text="musicPlayerStore.isPlaying ? '暂停' : '播放'"></span>
|
||||
</t-button>
|
||||
<t-button variant="outline" @click="musicPlayerStore.playNext">
|
||||
下一首
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<div class="volume">
|
||||
<p class="volume-label">音量</p>
|
||||
<t-slider v-model="volumePercent" :min="0" :max="100" />
|
||||
</div>
|
||||
</div>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMusicPlayerStore } from "@/store/musicPlayerStore";
|
||||
|
||||
const musicPlayerStore = useMusicPlayerStore();
|
||||
|
||||
const currentFile = computed(() => musicPlayerStore.currentFile);
|
||||
const currentSong = computed(() => musicPlayerStore.currentSong);
|
||||
const volumePercent = computed({
|
||||
get() {
|
||||
return Math.round(musicPlayerStore.volume * 100);
|
||||
},
|
||||
set(value: number) {
|
||||
musicPlayerStore.setVolume(value / 100);
|
||||
}
|
||||
});
|
||||
|
||||
function handleVisibleChange(visible: boolean): void {
|
||||
musicPlayerStore.setPopupVisible(visible);
|
||||
}
|
||||
|
||||
function closePopup(): void {
|
||||
musicPlayerStore.setPopupVisible(false);
|
||||
}
|
||||
|
||||
function clearQueue(): void {
|
||||
musicPlayerStore.clearQueue();
|
||||
}
|
||||
|
||||
function playItem(index: number): void {
|
||||
musicPlayerStore.setCurrentIndex(index);
|
||||
musicPlayerStore.setPlaying(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.music-popup {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 1.25rem 1.25rem 0 0;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, .98), rgba(246, 247, 250, .98));
|
||||
box-shadow: 0 -.35rem 1.5rem rgba(17, 32, 56, .08);
|
||||
|
||||
.summary,
|
||||
.toolbar,
|
||||
.controls,
|
||||
.volume {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
flex: none;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1f6fff, #51a2ff);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
gap: .35rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title,
|
||||
.artist,
|
||||
.queue-title,
|
||||
.queue-count,
|
||||
.queue-name,
|
||||
.queue-artist,
|
||||
.volume-label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title,
|
||||
.queue-name {
|
||||
color: var(--app-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.artist,
|
||||
.queue-count,
|
||||
.queue-artist,
|
||||
.volume-label {
|
||||
color: var(--app-sub);
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.controls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.queue {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.queue-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
gap: .625rem;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
max-height: 15rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
gap: .75rem;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding: .875rem 1rem;
|
||||
text-align: left;
|
||||
border-radius: 1rem;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, .72);
|
||||
justify-content: space-between;
|
||||
|
||||
&.active {
|
||||
background: rgba(31, 111, 255, .12);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-main {
|
||||
gap: .25rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.queue-name,
|
||||
.queue-artist,
|
||||
.title,
|
||||
.artist {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.controls {
|
||||
> * {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
.volume {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.theme-dark) .music-popup {
|
||||
background: linear-gradient(180deg, rgba(22, 26, 31, .98), rgba(14, 18, 23, .98));
|
||||
box-shadow: 0 -.35rem 1.5rem rgba(0, 0, 0, .28);
|
||||
|
||||
.queue-item {
|
||||
background: rgba(255, 255, 255, .05);
|
||||
|
||||
&.active {
|
||||
background: rgba(81, 162, 255, .2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,37 @@
|
||||
<template>
|
||||
<file-explorer-page />
|
||||
<file-explorer-page
|
||||
@open-file="handleOpenFile"
|
||||
@queue-file="handleQueueFile"
|
||||
@play-file="handleOpenFile"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FileExplorerPage from "@/pages/file/FileExplorerPage.vue";
|
||||
import { isBrowserSupportedAudio } from "@/pages/file/fileAudio.shared";
|
||||
import type { ExplorerItem } from "@/pages/file/fileExplorer.shared";
|
||||
import { useMusicPlayerStore } from "@/store/musicPlayerStore";
|
||||
|
||||
const musicPlayerStore = useMusicPlayerStore();
|
||||
|
||||
function handleOpenFile(item: ExplorerItem): void {
|
||||
console.log("handleOpenFile", item);
|
||||
if (!isBrowserSupportedAudio(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void musicPlayerStore.playFile(item, {
|
||||
title: item.name
|
||||
});
|
||||
}
|
||||
|
||||
function handleQueueFile(item: ExplorerItem): void {
|
||||
if (!isBrowserSupportedAudio(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
musicPlayerStore.enqueue(item, {
|
||||
title: item.name
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user