Basic groundwork for replay viewer
This commit is contained in:
parent
45cb5afc0a
commit
dcdd60c318
6
nise-frontend/package-lock.json
generated
6
nise-frontend/package-lock.json
generated
@ -21,6 +21,7 @@
|
||||
"chart.js": "^4.4.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"lz-string": "^1.5.0",
|
||||
"lzma-web": "^3.0.1",
|
||||
"ng2-charts": "^5.0.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
@ -8081,6 +8082,11 @@
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lzma-web": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lzma-web/-/lzma-web-3.0.1.tgz",
|
||||
"integrity": "sha512-sb5cdfd+PLNljK/HUgYzvnz4G7r0GFK8sonyGrqJS0FVyUQjFYcnmU2LqTWFi6r48lH1ZBstnxyLWepKM/t7QA=="
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.7",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
"chart.js": "^4.4.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"lz-string": "^1.5.0",
|
||||
"lzma-web": "^3.0.1",
|
||||
"ng2-charts": "^5.0.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
|
||||
@ -1,31 +1,3 @@
|
||||
<div class="header">
|
||||
<div>
|
||||
<a [routerLink]="['/']">
|
||||
<img src="/assets/keisatsu-chan.png" class="header-image" alt="keisatsu-chan~!">
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h2>/nise.moe/</h2>
|
||||
<ul style="font-size: 15px; line-height: 19px;">
|
||||
<li><a [routerLink]="['/']">./home</a></li>
|
||||
<li><a [routerLink]="['/sus']">./suspicious-scores</a></li>
|
||||
<li><a [routerLink]="['/stolen']">./stolen-replays</a></li>
|
||||
<li><a [routerLink]="['/search']">./advanced-search</a></li>
|
||||
</ul>
|
||||
<form (ngSubmit)="onSubmit()">
|
||||
<input type="text" [(ngModel)]="term" [ngModelOptions]="{standalone: true}" id="nise-osu-username" required minlength="2" maxlength="50" placeholder="Search for users...">
|
||||
</form>
|
||||
<div style="margin-top: 6px">
|
||||
<ng-container *ngIf="this.userService.isUserLoggedIn()">
|
||||
hi, <span class="user-details" [title]="this.userService.currentUser?.username">{{this.userService.currentUser?.username}}</span> <a [href]="this.userService.getLogoutUrl()">Logout</a>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!this.userService.isUserLoggedIn()">
|
||||
<a [href]="this.userService.getLoginUrl()">Login</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-outlet></router-outlet>
|
||||
<div class="text-center version">
|
||||
v20240225
|
||||
<app-replay-viewer></app-replay-viewer>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@ import {Router, RouterLink, RouterOutlet} from "@angular/router";
|
||||
import {UserService} from "../corelib/service/user.service";
|
||||
import {NgIf} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {ReplayViewerComponent} from "../corelib/components/replay-viewer/replay-viewer.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@ -10,7 +11,7 @@ import {FormsModule} from '@angular/forms';
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
NgIf, RouterOutlet
|
||||
NgIf, RouterOutlet, ReplayViewerComponent
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css']
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
export enum HitObjectType {
|
||||
HitCircle = 1,
|
||||
Slider = 2,
|
||||
Spinner = 8
|
||||
}
|
||||
|
||||
export type HitObject = {
|
||||
x: number;
|
||||
y: number;
|
||||
time: number;
|
||||
type: number;
|
||||
hitSound: number;
|
||||
objectParams: string;
|
||||
hitSample: string;
|
||||
};
|
||||
|
||||
export function parseHitObjects(beatmap: string): any[] {
|
||||
const lines = beatmap.split('\n');
|
||||
let recording = false;
|
||||
const hitObjects = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '[HitObjects]') {
|
||||
recording = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!recording) continue;
|
||||
|
||||
if (line.startsWith('[')) break;
|
||||
|
||||
const parts = line.split(',');
|
||||
if (parts.length < 5) continue;
|
||||
|
||||
const type = parseInt(parts[3], 10);
|
||||
const hitObject = {
|
||||
x: parseInt(parts[0], 10),
|
||||
y: parseInt(parts[1], 10),
|
||||
time: parseInt(parts[2], 10),
|
||||
type: getTypeFromFlag(type),
|
||||
hitSound: parseInt(parts[4], 10),
|
||||
objectParams: parts[5],
|
||||
hitSample: parts.length > 6 ? parts[6] : '0:0:0:0:'
|
||||
};
|
||||
|
||||
hitObjects.push(hitObject);
|
||||
}
|
||||
|
||||
return hitObjects;
|
||||
}
|
||||
|
||||
function getTypeFromFlag(flag: number): HitObjectType | null {
|
||||
if (flag & HitObjectType.HitCircle) return HitObjectType.HitCircle;
|
||||
if (flag & HitObjectType.Slider) return HitObjectType.Slider;
|
||||
if (flag & HitObjectType.Spinner) return HitObjectType.Spinner;
|
||||
return null;
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import {KeyPress, ReplayEvent} from "./replay-viewer.component";
|
||||
import LZMA from 'lzma-web'
|
||||
|
||||
export async function getEvents(replayString: string): Promise<ReplayEvent[]> {
|
||||
const decompressedData = await decompressData(replayString);
|
||||
const replayDataStr = new TextDecoder("utf-8").decode(decompressedData);
|
||||
const trimmedReplayDataStr = replayDataStr.endsWith(',') ? replayDataStr.slice(0, -1) : replayDataStr;
|
||||
return processEvents(trimmedReplayDataStr);
|
||||
}
|
||||
|
||||
function processEvents(replayDataStr: string): ReplayEvent[] {
|
||||
const eventStrings = replayDataStr.split(",");
|
||||
const playData: ReplayEvent[] = [];
|
||||
eventStrings.forEach((eventStr, index) => {
|
||||
const event = createReplayEvent(eventStr.split('|'), index, eventStrings.length);
|
||||
if (event) playData.push(event);
|
||||
});
|
||||
return playData;
|
||||
}
|
||||
|
||||
function createReplayEvent(eventParts: string[], index: number, totalEvents: number): ReplayEvent | null {
|
||||
|
||||
const timeDelta = parseInt(eventParts[0], 10);
|
||||
const x = parseFloat(eventParts[1]);
|
||||
const y = parseFloat(eventParts[2]);
|
||||
const rawKey = parseInt(eventParts[3], 10);
|
||||
|
||||
if (timeDelta == -12345 && index == totalEvents - 1) {
|
||||
return null;
|
||||
}
|
||||
if (index < 2 && x == 256.0 && y == -500.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Safely cast the integer value to the KeyPress enum
|
||||
let keyPress = KeyPress[rawKey as unknown as keyof typeof KeyPress];
|
||||
|
||||
if (keyPress === undefined) {
|
||||
// TODO: Fix
|
||||
console.error("Unknown key press:", rawKey);
|
||||
keyPress = KeyPress.Smoke;
|
||||
}
|
||||
|
||||
return {
|
||||
timeDelta,
|
||||
x,
|
||||
y,
|
||||
key: keyPress
|
||||
};
|
||||
}
|
||||
|
||||
async function decompressData(base64Data: string): Promise<Uint8Array> {
|
||||
const lzma = new LZMA();
|
||||
|
||||
const binaryString = atob(base64Data);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const result = await lzma.decompress(bytes);
|
||||
|
||||
if (typeof result === 'string') {
|
||||
return new TextEncoder().encode(result);
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
import {KeyPress, ReplayEvent} from "./replay-viewer.component";
|
||||
|
||||
export class ReplayEventProcessed {
|
||||
x: number;
|
||||
y: number;
|
||||
t: number; // Time
|
||||
key: KeyPress;
|
||||
|
||||
constructor(x: number, y: number, t: number, key: KeyPress) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.t = t;
|
||||
this.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
export function processReplay(events: ReplayEvent[]): ReplayEventProcessed[] {
|
||||
if (events.length === 0) throw new Error("This replay's replay data was empty. It indicates a misbehaved replay.");
|
||||
|
||||
if (events[0].timeDelta === 0 && events.length > 1) events.shift();
|
||||
|
||||
const pEvents: ReplayEventProcessed[] = [];
|
||||
|
||||
let cumulativeTimeDelta = events[0].timeDelta;
|
||||
let highestTimeDelta = Number.NEGATIVE_INFINITY;
|
||||
let lastPositiveFrame: ReplayEvent | null = null;
|
||||
|
||||
let wasInNegativeSection = false;
|
||||
const lastPositiveFrameData: [number, [number, number]][] = [];
|
||||
|
||||
events.slice(1).forEach((currentFrame, index) => {
|
||||
const previousCumulativeTime = cumulativeTimeDelta;
|
||||
cumulativeTimeDelta += currentFrame.timeDelta;
|
||||
|
||||
highestTimeDelta = Math.max(highestTimeDelta, cumulativeTimeDelta);
|
||||
|
||||
const isInNegativeSection = cumulativeTimeDelta < highestTimeDelta;
|
||||
if (isInNegativeSection) {
|
||||
if (!wasInNegativeSection) {
|
||||
lastPositiveFrame = index > 0 ? events[index] : null;
|
||||
}
|
||||
} else {
|
||||
if (wasInNegativeSection && lastPositiveFrame) {
|
||||
const lastPositiveTime = lastPositiveFrameData.length > 0 ? lastPositiveFrameData[lastPositiveFrameData.length - 1][0] : previousCumulativeTime;
|
||||
const ratio = (lastPositiveTime - previousCumulativeTime) / (cumulativeTimeDelta - previousCumulativeTime);
|
||||
|
||||
const interpolatedX = lastPositiveFrame.x + ratio * (currentFrame.x - lastPositiveFrame.x);
|
||||
const interpolatedY = lastPositiveFrame.y + ratio * (currentFrame.y - lastPositiveFrame.y);
|
||||
|
||||
pEvents.push(new ReplayEventProcessed(interpolatedX, interpolatedY, lastPositiveTime, lastPositiveFrame.key));
|
||||
}
|
||||
wasInNegativeSection = false;
|
||||
}
|
||||
|
||||
wasInNegativeSection = isInNegativeSection;
|
||||
|
||||
if (!isInNegativeSection) {
|
||||
pEvents.push(new ReplayEventProcessed(currentFrame.x, currentFrame.y, cumulativeTimeDelta, currentFrame.key));
|
||||
}
|
||||
|
||||
if (!isInNegativeSection) {
|
||||
lastPositiveFrameData.push([cumulativeTimeDelta, [currentFrame.x, currentFrame.y]]);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensuring uniqueness based on time to avoid duplicates
|
||||
return pEvents.filter((event, index, self) =>
|
||||
index === self.findIndex((t) => (
|
||||
t.t === event.t
|
||||
))
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,213 @@
|
||||
import { ElementRef, Injectable } from '@angular/core';
|
||||
import {HitObject, HitObjectType} from "./decode-beatmap";
|
||||
import {ReplayEventProcessed} from "./process-replay";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ReplayService {
|
||||
|
||||
private hitObjects: HitObject[] = [];
|
||||
private replayEvents: ReplayEventProcessed[] = [];
|
||||
|
||||
currentTime = 0;
|
||||
private totalDuration = 0;
|
||||
private lastRenderTime = 0;
|
||||
|
||||
private isPlaying = false;
|
||||
private requestId: number | null = null;
|
||||
|
||||
private replayCanvas: ElementRef<HTMLCanvasElement> | null = null;
|
||||
private ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
setCanvasElement(canvas: ElementRef<HTMLCanvasElement>) {
|
||||
this.replayCanvas = canvas;
|
||||
}
|
||||
|
||||
setCanvasContext(ctx: CanvasRenderingContext2D) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
setHitObjects(hitObjects: HitObject[]) {
|
||||
this.hitObjects = hitObjects;
|
||||
console.log('Hit objects:', this.hitObjects);
|
||||
}
|
||||
|
||||
setEvents(events: ReplayEventProcessed[]) {
|
||||
this.replayEvents = events;
|
||||
this.calculateTotalDuration();
|
||||
console.log('Replay events:', this.replayEvents);
|
||||
}
|
||||
|
||||
private calculateTotalDuration() {
|
||||
if (this.replayEvents.length === 0) {
|
||||
this.totalDuration = 0;
|
||||
return;
|
||||
}
|
||||
const lastEvent = this.replayEvents[this.replayEvents.length - 1];
|
||||
this.totalDuration = lastEvent.t;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.isPlaying = true;
|
||||
if(this.currentTime >= this.totalDuration) {
|
||||
this.currentTime = 0;
|
||||
}
|
||||
this.animate();
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.isPlaying = false;
|
||||
if (this.requestId) {
|
||||
cancelAnimationFrame(this.requestId);
|
||||
this.requestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
seek(time: number) {
|
||||
this.currentTime = Math.min(Math.max(time, 0), this.totalDuration);
|
||||
this.lastRenderTime = 0;
|
||||
this.isPlaying = true;
|
||||
this.animate();
|
||||
this.isPlaying = false;
|
||||
console.log('Seeking to:', this.currentTime);
|
||||
}
|
||||
|
||||
private animate(currentTimestamp: number = 0) {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
if (!this.lastRenderTime) {
|
||||
this.lastRenderTime = currentTimestamp;
|
||||
}
|
||||
|
||||
const elapsedTime = currentTimestamp - this.lastRenderTime;
|
||||
|
||||
// Check if enough time has passed for the next frame (approximately 16.67ms for 60 FPS)
|
||||
if (elapsedTime < 16.67) {
|
||||
// Request the next animation frame and return early
|
||||
this.requestId = requestAnimationFrame(this.animate.bind(this));
|
||||
return;
|
||||
}
|
||||
|
||||
// Assuming elapsedTime is sufficient for 1 frame, update currentTime for real-time playback
|
||||
this.currentTime += elapsedTime;
|
||||
|
||||
if (this.currentTime > this.totalDuration) {
|
||||
this.currentTime = this.totalDuration;
|
||||
this.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
this.drawCurrentEventPosition();
|
||||
this.drawHitCircles();
|
||||
this.drawSliders();
|
||||
|
||||
this.lastRenderTime = currentTimestamp;
|
||||
|
||||
// Request the next frame
|
||||
this.requestId = requestAnimationFrame(this.animate.bind(this));
|
||||
}
|
||||
|
||||
public getCurrentReplayEvent(): ReplayEventProcessed | null {
|
||||
let currentEvent: ReplayEventProcessed | null = null;
|
||||
for (const event of this.replayEvents) {
|
||||
if (event.t <= this.currentTime) {
|
||||
currentEvent = event;
|
||||
} else {
|
||||
break; // Exit the loop once an event exceeds the current time
|
||||
}
|
||||
}
|
||||
return currentEvent;
|
||||
}
|
||||
|
||||
private drawCurrentEventPosition() {
|
||||
const currentEvent = this.getCurrentReplayEvent();
|
||||
if (currentEvent) {
|
||||
this.updateCanvas(currentEvent.x, currentEvent.y);
|
||||
}
|
||||
}
|
||||
|
||||
private drawHitCircles() {
|
||||
if (!this.ctx || !this.replayCanvas) {
|
||||
console.error('Canvas context not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleHitCircles = this.hitObjects.filter(obj =>
|
||||
obj.type === HitObjectType.HitCircle &&
|
||||
this.currentTime >= obj.time - 200 && // Start showing 200ms before
|
||||
this.currentTime <= obj.time // Hide after its time
|
||||
);
|
||||
|
||||
visibleHitCircles.forEach(hitCircle => {
|
||||
const opacity = this.calculateOpacity(hitCircle.time);
|
||||
this.drawHitCircle(hitCircle.x, hitCircle.y, opacity);
|
||||
});
|
||||
}
|
||||
|
||||
private drawSliders() {
|
||||
if (!this.ctx || !this.replayCanvas) {
|
||||
console.error('Canvas context not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleSliders = this.hitObjects.filter(obj =>
|
||||
obj.type === HitObjectType.Slider &&
|
||||
this.currentTime >= obj.time - 200 && // Start showing 200ms before
|
||||
this.currentTime <= obj.time // Hide after its time
|
||||
);
|
||||
|
||||
visibleSliders.forEach(slider => {
|
||||
const opacity = this.calculateOpacity(slider.time);
|
||||
this.drawSlider(slider, opacity);
|
||||
});
|
||||
}
|
||||
|
||||
private drawSlider(slider: HitObject, opacity: number) {
|
||||
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
|
||||
this.ctx!.beginPath();
|
||||
this.ctx!.arc(slider.x, slider.y, 25, 0, 2 * Math.PI); // Assuming a radius of 5px
|
||||
this.ctx!.fill();
|
||||
}
|
||||
|
||||
private calculateOpacity(circleTime: number): number {
|
||||
const timeDifference = circleTime - this.currentTime;
|
||||
if (timeDifference < 0) {
|
||||
return 0; // Circle time has passed
|
||||
}
|
||||
|
||||
// Calculate fade-in effect (0 to 1 over 200ms)
|
||||
return Math.min(1, (200 - timeDifference) / 200);
|
||||
}
|
||||
|
||||
private drawHitCircle(x: number, y: number, opacity: number) {
|
||||
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
|
||||
this.ctx!.beginPath();
|
||||
this.ctx!.arc(x, y, 25, 0, 2 * Math.PI); // Assuming a radius of 50px
|
||||
this.ctx!.fill();
|
||||
}
|
||||
|
||||
private updateCanvas(x: number, y: number) {
|
||||
if (!this.ctx || !this.replayCanvas) {
|
||||
console.error('Canvas context not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
this.ctx.clearRect(0, 0, this.replayCanvas.nativeElement.width, this.replayCanvas.nativeElement.height);
|
||||
this.ctx.fillStyle = '#FFFFFF';
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(x, y, 5, 0, 2 * Math.PI);
|
||||
this.ctx.fill();
|
||||
}
|
||||
|
||||
getTotalDuration() {
|
||||
return this.totalDuration;
|
||||
}
|
||||
|
||||
getIsPlaying() {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
<canvas #replayCanvas width="600" height="400"></canvas>
|
||||
<div>Current Time: {{ replayService.currentTime | number: '1.0-0' }}</div>
|
||||
<div>Total Duration: {{ replayService.getTotalDuration() | number: '1.0-0' }}</div>
|
||||
<button (click)="togglePlayPause()">{{ replayService.getIsPlaying() ? 'Pause' : 'Play' }}</button>
|
||||
<input type="range" min="0" [max]="replayService.getTotalDuration()" [(ngModel)]="replayService.currentTime" (input)="seek(replayService.currentTime)">
|
||||
@ -0,0 +1,91 @@
|
||||
import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core';
|
||||
import {getEvents} from "./decode-replay";
|
||||
import {DecimalPipe, JsonPipe} from "@angular/common";
|
||||
import {beatmapData, replayData} from "./sample-replay";
|
||||
import {ReplayService} from "./replay-service";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {parseHitObjects} from "./decode-beatmap";
|
||||
import {processReplay} from "./process-replay";
|
||||
|
||||
export enum KeyPress {
|
||||
M1 = 1,
|
||||
M2 = 2,
|
||||
K1 = 5,
|
||||
K2 = 10,
|
||||
Smoke = 16,
|
||||
}
|
||||
|
||||
export interface ReplayEvent {
|
||||
/**
|
||||
* Time in milliseconds since the previous action
|
||||
*/
|
||||
timeDelta: number;
|
||||
|
||||
/**
|
||||
* x-coordinate of the cursor from 0 - 512
|
||||
*/
|
||||
x: number;
|
||||
|
||||
/**
|
||||
* y-coordinate of the cursor from 0 - 384
|
||||
*/
|
||||
y: number;
|
||||
|
||||
/**
|
||||
* Key being pressed.
|
||||
*/
|
||||
key: KeyPress;
|
||||
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-replay-viewer',
|
||||
standalone: true,
|
||||
imports: [
|
||||
JsonPipe,
|
||||
FormsModule,
|
||||
DecimalPipe
|
||||
],
|
||||
templateUrl: './replay-viewer.component.html',
|
||||
styleUrl: './replay-viewer.component.css'
|
||||
})
|
||||
export class ReplayViewerComponent implements OnInit, AfterViewInit {
|
||||
|
||||
@ViewChild('replayCanvas') replayCanvas!: ElementRef<HTMLCanvasElement>;
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
|
||||
constructor(public replayService: ReplayService) { }
|
||||
|
||||
ngOnInit() {
|
||||
// Assume getEvents() method returns a promise of ReplayEvent[]
|
||||
getEvents(replayData).then(events => {
|
||||
this.replayService.setEvents(processReplay(events));
|
||||
this.replayService.setHitObjects(parseHitObjects(beatmapData))
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.ctx = this.replayCanvas.nativeElement.getContext('2d')!;
|
||||
|
||||
this.replayService.setCanvasElement(this.replayCanvas);
|
||||
this.replayService.setCanvasContext(this.ctx);
|
||||
|
||||
this.replayService.start(); // Start the animation loop
|
||||
}
|
||||
|
||||
togglePlayPause() {
|
||||
if (this.replayService.getIsPlaying()) {
|
||||
this.replayService.pause();
|
||||
} else {
|
||||
this.replayService.start();
|
||||
}
|
||||
}
|
||||
|
||||
seek(time: number) {
|
||||
this.replayService.seek(time);
|
||||
if (!this.replayService.getIsPlaying()) {
|
||||
// Redraw the canvas for the new current time without resuming playback
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user