Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/player-web-marquee-long-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@be-music/player-web': patch
---

Long song titles, artists, genres, and stat values in the built-in skins no longer get squeezed until they are unreadable: text is narrowed to at most 85% width, and anything still too long is clipped to its box and scrolls through it like a marquee (rests at the start, scrolls, rests at the end, then returns), including on screens that are not redrawn every frame. Synesthesia, Lattice, and Phantom share this through their Pixi helpers, with unfocused list rows clipped at the start instead of scrolling; Plain does the same with its own Canvas 2D code.

Also fixes text that ran outside its panel: the genre line on Synesthesia's result screen, and the BPM, gimmick, and other stat values (plus the list row facts and folder label) on Plain's screens.
30 changes: 26 additions & 4 deletions packages/player-web/src/skins/lattice/draw.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Container, Graphics } from 'pixi.js';
import { Container, type Graphics } from 'pixi.js';
import {
bendNeedle,
digitTransitions,
Expand All @@ -12,6 +12,8 @@ import {
addHudText,
addSkinText,
type ChildPool,
resolveTextSqueeze,
startMarquee,
type HudTextOptions,
pointLayerFor,
type SkinTextOptions,
Expand Down Expand Up @@ -247,7 +249,8 @@ export function displayStyle(

/**
* Non-pooled twin of {@link addStaggeredHudText} for scenes rebuilt per render (select / result). A word wider than
* `options.maxWidth` is squeezed as a whole, letters and advances alike.
* `options.maxWidth` is squeezed as a whole, letters and advances alike, down to `MIN_TEXT_SQUEEZE`; past that the
* letters are grouped, clipped to the box, and scrolled as one marquee.
*/
export function addStaggeredSkinText(
layer: Container,
Expand All @@ -261,8 +264,12 @@ export function addStaggeredSkinText(
const nodes = chars.map((char) => addSkinText(layer, char, 0, y, { ...options, anchorX: 0, maxWidth: undefined }));
const widths = nodes.map((node) => node.width);
const total = widths.reduce((sum, width) => sum + width, 0);
const squeeze = options.maxWidth !== undefined && total > options.maxWidth ? options.maxWidth / total : 1;
let cursor = x - total * squeeze * (options.anchorX ?? 0);
const maxWidth = options.maxWidth;
const squeeze = maxWidth !== undefined ? resolveTextSqueeze(total, maxWidth) : 1;
const overflow = maxWidth !== undefined ? total * squeeze - maxWidth : 0;
// An overflowing word moves as one group inside its clip box; the letters are laid out from the group's left edge.
const group = overflow > 0.5 ? new Container() : undefined;
let cursor = group ? 0 : x - total * squeeze * (options.anchorX ?? 0);
const baseAlpha = options.alpha ?? 1;
nodes.forEach((node, index) => {
const move = motion(index, nodes.length);
Expand All @@ -273,5 +280,20 @@ export function addStaggeredSkinText(
node.scale.set(scale * squeeze, scale);
node.alpha = baseAlpha * (move.alpha ?? 1);
cursor += advance;
group?.addChild(node);
});
if (group && maxWidth !== undefined) {
layer.addChild(group);
const height = (options.size ?? 10) * 1.5;
const middle = y + (0.5 - (options.anchorY ?? 0)) * height;
const left = x - maxWidth * (options.anchorX ?? 0);
startMarquee(
group,
layer,
{ left, top: middle - height * 1.5, width: maxWidth, height: height * 3 },
overflow,
`${Math.round(maxWidth)}|${text}`,
options.marquee,
);
}
}
4 changes: 4 additions & 0 deletions packages/player-web/src/skins/lattice/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ class LatticeSelectRenderer implements PixiSelectRenderer {
anchorY: 0.5,
alpha,
maxWidth: facts ? ROW_BPM_W : 70,
marquee: active,
},
);
metaNode.label = `fallback-meta[idx=${entryIndex}]`;
Expand Down Expand Up @@ -547,6 +548,8 @@ class LatticeSelectRenderer implements PixiSelectRenderer {
anchorY: 0.5,
alpha,
maxWidth: Math.max(24, factsLeft - x - 62),
// Only the focused row scrolls a long title; the others clip it at its start.
marquee: active,
},
);
title.label = `fallback-title[idx=${entryIndex}]`;
Expand All @@ -562,6 +565,7 @@ class LatticeSelectRenderer implements PixiSelectRenderer {
anchorY: 0.5,
alpha,
maxWidth: factsLeft - artistX,
marquee: active,
});
}
return introT < 1 || titleScramble < 1;
Expand Down
22 changes: 15 additions & 7 deletions packages/player-web/src/skins/phantom/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ import {
rollUpValue,
stageProgress,
} from '@be-music/skin-sdk';
import { alignCapCenter, type PixiResultFrame, type PixiResultSkin, resolveSkinTextStyle } from '../pixi-kit/index.ts';
import {
alignCapCenter,
fitTextToWidth,
type PixiResultFrame,
type PixiResultSkin,
resolveSkinTextStyle,
} from '../pixi-kit/index.ts';

/** How fast the entrance timeline plays: the choreography below is authored at 1x and runs 1.3x, so the card settles
* sooner without changing its order or easing. */
Expand Down Expand Up @@ -121,10 +127,14 @@ export function renderPhantomResult(frame: PixiResultFrame): void {
alignCapCenter(node, options.fontFamily ?? DEFAULT_TEXT_FONT, options.weight ?? '500', options.size ?? 10);
node.position.set(x, y);
node.skew.set(options.skew ?? 0, 0);
if (options.maxWidth !== undefined && node.width > options.maxWidth) {
node.scale.x = options.maxWidth / node.width;
}
target.addChild(node);
if (options.maxWidth !== undefined) {
fitTextToWidth(node, target, {
maxWidth: options.maxWidth,
anchorX: options.anchorX ?? 0,
anchorY: options.anchorY ?? 0,
});
}
return node;
};
/** Slide a group in from an offset with an ease-out, fading as it lands. */
Expand Down Expand Up @@ -556,10 +566,8 @@ function renderMetric(
valueText.anchor.set(1, 0.5);
alignCapCenter(valueText, DEFAULT_DISPLAY_FONT, '400', 22);
valueText.position.set(x + METRIC_W - 8, y + METRIC_H / 2 + 1);
if (valueText.width > 100) {
valueText.scale.x = 100 / valueText.width;
}
target.addChild(valueText);
fitTextToWidth(valueText, target, { maxWidth: 100, anchorX: 1, anchorY: 0.5 });
}

/** Polyline of `points` inside the rect, drawn up to `progress` (0..1) of the horizontal span. */
Expand Down
4 changes: 4 additions & 0 deletions packages/player-web/src/skins/phantom/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@ class PhantomSelectRenderer implements PixiSelectRenderer {
anchorX: 1,
anchorY: 0.5,
maxWidth: facts ? ROW_BPM_W : 64,
marquee: active,
});
meta.label = `fallback-meta[idx=${entryIndex}]`;
let factsLeft = rowX + rowW - 12 - (facts ? ROW_BPM_W : metaText ? Math.min(64, meta.width) : 0) - 10;
Expand Down Expand Up @@ -685,6 +686,8 @@ class PhantomSelectRenderer implements PixiSelectRenderer {
fill: active ? PHANTOM_INK : PHANTOM_WHITE,
anchorY: 0.5,
maxWidth: titleMax,
// Only the focused row scrolls a long title; the others clip it at its start.
marquee: active,
});
title.label = `fallback-title[idx=${entryIndex}]`;
// The artist trails the title in a quieter weight while there is room for it.
Expand All @@ -697,6 +700,7 @@ class PhantomSelectRenderer implements PixiSelectRenderer {
fill: active ? PHANTOM_RED : PHANTOM_ASH,
anchorY: 0.5,
maxWidth: factsLeft - artistX,
marquee: active,
});
}
return slide > 0 || intro > 0;
Expand Down
24 changes: 17 additions & 7 deletions packages/player-web/src/skins/pixi-kit/hud-text.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CanvasTextMetrics, type Container, Text, TextStyle } from 'pixi.js';
import { DEFAULT_TEXT_FONT, layoutTabularRun } from '@be-music/skin-sdk';
import { fitTextToWidth, stopMarquee } from './marquee.ts';
import type { ChildPool } from './pools.ts';
import { textResolution } from './resolution.ts';
import { alignCapCenter } from './text-metrics.ts';
Expand All @@ -20,7 +21,10 @@ export interface HudTextOptions {
letterSpacing?: number;
anchorX?: number;
anchorY?: number;
/** Width to fit the text into (squeezed a little, then scrolled as a marquee; see `fitTextToWidth`). */
maxWidth?: number;
/** `false` clips an overflowing text at its start instead of scrolling it. */
marquee?: boolean;
/** Horizontal skew in radians (negative leans the glyph tops right, like italic). */
skewX?: number;
rotation?: number;
Expand Down Expand Up @@ -60,16 +64,22 @@ export function addHudText(
// Pooled texts keep their previous skew / rotation, so both are always written.
node.skew.set(opts.skewX ?? 0, 0);
node.rotation = opts.rotation ?? 0;
if (opts.maxWidth !== undefined && node.width > opts.maxWidth) {
node.scale.x = opts.maxWidth / node.width;
}
if (opts.scale !== undefined && opts.scale !== 1) {
node.scale.x *= opts.scale;
node.scale.y = opts.scale;
}
if (!pool) {
layer.addChild(node);
}
if (opts.maxWidth !== undefined) {
// Pooled nodes live in the pool's text host; the clip box joins them there.
fitTextToWidth(node, node.parent ?? layer, {
maxWidth: opts.maxWidth,
anchorX: opts.anchorX ?? 0,
anchorY: opts.anchorY ?? 0,
scale: opts.scale,
scroll: opts.marquee,
});
} else {
stopMarquee(node);
if (opts.scale !== undefined && opts.scale !== 1) node.scale.set(opts.scale, opts.scale);
}
return node;
}

Expand Down
1 change: 1 addition & 0 deletions packages/player-web/src/skins/pixi-kit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './types.ts';
export * from './pools.ts';
export * from './hud-text.ts';
export * from './skin-text.ts';
export * from './marquee.ts';
export * from './text-metrics.ts';
export * from './particle-layer.ts';
export * from './key-beam.ts';
Expand Down
92 changes: 92 additions & 0 deletions packages/player-web/src/skins/pixi-kit/marquee.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vite-plus/test';
import {
DEFAULT_MARQUEE_TIMING,
MIN_TEXT_SQUEEZE,
marqueeCycleMs,
resolveMarqueeAlpha,
resolveMarqueeOffset,
resolveTextSqueeze,
type MarqueeTiming,
} from './marquee.ts';

const timing: MarqueeTiming = { startHoldMs: 1000, speedPxPerSecond: 50, endHoldMs: 500, fadeMs: 200 };

describe('resolveTextSqueeze', () => {
it('leaves text that fits at full width', () => {
expect(resolveTextSqueeze(80, 100)).toBe(1);
expect(resolveTextSqueeze(100, 100)).toBe(1);
});

it('squeezes text that fits within the minimum squeeze', () => {
expect(resolveTextSqueeze(110, 100)).toBeCloseTo(100 / 110);
expect(resolveTextSqueeze(100 / MIN_TEXT_SQUEEZE, 100)).toBeCloseTo(MIN_TEXT_SQUEEZE);
});

it('stops at the minimum squeeze for text that still overflows', () => {
expect(resolveTextSqueeze(400, 100)).toBe(MIN_TEXT_SQUEEZE);
expect(resolveTextSqueeze(400, 100, 0.5)).toBe(0.5);
});

it('treats empty or degenerate widths as fitting', () => {
expect(resolveTextSqueeze(0, 100)).toBe(1);
expect(resolveTextSqueeze(Number.NaN, 100)).toBe(1);
expect(resolveTextSqueeze(50, 0)).toBe(MIN_TEXT_SQUEEZE);
});
});

describe('marqueeCycleMs', () => {
it('adds the holds to the scroll time', () => {
// 100 px at 50 px/s scrolls for 2 s.
expect(marqueeCycleMs(100, timing)).toBe(1000 + 2000 + 500);
});

it('is zero without overflow', () => {
expect(marqueeCycleMs(0, timing)).toBe(0);
expect(marqueeCycleMs(-5, timing)).toBe(0);
});
});

describe('resolveMarqueeOffset', () => {
it('holds at the start, scrolls at a constant speed, then holds at the end', () => {
expect(resolveMarqueeOffset(0, 100, timing)).toBe(0);
expect(resolveMarqueeOffset(999, 100, timing)).toBe(0);
expect(resolveMarqueeOffset(1500, 100, timing)).toBeCloseTo(25);
expect(resolveMarqueeOffset(2000, 100, timing)).toBeCloseTo(50);
expect(resolveMarqueeOffset(3000, 100, timing)).toBeCloseTo(100);
expect(resolveMarqueeOffset(3400, 100, timing)).toBe(100);
});

it('starts over after each cycle', () => {
expect(resolveMarqueeOffset(3500 + 500, 100, timing)).toBe(0);
expect(resolveMarqueeOffset(3500 * 2 + 1500, 100, timing)).toBeCloseTo(25);
});

it('never scrolls text that fits or before it appears', () => {
expect(resolveMarqueeOffset(2000, 0, timing)).toBe(0);
expect(resolveMarqueeOffset(-100, 100, timing)).toBe(0);
});

it('uses the default timing when none is given', () => {
const { startHoldMs, speedPxPerSecond } = DEFAULT_MARQUEE_TIMING;
expect(resolveMarqueeOffset(startHoldMs + 1000, 500)).toBeCloseTo(speedPxPerSecond);
});
});

describe('resolveMarqueeAlpha', () => {
it('shows the first appearance without a fade in', () => {
expect(resolveMarqueeAlpha(0, 100, timing)).toBe(1);
expect(resolveMarqueeAlpha(50, 100, timing)).toBe(1);
});

it('fades out at the end of the end hold and back in on the next cycle', () => {
expect(resolveMarqueeAlpha(3200, 100, timing)).toBe(1);
expect(resolveMarqueeAlpha(3400, 100, timing)).toBeCloseTo(0.5);
expect(resolveMarqueeAlpha(3500 + 100, 100, timing)).toBeCloseTo(0.5);
expect(resolveMarqueeAlpha(3500 + 200, 100, timing)).toBe(1);
});

it('stays opaque for text that fits or without a fade', () => {
expect(resolveMarqueeAlpha(3400, 0, timing)).toBe(1);
expect(resolveMarqueeAlpha(3400, 100, { ...timing, fadeMs: 0 })).toBe(1);
});
});
Loading