ow
This commit is contained in:
1 parent
501c92d87c
commit
f1a88acb89
9 files changed
+1040
-165
No files matched your search
@@ -1190,7 +1190,7 @@ namespace CloudBuilder.Master.Service
|
||||
string backupFileName = Path.Combine(backupRootPath, filePath, file.FileName);
|
||||
if (!Directory.Exists(Path.Combine(backupRootPath, filePath)))
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(backupRootPath, filePath));
|
||||
Directory.CreateDirectory(Path.Combine(backupRootPath, filePath));
|
||||
}
|
||||
|
||||
if (fns[fns.Length - 1].Split('.')[0] == "1")
|
||||
@@ -1357,7 +1357,7 @@ namespace CloudBuilder.Master.Service
|
||||
{
|
||||
if (File.Exists(fileName)) File.Delete(fileName);
|
||||
}
|
||||
string m3u8FilePath = fullName.Replace(fileExtension, ".png");
|
||||
string m3u8FilePath = fullName.Replace(fileExtension, FileTypePolicy.JPG);
|
||||
//生成m3u8(暂不生成,只生成截图)
|
||||
TsToM3u8(dmsFile.OrderKey, fullName, m3u8FilePath);
|
||||
}
|
||||
@@ -1405,7 +1405,14 @@ namespace CloudBuilder.Master.Service
|
||||
public void TsToM3u8(string orderKey, string mp4Url, string m3u8Url, int videoLength = 6)
|
||||
{
|
||||
string para = string.Format(service.Configuration[ServiceSettingsPolicy.FfmpegPictrue], mp4Url, m3u8Url);
|
||||
RunMyProcess(service.Configuration[ServiceSettingsPolicy.FfmpegPath], para);
|
||||
try
|
||||
{
|
||||
RunMyProcess(service.Configuration[ServiceSettingsPolicy.FfmpegPath], para);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
new FileLog(service.Configuration).WriteToFile(ex.Message);
|
||||
}
|
||||
return;
|
||||
para = string.Format(service.Configuration["Ffmpeg:Arguments"], mp4Url, videoLength, m3u8Url);
|
||||
//不直接转码视频文件,影响服务器性能,改为在闭时自动生成
|
||||
@@ -1483,8 +1490,10 @@ namespace CloudBuilder.Master.Service
|
||||
/// <param name="Parameters"></param>
|
||||
public void RunMyProcess(string exePath, string Parameters)
|
||||
{
|
||||
FileLog log = new FileLog(service.Configuration);
|
||||
try
|
||||
{
|
||||
log.WriteToFile(exePath + Parameters);
|
||||
if (string.IsNullOrEmpty(exePath)) return;
|
||||
Cli.Wrap(exePath)
|
||||
.WithArguments(Parameters)
|
||||
@@ -1492,6 +1501,7 @@ namespace CloudBuilder.Master.Service
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.WriteToFile(ex.Message);
|
||||
throw new ValidatedException(ex.Message);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,18 +88,10 @@ namespace CloudBuilder.Master.Service
|
||||
{
|
||||
var upload = ServiceSettingsPolicy.GetDmsFileUploadDirectory(service).Replace(phy, "");
|
||||
|
||||
var filePathRel = Path.Combine(upload, item.CreatedBy, "m3u8", "");
|
||||
var filePath = filePathRel.Replace(Path.DirectorySeparatorChar, '/');
|
||||
item.FormattedUrl = service.Configuration[AppsettingsPolicy.ApplicationUrl] + webRootPath + filePath + '/' + Path.GetFileNameWithoutExtension(item.Url) + "/main.m3u8";
|
||||
|
||||
item.FormattedPic = item.FormattedUrl.Replace("main.m3u8", "main.png");
|
||||
|
||||
DmsFileEntity dmsFileEntity = repositoryDmsFile.DetachedEntities.Where(x => x.OrderKey == Path.GetFileNameWithoutExtension(item.Url)).FirstOrDefault();
|
||||
string transcodeIndc = YesNoPolicy.NO;
|
||||
if (dmsFileEntity != null) transcodeIndc = dmsFileEntity.TranscodeIndc;
|
||||
item.FormattedUrl = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Url, transcodeIndc);
|
||||
|
||||
|
||||
//暂时不支持转码
|
||||
//DmsFileEntity dmsFileEntity = repositoryDmsFile.DetachedEntities.Where(x => x.OrderKey == item.Url).FirstOrDefault();
|
||||
item.FormattedPic = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Url, YesNoPolicy.YES);
|
||||
item.FormattedUrl = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Url, YesNoPolicy.NO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ namespace CloudBuilder.Security.Policy
|
||||
public const string DOCUMENT = "D";
|
||||
public const string LYRIC = "L";
|
||||
|
||||
public const string JPG = ".jpg";
|
||||
|
||||
public static FileTypePolicy instance = null;
|
||||
|
||||
public FileTypePolicy(IContext context) : base(context)
|
||||
@@ -115,7 +117,7 @@ namespace CloudBuilder.Security.Policy
|
||||
var upload = (ServiceSettingsPolicy.GetDmsFileBackupDirectory(service) ?? "").Replace(phy ?? "", "");
|
||||
var filePathRel = Path.Combine(upload, createdBy, fileExtension.Replace(".", ""), "");
|
||||
var filePath = filePathRel.Replace(Path.DirectorySeparatorChar, '/') + "/";
|
||||
url = service.Configuration[AppsettingsPolicy.ApplicationUrl] + webRootPath + filePath + Path.GetFileNameWithoutExtension(orderKey) + ".png";
|
||||
url = service.Configuration[AppsettingsPolicy.ApplicationUrl] + webRootPath + filePath + Path.GetFileNameWithoutExtension(orderKey) + JPG;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -5,35 +5,17 @@ FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
# 此階段是用來組建服務專案
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj", "CloudBuilder.Web.Entry/"]
|
||||
RUN dotnet restore "./CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/CloudBuilder.Web.Entry"
|
||||
RUN dotnet build "./CloudBuilder.Web.Entry.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
# 此階段可用來發佈要複製到最終階段的服務專案
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./CloudBuilder.Web.Entry.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# 此階段用於生產環境,或以一般模式從 VS 執行時 (未使用偵錯設定時的預設值)
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=publish /app/publish .
|
||||
COPY ./dist ./dist
|
||||
ENTRYPOINT ["dotnet", "CloudBuilder.Web.Entry.dll"]
|
||||
|
||||
#version: "3"
|
||||
#services:
|
||||
#cb:
|
||||
#image: mcr.microsoft.com/dotnet/runtime-deps:8.0
|
||||
#container_name: CloudBuilder.Web.Entry
|
||||
#build: .
|
||||
#container_name: youjiala.com
|
||||
#restart: always
|
||||
#ports:
|
||||
#- "5200:8080"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<_PublishTargetUrl>C:\inetpub\wwwroot\dockwebapi2</_PublishTargetUrl>
|
||||
<History>True|2026-09-16T05:56:51.8724546Z||;True|2026-09-15T20:44:46.6559899+08:00||;True|2026-09-13T19:02:59.5654245+08:00||;True|2026-09-10T09:14:56.2914193+08:00||;True|2026-09-09T16:40:16.6461154+08:00||;True|2026-09-08T13:55:23.1502435+08:00||;True|2026-09-08T08:04:53.4279263+08:00||;True|2026-09-07T08:48:17.4682304+08:00||;True|2026-09-04T14:39:08.7878670+08:00||;True|2026-09-04T14:35:48.2937877+08:00||;True|2026-09-04T11:45:16.1372876+08:00||;True|2026-09-04T11:42:59.9356436+08:00||;True|2026-09-03T16:05:52.5270232+08:00||;True|2026-09-03T14:52:56.8559144+08:00||;True|2026-09-02T11:08:23.8720038+08:00||;True|2026-09-02T10:58:16.8081797+08:00||;True|2026-08-26T13:34:12.3805892+08:00||;True|2026-08-25T08:31:30.7787234+08:00||;True|2026-08-20T13:10:46.4725468+08:00||;True|2026-08-20T13:10:28.3142576+08:00||;True|2026-08-19T16:10:27.3362887+08:00||;True|2026-08-19T16:01:12.6234971+08:00||;True|2026-08-19T15:42:46.6576265+08:00||;True|2026-08-19T13:39:59.9363522+08:00||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||;</History>
|
||||
<History>True|2026-09-17T10:11:11.5364547Z||;True|2026-09-17T18:07:27.7830758+08:00||;True|2026-09-17T17:58:16.5798560+08:00||;True|2026-09-17T15:37:30.9025614+08:00||;True|2026-09-17T15:25:46.9116292+08:00||;True|2026-09-17T15:05:18.6592403+08:00||;True|2026-09-17T14:57:28.7997437+08:00||;True|2026-09-17T14:36:00.6339386+08:00||;True|2026-09-17T13:56:43.2376873+08:00||;True|2026-09-16T13:56:51.8724546+08:00||;True|2026-09-15T20:44:46.6559899+08:00||;True|2026-09-13T19:02:59.5654245+08:00||;True|2026-09-10T09:14:56.2914193+08:00||;True|2026-09-09T16:40:16.6461154+08:00||;True|2026-09-08T13:55:23.1502435+08:00||;True|2026-09-08T08:04:53.4279263+08:00||;True|2026-09-07T08:48:17.4682304+08:00||;True|2026-09-04T14:39:08.7878670+08:00||;True|2026-09-04T14:35:48.2937877+08:00||;True|2026-09-04T11:45:16.1372876+08:00||;True|2026-09-04T11:42:59.9356436+08:00||;True|2026-09-03T16:05:52.5270232+08:00||;True|2026-09-03T14:52:56.8559144+08:00||;True|2026-09-02T11:08:23.8720038+08:00||;True|2026-09-02T10:58:16.8081797+08:00||;True|2026-08-26T13:34:12.3805892+08:00||;True|2026-08-25T08:31:30.7787234+08:00||;True|2026-08-20T13:10:46.4725468+08:00||;True|2026-08-20T13:10:28.3142576+08:00||;True|2026-08-19T16:10:27.3362887+08:00||;True|2026-08-19T16:01:12.6234971+08:00||;True|2026-08-19T15:42:46.6576265+08:00||;True|2026-08-19T13:39:59.9363522+08:00||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||;</History>
|
||||
<LastFailureDetails />
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"Ffmpeg": {
|
||||
"Path": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe", //视频转码
|
||||
"Pictrue": " -i {0} -ss 00:00:2.000 -frames:v 1 {1}",
|
||||
"Pictrue": " -i {0} -ss 00:00:2.000 -frames:v 1 -vf \"scale=1280:-1\" -q:v 4 {1}", //" -i {0} -ss 00:00:2.000 -frames:v 1 {1}",
|
||||
"Arguments": " -i {0} -c:v libx264 -vf scale=1280:-1 -start_number 0 -hls_time {1} -hls_list_size 0 -f hls {2}" //0-源路径,1-目标路径,2-存放多少秒 1280×720,852*480
|
||||
},
|
||||
"CloudBuilder.Topshelf": {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,967 @@
|
||||
<template>
|
||||
<div class="vp">
|
||||
<!-- 空列表 -->
|
||||
<div v-if="!current" class="vp-empty">
|
||||
<VideoCameraOutlined />
|
||||
<p>暂无视频</p>
|
||||
</div>
|
||||
|
||||
<!-- 画面区:原生 video 只负责解码播放,控制栏全部自绘(与 VinylAudioPlayer 一致) -->
|
||||
<div v-else class="vp-stage" ref="stageRef" @click="toggle">
|
||||
<video ref="videoRef" class="vp-media" :class="{ 'is-rot': rotated }" :style="rotStyle"
|
||||
:src="current.FormattedUrl" :poster="current.FormattedPic"
|
||||
preload="metadata" playsinline @timeupdate="onTimeUpdate" @loadedmetadata="onLoadedMetadata"
|
||||
@durationchange="onLoadedMetadata" @ended="onEnded" @play="isPlaying = true"
|
||||
@pause="isPlaying = false" @error="onError"></video>
|
||||
<button v-if="!isPlaying" class="vp-bigplay" type="button" title="播放" @click.stop="toggle">
|
||||
<CaretRightFilled />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 控制区 -->
|
||||
<div v-if="current" class="vp-body">
|
||||
<!-- 功能图标行 -->
|
||||
<div class="vp-actions">
|
||||
<button class="vp-act" :class="{ 'is-fav': isFaved }" type="button" title="喜欢" @click="onFav">
|
||||
<HeartFilled v-if="isFaved" />
|
||||
<HeartOutlined v-else />
|
||||
</button>
|
||||
<button class="vp-act" type="button" title="下载" @click="onDownload">
|
||||
<DownloadOutlined />
|
||||
<span class="vp-badge" :class="{ 'is-on': downloaded }">{{ downloaded ? "ON" : "OFF" }}</span>
|
||||
</button>
|
||||
<button class="vp-act" :class="{ 'is-on': isFullscreen }" type="button" title="全屏" @click="onFullscreen">
|
||||
<FullscreenExitOutlined v-if="isFullscreen" />
|
||||
<FullscreenOutlined v-else />
|
||||
</button>
|
||||
<button class="vp-act" :class="{ 'is-on': rotated }" type="button" :title="rotTitle"
|
||||
@click="cycleRot">
|
||||
<RotateRightOutlined />
|
||||
<span v-if="rotMode === 'auto'" class="vp-rot-auto">A</span>
|
||||
</button>
|
||||
<button class="vp-act" :class="{ 'is-on': sleepAt > 0 || stopAfterEnd }" type="button" title="定时关闭"
|
||||
@click.stop="toggleMenu('timer')">
|
||||
<FieldTimeOutlined />
|
||||
</button>
|
||||
<button class="vp-act" :class="{ 'is-muted': isMuted }" type="button" title="音量"
|
||||
@click.stop="toggleMenu('volume')">
|
||||
<AudioMutedOutlined v-if="isMuted" />
|
||||
<AudioOutlined v-else />
|
||||
</button>
|
||||
<button class="vp-act" type="button" title="更多" @click.stop="toggleMenu('more')">
|
||||
<EllipsisOutlined class="vp-rot90" />
|
||||
</button>
|
||||
|
||||
<!-- 弹层:定时关闭 -->
|
||||
<div v-if="menu === 'timer'" class="vp-pop">
|
||||
<button v-for="o in timerOptions" :key="o.value" type="button"
|
||||
:class="{ 'is-on': sleepMinutes === o.value }" @click="onSleep(o.value)">
|
||||
{{ o.label }}<template v-if="sleepMinutes === o.value && sleepLeft">({{ sleepLeft }})</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 弹层:音量 -->
|
||||
<div v-if="menu === 'volume'" class="vp-pop vp-pop-vol">
|
||||
<span class="vp-vol-num">{{ Math.round(volume * 100) }}</span>
|
||||
<div class="vp-vol-track" ref="volRef" @pointerdown="onVolDown" @pointermove="onVolMove"
|
||||
@pointerup="onVolUp" @pointercancel="onVolUp">
|
||||
<span class="vp-vol-fill" :style="{ height: volume * 100 + '%' }"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹层:更多(倍速) -->
|
||||
<div v-if="menu === 'more'" class="vp-pop">
|
||||
<button v-for="r in rateOptions" :key="r" type="button" :class="{ 'is-on': rate === r }"
|
||||
@click="rate = r">倍速 {{ r }}x</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度 -->
|
||||
<div class="vp-progress" ref="barRef" @pointerdown="onBarDown" @pointermove="onBarMove" @pointerup="onBarUp"
|
||||
@pointercancel="onBarUp">
|
||||
<div class="vp-bar">
|
||||
<span class="vp-bar-fill" :style="{ width: displayRatio * 100 + '%' }"></span>
|
||||
<span class="vp-bar-thumb" :style="{ left: displayRatio * 100 + '%' }"></span>
|
||||
</div>
|
||||
<div class="vp-times">
|
||||
<span>{{ fmt(displayTime) }}</span>
|
||||
<span>{{ fmt(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主控制 -->
|
||||
<div class="vp-controls">
|
||||
<button class="vp-ctl" type="button" :title="modeTitle" @click="cycleMode">
|
||||
<RetweetOutlined v-if="mode !== 'random'" />
|
||||
<SwapOutlined v-else />
|
||||
<span v-if="mode === 'one'" class="vp-mode-one">1</span>
|
||||
</button>
|
||||
<button class="vp-ctl" type="button" title="上一个" @click="goPrev">
|
||||
<StepBackwardFilled />
|
||||
</button>
|
||||
<button class="vp-play" type="button" :title="isPlaying ? '暂停' : '播放'" @click="toggle">
|
||||
<PauseOutlined v-if="isPlaying" />
|
||||
<CaretRightFilled v-else />
|
||||
</button>
|
||||
<button class="vp-ctl" type="button" title="下一个" @click="goNext(false)">
|
||||
<StepForwardFilled />
|
||||
</button>
|
||||
<button class="vp-ctl" type="button" title="播放列表" @click="showList = !showList">
|
||||
<UnorderedListOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 播放列表 -->
|
||||
<transition name="vp-sheet">
|
||||
<section v-if="showList && videos.length" class="vp-list">
|
||||
<header class="vp-list-head">
|
||||
<span>播放列表({{ videos.length }})</span>
|
||||
<button type="button" title="关闭" @click="showList = false">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</header>
|
||||
<ul class="vp-list-body">
|
||||
<li v-for="(v, i) in videos" :key="i" :class="{ 'is-active': i === index }" @click="playAt(i)">
|
||||
<span class="vp-list-idx">
|
||||
<span v-if="i === index && isPlaying" class="vp-eq is-on"><i></i><i></i><i></i></span>
|
||||
<template v-else>{{ i + 1 }}</template>
|
||||
</span>
|
||||
<span class="vp-list-name">{{ v.Title }}</span>
|
||||
<span class="vp-list-author">{{ v.Author }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import { request } from "@/utils/request";
|
||||
import type { PublishVideoDetailDisplayData } from "@/data/auto/publishVideoDetailDisplayData";
|
||||
import {
|
||||
HeartOutlined, HeartFilled, DownloadOutlined, FieldTimeOutlined,
|
||||
AudioOutlined, AudioMutedOutlined, EllipsisOutlined, RotateRightOutlined,
|
||||
RetweetOutlined, SwapOutlined, StepBackwardFilled, StepForwardFilled,
|
||||
CaretRightFilled, PauseOutlined, UnorderedListOutlined, CloseOutlined,
|
||||
FullscreenOutlined, FullscreenExitOutlined, VideoCameraOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
|
||||
/** 一个视频 */
|
||||
export type VideoItem = PublishVideoDetailDisplayData;
|
||||
|
||||
const props = withDefaults(defineProps<{ videos?: VideoItem[] }>(), { videos: () => [] });
|
||||
|
||||
/* ---------------- 基础状态 ---------------- */
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const index = ref(0);
|
||||
const isPlaying = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(0);
|
||||
const volume = ref(0.9);
|
||||
const muted = ref(false);
|
||||
const rate = ref(1);
|
||||
const mode = ref<"list" | "one" | "random">("list");
|
||||
const showList = ref(false);
|
||||
const menu = ref<"" | "timer" | "volume" | "more">("");
|
||||
const downloaded = ref(false);
|
||||
/** 切换后是否接着播(点上一个/下一个/列表点视频时为 true) */
|
||||
let resumeAfterSwitch = false;
|
||||
|
||||
const videos = computed(() => props.videos || []);
|
||||
const current = computed<VideoItem | null>(() => videos.value[index.value] ?? null);
|
||||
const isMuted = computed(() => muted.value || volume.value === 0);
|
||||
const modeTitle = computed(() => ({ list: "列表循环", one: "单集循环", random: "随机播放" }[mode.value]));
|
||||
const rateOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
/* ---------------- 播放控制 ---------------- */
|
||||
const tryPlay = () => {
|
||||
// 自动播放被浏览器拦截时静默失败,等用户点播放键
|
||||
videoRef.value?.play().catch(() => { });
|
||||
};
|
||||
const toggle = () => {
|
||||
const v = videoRef.value;
|
||||
if (!v || !current.value) return;
|
||||
if (v.paused) tryPlay();
|
||||
else v.pause();
|
||||
};
|
||||
const playAt = (i: number) => {
|
||||
if (i === index.value) {
|
||||
toggle();
|
||||
return;
|
||||
}
|
||||
resumeAfterSwitch = true;
|
||||
index.value = i;
|
||||
};
|
||||
const goPrev = () => {
|
||||
const n = videos.value.length;
|
||||
if (!n) return;
|
||||
resumeAfterSwitch = true;
|
||||
index.value = (index.value - 1 + n) % n;
|
||||
};
|
||||
const goNext = (auto: boolean) => {
|
||||
const n = videos.value.length;
|
||||
if (!n) return;
|
||||
resumeAfterSwitch = true;
|
||||
if (auto && mode.value === "random" && n > 1) {
|
||||
let r = index.value;
|
||||
while (r === index.value) r = Math.floor(Math.random() * n);
|
||||
index.value = r;
|
||||
} else {
|
||||
index.value = (index.value + 1) % n;
|
||||
}
|
||||
};
|
||||
const cycleMode = () => {
|
||||
mode.value = mode.value === "list" ? "one" : mode.value === "one" ? "random" : "list";
|
||||
};
|
||||
|
||||
/* ---------------- video 事件 ---------------- */
|
||||
const onTimeUpdate = () => {
|
||||
const v = videoRef.value;
|
||||
if (!v || dragRatio.value !== null) return;
|
||||
currentTime.value = v.currentTime;
|
||||
};
|
||||
const onLoadedMetadata = () => {
|
||||
const v = videoRef.value;
|
||||
if (!v) return;
|
||||
if (isFinite(v.duration)) duration.value = v.duration;
|
||||
// 自动识别横竖屏:宽 > 高 视为横屏,auto 模式下旋转90°播放
|
||||
landscape.value = v.videoWidth > v.videoHeight;
|
||||
};
|
||||
const onEnded = () => {
|
||||
if (stopAfterEnd.value) {
|
||||
stopAfterEnd.value = false;
|
||||
sleepMinutes.value = 0;
|
||||
return;
|
||||
}
|
||||
if (mode.value === "one") {
|
||||
const v = videoRef.value;
|
||||
if (v) {
|
||||
v.currentTime = 0;
|
||||
tryPlay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
goNext(true);
|
||||
};
|
||||
const onError = () => {
|
||||
// 视频地址拉不起来时只静默处理,避免打断页面
|
||||
};
|
||||
|
||||
/* ---------------- 进度条(支持鼠标/触摸拖动) ---------------- */
|
||||
const barRef = ref<HTMLElement | null>(null);
|
||||
let draggingBar = false;
|
||||
const dragRatio = ref<number | null>(null);
|
||||
const ratioFromEvent = (e: PointerEvent) => {
|
||||
const el = barRef.value;
|
||||
if (!el) return 0;
|
||||
const r = el.getBoundingClientRect();
|
||||
return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
|
||||
};
|
||||
const onBarDown = (e: PointerEvent) => {
|
||||
if (!current.value || !duration.value) return;
|
||||
draggingBar = true;
|
||||
dragRatio.value = ratioFromEvent(e);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
const onBarMove = (e: PointerEvent) => {
|
||||
if (draggingBar) dragRatio.value = ratioFromEvent(e);
|
||||
};
|
||||
const onBarUp = (e: PointerEvent) => {
|
||||
if (!draggingBar) return;
|
||||
draggingBar = false;
|
||||
const r = ratioFromEvent(e);
|
||||
if (videoRef.value && duration.value > 0) videoRef.value.currentTime = r * duration.value;
|
||||
dragRatio.value = null;
|
||||
};
|
||||
const displayRatio = computed(
|
||||
() => dragRatio.value ?? (duration.value > 0 ? currentTime.value / duration.value : 0)
|
||||
);
|
||||
const displayTime = computed(() =>
|
||||
dragRatio.value !== null && duration.value > 0 ? dragRatio.value * duration.value : currentTime.value
|
||||
);
|
||||
|
||||
/* ---------------- 音量 ---------------- */
|
||||
const volRef = ref<HTMLElement | null>(null);
|
||||
let draggingVol = false;
|
||||
const volFromEvent = (e: PointerEvent) => {
|
||||
const el = volRef.value;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
volume.value = Math.min(1, Math.max(0, 1 - (e.clientY - r.top) / r.height));
|
||||
muted.value = false;
|
||||
};
|
||||
const onVolDown = (e: PointerEvent) => {
|
||||
draggingVol = true;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
volFromEvent(e);
|
||||
};
|
||||
const onVolMove = (e: PointerEvent) => {
|
||||
if (draggingVol) volFromEvent(e);
|
||||
};
|
||||
const onVolUp = () => {
|
||||
draggingVol = false;
|
||||
};
|
||||
|
||||
/* ---------------- 定时关闭 ---------------- */
|
||||
const timerOptions = [
|
||||
{ label: "不开启", value: 0 },
|
||||
{ label: "15 分钟", value: 15 },
|
||||
{ label: "30 分钟", value: 30 },
|
||||
{ label: "60 分钟", value: 60 },
|
||||
{ label: "播完当前", value: -1 },
|
||||
];
|
||||
const sleepMinutes = ref(0);
|
||||
const sleepAt = ref(0);
|
||||
const stopAfterEnd = ref(false);
|
||||
const nowTick = ref(Date.now());
|
||||
const sleepLeft = computed(() => {
|
||||
if (sleepAt.value <= nowTick.value) return "";
|
||||
const s = Math.ceil((sleepAt.value - nowTick.value) / 1000);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
});
|
||||
const onSleep = (v: number) => {
|
||||
sleepMinutes.value = v;
|
||||
stopAfterEnd.value = v === -1;
|
||||
sleepAt.value = v > 0 ? Date.now() + v * 60000 : 0;
|
||||
menu.value = "";
|
||||
};
|
||||
|
||||
/* ---------------- 喜欢 / 下载 / 信息 ---------------- */
|
||||
const FAV_KEY = "vp-favs";
|
||||
const loadFavs = (): Set<string> => {
|
||||
try {
|
||||
return new Set<string>(JSON.parse(localStorage.getItem(FAV_KEY) || "[]"));
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
};
|
||||
const favs = ref<Set<string>>(loadFavs());
|
||||
const isFaved = computed(() => !!current.value && favs.value.has(current.value.FormattedUrl));
|
||||
const onFav = () => {
|
||||
if (!current.value) return;
|
||||
const s = new Set(favs.value);
|
||||
if (s.has(current.value.FormattedUrl)) s.delete(current.value.FormattedUrl);
|
||||
else s.add(current.value.FormattedUrl);
|
||||
favs.value = s;
|
||||
localStorage.setItem(FAV_KEY, JSON.stringify([...s]));
|
||||
};
|
||||
const onDownload = async () => {
|
||||
if (!current.value) return;
|
||||
// 参考 DmsFileEnquiry.vue:先取回字节流,再用 Blob + <a download> 落盘
|
||||
try {
|
||||
const res = await request<ArrayBuffer>({
|
||||
url: current.value.FormattedUrl,
|
||||
method: "get",
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
const blobUrl = window.URL.createObjectURL(new Blob([res]));
|
||||
const ext = (current.value.FormattedUrl.match(/\.(\w{2,5})(?:\?|#|$)/) || [])[1];
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = `${current.value.Title || "video"}.${ext || "mp4"}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
|
||||
downloaded.value = true;
|
||||
} catch {
|
||||
message.error("下载失败");
|
||||
}
|
||||
};
|
||||
/* ---------------- 旋转90° ---------------- */
|
||||
/** auto:横屏视频自动转90°(默认);on:强制旋转;off:关闭 */
|
||||
const rotMode = ref<"auto" | "on" | "off">("auto");
|
||||
/** 当前视频是否横屏(宽 > 高),由 loadedmetadata 检测 */
|
||||
const landscape = ref(false);
|
||||
const rotated = computed(
|
||||
() => rotMode.value === "on" ? true : rotMode.value === "off" ? false : landscape.value
|
||||
);
|
||||
const rotTitle = computed(() => ({
|
||||
auto: "自动旋转(横屏视频转90°)",
|
||||
on: "强制旋转90°",
|
||||
off: "关闭旋转",
|
||||
}[rotMode.value]));
|
||||
const cycleRot = () => {
|
||||
rotMode.value = rotMode.value === "auto" ? "on" : rotMode.value === "on" ? "off" : "auto";
|
||||
};
|
||||
/** 旋转后 video 宽高对调,横屏视频即可铺满竖版画面区 */
|
||||
const stageRef = ref<HTMLElement | null>(null);
|
||||
const stageSize = ref({ w: 0, h: 0 });
|
||||
let stageRO: ResizeObserver | null = null;
|
||||
const rotStyle = computed(() =>
|
||||
rotated.value && stageSize.value.h > 0
|
||||
? { width: stageSize.value.h + "px", height: stageSize.value.w + "px" }
|
||||
: undefined
|
||||
);
|
||||
const syncStageSize = () => {
|
||||
const el = stageRef.value;
|
||||
if (el) stageSize.value = { w: el.clientWidth, h: el.clientHeight };
|
||||
};
|
||||
/** stage 在 v-else 分支里,数据到了才挂载,所以延后观察尺寸变化 */
|
||||
const ensureStageRO = () => {
|
||||
const el = stageRef.value;
|
||||
if (!el || stageRO || typeof ResizeObserver === "undefined") return;
|
||||
stageRO = new ResizeObserver(syncStageSize);
|
||||
stageRO.observe(el);
|
||||
syncStageSize();
|
||||
};
|
||||
|
||||
/* ---------------- 全屏 ---------------- */
|
||||
const isFullscreen = ref(false);
|
||||
const onFullscreen = async () => {
|
||||
const v = videoRef.value;
|
||||
if (!v) return;
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
else await v.requestFullscreen();
|
||||
} catch {
|
||||
message.error("当前浏览器不支持全屏");
|
||||
}
|
||||
};
|
||||
const onFullscreenChange = () => {
|
||||
isFullscreen.value = !!document.fullscreenElement;
|
||||
};
|
||||
|
||||
/* ---------------- 弹层开合 ---------------- */
|
||||
const toggleMenu = (m: "" | "timer" | "volume" | "more") => {
|
||||
menu.value = menu.value === m ? "" : m;
|
||||
};
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement;
|
||||
if (menu.value && !t.closest(".vp-pop") && !t.closest(".vp-act")) menu.value = "";
|
||||
};
|
||||
|
||||
/* ---------------- 时间格式 ---------------- */
|
||||
const fmt = (t: number) => {
|
||||
const v = isFinite(t) && t > 0 ? t : 0;
|
||||
return `${String(Math.floor(v / 60)).padStart(2, "0")}:${String(Math.floor(v % 60)).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/* ---------------- 联动 ---------------- */
|
||||
watch(current, async (t) => {
|
||||
currentTime.value = 0;
|
||||
duration.value = 0;
|
||||
downloaded.value = false;
|
||||
if (!t) return;
|
||||
await nextTick();
|
||||
ensureStageRO();
|
||||
// 换了 src 后主动 load,保证 poster 与时长重新计算
|
||||
videoRef.value?.load();
|
||||
if (resumeAfterSwitch) {
|
||||
resumeAfterSwitch = false;
|
||||
tryPlay();
|
||||
}
|
||||
});
|
||||
|
||||
// 播放列表默认收起,由用户点击列表按钮展开
|
||||
watch(
|
||||
() => videos.value.length,
|
||||
(n) => {
|
||||
if (index.value >= n) index.value = 0;
|
||||
}
|
||||
);
|
||||
|
||||
watch([volume, muted], () => {
|
||||
const v = videoRef.value;
|
||||
if (!v) return;
|
||||
v.volume = volume.value;
|
||||
v.muted = muted.value || volume.value === 0;
|
||||
});
|
||||
watch(rate, () => {
|
||||
if (videoRef.value) videoRef.value.playbackRate = rate.value;
|
||||
});
|
||||
|
||||
let timerId = 0;
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", onDocClick);
|
||||
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||
timerId = window.setInterval(() => {
|
||||
nowTick.value = Date.now();
|
||||
if (sleepAt.value && nowTick.value >= sleepAt.value) {
|
||||
sleepAt.value = 0;
|
||||
sleepMinutes.value = 0;
|
||||
videoRef.value?.pause();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", onDocClick);
|
||||
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||
if (timerId) clearInterval(timerId);
|
||||
stageRO?.disconnect();
|
||||
videoRef.value?.pause();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vp {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
border-radius: 14px;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
background:
|
||||
radial-gradient(130% 55% at 50% -8%, rgba(255, 255, 255, 0.18), transparent 60%),
|
||||
linear-gradient(178deg, #9aa8a3 0%, #7f9d9a 28%, #4d9093 54%, #12818f 78%, #0b7280 100%);
|
||||
}
|
||||
|
||||
.vp button {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 原生 video 仅作解码,画面铺满画面区并按比例留边 */
|
||||
.vp-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* 旋转90°:宽高对调(内联 style 提供),绕中心旋转 */
|
||||
.vp-media.is-rot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
|
||||
/* 全屏时浏览器自己接管布局,还原为正常方向 */
|
||||
.vp-media:fullscreen {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
position: static !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* ---------- 空状态 ---------- */
|
||||
.vp-empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 46px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.vp-empty p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---------- 主体布局 ---------- */
|
||||
.vp-body {
|
||||
position: relative;
|
||||
padding: 14px 24px 26px;
|
||||
}
|
||||
|
||||
.vp-bigplay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #fff;
|
||||
background: rgba(6, 56, 62, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.vp-bigplay:active {
|
||||
transform: translate(-50%, -50%) scale(0.94);
|
||||
}
|
||||
|
||||
/* ---------- 画面区 ---------- */
|
||||
.vp-stage {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------- 功能图标行 ---------- */
|
||||
.vp-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 18px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.vp-act {
|
||||
position: relative;
|
||||
font-size: 22px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.vp-act.is-on {
|
||||
color: #7ff3e8;
|
||||
}
|
||||
|
||||
.vp-act.is-fav {
|
||||
color: #ff5b7f;
|
||||
}
|
||||
|
||||
.vp-act.is-muted {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.vp-rot90 {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* 旋转按钮的 A 角标 = auto 模式 */
|
||||
.vp-rot-auto {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #7ff3e8;
|
||||
}
|
||||
|
||||
.vp-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -16px;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
padding: 2px 3px;
|
||||
border-radius: 3px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.vp-badge.is-on {
|
||||
color: #0b7280;
|
||||
background: #7ff3e8;
|
||||
}
|
||||
|
||||
/* 均衡器小图标 */
|
||||
.vp-eq {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.vp-eq i {
|
||||
width: 3px;
|
||||
height: 6px;
|
||||
border-radius: 1px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.vp-eq i:nth-child(2) {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.vp-eq i:nth-child(3) {
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.vp-eq.is-on i {
|
||||
animation: vp-eq 0.9s ease-in-out infinite;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.vp-eq.is-on i:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.vp-eq.is-on i:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes vp-eq {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.5);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleY(1.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 弹层 ---------- */
|
||||
.vp-pop {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 12px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 122px;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
background: rgba(6, 40, 44, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.vp-pop button {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vp-pop button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.vp-pop button.is-on {
|
||||
color: #7ff3e8;
|
||||
}
|
||||
|
||||
.vp-pop-vol {
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vp-vol-num {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.vp-vol-track {
|
||||
position: relative;
|
||||
width: 6px;
|
||||
height: 96px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.vp-vol-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 3px;
|
||||
background: #7ff3e8;
|
||||
}
|
||||
|
||||
/* ---------- 进度 ---------- */
|
||||
.vp-progress {
|
||||
margin-top: 16px;
|
||||
padding: 10px 2px 0;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.vp-bar {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
|
||||
.vp-bar-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.vp-bar-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 26px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.vp-times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
/* ---------- 主控制 ---------- */
|
||||
.vp-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.vp-ctl {
|
||||
position: relative;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.vp-mode-one {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #7ff3e8;
|
||||
}
|
||||
|
||||
.vp-play {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #0e6d7a;
|
||||
font-size: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.vp-play:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* ---------- 播放列表 ---------- */
|
||||
.vp-list {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 8;
|
||||
max-height: 72%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: rgba(7, 38, 42, 0.94);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.vp-list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.vp-list-head button {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 16px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.vp-list-body {
|
||||
margin: 0;
|
||||
padding: 6px 0 14px;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.vp-list-body li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.vp-list-body li:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.vp-list-body li.is-active {
|
||||
color: #7ff3e8;
|
||||
}
|
||||
|
||||
.vp-list-idx {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.vp-list-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.vp-list-author {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.vp-sheet-enter-active,
|
||||
.vp-sheet-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.vp-sheet-enter-from,
|
||||
.vp-sheet-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
/* ---------- 窄屏微调 ---------- */
|
||||
@media (max-width: 480px) {
|
||||
.vp-body {
|
||||
padding: 12px 18px 22px;
|
||||
}
|
||||
|
||||
.vp-play {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,93 +1,49 @@
|
||||
<template>
|
||||
<vue3VideoPlay v-if="options.type == 'm3u8'" :width="videoWidth" :title="title" :src="modal.videoSrc"
|
||||
:poster="modal.poster" type="m3u8" @ended="handleEnded" :autoPlay="false" />
|
||||
<vue3VideoPlay v-else :width="videoWidth" :title="title" :src="modal.videoSrc" :poster="modal.poster"
|
||||
type="video/mp4" @ended="handleEnded" :autoPlay="false" />
|
||||
<div class="list-container" :style="'height:' + listHeight + 'px;'">
|
||||
<a-list bordered :data-source="modal.data.Details">
|
||||
<template #renderItem="{ item, index }">
|
||||
<a-list-item slot="renderItem" slot-scope="item, index" @click="handleChange(index)">
|
||||
<span
|
||||
:style="modal.index == index ? 'color: black;font-weight: bold;font-size: 24px;' : 'font-size: 20px;'">
|
||||
{{ index + 1
|
||||
}}.{{
|
||||
item.Title }}</span>
|
||||
</a-list-item>
|
||||
</template>
|
||||
<template #header>
|
||||
<div>播放列表</div>
|
||||
</template>
|
||||
</a-list>
|
||||
<div class="pv-page" :style="{
|
||||
height: bsInfo.height - 120 + 'px',
|
||||
}">
|
||||
<VideoPlayer :videos="videos" />
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
watch,
|
||||
computed,
|
||||
inject,
|
||||
reactive,
|
||||
ref,
|
||||
VNode,
|
||||
h,
|
||||
unref,
|
||||
onMounted,
|
||||
onBeforeMount,
|
||||
} from "vue";
|
||||
import dragModal from "@/components/basic/DragModal.vue";
|
||||
import { PublishVideoData } from "@/data/auto/publishVideoData";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import publishVideoService from "@/service/master/publishVideoService";
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import VideoPlayer from "@/components/basic/VideoPlayer.vue";
|
||||
import type { VideoItem } from "@/components/basic/VideoPlayer.vue";
|
||||
|
||||
import { bsInfo } from "@/utils/browser";
|
||||
import publishVideoService from "@/service/master/publishVideoService";
|
||||
import { PublishVideoData } from "@/data/auto/publishVideoData";
|
||||
import { PublishVideoDetailDisplayData } from "@/data/auto/publishVideoDetailDisplayData";
|
||||
import { DmsFileSearchCriteria } from "@/data/auto/dmsFileSearchCriteria";
|
||||
import dmsFileService from "@/service/master/dmsFileService";
|
||||
import { DmsFileUploadViewDisplayData } from "@/data/auto/dmsFileUploadViewDisplayData";
|
||||
import { SearchFilter } from "@/data/auto/searchFilter";
|
||||
import { FileTypePolicy } from "@/policy/custom/fileTypePolicy";
|
||||
import { DmsFileResultData } from "@/data/auto/dmsFileResultData";
|
||||
import dmsFileService from "@/service/master/dmsFileService";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const publishName = ref<string>(route.params.id as string);
|
||||
const mode = ref<string>(route.params.mode as string)
|
||||
const mode = ref<string>(route.params.mode as string);
|
||||
|
||||
const videoWidth = computed(() => {
|
||||
if (bsInfo.size.sm) return bsInfo.width;
|
||||
else return bsInfo.width - 600;
|
||||
});
|
||||
|
||||
|
||||
const listHeight = computed(() => {
|
||||
if (bsInfo.size.sm) return bsInfo.height - 550;
|
||||
else return bsInfo.height - 560;
|
||||
});
|
||||
|
||||
|
||||
const modal = reactive({
|
||||
loading: false,
|
||||
showModal: false,
|
||||
videoSrc: "",
|
||||
poster: "",
|
||||
index: 0,
|
||||
data: new PublishVideoData(),
|
||||
});
|
||||
|
||||
const title = "有家视频";
|
||||
// 视频列表:与 VinylAudioPlayer 的 tracks 一致,组件负责 UI,页面只负责数据
|
||||
const videos = ref<VideoItem[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
// 加载歌曲信息
|
||||
loadVideos();
|
||||
await loadVideos();
|
||||
});
|
||||
|
||||
const loadVideos = async () => {
|
||||
try {
|
||||
if (mode.value == "list") {
|
||||
let c: DmsFileSearchCriteria = new DmsFileSearchCriteria();
|
||||
// 模式一:从网盘中心加载某个路径下的所有视频
|
||||
if (mode.value === "list") {
|
||||
const c = new DmsFileSearchCriteria();
|
||||
c.limit = 100;
|
||||
c.page = 1;
|
||||
|
||||
let filter: SearchFilter;
|
||||
filter = new SearchFilter();
|
||||
filter.Column = DmsFileUploadViewDisplayData.DB_FILE_PATH;
|
||||
@@ -95,6 +51,7 @@ const loadVideos = async () => {
|
||||
filter.Operator = "single";
|
||||
filter.Value = publishName.value;
|
||||
c.Filter.push(filter);
|
||||
|
||||
filter = new SearchFilter();
|
||||
filter.Column = DmsFileUploadViewDisplayData.DB_FILE_TYPE;
|
||||
filter.Type = "SingleSelection";
|
||||
@@ -102,76 +59,41 @@ const loadVideos = async () => {
|
||||
filter.Value = FileTypePolicy.VIDEO;
|
||||
c.Filter.push(filter);
|
||||
|
||||
let dr: DmsFileResultData = await dmsFileService.FindBySearchCriteria(c);
|
||||
const dr: DmsFileResultData = await dmsFileService.FindBySearchCriteria(c);
|
||||
|
||||
if (dr && dr.Datas && dr.Datas.length > 0) {
|
||||
|
||||
modal.data.Details = [];
|
||||
let detail: PublishVideoDetailDisplayData;
|
||||
for (let i = 0; i < dr.Datas.length; i++) {
|
||||
detail = new PublishVideoDetailDisplayData()
|
||||
detail.Title = dr.Datas[i].OrgFileName;
|
||||
detail.FormattedUrl = dr.Datas[i].Url;
|
||||
detail.FormattedPic = dr.Datas[i].FormattedPic.replace('.m3u8', '.png');
|
||||
modal.data.Details.push(detail);
|
||||
for (const item of dr.Datas) {
|
||||
const detail = new PublishVideoDetailDisplayData();
|
||||
detail.Title = item.OrgFileName;
|
||||
detail.FormattedUrl = item.Url;
|
||||
// m3u8 通常配套同名 jpg/png 封面,这里把后缀替换成 .png 兜底
|
||||
detail.FormattedPic = (item.FormattedPic || "").replace(/\.m3u8$/, ".png");
|
||||
videos.value.push(detail);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
else if (!publishName.value || publishName.value.length == 0) {
|
||||
modal.data = await publishVideoService.FindPublishVideoDefault();
|
||||
} else {
|
||||
modal.data = await publishVideoService.FindByKey(publishName.value);
|
||||
// 默认发布视频
|
||||
else if (!publishName.value || publishName.value.length === 0) {
|
||||
const data: PublishVideoData = await publishVideoService.FindPublishVideoDefault();
|
||||
videos.value = data?.Details ?? [];
|
||||
}
|
||||
// 按 key 加载
|
||||
else {
|
||||
const data: PublishVideoData = await publishVideoService.FindByKey(publishName.value);
|
||||
videos.value = data?.Details ?? [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error); // for debug
|
||||
}
|
||||
|
||||
if (modal.data && modal.data.Details && modal.data.Details.length > 0) {
|
||||
options.type = modal.data.Details[0].FormattedUrl.indexOf(".m3u8") > 0 ? "m3u8" : "video/mp4";
|
||||
modal.videoSrc = modal.data.Details[0].FormattedUrl;
|
||||
modal.poster = modal.data.Details[0].FormattedPic;
|
||||
modal.index = 0;
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (index: number) => {
|
||||
modal.index = index;
|
||||
options.type = modal.data.Details[index].FormattedUrl.indexOf(".m3u8") > 0 ? "m3u8" : "video/mp4";
|
||||
modal.videoSrc = modal.data.Details[index].FormattedUrl;
|
||||
modal.poster = modal.data.Details[index].FormattedPic;
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
// 视频播放结束后,自动播放下一个视频
|
||||
const index = modal.index + 1;
|
||||
if (index < modal.data.Details.length) {
|
||||
modal.index = index;
|
||||
options.type = modal.data.Details[index].FormattedUrl.indexOf(".m3u8") > 0 ? "m3u8" : "video/mp4";
|
||||
modal.videoSrc = modal.data.Details[index].FormattedUrl;
|
||||
modal.poster = modal.data.Details[index].FormattedPic;
|
||||
} else {
|
||||
// 所有视频播放完毕,跳转到下一个页面
|
||||
//router.push("/master/video-publish");
|
||||
}
|
||||
};
|
||||
|
||||
//const videoSrc = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8');
|
||||
// 视频播放器配置
|
||||
const options = reactive({
|
||||
src: modal.videoSrc, //视频源
|
||||
type: "m3u8", //视频类型
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.layout-content-view {
|
||||
padding-top: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
<style scoped>
|
||||
.pv-page {
|
||||
width: 100%;
|
||||
/* 外层 .layout-content-view 只有 padding-left: 8px,这里补上右侧
|
||||
同样的间距,播放器才会左右居中而不是偏右贴边 */
|
||||
padding-right: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
overflow-y: scroll;
|
||||
/* 添加滚动条 */
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Reference in new issue
Block a user