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:
luoqian 2026-06-12 15:45:06 +08:00
parent 8d9c7f17ff
commit 8c92d2fbac
2 changed files with 312 additions and 182 deletions

View File

@ -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>

View File

@ -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" />