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 />
|
/// <inheritdoc />
|
||||||
public async Task<LibraryRootDto> ScanRootAsync(int rootId, CancellationToken cancellationToken = default)
|
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("文件库目录不存在。");
|
?? throw new InvalidOperationException("文件库目录不存在。");
|
||||||
|
|
||||||
Serilog.Log.Information("开始扫描文件库根目录 RootId={RootId} Path={Path}", root.Id, root.Path);
|
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 staleFileCount = 0;
|
||||||
var staleThumbnailPaths = new List<string>();
|
var staleThumbnailPaths = new List<string>();
|
||||||
|
var pendingVideos = new List<ManagedFileRecord>();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -151,6 +175,7 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
.Where(file => file.LibraryRootId == root.Id)
|
.Where(file => file.LibraryRootId == root.Id)
|
||||||
.ToDictionaryAsync(file => file.AbsolutePath, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
.ToDictionaryAsync(file => file.AbsolutePath, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||||
|
|
||||||
|
// ── 3. 快速文件枚举(只更新元数据,不生成缩略图) ──
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var path in EnumerateSupportedFiles(root.Path))
|
foreach (var path in EnumerateSupportedFiles(root.Path))
|
||||||
{
|
{
|
||||||
@ -186,25 +211,14 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
record.Exists = true;
|
record.Exists = true;
|
||||||
record.LastSeenAt = DateTime.UtcNow;
|
record.LastSeenAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// 收集缺少缩略图的视频,稍后批量生成(不在此处阻塞扫描)
|
||||||
if (mediaType == "video" && record.ThumbnailId is null)
|
if (mediaType == "video" && record.ThumbnailId is null)
|
||||||
{
|
{
|
||||||
var thumbnail = await thumbnailService.GenerateThumbnailAsync(root.Id, absolutePath, cancellationToken);
|
pendingVideos.Add(record);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 4. 清理过期文件记录 ──
|
||||||
var staleFiles = existing.Values
|
var staleFiles = existing.Values
|
||||||
.Where(file => !seen.Contains(file.AbsolutePath))
|
.Where(file => !seen.Contains(file.AbsolutePath))
|
||||||
.ToList();
|
.ToList();
|
||||||
@ -215,6 +229,7 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
stale.Exists = false;
|
stale.Exists = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 5. 清理过期缩略图 ──
|
||||||
var activeThumbnailIds = existing.Values
|
var activeThumbnailIds = existing.Values
|
||||||
.Where(file => file.Exists && file.ThumbnailId is not null)
|
.Where(file => file.Exists && file.ThumbnailId is not null)
|
||||||
.Select(file => file.ThumbnailId!.Value)
|
.Select(file => file.ThumbnailId!.Value)
|
||||||
@ -244,9 +259,16 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
db.ManagedThumbnailMaps.RemoveRange(staleThumbnails);
|
db.ManagedThumbnailMaps.RemoveRange(staleThumbnails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 6. 保存文件元数据(快速阶段结束) ──
|
||||||
root.LastScanCompletedAt = DateTime.UtcNow;
|
root.LastScanCompletedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
DeleteThumbnailFiles(staleThumbnailPaths);
|
DeleteThumbnailFiles(staleThumbnailPaths);
|
||||||
|
|
||||||
|
// ── 7. 批量生成缩略图和视频时长(与扫描解耦,有限并发) ──
|
||||||
|
if (pendingVideos.Count > 0)
|
||||||
|
{
|
||||||
|
await GenerateThumbnailsBatchAsync(root.Id, pendingVideos, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -258,13 +280,15 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
throw;
|
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(
|
Serilog.Log.Information(
|
||||||
"扫描文件库根目录完成 RootId={RootId} FileCount={FileCount} StaleFiles={StaleFiles} RemovedThumbnails={RemovedThumbnails}",
|
"扫描文件库根目录完成 RootId={RootId} FileCount={FileCount} StaleFiles={StaleFiles} RemovedThumbnails={RemovedThumbnails} PendingThumbnails={PendingThumbnails}",
|
||||||
root.Id,
|
root.Id,
|
||||||
count,
|
count,
|
||||||
staleFileCount,
|
staleFileCount,
|
||||||
staleThumbnailPaths.Count);
|
staleThumbnailPaths.Count,
|
||||||
|
pendingVideos.Count);
|
||||||
return ToRootDto(root, count);
|
return ToRootDto(root, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,6 +302,13 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
var dueRoots = roots
|
var dueRoots = roots
|
||||||
.Where(root =>
|
.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);
|
var interval = Math.Max(1, root.ScanIntervalMinutes);
|
||||||
return root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now;
|
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 page = Math.Clamp(request.Page, 1, 100000);
|
||||||
var pageSize = Math.Clamp(request.PageSize, 1, 100);
|
var pageSize = Math.Clamp(request.PageSize, 1, 100);
|
||||||
var mediaType = request.MediaType?.Trim();
|
var mediaType = request.MediaType?.Trim();
|
||||||
// URL 友好的正斜杠,用于响应和内存处理
|
// URL 友好的正斜杠,用于响应
|
||||||
var prefix = (request.Path ?? "").Trim().Replace('\\', '/').Trim('/');
|
var prefix = (request.Path ?? "").Trim().Replace('\\', '/').Trim('/');
|
||||||
// Windows 反斜杠,用于数据库查询
|
// Windows 反斜杠,用于数据库查询
|
||||||
var dbPrefix = prefix.Replace('/', '\\');
|
var dbPrefix = prefix.Replace('/', '\\');
|
||||||
|
|
||||||
var query = db.ManagedFileRecords
|
// ── 基础查询:根目录下存在且可用的所有文件 ──
|
||||||
|
var baseQuery = db.ManagedFileRecords
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(f => f.LibraryRootId == rootId && f.Exists
|
.Where(f => f.LibraryRootId == rootId && f.Exists
|
||||||
&& f.LibraryRoot != null && f.LibraryRoot.IsAvailable);
|
&& 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 + "\\";
|
var dbPrefixWithSlash = dbPrefix + "\\";
|
||||||
query = query.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash));
|
var prefixLen = dbPrefixWithSlash.Length;
|
||||||
}
|
currentFilesQuery = baseQuery
|
||||||
|
.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash)
|
||||||
var allFiles = await query.ToListAsync(cancellationToken);
|
&& !f.RelativePath.Substring(prefixLen).Contains("\\"));
|
||||||
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 媒体类型过滤(数据库层面) ──
|
||||||
if (!string.IsNullOrWhiteSpace(mediaType) && !mediaType.Equals("all", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrWhiteSpace(mediaType) && !mediaType.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
currentFiles = currentFiles
|
currentFilesQuery = currentFilesQuery.Where(f => f.MediaType == mediaType);
|
||||||
.Where(file => file.MediaType.Equals(mediaType, StringComparison.OrdinalIgnoreCase))
|
}
|
||||||
|
|
||||||
|
// ── 计数 + 分页文件查询(全部在数据库完成) ──
|
||||||
|
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();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
var total = currentFiles.Count;
|
subdirs.Sort(StringComparer.OrdinalIgnoreCase);
|
||||||
var files = ApplyFileSort(currentFiles, request.SortBy, request.SortDirection)
|
|
||||||
.Skip((page - 1) * pageSize)
|
|
||||||
.Take(pageSize)
|
|
||||||
.Select(ToFileDto)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new BrowseDirectoryResponse(
|
return new BrowseDirectoryResponse(
|
||||||
prefix,
|
prefix,
|
||||||
subdirs.OrderBy(d => d, StringComparer.OrdinalIgnoreCase).ToList(),
|
subdirs,
|
||||||
files,
|
files,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
pageSize > 0 ? (int)Math.Ceiling((double)total / pageSize) : 0);
|
(int)Math.Ceiling((double)total / pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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>
|
/// <summary>
|
||||||
/// 规范化扫描间隔,限制在 1 到 1440 分钟范围内,未指定时使用默认值。
|
/// 规范化扫描间隔,限制在 1 到 1440 分钟范围内,未指定时使用默认值。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -36,7 +36,11 @@ const recentLoading = ref(false)
|
|||||||
const viewMode = ref<'list' | 'grid'>('list')
|
const viewMode = ref<'list' | 'grid'>('list')
|
||||||
|
|
||||||
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
|
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 searchQuery = ref('')
|
||||||
const searchResults = ref<FileRecordDto[]>([])
|
const searchResults = ref<FileRecordDto[]>([])
|
||||||
@ -91,12 +95,11 @@ const clientTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function getVideoElement() {
|
function getVideoElement() {
|
||||||
const player = Array.isArray(mediaPlayer.value) ? mediaPlayer.value[0] : mediaPlayer.value
|
return mediaPlayer.value?.getVideoElement() ?? null
|
||||||
return player?.getVideoElement() ?? null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMediaPlayer() {
|
function getMediaPlayer() {
|
||||||
return Array.isArray(mediaPlayer.value) ? mediaPlayer.value[0] : mediaPlayer.value
|
return mediaPlayer.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function setError(error: unknown) {
|
function setError(error: unknown) {
|
||||||
@ -461,51 +464,51 @@ onBeforeUnmount(() => {
|
|||||||
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
||||||
<template v-for="file in searchResults" :key="file.id">
|
<template v-for="file in searchResults" :key="file.id">
|
||||||
<FileCard :file="file" show-created-time @select="selectSearchFile" />
|
<FileCard :file="file" show-created-time @select="selectSearchFile" />
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
ref="mediaPlayer"
|
:ref="setMediaPlayer"
|
||||||
:selected-file="selectedFile"
|
:selected-file="selectedFile"
|
||||||
:selected-root="selectedRoot"
|
:selected-root="selectedRoot"
|
||||||
:selected-media-url="selectedMediaUrl"
|
:selected-media-url="selectedMediaUrl"
|
||||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||||
:show-resume-prompt="showResumePrompt"
|
:show-resume-prompt="showResumePrompt"
|
||||||
:resume-position="resumePosition"
|
:resume-position="resumePosition"
|
||||||
:text-preview="textPreview"
|
:text-preview="textPreview"
|
||||||
@resume-playback="resumePlayback"
|
@resume-playback="resumePlayback"
|
||||||
@dismiss-resume="dismissResume"
|
@dismiss-resume="dismissResume"
|
||||||
@loadedmetadata="seekToResumePosition"
|
@loadedmetadata="seekToResumePosition"
|
||||||
@canplay="seekToResumePosition"
|
@canplay="seekToResumePosition"
|
||||||
@play="handleVideoPlay"
|
@play="handleVideoPlay"
|
||||||
@timeupdate="handleVideoTimeUpdate"
|
@timeupdate="handleVideoTimeUpdate"
|
||||||
@pause="handleVideoPause"
|
@pause="handleVideoPause"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
@seeked="handleVideoPause"
|
@seeked="handleVideoPause"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="file-list">
|
<div v-else class="file-list">
|
||||||
<template v-for="file in searchResults" :key="file.id">
|
<template v-for="file in searchResults" :key="file.id">
|
||||||
<FileListItem :file="file" show-created-time @select="selectSearchFile" />
|
<FileListItem :file="file" show-created-time @select="selectSearchFile" />
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
ref="mediaPlayer"
|
:ref="setMediaPlayer"
|
||||||
:selected-file="selectedFile"
|
:selected-file="selectedFile"
|
||||||
:selected-root="selectedRoot"
|
:selected-root="selectedRoot"
|
||||||
:selected-media-url="selectedMediaUrl"
|
:selected-media-url="selectedMediaUrl"
|
||||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||||
:show-resume-prompt="showResumePrompt"
|
:show-resume-prompt="showResumePrompt"
|
||||||
:resume-position="resumePosition"
|
:resume-position="resumePosition"
|
||||||
:text-preview="textPreview"
|
:text-preview="textPreview"
|
||||||
@resume-playback="resumePlayback"
|
@resume-playback="resumePlayback"
|
||||||
@dismiss-resume="dismissResume"
|
@dismiss-resume="dismissResume"
|
||||||
@loadedmetadata="seekToResumePosition"
|
@loadedmetadata="seekToResumePosition"
|
||||||
@canplay="seekToResumePosition"
|
@canplay="seekToResumePosition"
|
||||||
@play="handleVideoPlay"
|
@play="handleVideoPlay"
|
||||||
@timeupdate="handleVideoTimeUpdate"
|
@timeupdate="handleVideoTimeUpdate"
|
||||||
@pause="handleVideoPause"
|
@pause="handleVideoPause"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
@seeked="handleVideoPause"
|
@seeked="handleVideoPause"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<MobilePager
|
<MobilePager
|
||||||
@ -529,26 +532,26 @@ onBeforeUnmount(() => {
|
|||||||
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
||||||
<template v-for="file in recentFiles" :key="file.id">
|
<template v-for="file in recentFiles" :key="file.id">
|
||||||
<FileCard :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" />
|
<FileCard :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" />
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
ref="mediaPlayer"
|
:ref="setMediaPlayer"
|
||||||
:selected-file="selectedFile"
|
:selected-file="selectedFile"
|
||||||
:selected-root="selectedRoot"
|
:selected-root="selectedRoot"
|
||||||
:selected-media-url="selectedMediaUrl"
|
:selected-media-url="selectedMediaUrl"
|
||||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||||
:show-resume-prompt="showResumePrompt"
|
:show-resume-prompt="showResumePrompt"
|
||||||
:resume-position="resumePosition"
|
:resume-position="resumePosition"
|
||||||
:text-preview="textPreview"
|
:text-preview="textPreview"
|
||||||
@resume-playback="resumePlayback"
|
@resume-playback="resumePlayback"
|
||||||
@dismiss-resume="dismissResume"
|
@dismiss-resume="dismissResume"
|
||||||
@loadedmetadata="seekToResumePosition"
|
@loadedmetadata="seekToResumePosition"
|
||||||
@canplay="seekToResumePosition"
|
@canplay="seekToResumePosition"
|
||||||
@play="handleVideoPlay"
|
@play="handleVideoPlay"
|
||||||
@timeupdate="handleVideoTimeUpdate"
|
@timeupdate="handleVideoTimeUpdate"
|
||||||
@pause="handleVideoPause"
|
@pause="handleVideoPause"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
@seeked="handleVideoPause"
|
@seeked="handleVideoPause"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="file-list">
|
<div v-else class="file-list">
|
||||||
@ -559,26 +562,26 @@ onBeforeUnmount(() => {
|
|||||||
:show-last-played="activeTab === 'recent-played'"
|
:show-last-played="activeTab === 'recent-played'"
|
||||||
@select="selectFile"
|
@select="selectFile"
|
||||||
/>
|
/>
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
ref="mediaPlayer"
|
:ref="setMediaPlayer"
|
||||||
:selected-file="selectedFile"
|
:selected-file="selectedFile"
|
||||||
:selected-root="selectedRoot"
|
:selected-root="selectedRoot"
|
||||||
:selected-media-url="selectedMediaUrl"
|
:selected-media-url="selectedMediaUrl"
|
||||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||||
:show-resume-prompt="showResumePrompt"
|
:show-resume-prompt="showResumePrompt"
|
||||||
:resume-position="resumePosition"
|
:resume-position="resumePosition"
|
||||||
:text-preview="textPreview"
|
:text-preview="textPreview"
|
||||||
@resume-playback="resumePlayback"
|
@resume-playback="resumePlayback"
|
||||||
@dismiss-resume="dismissResume"
|
@dismiss-resume="dismissResume"
|
||||||
@loadedmetadata="seekToResumePosition"
|
@loadedmetadata="seekToResumePosition"
|
||||||
@canplay="seekToResumePosition"
|
@canplay="seekToResumePosition"
|
||||||
@play="handleVideoPlay"
|
@play="handleVideoPlay"
|
||||||
@timeupdate="handleVideoTimeUpdate"
|
@timeupdate="handleVideoTimeUpdate"
|
||||||
@pause="handleVideoPause"
|
@pause="handleVideoPause"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
@seeked="handleVideoPause"
|
@seeked="handleVideoPause"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -628,26 +631,26 @@ onBeforeUnmount(() => {
|
|||||||
show-created-time
|
show-created-time
|
||||||
@select="selectFile"
|
@select="selectFile"
|
||||||
/>
|
/>
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
ref="mediaPlayer"
|
:ref="setMediaPlayer"
|
||||||
:selected-file="selectedFile"
|
:selected-file="selectedFile"
|
||||||
:selected-root="selectedRoot"
|
:selected-root="selectedRoot"
|
||||||
:selected-media-url="selectedMediaUrl"
|
:selected-media-url="selectedMediaUrl"
|
||||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||||
:show-resume-prompt="showResumePrompt"
|
:show-resume-prompt="showResumePrompt"
|
||||||
:resume-position="resumePosition"
|
:resume-position="resumePosition"
|
||||||
:text-preview="textPreview"
|
:text-preview="textPreview"
|
||||||
@resume-playback="resumePlayback"
|
@resume-playback="resumePlayback"
|
||||||
@dismiss-resume="dismissResume"
|
@dismiss-resume="dismissResume"
|
||||||
@loadedmetadata="seekToResumePosition"
|
@loadedmetadata="seekToResumePosition"
|
||||||
@canplay="seekToResumePosition"
|
@canplay="seekToResumePosition"
|
||||||
@play="handleVideoPlay"
|
@play="handleVideoPlay"
|
||||||
@timeupdate="handleVideoTimeUpdate"
|
@timeupdate="handleVideoTimeUpdate"
|
||||||
@pause="handleVideoPause"
|
@pause="handleVideoPause"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
@seeked="handleVideoPause"
|
@seeked="handleVideoPause"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -659,26 +662,26 @@ onBeforeUnmount(() => {
|
|||||||
show-created-time
|
show-created-time
|
||||||
@select="selectFile"
|
@select="selectFile"
|
||||||
/>
|
/>
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
ref="mediaPlayer"
|
:ref="setMediaPlayer"
|
||||||
:selected-file="selectedFile"
|
:selected-file="selectedFile"
|
||||||
:selected-root="selectedRoot"
|
:selected-root="selectedRoot"
|
||||||
:selected-media-url="selectedMediaUrl"
|
:selected-media-url="selectedMediaUrl"
|
||||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||||
:show-resume-prompt="showResumePrompt"
|
:show-resume-prompt="showResumePrompt"
|
||||||
:resume-position="resumePosition"
|
:resume-position="resumePosition"
|
||||||
:text-preview="textPreview"
|
:text-preview="textPreview"
|
||||||
@resume-playback="resumePlayback"
|
@resume-playback="resumePlayback"
|
||||||
@dismiss-resume="dismissResume"
|
@dismiss-resume="dismissResume"
|
||||||
@loadedmetadata="seekToResumePosition"
|
@loadedmetadata="seekToResumePosition"
|
||||||
@canplay="seekToResumePosition"
|
@canplay="seekToResumePosition"
|
||||||
@play="handleVideoPlay"
|
@play="handleVideoPlay"
|
||||||
@timeupdate="handleVideoTimeUpdate"
|
@timeupdate="handleVideoTimeUpdate"
|
||||||
@pause="handleVideoPause"
|
@pause="handleVideoPause"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
@seeked="handleVideoPause"
|
@seeked="handleVideoPause"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -698,6 +701,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<p v-else-if="!loading" class="empty-state">加载中...</p>
|
<p v-else-if="!loading" class="empty-state">加载中...</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<QrCodeModal ref="qrModal" />
|
<QrCodeModal ref="qrModal" />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user