Show the zoom of the music tabs and the EQ as a transition (#377)
The zoom transition of #309 left out the Pattern, Song and Sound tabs
and the Parametric EQ's curve. There a wheel step or Fit still jumped,
whatever "Zoom (ms)" in the general settings said.
- axisTween wraps ViewTween for a single axis. The music tabs zoom
their time across and their rows, tracks or height down apart, so
each keeps one tween per axis and either goes its own way.
- Fit by its button is shown as a transition too, as in the other
tabs. The fit on opening a document still jumps.
- A middle-button pan first lands a transition under way, and saving
a document's view lands it on the step it was heading for.
- The EQ's range is saved on the node at once. While the zoom is on
its way the curve draws a range of its own and writes nothing.
Its dB lines are those of the new range, sliding to their place,
so no line shows a crooked number.
Found on the way, not part of #377:
- CI task 611 for 82e96e6 failed the stream check of e2e 34. The
renderer had grown by 60.1 MB with nothing decoded. The check
summed the RSS of every Chrome renderer, and the test Chrome runs
four of them. It now measures the largest one, the tab's own, and
writes all of them into the check's detail.
Tests:
- view-tween.test.ts: 18 tests, 2 new. They fail when axisTween
writes the wrong offset.
- e2e 34, 64, 65 on melektaus-atx: ALL PASSED, 202 checks, 4 new.
At 1000 ms each zoom is between its two steps a frame after the
wheel and lands on the step a jump reaches.
- Stream check: the tab's renderer grew by 8.6 MB streamed and by
66.9 MB decoded.
- Gate on melektaus-atx: CHECK EXIT 0, 280 files, 4321 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82e96e680f
commit
23922ee4a2
13 files changed
+323
-40
No files matched your search
@@ -126,6 +126,8 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
picked: () => editor.eqEditor.picked,
|
||||
/** The dB the curve is zoomed to, above and below 0 (#376). */
|
||||
range: () => editor.eqEditor.range(),
|
||||
/** The dB the curve draws right now, and whether a zoom is on its way (#377). */
|
||||
zoomShown: () => editor.eqEditor.curve.zoomShown,
|
||||
handleAt: (index: number) => editor.eqEditor.curve.handleGlobal(index),
|
||||
pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db),
|
||||
fields: () => {
|
||||
|
||||
@@ -24,6 +24,8 @@ export function soundHooks({ state, editor }: HookContext) {
|
||||
level: () => soundEditor.canvas.levelZoom,
|
||||
scale: () => soundEditor.canvas.scaleCentre(),
|
||||
fullScaleTop: () => soundEditor.canvas.fullScaleTop(),
|
||||
/** The time zoom, and whether a zoom is on its way (#377). */
|
||||
zoom: () => soundEditor.canvas.zoomInfo(),
|
||||
fitButton: () => centreOf(soundEditor.fitButton),
|
||||
cursor: () => soundEditor.canvas.cursor,
|
||||
playhead: () => soundEditor.canvas.playhead,
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
zoomTime,
|
||||
} from '../music/timeView';
|
||||
import type { EditorState } from '../state';
|
||||
import { axisTween } from '../viewTween';
|
||||
import {
|
||||
clampShift,
|
||||
draggedLength,
|
||||
@@ -176,6 +177,17 @@ export class PianoRoll extends Widget {
|
||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||
private readonly laneLabel = crispText({ text: 'Velocity', style: textStyle('small') });
|
||||
private view: RollView = { panX: 0, pxPerTick: 0.1, panY: 0, rowHeight: DEFAULT_ROW_HEIGHT };
|
||||
/** A zoom or a Fit shown as a transition (#377), the time and the rows each on their own. */
|
||||
private readonly timeTween = axisTween(
|
||||
() => ({ scale: this.view.pxPerTick, offset: this.view.panX }),
|
||||
({ scale, offset }) => (this.view = { ...this.view, pxPerTick: scale, panX: offset }),
|
||||
() => this.redraw(),
|
||||
);
|
||||
private readonly rowTween = axisTween(
|
||||
() => ({ scale: this.view.rowHeight, offset: this.view.panY }),
|
||||
({ scale, offset }) => (this.view = { ...this.view, rowHeight: scale, panY: offset }),
|
||||
() => this.redraw(),
|
||||
);
|
||||
private needsFit = true;
|
||||
private assetId = '';
|
||||
private gesture: Gesture | null = null;
|
||||
@@ -268,6 +280,8 @@ export class PianoRoll extends Widget {
|
||||
}
|
||||
|
||||
saveView(): RollView {
|
||||
this.timeTween.land();
|
||||
this.rowTween.land();
|
||||
return { ...this.view };
|
||||
}
|
||||
|
||||
@@ -277,8 +291,13 @@ export class PianoRoll extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** The whole pattern across, and its notes – or what the instrument plays – down the middle. */
|
||||
/** Fit by hand, shown as a transition. */
|
||||
fit(): void {
|
||||
this.timeTween.change(() => this.rowTween.change(() => this.frame()));
|
||||
}
|
||||
|
||||
/** The whole pattern across, and its notes – or what the instrument plays – down the middle. */
|
||||
private frame(): void {
|
||||
const asset = this.asset();
|
||||
const width = this.gridWidth;
|
||||
const height = this.gridHeight;
|
||||
@@ -484,10 +503,12 @@ export class PianoRoll extends Widget {
|
||||
keys: number | null;
|
||||
hover: NoteHit | null;
|
||||
cursor: string;
|
||||
moving: boolean;
|
||||
} {
|
||||
const g = this.gesture;
|
||||
return {
|
||||
...this.view,
|
||||
moving: this.timeTween.moving || this.rowTween.moving,
|
||||
start: this.startTick,
|
||||
playhead: this.playhead,
|
||||
loop: this.shownLoop(),
|
||||
@@ -545,6 +566,8 @@ export class PianoRoll extends Widget {
|
||||
e.stopPropagation();
|
||||
ui().focus.focus(this);
|
||||
if (e.button === 1) {
|
||||
this.timeTween.finish();
|
||||
this.rowTween.finish();
|
||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||
return;
|
||||
}
|
||||
@@ -900,14 +923,16 @@ export class PianoRoll extends Widget {
|
||||
const area = this.areaAt({ x: local.x, y: local.y });
|
||||
const g = this.inGrid({ x: local.x, y: local.y });
|
||||
const factor = zoomFactor(e.deltaY);
|
||||
let view: RollView;
|
||||
if (area === 'keys') view = zoomRows(this.view, factor, g.y);
|
||||
else if (area === 'ruler' || area === 'grid' || area === 'lane')
|
||||
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
||||
else return;
|
||||
this.view = clampRows(clampTimePan(view, this.gridWidth, this.earliest), this.gridHeight);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
const rows = area === 'keys';
|
||||
if (!rows && area !== 'ruler' && area !== 'grid' && area !== 'lane') return;
|
||||
(rows ? this.rowTween : this.timeTween).change(() => {
|
||||
const view = rows
|
||||
? zoomRows(this.view, factor, g.y)
|
||||
: { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
||||
this.view = clampRows(clampTimePan(view, this.gridWidth, this.earliest), this.gridHeight);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -951,12 +976,12 @@ export class PianoRoll extends Widget {
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
if (this.needsFit) this.fit();
|
||||
if (this.needsFit) this.frame();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
if (this.needsFit && this.gridWidth > 0 && this.gridHeight > 0 && this.asset()) {
|
||||
this.fit();
|
||||
this.frame();
|
||||
return;
|
||||
}
|
||||
const c = Theme.colors;
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
} from '../music/timeView';
|
||||
import { snapDown, snapNear } from '../pattern/rollEdit';
|
||||
import type { EditorState } from '../state';
|
||||
import { axisTween } from '../viewTween';
|
||||
import { drawMeter } from './meterDraw';
|
||||
import {
|
||||
gainToDb,
|
||||
@@ -240,6 +241,17 @@ export class SongTimeline extends Widget {
|
||||
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
|
||||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||||
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
|
||||
/** A zoom or a Fit shown as a transition (#377), the time and the tracks each on their own. */
|
||||
private readonly timeTween = axisTween(
|
||||
() => ({ scale: this.view.pxPerTick, offset: this.view.panX }),
|
||||
({ scale, offset }) => (this.view = { ...this.view, pxPerTick: scale, panX: offset }),
|
||||
() => this.redraw(),
|
||||
);
|
||||
private readonly trackTween = axisTween(
|
||||
() => ({ scale: this.view.trackHeight, offset: this.view.panY }),
|
||||
({ scale, offset }) => (this.view = { ...this.view, trackHeight: scale, panY: offset }),
|
||||
() => this.redraw(),
|
||||
);
|
||||
private needsFit = true;
|
||||
private assetId = '';
|
||||
private gesture: Gesture | null = null;
|
||||
@@ -340,6 +352,8 @@ export class SongTimeline extends Widget {
|
||||
}
|
||||
|
||||
saveView(): SongView {
|
||||
this.timeTween.land();
|
||||
this.trackTween.land();
|
||||
return { ...this.view };
|
||||
}
|
||||
|
||||
@@ -349,8 +363,13 @@ export class SongTimeline extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** The whole song across and all its tracks down, with some room around. */
|
||||
/** Fit by hand, shown as a transition. */
|
||||
fit(): void {
|
||||
this.timeTween.change(() => this.trackTween.change(() => this.frame()));
|
||||
}
|
||||
|
||||
/** The whole song across and all its tracks down, with some room around. */
|
||||
private frame(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) {
|
||||
this.needsFit = true;
|
||||
@@ -660,9 +679,11 @@ export class SongTimeline extends Widget {
|
||||
hover: { id: string; part: ItemPart } | null;
|
||||
cursor: string;
|
||||
take: (Omit<TakeView, 'peaks'> & { peaks: number }) | null;
|
||||
moving: boolean;
|
||||
} {
|
||||
return {
|
||||
...this.view,
|
||||
moving: this.timeTween.moving || this.trackTween.moving,
|
||||
start: this.startTick,
|
||||
playhead: this.playhead,
|
||||
loop: this.shownLoop(),
|
||||
@@ -735,6 +756,8 @@ export class SongTimeline extends Widget {
|
||||
e.stopPropagation();
|
||||
ui().focus.focus(this);
|
||||
if (e.button === 1) {
|
||||
this.timeTween.finish();
|
||||
this.trackTween.finish();
|
||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||
return;
|
||||
}
|
||||
@@ -1263,14 +1286,16 @@ export class SongTimeline extends Widget {
|
||||
const area = this.areaAt({ x: local.x, y: local.y });
|
||||
const g = this.inLanes({ x: local.x, y: local.y });
|
||||
const factor = zoomFactor(e.deltaY);
|
||||
let view: SongView;
|
||||
if (area === 'heads') view = zoomTracks(this.view, factor, g.y);
|
||||
else if (area === 'ruler' || area === 'lanes')
|
||||
view = { ...this.view, ...zoomTime(this.view, factor, g.x, SONG_MAX_PX_PER_TICK) };
|
||||
else return;
|
||||
this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
const tracks = area === 'heads';
|
||||
if (!tracks && area !== 'ruler' && area !== 'lanes') return;
|
||||
(tracks ? this.trackTween : this.timeTween).change(() => {
|
||||
const view = tracks
|
||||
? zoomTracks(this.view, factor, g.y)
|
||||
: { ...this.view, ...zoomTime(this.view, factor, g.x, SONG_MAX_PX_PER_TICK) };
|
||||
this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -1374,12 +1399,12 @@ export class SongTimeline extends Widget {
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
if (this.needsFit) this.fit();
|
||||
if (this.needsFit) this.frame();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) {
|
||||
this.fit();
|
||||
this.frame();
|
||||
return;
|
||||
}
|
||||
const c = Theme.colors;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { cutOf, END_KEY, START_KEY } from '../../core/properties/sounds';
|
||||
import { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '../../ui';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import type { EditorState } from '../state';
|
||||
import { axisTween } from '../viewTween';
|
||||
import {
|
||||
clampWave,
|
||||
type ClipBand,
|
||||
@@ -145,6 +146,17 @@ export class WaveCanvas extends Widget {
|
||||
private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 };
|
||||
/** How many times its full height the wave is drawn. */
|
||||
private level = 1;
|
||||
/** A zoom or a Fit shown as a transition (#377), the time and the height each on their own. */
|
||||
private readonly timeTween = axisTween(
|
||||
() => ({ scale: this.view.pxPerSecond, offset: this.view.panX }),
|
||||
({ scale, offset }) => (this.view = { pxPerSecond: scale, panX: offset }),
|
||||
() => this.redraw(),
|
||||
);
|
||||
private readonly levelTween = axisTween(
|
||||
() => ({ scale: this.level, offset: 0 }),
|
||||
({ scale }) => (this.level = scale),
|
||||
() => this.redraw(),
|
||||
);
|
||||
private needsFit = true;
|
||||
private assetId = '';
|
||||
private gesture: Gesture | null = null;
|
||||
@@ -192,6 +204,8 @@ export class WaveCanvas extends Widget {
|
||||
}
|
||||
|
||||
saveView(): WaveView & { level: number } {
|
||||
this.timeTween.land();
|
||||
this.levelTween.land();
|
||||
return { ...this.view, level: this.level };
|
||||
}
|
||||
|
||||
@@ -204,8 +218,13 @@ export class WaveCanvas extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** The whole file across, at its full height. */
|
||||
/** Fit by hand, shown as a transition. */
|
||||
fit(): void {
|
||||
this.timeTween.change(() => this.levelTween.change(() => this.frame()));
|
||||
}
|
||||
|
||||
/** The whole file across, at its full height. */
|
||||
private frame(): void {
|
||||
const wave = this.wave();
|
||||
if (!wave || this.areaWidth <= 0) {
|
||||
this.needsFit = true;
|
||||
@@ -222,6 +241,11 @@ export class WaveCanvas extends Widget {
|
||||
return this.level;
|
||||
}
|
||||
|
||||
/** The zoom across and down, and whether a transition is on its way (test hook). */
|
||||
zoomInfo(): WaveView & { level: number; moving: boolean } {
|
||||
return { ...this.view, level: this.level, moving: this.timeTween.moving || this.levelTween.moving };
|
||||
}
|
||||
|
||||
/** The middle of the level scale on screen (test hook). */
|
||||
scaleCentre(): Vec2 {
|
||||
const origin = this.getGlobalPosition();
|
||||
@@ -353,6 +377,7 @@ export class WaveCanvas extends Widget {
|
||||
e.stopPropagation();
|
||||
ui().focus.focus(this);
|
||||
if (e.button === 1) {
|
||||
this.timeTween.finish();
|
||||
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
|
||||
return;
|
||||
}
|
||||
@@ -453,10 +478,14 @@ export class WaveCanvas extends Widget {
|
||||
const wave = this.wave();
|
||||
if (!wave) return;
|
||||
const p = this.areaPoint(e);
|
||||
if (p.x < 0) this.level = zoomLevel(this.level, zoomFactor(e.deltaY));
|
||||
else this.view = clampWave(zoomWave(this.view, zoomFactor(e.deltaY), p.x), wave.duration, this.areaWidth);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
const level = p.x < 0;
|
||||
(level ? this.levelTween : this.timeTween).change(() => {
|
||||
if (level) this.level = zoomLevel(this.level, zoomFactor(e.deltaY));
|
||||
else
|
||||
this.view = clampWave(zoomWave(this.view, zoomFactor(e.deltaY), p.x), wave.duration, this.areaWidth);
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -468,12 +497,12 @@ export class WaveCanvas extends Widget {
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
if (this.needsFit) this.fit();
|
||||
if (this.needsFit) this.frame();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const wave = this.wave();
|
||||
if (this.needsFit && wave && this.areaWidth > 0) return this.fit();
|
||||
if (this.needsFit && wave && this.areaWidth > 0) return this.frame();
|
||||
const c = Theme.colors;
|
||||
const { h } = this;
|
||||
const w = this.areaWidth;
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { FramePreview } from '../framePreview';
|
||||
import type { EditorState } from '../state';
|
||||
import { TabHeader } from '../TabHeader';
|
||||
import { ViewTween } from '../viewTween';
|
||||
import {
|
||||
bandAt,
|
||||
EQ_GRID_FREQUENCIES,
|
||||
@@ -110,6 +111,16 @@ class EqCurve extends Widget {
|
||||
private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null;
|
||||
/** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */
|
||||
private heard: Spectrum | null = null;
|
||||
/** The dB drawn while a zoom is on its way (#377); null draws the node's. */
|
||||
private shownRange: number | null = null;
|
||||
private readonly tween = new ViewTween({
|
||||
read: () => ({ scale: 1 / this.range, x: 0, y: 0 }),
|
||||
write: (view) => (this.shownRange = 1 / view.scale),
|
||||
draw: () => {
|
||||
if (!this.tween.moving) this.shownRange = null;
|
||||
this.redraw();
|
||||
},
|
||||
});
|
||||
/** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */
|
||||
seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 };
|
||||
|
||||
@@ -139,7 +150,22 @@ class EqCurve extends Widget {
|
||||
}
|
||||
|
||||
get area(): EqArea {
|
||||
return { w: this.w, h: this.h, range: this.host.range() };
|
||||
return { w: this.w, h: this.h, range: this.range };
|
||||
}
|
||||
|
||||
private get range(): number {
|
||||
return this.shownRange ?? this.host.range();
|
||||
}
|
||||
|
||||
/** The dB drawn and whether a zoom is on its way – the check scripts sample it. */
|
||||
get zoomShown(): { range: number; moving: boolean } {
|
||||
return { range: this.range, moving: this.tween.moving };
|
||||
}
|
||||
|
||||
/** Another node: a zoom on its way to the one before ends. */
|
||||
settle(): void {
|
||||
this.tween.land();
|
||||
this.shownRange = null;
|
||||
}
|
||||
|
||||
/** Where a band's handle is on screen – the check scripts aim with it. */
|
||||
@@ -224,7 +250,11 @@ class EqCurve extends Widget {
|
||||
const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS);
|
||||
const band = bands[index];
|
||||
if (!band) {
|
||||
this.host.zoom(wheeledRange(this.host.range(), e.deltaY));
|
||||
const range = wheeledRange(this.host.range(), e.deltaY);
|
||||
this.tween.change(() => {
|
||||
this.shownRange = null;
|
||||
this.host.zoom(range);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasQ(band.type)) return;
|
||||
@@ -245,12 +275,13 @@ class EqCurve extends Widget {
|
||||
const text = this.labels[label++]!;
|
||||
text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2);
|
||||
}
|
||||
const dbs = eqGridDb(area.range);
|
||||
// The lines of the range a zoom is heading to, so they slide to their place with whole numbers.
|
||||
const dbs = eqGridDb(this.host.range());
|
||||
this.dbLabels.forEach((text, i) => {
|
||||
const db = dbs[i];
|
||||
text.visible = db !== undefined;
|
||||
if (db === undefined) return;
|
||||
const y = Math.round(dbToY(db, area)) + 0.5;
|
||||
const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5;
|
||||
text.visible = y > 0 && y < this.h;
|
||||
if (!text.visible || db === undefined) return;
|
||||
this.grid.moveTo(0, y).lineTo(this.w, y);
|
||||
text.text = db > 0 ? `+${db}` : `${db}`;
|
||||
text.position.set(3, y - text.height - 1);
|
||||
@@ -429,6 +460,7 @@ export class EqEditor extends VStack implements EqHost {
|
||||
/** Points the tab at a node; the band picked stays when it is the same node again. */
|
||||
show(assetId: string, nodeId: string): void {
|
||||
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
||||
this.curve.settle();
|
||||
this.perFrame.cancel();
|
||||
this.curve.showSpectrum(null);
|
||||
this.before = null;
|
||||
|
||||
@@ -154,3 +154,31 @@ export class ViewTween {
|
||||
this.clock.remove(this.tick);
|
||||
}
|
||||
}
|
||||
|
||||
/** One axis of a view whose axes zoom apart (#377): `scale` pixels per unit, unit 0 at `offset`. */
|
||||
export interface AxisMap {
|
||||
scale: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** A tween along one axis; the music tabs keep one per axis, so either zooms on its own. */
|
||||
export function axisTween(
|
||||
read: () => AxisMap,
|
||||
write: (axis: AxisMap) => void,
|
||||
draw: () => void,
|
||||
duration?: () => number,
|
||||
clock?: TweenClock,
|
||||
): ViewTween {
|
||||
return new ViewTween(
|
||||
{
|
||||
read: () => {
|
||||
const { scale, offset } = read();
|
||||
return { scale, x: offset, y: 0 };
|
||||
},
|
||||
write: (view) => write({ scale: view.scale, offset: view.x }),
|
||||
draw,
|
||||
},
|
||||
duration,
|
||||
clock,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user