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,22 @@
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Service for reading and parsing danmaku XML files produced by the recording system.
|
||||
/// </summary>
|
||||
public interface IDanmakuService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns parsed danmaku events for a single recording task (one segment).
|
||||
/// Returns null if the task does not exist or has no danmaku file.
|
||||
/// </summary>
|
||||
Task<DanmakuResponseDto?> GetTaskDanmakuAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregated danmaku events for all tasks in a recording session.
|
||||
/// Returns null if the session does not exist or has no tasks with danmaku.
|
||||
/// Offsets for segments beyond the first are adjusted so they are relative to the session start.
|
||||
/// </summary>
|
||||
Task<SessionDanmakuResponseDto?> GetSessionDanmakuAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
/// <summary>
|
||||
/// A single parsed danmaku event (chat message or non-chat event like gift/like/member/enter).
|
||||
/// </summary>
|
||||
public sealed class DanmakuEventDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Offset in seconds from the segment's video start time.
|
||||
/// </summary>
|
||||
public double OffsetSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type: "chat", "gift", "like", "member", "enter", "superchat", "live", "preparing", or platform-specific types.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For chat: the message text. For non-chat events: a descriptive label (e.g., "gift: rose x1").
|
||||
/// </summary>
|
||||
public required string Content { get; init; }
|
||||
|
||||
public string? User { get; init; }
|
||||
|
||||
public string? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color string for chat messages (e.g., "FFFFFF"). Only meaningful for chat events.
|
||||
/// </summary>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Font size for chat messages (e.g., 25). Only meaningful for chat events.
|
||||
/// </summary>
|
||||
public int? FontSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display mode for chat messages (1 = scroll right-to-left). Only meaningful for chat events.
|
||||
/// </summary>
|
||||
public int? Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unix timestamp in milliseconds when the event occurred (from the platform or recorded time).
|
||||
/// </summary>
|
||||
public long? TimestampMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For gift events: the gift name (e.g., "rose").
|
||||
/// </summary>
|
||||
public string? GiftName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For gift/like events: the repeat count.
|
||||
/// </summary>
|
||||
public int? Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Truncated raw payload from the platform (for debugging).
|
||||
/// </summary>
|
||||
public string? Raw { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Danmaku response for a single recording task (one segment).
|
||||
/// </summary>
|
||||
public sealed class DanmakuResponseDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
|
||||
public int SegmentIndex { get; init; }
|
||||
|
||||
public string? Platform { get; init; }
|
||||
|
||||
public string? RoomId { get; init; }
|
||||
|
||||
public string? LiveRoomId { get; init; }
|
||||
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The UTC time when this segment's recording started (anchor for offset calculation).
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
public required IReadOnlyList<DanmakuEventDto> Events { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated danmaku response for an entire recording session (all segments).
|
||||
/// </summary>
|
||||
public sealed class SessionDanmakuResponseDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
public required IReadOnlyList<DanmakuResponseDto> Tasks { get; init; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
@@ -16,15 +17,18 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
private readonly RecordSessionService _recordSessionService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
|
||||
private readonly IDanmakuService _danmakuService;
|
||||
|
||||
public RecordSessionsController(
|
||||
RecordSessionService recordSessionService,
|
||||
RecordUploadService recordUploadService,
|
||||
CleanupOperationCoordinator cleanupOperationCoordinator)
|
||||
CleanupOperationCoordinator cleanupOperationCoordinator,
|
||||
IDanmakuService danmakuService)
|
||||
{
|
||||
_recordSessionService = recordSessionService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_cleanupOperationCoordinator = cleanupOperationCoordinator;
|
||||
_danmakuService = danmakuService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -100,4 +104,11 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
[HttpPost("{id:guid}/upload")]
|
||||
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
|
||||
|
||||
[HttpGet("{id:guid}/danmaku")]
|
||||
public async Task<ActionResult<SessionDanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _danmakuService.GetSessionDanmakuAsync(id, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,20 @@ public sealed class RecordTasksController : ControllerBase
|
||||
private readonly RecordService _recordService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly IRecordMediaService _recordMediaService;
|
||||
private readonly IDanmakuService _danmakuService;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
|
||||
public RecordTasksController(
|
||||
RecordService recordService,
|
||||
RecordUploadService recordUploadService,
|
||||
IRecordMediaService recordMediaService,
|
||||
IDanmakuService danmakuService,
|
||||
LinkGenerator linkGenerator)
|
||||
{
|
||||
_recordService = recordService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_recordMediaService = recordMediaService;
|
||||
_danmakuService = danmakuService;
|
||||
_linkGenerator = linkGenerator;
|
||||
}
|
||||
|
||||
@@ -89,4 +92,11 @@ public sealed class RecordTasksController : ControllerBase
|
||||
[HttpPost("{id:guid}/upload")]
|
||||
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordUploadService.UploadTaskAsync(id, cancellationToken));
|
||||
|
||||
[HttpGet("{id:guid}/danmaku")]
|
||||
public async Task<ActionResult<DanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _danmakuService.GetTaskDanmakuAsync(id, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
||||
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
||||
builder.Services.AddScoped<PlatformHttpRequestService>();
|
||||
builder.Services.AddScoped<RecordUploadService>();
|
||||
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
|
||||
builder.Services.AddScoped<DatabaseInitializer>();
|
||||
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user