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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user