add MusicPlayer
This commit is contained in:
@@ -35,4 +35,13 @@ body,
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.t-overlay {
|
||||||
|
transition-duration: 500ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-popup {
|
||||||
|
animation-duration: 460ms;
|
||||||
|
animation-timing-function: var(--tui-bezier);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ export async function listServerFiles(pathSegments: string[]): Promise<ServerFil
|
|||||||
return unwrapServerFileList(response.data);
|
return unwrapServerFileList(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildServerFileURL(path: string, action = "download"): string {
|
||||||
|
const normalizedPath = typeof path === "string" ? path.trim() : "";
|
||||||
|
const segments = normalizedPath.split("/").filter((segment) => !!segment);
|
||||||
|
const requestPath = segments.length ? `/${segments.map((segment) => encodeURIComponent(segment)).join("/")}` : "";
|
||||||
|
const url = new URL(`${resolveBaseURL()}/system/file/${action}${requestPath}`);
|
||||||
|
const token = useSettingStore().connect.token.trim();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
url.searchParams.set("token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveRequestErrorMessage(error: unknown): string {
|
export function resolveRequestErrorMessage(error: unknown): string {
|
||||||
if (error instanceof AxiosError) {
|
if (error instanceof AxiosError) {
|
||||||
const message = resolveApiMessage(error.response?.data);
|
const message = resolveApiMessage(error.response?.data);
|
||||||
|
|||||||
@@ -27,12 +27,18 @@
|
|||||||
:disabled="!tabBarStore.isShowing"
|
:disabled="!tabBarStore.isShowing"
|
||||||
@change="onChangeTab"
|
@change="onChangeTab"
|
||||||
>
|
>
|
||||||
|
<t-tab-bar-item v-if="musicPlayerStore.hasQueue" class="item bg-transparent music-item" value="__music__">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="music" />
|
||||||
|
</template>
|
||||||
|
</t-tab-bar-item>
|
||||||
<t-tab-bar-item class="item bg-transparent" v-for="item in tabList" :key="item.value" :value="item.value">
|
<t-tab-bar-item class="item bg-transparent" v-for="item in tabList" :key="item.value" :value="item.value">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<t-icon :name="item.icon" />
|
<t-icon :name="item.icon" />
|
||||||
</template>
|
</template>
|
||||||
</t-tab-bar-item>
|
</t-tab-bar-item>
|
||||||
</t-tab-bar>
|
</t-tab-bar>
|
||||||
|
<music-player-popup />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -40,11 +46,14 @@
|
|||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import { useTabBarStore } from "@/store/tabBarStore";
|
import { useTabBarStore } from "@/store/tabBarStore";
|
||||||
import PageTransition from "@/components/PageTransition.vue";
|
import PageTransition from "@/components/PageTransition.vue";
|
||||||
|
import MusicPlayerPopup from "@/pages/system/MusicPlayerPopup.vue";
|
||||||
|
import { useMusicPlayerStore } from "@/store/musicPlayerStore";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const navBarStore = useNavBarStore();
|
const navBarStore = useNavBarStore();
|
||||||
const tabBarStore = useTabBarStore();
|
const tabBarStore = useTabBarStore();
|
||||||
|
const musicPlayerStore = useMusicPlayerStore();
|
||||||
|
|
||||||
// ---------- 导航栏高度 ----------
|
// ---------- 导航栏高度 ----------
|
||||||
|
|
||||||
@@ -118,6 +127,12 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function onChangeTab(value: string): void {
|
function onChangeTab(value: string): void {
|
||||||
|
if (value === "__music__") {
|
||||||
|
musicPlayerStore.setPopupVisible(true);
|
||||||
|
tabVal.value = resolveTabValue(route.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.push(value);
|
router.push(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div aria-hidden="true" class="spacer top"></div>
|
<div aria-hidden="true" class="spacer top"></div>
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<t-swipe-cell>
|
<t-swipe-cell :disabled="!isSwipeActionVisible(item)" :left="resolveLeftActions(item)">
|
||||||
<t-cell
|
<t-cell
|
||||||
class="cell"
|
class="cell"
|
||||||
:title="item.name"
|
:title="item.name"
|
||||||
@@ -35,17 +35,52 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { h, resolveComponent } from "vue";
|
||||||
import { RecycleScroller } from "vue-virtual-scroller";
|
import { RecycleScroller } from "vue-virtual-scroller";
|
||||||
import { type ExplorerItem } from "./fileExplorer.shared";
|
import { type ExplorerItem } from "./fileExplorer.shared";
|
||||||
|
import { isBrowserSupportedAudio } from "./fileAudio.shared";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: ExplorerItem[];
|
items: ExplorerItem[];
|
||||||
pendingPath?: string;
|
pendingPath?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits(["open", "queue", "play"]);
|
||||||
open: [item: ExplorerItem];
|
|
||||||
}>();
|
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 {
|
function handleOpen(item: ExplorerItem): void {
|
||||||
if (props.pendingPath) {
|
if (props.pendingPath) {
|
||||||
@@ -54,6 +89,22 @@ function handleOpen(item: ExplorerItem): void {
|
|||||||
|
|
||||||
emit("open", item);
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
@@ -113,6 +164,28 @@ function handleOpen(item: ExplorerItem): void {
|
|||||||
color: var(--app-sub);
|
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 {
|
.cell {
|
||||||
|
|
||||||
&:deep(.t-cell__content) {
|
&:deep(.t-cell__content) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
:items="currentItems"
|
:items="currentItems"
|
||||||
:pending-path="pendingFolderPath"
|
:pending-path="pendingFolderPath"
|
||||||
@open="openItem"
|
@open="openItem"
|
||||||
|
@queue="queueItem"
|
||||||
|
@play="playItem"
|
||||||
/>
|
/>
|
||||||
<file-explorer-grid
|
<file-explorer-grid
|
||||||
v-else
|
v-else
|
||||||
@@ -44,12 +46,14 @@ const props = withDefaults(defineProps<{
|
|||||||
useRoutePath: true
|
useRoutePath: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits([
|
||||||
"update:path": [value: string[]];
|
"update:path",
|
||||||
"open-folder": [value: string[]];
|
"open-folder",
|
||||||
"open-file": [value: ExplorerItem];
|
"open-file",
|
||||||
"update:mode": [value: DisplayMode];
|
"queue-file",
|
||||||
}>();
|
"play-file",
|
||||||
|
"update:mode"
|
||||||
|
]);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -220,6 +224,15 @@ async function openItem(item: ExplorerItem): Promise<void> {
|
|||||||
emit("open-file", item);
|
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> {
|
async function syncPath(nextSegments: string[]): Promise<void> {
|
||||||
if (shouldUseRoutePath.value) {
|
if (shouldUseRoutePath.value) {
|
||||||
if (!nextSegments.length) {
|
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 {
|
export interface ExplorerItem {
|
||||||
name: string;
|
name: string;
|
||||||
type: FileItemType;
|
type: FileItemType;
|
||||||
|
extension?: string;
|
||||||
size?: string;
|
size?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -55,6 +56,7 @@ export function mapServerFileToExplorerItem(file: ServerFile): ExplorerItem {
|
|||||||
return {
|
return {
|
||||||
name: file.name || "未命名",
|
name: file.name || "未命名",
|
||||||
type: isDirectory ? "dir" : "file",
|
type: isDirectory ? "dir" : "file",
|
||||||
|
extension: file.extension,
|
||||||
size: isDirectory ? undefined : formatFileSize(file.size),
|
size: isDirectory ? undefined : formatFileSize(file.size),
|
||||||
updatedAt: formatModifiedAt(file.modifiedAt),
|
updatedAt: formatModifiedAt(file.modifiedAt),
|
||||||
icon: resolveFileIcon(file, isDirectory),
|
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>
|
<template>
|
||||||
<file-explorer-page />
|
<file-explorer-page
|
||||||
|
@open-file="handleOpenFile"
|
||||||
|
@queue-file="handleQueueFile"
|
||||||
|
@play-file="handleOpenFile"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FileExplorerPage from "@/pages/file/FileExplorerPage.vue";
|
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>
|
</script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { DEFAULT_BODY_BACKGROUND, useGlobalUIStore } from "@/store/globalUIStore
|
|||||||
import { useSettingStore } from "@/store/settingStore";
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
import NotFoundPage from "@/pages/system/NotFoundPage.vue";
|
import NotFoundPage from "@/pages/system/NotFoundPage.vue";
|
||||||
import ServerIndexPage from "@/pages/system/ServerIndexPage.vue";
|
import ServerIndexPage from "@/pages/system/ServerIndexPage.vue";
|
||||||
import FileExplorerPage from "@/pages/file/FileExplorerPage.vue";
|
import FilePage from "@/pages/tabs/FilePage.vue";
|
||||||
import ServerLogPage from "@/pages/detail/ServerLogPage.vue";
|
import ServerLogPage from "@/pages/detail/ServerLogPage.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -63,7 +63,7 @@ const router = createRouter({
|
|||||||
contentFixedHeight: true,
|
contentFixedHeight: true,
|
||||||
bodyBackground: "#F4F4F4"
|
bodyBackground: "#F4F4F4"
|
||||||
},
|
},
|
||||||
component: FileExplorerPage
|
component: FilePage
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/server/logs",
|
path: "/server/logs",
|
||||||
|
|||||||
566
src/store/musicPlayerStore.ts
Normal file
566
src/store/musicPlayerStore.ts
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { parseBlob } from "music-metadata-browser";
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { buildServerFileURL } from "@/api/file";
|
||||||
|
import { isBrowserSupportedAudio } from "@/pages/file/fileAudio.shared";
|
||||||
|
import type { ExplorerItem } from "@/pages/file/fileExplorer.shared";
|
||||||
|
import Storage from "@/utils/Storage";
|
||||||
|
|
||||||
|
const MUSIC_PLAYER_STORAGE_KEY = "timi-server.music-player";
|
||||||
|
|
||||||
|
export type MusicPlayMode = "single-loop" | "queue-loop" | "order";
|
||||||
|
|
||||||
|
export interface MusicSongInfo {
|
||||||
|
cover: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicQueueItem {
|
||||||
|
id: string;
|
||||||
|
file: ExplorerItem;
|
||||||
|
song: MusicSongInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MusicPlayerState {
|
||||||
|
playMode: MusicPlayMode;
|
||||||
|
queue: MusicQueueItem[];
|
||||||
|
currentIndex: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
volume: number;
|
||||||
|
popupVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSongInfo = (): MusicSongInfo => ({
|
||||||
|
cover: "",
|
||||||
|
title: "",
|
||||||
|
artist: "未知艺术家",
|
||||||
|
album: "",
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultState = (): MusicPlayerState => ({
|
||||||
|
playMode: "queue-loop",
|
||||||
|
queue: [],
|
||||||
|
currentIndex: -1,
|
||||||
|
isPlaying: false,
|
||||||
|
volume: .8,
|
||||||
|
popupVisible: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataLoadedMap = new Set<string>();
|
||||||
|
const coverURLMap = new Map<string, string>();
|
||||||
|
let playerAudio: HTMLAudioElement | undefined;
|
||||||
|
|
||||||
|
function normalizeSongInfo(song?: Partial<MusicSongInfo>, fallbackTitle = ""): MusicSongInfo {
|
||||||
|
return {
|
||||||
|
cover: song?.cover?.trim() || "",
|
||||||
|
title: song?.title?.trim() || fallbackTitle,
|
||||||
|
artist: song?.artist?.trim() || "未知艺术家",
|
||||||
|
album: song?.album?.trim() || "",
|
||||||
|
duration: typeof song?.duration === "number" && !Number.isNaN(song.duration) && 0 <= song.duration ? song.duration : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVolume(volume?: number): number {
|
||||||
|
if (typeof volume !== "number" || Number.isNaN(volume)) {
|
||||||
|
return .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volume < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 < volume) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueueId(file: ExplorerItem): string {
|
||||||
|
return file.path || file.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState(source?: Partial<MusicPlayerState>): MusicPlayerState {
|
||||||
|
const queue = Array.isArray(source?.queue)
|
||||||
|
? source.queue
|
||||||
|
.filter((item) => !!item?.file)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id || buildQueueId(item.file),
|
||||||
|
file: item.file,
|
||||||
|
song: normalizeSongInfo(item.song, item.file.name)
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const currentIndex = queue.length && typeof source?.currentIndex === "number"
|
||||||
|
? Math.min(Math.max(source.currentIndex, 0), queue.length - 1)
|
||||||
|
: queue.length ? 0 : -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
playMode: source?.playMode === "single-loop" || source?.playMode === "order" ? source.playMode : "queue-loop",
|
||||||
|
queue,
|
||||||
|
currentIndex,
|
||||||
|
isPlaying: !!queue.length && !!source?.isPlaying,
|
||||||
|
volume: normalizeVolume(source?.volume),
|
||||||
|
popupVisible: !!queue.length && !!source?.popupVisible
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMusicPlayerStore = defineStore("music-player", () => {
|
||||||
|
const state = ref<MusicPlayerState>(normalizeState(Storage.getDefault<MusicPlayerState>(MUSIC_PLAYER_STORAGE_KEY, defaultState())));
|
||||||
|
|
||||||
|
const playMode = computed(() => state.value.playMode);
|
||||||
|
const queue = computed(() => state.value.queue);
|
||||||
|
const isPlaying = computed(() => state.value.isPlaying);
|
||||||
|
const volume = computed(() => state.value.volume);
|
||||||
|
const popupVisible = computed(() => state.value.popupVisible);
|
||||||
|
const hasQueue = computed(() => 0 < state.value.queue.length);
|
||||||
|
const currentIndex = computed(() => state.value.currentIndex);
|
||||||
|
const currentQueueItem = computed(() => {
|
||||||
|
if (state.value.currentIndex < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.value.queue[state.value.currentIndex];
|
||||||
|
});
|
||||||
|
const currentFile = computed(() => currentQueueItem.value?.file);
|
||||||
|
const currentSong = computed(() => currentQueueItem.value?.song || defaultSongInfo());
|
||||||
|
const playModeLabel = computed(() => {
|
||||||
|
switch (state.value.playMode) {
|
||||||
|
case "single-loop":
|
||||||
|
return "单曲循环";
|
||||||
|
case "order":
|
||||||
|
return "顺序播放";
|
||||||
|
default:
|
||||||
|
return "队列循环";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
Storage.setObject(MUSIC_PLAYER_STORAGE_KEY, state.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAudio(): HTMLAudioElement | undefined {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerAudio) {
|
||||||
|
return playerAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerAudio = new Audio();
|
||||||
|
playerAudio.preload = "metadata";
|
||||||
|
playerAudio.volume = state.value.volume;
|
||||||
|
playerAudio.addEventListener("ended", () => {
|
||||||
|
handleTrackEnded();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("loadedmetadata", () => {
|
||||||
|
const item = currentQueueItem.value;
|
||||||
|
if (!item || !playerAudio || !Number.isFinite(playerAudio.duration)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.song.duration = Math.max(playerAudio.duration, 0);
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("play", () => {
|
||||||
|
state.value.isPlaying = true;
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("pause", () => {
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("volumechange", () => {
|
||||||
|
if (!playerAudio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value.volume = normalizeVolume(playerAudio.volume);
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("error", () => {
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
persist();
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: "音频播放失败"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return playerAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseCoverURL(id: string): void {
|
||||||
|
const coverURL = coverURLMap.get(id);
|
||||||
|
if (!coverURL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(coverURL);
|
||||||
|
coverURLMap.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAudioSource(): void {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
const item = currentQueueItem.value;
|
||||||
|
|
||||||
|
if (!audio || !item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetURL = buildServerFileURL(item.file.path, "download");
|
||||||
|
if (audio.src !== targetURL) {
|
||||||
|
audio.src = targetURL;
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void hydrateSongInfo(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playCurrent(): Promise<void> {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (!audio || !currentQueueItem.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAudioSource();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
state.value.isPlaying = true;
|
||||||
|
persist();
|
||||||
|
} catch (error) {
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
persist();
|
||||||
|
console.warn(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseCurrent(): void {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (!audio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.pause();
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackEnded(): void {
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.playMode === "single-loop") {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (audio) {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
}
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateSongInfo(item: MusicQueueItem): Promise<void> {
|
||||||
|
if (metadataLoadedMap.has(item.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataLoadedMap.add(item.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildServerFileURL(item.file.path, "download"));
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const metadata = await parseBlob(blob);
|
||||||
|
const common = metadata.common;
|
||||||
|
const cover = common.picture?.[0];
|
||||||
|
let coverURL = item.song.cover;
|
||||||
|
|
||||||
|
if (cover?.data) {
|
||||||
|
releaseCoverURL(item.id);
|
||||||
|
coverURL = URL.createObjectURL(new Blob([cover.data], { type: cover.format || "image/jpeg" }));
|
||||||
|
coverURLMap.set(item.id, coverURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.song = normalizeSongInfo({
|
||||||
|
cover: coverURL,
|
||||||
|
title: common.title || item.song.title || item.file.name,
|
||||||
|
artist: common.artist || item.song.artist,
|
||||||
|
album: common.album || item.song.album,
|
||||||
|
duration: metadata.format.duration || item.song.duration
|
||||||
|
}, item.file.name);
|
||||||
|
persist();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertQueueItem(file: ExplorerItem, song?: Partial<MusicSongInfo>, activate = false): number {
|
||||||
|
const queueId = buildQueueId(file);
|
||||||
|
const targetIndex = state.value.queue.findIndex((item) => item.id === queueId);
|
||||||
|
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
state.value.queue.push({
|
||||||
|
id: queueId,
|
||||||
|
file,
|
||||||
|
song: normalizeSongInfo(song, file.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activate || state.value.currentIndex < 0) {
|
||||||
|
state.value.currentIndex = state.value.queue.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.value.queue.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentItem = state.value.queue[targetIndex];
|
||||||
|
state.value.queue[targetIndex] = {
|
||||||
|
...currentItem,
|
||||||
|
file,
|
||||||
|
song: normalizeSongInfo(song || currentItem.song, file.name)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activate) {
|
||||||
|
state.value.currentIndex = targetIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlayMode(mode: MusicPlayMode): void {
|
||||||
|
state.value.playMode = mode;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cyclePlayMode(): void {
|
||||||
|
if (state.value.playMode === "single-loop") {
|
||||||
|
state.value.playMode = "queue-loop";
|
||||||
|
} else if (state.value.playMode === "queue-loop") {
|
||||||
|
state.value.playMode = "order";
|
||||||
|
} else {
|
||||||
|
state.value.playMode = "single-loop";
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(nextVolume: number): void {
|
||||||
|
state.value.volume = normalizeVolume(nextVolume);
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = state.value.volume;
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPopupVisible(visible: boolean): void {
|
||||||
|
state.value.popupVisible = visible;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlaying(playing: boolean): void {
|
||||||
|
if (playing) {
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlaying(): void {
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.isPlaying) {
|
||||||
|
pauseCurrent();
|
||||||
|
} else {
|
||||||
|
void playCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrentIndex(index: number): void {
|
||||||
|
if (index < 0 || state.value.queue.length <= index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value.currentIndex = index;
|
||||||
|
syncAudioSource();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQueue(queueItems: MusicQueueItem[], nextIndex = 0): void {
|
||||||
|
state.value.queue = queueItems;
|
||||||
|
state.value.currentIndex = queueItems.length ? Math.min(Math.max(nextIndex, 0), queueItems.length - 1) : -1;
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
syncAudioSource();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToQueue(file: ExplorerItem, song?: Partial<MusicSongInfo>): void {
|
||||||
|
enqueue(file, song);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(file: ExplorerItem, song?: Partial<MusicSongInfo>): void {
|
||||||
|
if (!isBrowserSupportedAudio(file)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertQueueItem(file, song, false);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playFile(file: ExplorerItem, song?: Partial<MusicSongInfo>): Promise<void> {
|
||||||
|
if (!isBrowserSupportedAudio(file)) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
message: "当前浏览器不支持这个音频格式"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertQueueItem(file, song, true);
|
||||||
|
state.value.popupVisible = true;
|
||||||
|
syncAudioSource();
|
||||||
|
persist();
|
||||||
|
await playCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromQueue(id: string): void {
|
||||||
|
const targetIndex = state.value.queue.findIndex((item) => item.id === id);
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasPlayingCurrent = state.value.isPlaying && state.value.currentIndex === targetIndex;
|
||||||
|
releaseCoverURL(id);
|
||||||
|
metadataLoadedMap.delete(id);
|
||||||
|
state.value.queue.splice(targetIndex, 1);
|
||||||
|
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
clearQueue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.currentIndex === targetIndex) {
|
||||||
|
state.value.currentIndex = Math.min(targetIndex, state.value.queue.length - 1);
|
||||||
|
} else if (targetIndex < state.value.currentIndex) {
|
||||||
|
state.value.currentIndex -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAudioSource();
|
||||||
|
|
||||||
|
if (wasPlayingCurrent) {
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueue(): void {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute("src");
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of state.value.queue) {
|
||||||
|
releaseCoverURL(item.id);
|
||||||
|
metadataLoadedMap.delete(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value.queue = [];
|
||||||
|
state.value.currentIndex = -1;
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
state.value.popupVisible = false;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playPrev(): void {
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.playMode === "single-loop") {
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 < state.value.currentIndex) {
|
||||||
|
state.value.currentIndex -= 1;
|
||||||
|
} else if (state.value.playMode === "queue-loop") {
|
||||||
|
state.value.currentIndex = state.value.queue.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAudioSource();
|
||||||
|
void playCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playNext(): void {
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.playMode === "single-loop") {
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.currentIndex < state.value.queue.length - 1) {
|
||||||
|
state.value.currentIndex += 1;
|
||||||
|
syncAudioSource();
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.playMode === "queue-loop") {
|
||||||
|
state.value.currentIndex = 0;
|
||||||
|
syncAudioSource();
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
playMode,
|
||||||
|
queue,
|
||||||
|
isPlaying,
|
||||||
|
volume,
|
||||||
|
popupVisible,
|
||||||
|
hasQueue,
|
||||||
|
currentIndex,
|
||||||
|
currentQueueItem,
|
||||||
|
currentFile,
|
||||||
|
currentSong,
|
||||||
|
playModeLabel,
|
||||||
|
setPlayMode,
|
||||||
|
cyclePlayMode,
|
||||||
|
setVolume,
|
||||||
|
setPopupVisible,
|
||||||
|
setPlaying,
|
||||||
|
togglePlaying,
|
||||||
|
setCurrentIndex,
|
||||||
|
setQueue,
|
||||||
|
addToQueue,
|
||||||
|
enqueue,
|
||||||
|
playFile,
|
||||||
|
removeFromQueue,
|
||||||
|
clearQueue,
|
||||||
|
playPrev,
|
||||||
|
playNext
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -45,6 +45,9 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5174
|
port: 84,
|
||||||
|
allowedHosts: [
|
||||||
|
"server.imyeyu.dev"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user