perf: 优化文件浏览性能、扫描流程和前端 ref 冲突
- BrowseDirectoryAsync: 将分组/过滤/排序/分页从内存推到数据库,
避免每次翻页加载全量文件实体(20万文件场景从 ~200MB 降到 KB 级)
- ScanRootAsync: 使用 ExecuteUpdateAsync 原子抢占扫描锁防止并发扫描;
缩略图/视频时长从扫描循环内同步生成改为扫描完成后 SemaphoreSlim(4) 批量生成,
扫描耗时从数小时降到数十秒
- ScanDueRootsAsync: 增加 LastScanStartedAt 判断,跳过正在执行中的扫描
- ClientPage: 6 个重复的 ref="mediaPlayer" 改为 :ref="setMediaPlayer" 函数引用,
修复 Vue 3 v-for 内字符串 ref 互相覆盖导致的播放器事件指向错误实例问题
This commit is contained in:
parent
8d9c7f17ff
commit
8c92d2fbac
@ -126,16 +126,40 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
/// <inheritdoc />
|
||||
public async Task<LibraryRootDto> ScanRootAsync(int rootId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == rootId, cancellationToken)
|
||||
// ── 1. 原子抢占扫描锁:仅在没有正在执行的扫描时才允许开始新的扫描 ──
|
||||
var now = DateTime.UtcNow;
|
||||
var claimed = await db.ManagedLibraryRoots
|
||||
.Where(r => r.Id == rootId
|
||||
&& (r.LastScanStartedAt == null || r.LastScanCompletedAt >= r.LastScanStartedAt))
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(r => r.LastScanStartedAt, now)
|
||||
.SetProperty(r => r.LastScanError, (string?)null),
|
||||
cancellationToken);
|
||||
|
||||
if (claimed == 0)
|
||||
{
|
||||
// 扫描已在执行中:返回当前状态,不报错
|
||||
var currentRoot = await db.ManagedLibraryRoots
|
||||
.FirstOrDefaultAsync(r => r.Id == rootId, cancellationToken);
|
||||
if (currentRoot is null)
|
||||
throw new InvalidOperationException("文件库目录不存在。");
|
||||
var currentCount = await db.ManagedFileRecords
|
||||
.CountAsync(f => f.LibraryRootId == rootId && f.Exists, cancellationToken);
|
||||
Serilog.Log.Information("扫描已在执行中,跳过重复扫描 RootId={RootId}", rootId);
|
||||
return ToRootDto(currentRoot, currentCount);
|
||||
}
|
||||
|
||||
// ── 2. 加载根目录实体(ExecuteUpdateAsync 已直接写入 DB,需重新加载以获取最新值) ──
|
||||
var root = await db.ManagedLibraryRoots
|
||||
.FirstOrDefaultAsync(r => r.Id == rootId, cancellationToken)
|
||||
?? throw new InvalidOperationException("文件库目录不存在。");
|
||||
|
||||
Serilog.Log.Information("开始扫描文件库根目录 RootId={RootId} Path={Path}", root.Id, root.Path);
|
||||
root.LastScanStartedAt = DateTime.UtcNow;
|
||||
root.LastScanError = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var staleFileCount = 0;
|
||||
var staleThumbnailPaths = new List<string>();
|
||||
var pendingVideos = new List<ManagedFileRecord>();
|
||||
|
||||
try
|
||||
{
|
||||
@ -151,6 +175,7 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
.Where(file => file.LibraryRootId == root.Id)
|
||||
.ToDictionaryAsync(file => file.AbsolutePath, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
|
||||
// ── 3. 快速文件枚举(只更新元数据,不生成缩略图) ──
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var path in EnumerateSupportedFiles(root.Path))
|
||||
{
|
||||
@ -186,25 +211,14 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
record.Exists = true;
|
||||
record.LastSeenAt = DateTime.UtcNow;
|
||||
|
||||
// 收集缺少缩略图的视频,稍后批量生成(不在此处阻塞扫描)
|
||||
if (mediaType == "video" && record.ThumbnailId is null)
|
||||
{
|
||||
var thumbnail = await thumbnailService.GenerateThumbnailAsync(root.Id, absolutePath, cancellationToken);
|
||||
if (thumbnail is not null)
|
||||
{
|
||||
var map = new ManagedThumbnailMap
|
||||
{
|
||||
LibraryRootId = root.Id,
|
||||
RelativePath = thumbnail.RelativePath,
|
||||
ContentType = thumbnail.ContentType,
|
||||
};
|
||||
db.ManagedThumbnailMaps.Add(map);
|
||||
record.Thumbnail = map;
|
||||
}
|
||||
|
||||
record.VideoDuration ??= thumbnailService.GetVideoDuration(absolutePath);
|
||||
pendingVideos.Add(record);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. 清理过期文件记录 ──
|
||||
var staleFiles = existing.Values
|
||||
.Where(file => !seen.Contains(file.AbsolutePath))
|
||||
.ToList();
|
||||
@ -215,6 +229,7 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
stale.Exists = false;
|
||||
}
|
||||
|
||||
// ── 5. 清理过期缩略图 ──
|
||||
var activeThumbnailIds = existing.Values
|
||||
.Where(file => file.Exists && file.ThumbnailId is not null)
|
||||
.Select(file => file.ThumbnailId!.Value)
|
||||
@ -244,9 +259,16 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
db.ManagedThumbnailMaps.RemoveRange(staleThumbnails);
|
||||
}
|
||||
|
||||
// ── 6. 保存文件元数据(快速阶段结束) ──
|
||||
root.LastScanCompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
DeleteThumbnailFiles(staleThumbnailPaths);
|
||||
|
||||
// ── 7. 批量生成缩略图和视频时长(与扫描解耦,有限并发) ──
|
||||
if (pendingVideos.Count > 0)
|
||||
{
|
||||
await GenerateThumbnailsBatchAsync(root.Id, pendingVideos, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -258,13 +280,15 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
throw;
|
||||
}
|
||||
|
||||
var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken);
|
||||
var count = await db.ManagedFileRecords
|
||||
.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken);
|
||||
Serilog.Log.Information(
|
||||
"扫描文件库根目录完成 RootId={RootId} FileCount={FileCount} StaleFiles={StaleFiles} RemovedThumbnails={RemovedThumbnails}",
|
||||
"扫描文件库根目录完成 RootId={RootId} FileCount={FileCount} StaleFiles={StaleFiles} RemovedThumbnails={RemovedThumbnails} PendingThumbnails={PendingThumbnails}",
|
||||
root.Id,
|
||||
count,
|
||||
staleFileCount,
|
||||
staleThumbnailPaths.Count);
|
||||
staleThumbnailPaths.Count,
|
||||
pendingVideos.Count);
|
||||
return ToRootDto(root, count);
|
||||
}
|
||||
|
||||
@ -278,6 +302,13 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
var dueRoots = roots
|
||||
.Where(root =>
|
||||
{
|
||||
// 跳过正在扫描中的目录(StartedAt 存在且晚于 CompletedAt)
|
||||
if (root.LastScanStartedAt is not null
|
||||
&& (root.LastScanCompletedAt is null || root.LastScanStartedAt > root.LastScanCompletedAt))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var interval = Math.Max(1, root.ScanIntervalMinutes);
|
||||
return root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now;
|
||||
})
|
||||
@ -361,67 +392,91 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
var page = Math.Clamp(request.Page, 1, 100000);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 100);
|
||||
var mediaType = request.MediaType?.Trim();
|
||||
// URL 友好的正斜杠,用于响应和内存处理
|
||||
// URL 友好的正斜杠,用于响应
|
||||
var prefix = (request.Path ?? "").Trim().Replace('\\', '/').Trim('/');
|
||||
// Windows 反斜杠,用于数据库查询
|
||||
var dbPrefix = prefix.Replace('/', '\\');
|
||||
|
||||
var query = db.ManagedFileRecords
|
||||
// ── 基础查询:根目录下存在且可用的所有文件 ──
|
||||
var baseQuery = db.ManagedFileRecords
|
||||
.AsNoTracking()
|
||||
.Where(f => f.LibraryRootId == rootId && f.Exists
|
||||
&& f.LibraryRoot != null && f.LibraryRoot.IsAvailable);
|
||||
|
||||
if (!string.IsNullOrEmpty(dbPrefix))
|
||||
// ── 当前目录的文件查询(下推到数据库) ──
|
||||
IQueryable<ManagedFileRecord> currentFilesQuery;
|
||||
|
||||
if (string.IsNullOrEmpty(dbPrefix))
|
||||
{
|
||||
// 根层级:RelativePath 中不含 '\' 的就是当前目录文件
|
||||
currentFilesQuery = baseQuery.Where(f => !f.RelativePath.Contains("\\"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 子目录层级:RelativePath 以 "prefix\" 开头,且去掉前缀后不含 '\'
|
||||
var dbPrefixWithSlash = dbPrefix + "\\";
|
||||
query = query.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash));
|
||||
}
|
||||
|
||||
var allFiles = await query.ToListAsync(cancellationToken);
|
||||
|
||||
var subdirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var currentFiles = new List<ManagedFileRecord>();
|
||||
|
||||
foreach (var file in allFiles)
|
||||
{
|
||||
var relativePath = file.RelativePath.Replace('\\', '/');
|
||||
var remaining = string.IsNullOrEmpty(prefix)
|
||||
? relativePath
|
||||
: relativePath[(prefix.Length + 1)..];
|
||||
|
||||
var slashIndex = remaining.IndexOf('/');
|
||||
if (slashIndex < 0)
|
||||
{
|
||||
currentFiles.Add(file);
|
||||
}
|
||||
else
|
||||
{
|
||||
subdirs.Add(remaining[..slashIndex]);
|
||||
}
|
||||
var prefixLen = dbPrefixWithSlash.Length;
|
||||
currentFilesQuery = baseQuery
|
||||
.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash)
|
||||
&& !f.RelativePath.Substring(prefixLen).Contains("\\"));
|
||||
}
|
||||
|
||||
// ── 媒体类型过滤(数据库层面) ──
|
||||
if (!string.IsNullOrWhiteSpace(mediaType) && !mediaType.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentFiles = currentFiles
|
||||
.Where(file => file.MediaType.Equals(mediaType, StringComparison.OrdinalIgnoreCase))
|
||||
currentFilesQuery = currentFilesQuery.Where(f => f.MediaType == mediaType);
|
||||
}
|
||||
|
||||
// ── 计数 + 分页文件查询(全部在数据库完成) ──
|
||||
var total = await currentFilesQuery.CountAsync(cancellationToken);
|
||||
|
||||
var files = await ApplyFileSort(currentFilesQuery, request.SortBy, request.SortDirection)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(file => ToFileDto(file))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// ── 子目录提取 ──
|
||||
List<string> subdirs;
|
||||
|
||||
if (string.IsNullOrEmpty(dbPrefix))
|
||||
{
|
||||
// 根层级:SQL 层面提取第一段目录名(Substring+IndexOf+Distinct 跨数据库可翻译)
|
||||
subdirs = await baseQuery
|
||||
.Where(f => f.RelativePath.Contains("\\"))
|
||||
.Select(f => f.RelativePath.Substring(0, f.RelativePath.IndexOf("\\")))
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 非根层级:只加载路径字符串(单列,无实体追踪开销),内存中提取第二层子目录名
|
||||
var dbPrefixWithSlash = dbPrefix + "\\";
|
||||
var prefixLen = dbPrefixWithSlash.Length;
|
||||
|
||||
var paths = await baseQuery
|
||||
.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash))
|
||||
.Select(f => f.RelativePath)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
subdirs = paths
|
||||
.Select(p => p.Substring(prefixLen))
|
||||
.Where(remaining => remaining.Contains('\\'))
|
||||
.Select(remaining => remaining.Substring(0, remaining.IndexOf('\\')))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var total = currentFiles.Count;
|
||||
var files = ApplyFileSort(currentFiles, request.SortBy, request.SortDirection)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(ToFileDto)
|
||||
.ToList();
|
||||
subdirs.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new BrowseDirectoryResponse(
|
||||
prefix,
|
||||
subdirs.OrderBy(d => d, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
subdirs,
|
||||
files,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
pageSize > 0 ? (int)Math.Ceiling((double)total / pageSize) : 0);
|
||||
(int)Math.Ceiling((double)total / pageSize));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@ -596,6 +651,77 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量生成视频缩略图和提取时长。与文件扫描解耦,使用有限并发控制 ffmpeg 进程数,
|
||||
/// 通过 <see cref="SemaphoreSlim"/> 限制并发数,通过 lock 保证 <see cref="AppDataContext"/> 的线程安全。
|
||||
/// </summary>
|
||||
/// <param name="rootId">库根目录 ID。</param>
|
||||
/// <param name="videos">缺少缩略图的视频文件记录。</param>
|
||||
/// <param name="ct">取消令牌。</param>
|
||||
private async Task GenerateThumbnailsBatchAsync(int rootId, List<ManagedFileRecord> videos, CancellationToken ct)
|
||||
{
|
||||
// 限制并发 ffmpeg 进程数,避免占满 CPU / 内存
|
||||
using var semaphore = new SemaphoreSlim(Math.Min(4, Environment.ProcessorCount));
|
||||
var dbLock = new object();
|
||||
var generated = 0;
|
||||
var failed = 0;
|
||||
|
||||
async Task ProcessVideo(ManagedFileRecord video)
|
||||
{
|
||||
await semaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// 耗时操作:ffmpeg / ffprobe 调用(可并发,不涉及 DbContext)
|
||||
var thumbnail = await thumbnailService.GenerateThumbnailAsync(rootId, video.AbsolutePath, ct);
|
||||
var duration = thumbnailService.GetVideoDuration(video.AbsolutePath);
|
||||
|
||||
// DbContext 操作:需串行化(EF Core DbContext 非线程安全)
|
||||
lock (dbLock)
|
||||
{
|
||||
if (thumbnail is not null)
|
||||
{
|
||||
var map = new ManagedThumbnailMap
|
||||
{
|
||||
LibraryRootId = rootId,
|
||||
RelativePath = thumbnail.RelativePath,
|
||||
ContentType = thumbnail.ContentType,
|
||||
};
|
||||
db.ManagedThumbnailMaps.Add(map);
|
||||
video.Thumbnail = map;
|
||||
}
|
||||
|
||||
video.VideoDuration ??= duration;
|
||||
generated++;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// 取消时不再处理
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Warning(ex, "生成缩略图失败 File={FilePath}", video.AbsolutePath);
|
||||
Interlocked.Increment(ref failed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
var tasks = videos.Select(ProcessVideo);
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
if (generated > 0)
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
Serilog.Log.Information(
|
||||
"缩略图批量生成完成 RootId={RootId} Generated={Generated} Failed={Failed} Total={Total}",
|
||||
rootId, generated, failed, videos.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规范化扫描间隔,限制在 1 到 1440 分钟范围内,未指定时使用默认值。
|
||||
/// </summary>
|
||||
|
||||
@ -36,7 +36,11 @@ const recentLoading = ref(false)
|
||||
const viewMode = ref<'list' | 'grid'>('list')
|
||||
|
||||
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
|
||||
const mediaPlayer = ref<MediaPlayerHandle | MediaPlayerHandle[] | null>(null)
|
||||
const mediaPlayer = ref<MediaPlayerHandle | null>(null)
|
||||
|
||||
function setMediaPlayer(el: unknown) {
|
||||
mediaPlayer.value = (el as MediaPlayerHandle) ?? null
|
||||
}
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<FileRecordDto[]>([])
|
||||
@ -91,12 +95,11 @@ const clientTitle = computed(() => {
|
||||
})
|
||||
|
||||
function getVideoElement() {
|
||||
const player = Array.isArray(mediaPlayer.value) ? mediaPlayer.value[0] : mediaPlayer.value
|
||||
return player?.getVideoElement() ?? null
|
||||
return mediaPlayer.value?.getVideoElement() ?? null
|
||||
}
|
||||
|
||||
function getMediaPlayer() {
|
||||
return Array.isArray(mediaPlayer.value) ? mediaPlayer.value[0] : mediaPlayer.value
|
||||
return mediaPlayer.value
|
||||
}
|
||||
|
||||
function setError(error: unknown) {
|
||||
@ -461,51 +464,51 @@ onBeforeUnmount(() => {
|
||||
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
||||
<template v-for="file in searchResults" :key="file.id">
|
||||
<FileCard :file="file" show-created-time @select="selectSearchFile" />
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
:ref="setMediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="file-list">
|
||||
<template v-for="file in searchResults" :key="file.id">
|
||||
<FileListItem :file="file" show-created-time @select="selectSearchFile" />
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
:ref="setMediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<MobilePager
|
||||
@ -529,26 +532,26 @@ onBeforeUnmount(() => {
|
||||
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
||||
<template v-for="file in recentFiles" :key="file.id">
|
||||
<FileCard :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" />
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
:ref="setMediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="file-list">
|
||||
@ -559,26 +562,26 @@ onBeforeUnmount(() => {
|
||||
:show-last-played="activeTab === 'recent-played'"
|
||||
@select="selectFile"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
:ref="setMediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
@ -628,26 +631,26 @@ onBeforeUnmount(() => {
|
||||
show-created-time
|
||||
@select="selectFile"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
:ref="setMediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@ -659,26 +662,26 @@ onBeforeUnmount(() => {
|
||||
show-created-time
|
||||
@select="selectFile"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
:ref="setMediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@ -698,6 +701,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<p v-else-if="!loading" class="empty-state">加载中...</p>
|
||||
</template>
|
||||
|
||||
</main>
|
||||
|
||||
<QrCodeModal ref="qrModal" />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user