feat: add danmaku replay player integration

- Add IDanmakuService interface and DanmakuService implementation to parse danmaku XML files
- Add GET /api/record-tasks/{id}/danmaku and GET /api/record-sessions/{id}/danmaku endpoints
- Add DanmakuPlayer Vue component with native video + CSS overlay danmaku rendering
- Add danmakuEngine.ts pure-TypeScript animation loop with binary search, track management, and event notifications
- Add useDanmakuPlayer composable for reusable danmaku data loading
- Integrate danmaku toggle button into RecordTaskDetailView
- Integrate danmaku replay modal dialog into RecordSessionDetailView segment table

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 18:09:43 +08:00
co-authored by Claude Opus 4.8
parent b2aaef093d
commit a5c2cc3202
12 changed files with 1305 additions and 6 deletions
@@ -0,0 +1,318 @@
using System.Globalization;
using System.Xml;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Entities;
namespace LiveRecorder.Infrastructure.Services;
public sealed class DanmakuService : IDanmakuService
{
private readonly IRecordTaskRepository _recordTaskRepository;
private readonly IRecordSessionRepository _recordSessionRepository;
public DanmakuService(
IRecordTaskRepository recordTaskRepository,
IRecordSessionRepository recordSessionRepository)
{
_recordTaskRepository = recordTaskRepository;
_recordSessionRepository = recordSessionRepository;
}
public async Task<DanmakuResponseDto?> GetTaskDanmakuAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
var task = await _recordTaskRepository.GetByIdAsync(recordTaskId, cancellationToken);
if (task is null)
{
return null;
}
var danmakuPath = ResolveDanmakuPath(task);
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
{
return null;
}
var taskStartedAt = task.StartedAt ?? task.CreatedAt;
var events = ParseDanmakuXml(danmakuPath);
return new DanmakuResponseDto
{
RecordTaskId = task.Id,
SegmentIndex = task.SegmentIndex,
Platform = task.LiveRoom?.Platform.ToString(),
RoomId = task.LiveRoom?.RoomId,
LiveRoomId = task.LiveRoomId.ToString(),
RecordSessionId = task.RecordSessionId,
StartedAt = taskStartedAt,
Events = events
};
}
public async Task<SessionDanmakuResponseDto?> GetSessionDanmakuAsync(
Guid recordSessionId,
CancellationToken cancellationToken = default)
{
var session = await _recordSessionRepository.GetByIdAsync(recordSessionId, cancellationToken);
if (session is null)
{
return null;
}
var tasks = await _recordTaskRepository.ListBySessionIdAsync(recordSessionId, cancellationToken);
if (tasks.Count == 0)
{
return null;
}
// Find the session anchor: the earliest task StartedAt (or CreatedAt)
var sessionAnchor = tasks
.Select(static task => task.StartedAt ?? task.CreatedAt)
.Min();
var taskResponses = new List<DanmakuResponseDto>(tasks.Count);
foreach (var task in tasks.OrderBy(static item => item.SegmentIndex).ThenBy(static item => item.CreatedAt))
{
var danmakuPath = ResolveDanmakuPath(task);
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
{
continue;
}
var taskStartedAt = task.StartedAt ?? task.CreatedAt;
var events = ParseDanmakuXml(danmakuPath);
// Adjust offsets so they are relative to the session anchor (not the individual segment)
var segmentOffsetFromSessionAnchor = (taskStartedAt - sessionAnchor).TotalSeconds;
if (Math.Abs(segmentOffsetFromSessionAnchor) > 0.01)
{
events = events
.Select(item => new DanmakuEventDto
{
OffsetSeconds = item.OffsetSeconds + segmentOffsetFromSessionAnchor,
Type = item.Type,
Content = item.Content,
User = item.User,
UserId = item.UserId,
Color = item.Color,
FontSize = item.FontSize,
Mode = item.Mode,
TimestampMs = item.TimestampMs,
GiftName = item.GiftName,
Count = item.Count,
Raw = item.Raw
})
.ToList();
}
taskResponses.Add(new DanmakuResponseDto
{
RecordTaskId = task.Id,
SegmentIndex = task.SegmentIndex,
Platform = task.LiveRoom?.Platform.ToString(),
RoomId = task.LiveRoom?.RoomId,
LiveRoomId = task.LiveRoomId.ToString(),
RecordSessionId = task.RecordSessionId,
StartedAt = taskStartedAt,
Events = events
});
}
if (taskResponses.Count == 0)
{
return null;
}
return new SessionDanmakuResponseDto
{
RecordSessionId = recordSessionId,
Tasks = taskResponses
};
}
private static string? ResolveDanmakuPath(RecordTask task)
{
if (!string.IsNullOrWhiteSpace(task.Result?.DanmakuFilePath))
{
return task.Result.DanmakuFilePath;
}
if (string.IsNullOrWhiteSpace(task.OutputFilePath))
{
return null;
}
return Path.ChangeExtension(task.OutputFilePath, ".xml");
}
private static IReadOnlyList<DanmakuEventDto> ParseDanmakuXml(string danmakuPath)
{
var events = new List<DanmakuEventDto>();
try
{
var settings = new XmlReaderSettings
{
IgnoreComments = true,
IgnoreWhitespace = true,
DtdProcessing = DtdProcessing.Ignore
};
using var reader = XmlReader.Create(danmakuPath, settings);
while (reader.Read())
{
if (reader.NodeType != XmlNodeType.Element)
{
continue;
}
if (string.Equals(reader.Name, "d", StringComparison.OrdinalIgnoreCase))
{
var chatEvent = ParseChatElement(reader);
if (chatEvent is not null)
{
events.Add(chatEvent);
}
}
else if (string.Equals(reader.Name, "event", StringComparison.OrdinalIgnoreCase))
{
var nonChatEvent = ParseEventElement(reader);
if (nonChatEvent is not null)
{
events.Add(nonChatEvent);
}
}
}
}
catch
{
return [];
}
return events;
}
private static DanmakuEventDto? ParseChatElement(XmlReader reader)
{
// <d p="offsetSeconds,mode,fontSize,color,timestampMs,?,userId,?" user="..." type="chat" raw="...">content</d>
var payload = reader.GetAttribute("p");
var user = reader.GetAttribute("user");
var raw = reader.GetAttribute("raw");
double? offsetSeconds = null;
int? fontSize = null;
int? mode = null;
string? color = null;
long? timestampMs = null;
string? userId = null;
if (!string.IsNullOrWhiteSpace(payload))
{
var parts = payload.Split(',');
if (parts.Length >= 1 && double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedOffset))
{
offsetSeconds = parsedOffset;
}
if (parts.Length >= 2 && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMode))
{
mode = parsedMode;
}
if (parts.Length >= 3 && int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedFontSize))
{
fontSize = parsedFontSize;
}
if (parts.Length >= 4)
{
color = string.IsNullOrWhiteSpace(parts[3]) ? null : parts[3].Trim();
}
if (parts.Length >= 5 && long.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTs))
{
timestampMs = parsedTs;
}
if (parts.Length >= 7)
{
userId = string.IsNullOrWhiteSpace(parts[6]) ? null : parts[6].Trim();
}
}
if (!offsetSeconds.HasValue)
{
return null;
}
var content = reader.ReadInnerXml().Trim();
return new DanmakuEventDto
{
OffsetSeconds = Math.Max(0, offsetSeconds.Value),
Type = "chat",
Content = string.IsNullOrWhiteSpace(content) ? string.Empty : content,
User = NormalizeNullable(user),
UserId = NormalizeNullable(userId),
Color = NormalizeNullable(color) ?? "FFFFFF",
FontSize = fontSize ?? 25,
Mode = mode ?? 1,
TimestampMs = timestampMs,
Raw = NormalizeNullable(raw)
};
}
private static DanmakuEventDto? ParseEventElement(XmlReader reader)
{
// <event type="gift" ts="123" offset="12.3" user="name" userId="uid" content="desc" raw="..." [extraKey="extraValue"] ... />
var type = reader.GetAttribute("type");
var offset = reader.GetAttribute("offset");
var ts = reader.GetAttribute("ts");
var user = reader.GetAttribute("user");
var userId = reader.GetAttribute("userId");
var content = reader.GetAttribute("content");
var raw = reader.GetAttribute("raw");
if (!double.TryParse(offset, NumberStyles.Float, CultureInfo.InvariantCulture, out var offsetSeconds))
{
return null;
}
long? timestampMs = null;
if (long.TryParse(ts, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTs))
{
timestampMs = parsedTs;
}
var giftName = reader.GetAttribute("giftName");
var count = reader.GetAttribute("count");
int? countValue = null;
if (int.TryParse(count, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedCount))
{
countValue = parsedCount;
}
return new DanmakuEventDto
{
OffsetSeconds = Math.Max(0, offsetSeconds),
Type = string.IsNullOrWhiteSpace(type) ? "other" : type.Trim(),
Content = NormalizeNullable(content) ?? string.Empty,
User = NormalizeNullable(user),
UserId = NormalizeNullable(userId),
Color = null,
FontSize = null,
Mode = null,
TimestampMs = timestampMs,
GiftName = NormalizeNullable(giftName),
Count = countValue,
Raw = NormalizeNullable(raw)
};
}
private static string? NormalizeNullable(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}