8 Commits

Author SHA1 Message Date
tkrmagid
6abc7f9475 v0.4.27: don't join decoder worker on close — fix place-then-delete freeze
Some checks failed
build / build (push) Has been cancelled
stopWorker() ran a 2 s bounded join on the client tick thread. When a
user runs /videoPlace and immediately /videoDelete, the decoder worker
is still inside native grabber.start() doing the initial HTTP probe
(probesize=8 MB, analyzeduration=2 s); that call doesn't honor the
running flag and Thread.interrupt() doesn't unblock native I/O, so the
join blocked the tick thread for as long as start() took — exactly the
brief in-game freeze the user reported.

Signal stop (running=false, audio line stop+flush, worker.interrupt())
and return. The worker is a daemon, the audio tail is already silenced,
the Entry has been removed from the map, and the worker's finally still
closes the grabber whenever start()/grab() eventually returns — nothing
observable depends on the grabber being closed synchronously before
close() returns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 03:41:03 +09:00
tkrmagid
e2c63fde7c v0.4.26: keep legacy 5-arg /videoPlace alongside new volume form
Some checks failed
build / build (push) Has been cancelled
Reviewer flagged v0.4.25 broke existing command blocks by inserting
'volume' as a required arg between height and url. Split into two
Brigadier branches: legacy 5-arg defaults volume=50, muted=false; new
6-arg keeps the -1=mute shortcut. Legacy branch uses a single-token url
arg so '50 https://...' parses as the new (int) branch, not as a legacy
url starting with a digit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 03:36:59 +09:00
tkrmagid
5722c299d3 v0.4.25: add volume argument to /videoPlace, -1 mutes
Some checks failed
build / build (push) Has been cancelled
New signature:
  /videoPlace <pos> <facing> <width> <height> <volume> <url>

volume is int -1..100. 0..100 sets percent and clears mute; -1 is a CLI
shortcut that sets muted=true (underlying volume kept at 0.5 so a later
/videoMute false restores audible level without re-typing). Matches the
percent scale shown in the config GUI.
2026-05-17 03:31:13 +09:00
tkrmagid
ecd254cb78 v0.4.24: sync anchor config in vanilla BE update packet
Some checks failed
build / build (push) Has been cancelled
Bug: /videoPlace (and any path that places an anchor) reported success
on the server but the panel was invisible on the client — no quad, no
video. Walking around did not help.

Root cause: VideoAnchorBlockEntity did not override getUpdateTag() or
getUpdatePacket(). The base implementations return an empty CompoundTag
and null respectively, so url/facing/width/height were never carried in
vanilla BE sync. There is also a packet-ordering race between
level.setBlock() (queues a deferred chunk broadcast) and
ServerPlayNetworking.send(SyncAnchorPayload) (writes immediately): if
the payload arrives first, the client drops it because the BE does not
exist yet, then the chunk packet creates the BE with defaults
(url="") and the renderer silently no-ops.

Fix: override getUpdateTag(HolderLookup.Provider) → toNbt(), and
getUpdatePacket() → ClientboundBlockEntityDataPacket.create(this). NBT
key names already line up between toNbt() and loadAdditional(), so
vanilla wraps the CompoundTag in TagValueInput and existing load logic
reads it. Also fixes the 'walk far away, come back' case — that path
has no SyncAnchorPayload, just vanilla chunk re-sync.
2026-05-17 03:21:25 +09:00
tkrmagid
d559c0c56a v0.4.23: fix wall z-fight and texture discard at distance
Some checks failed
build / build (push) Has been cancelled
Reported: with render_distance_blocks=256, the panel started shimmering
and the wall texture bled through at ~30 blocks away. Both issues are
inherent distance-rendering bugs that were previously hidden by the
default ~64-block view distance.

Two fixes in VideoAnchorRenderer:
1. SURFACE_EPSILON 0.001 → 0.02. With 24-bit depth and near=0.05, the
   smallest resolvable depth step at 30 blocks is ~1mm, so the old 1mm
   offset was right at the z-fight boundary. 2cm gives ~20× margin at
   30 blocks and remains visually unnoticeable up close.
2. RenderType entityCutout → entitySolid. swscale outputs RGBA with
   alpha=255, so there is no real cutout. Cutout's alpha-discard step
   makes distant sampling unstable on a dynamic non-mipmapped texture;
   solid removes that and is the semantically correct type.
2026-05-17 00:04:48 +09:00
tkrmagid
05aace294e v0.4.22: route video audio through Players sound category
Some checks failed
build / build (push) Has been cancelled
Previously the per-anchor audio gain was just volume × distance attenuation,
which ignored Minecraft's sound options sliders entirely — Master and Players
both had no effect on video audio. Now updateDistanceGains multiplies in
options.getSoundSourceVolume(MASTER) × getSoundSourceVolume(PLAYERS) to
match vanilla SoundEngine.calculateVolume, so the Players slider attenuates
video audio like other player sounds and Master gates everything.

Recomputed at the same 20Hz tick as distance gain — slider drags take
effect within ~50ms with no extra plumbing.
2026-05-16 23:46:07 +09:00
tkrmagid
41c7c48825 v0.4.21: extract publishIfNotCancelled, close main download publish race
Some checks failed
build / build (push) Has been cancelled
Reviewer-flagged: v0.4.20 fixed the reindex branch's post-put race, but
the main download completion path still did READY.put without a follow-up
epoch check. /videoCache clear landing between READY.put and the chat
notification could leave a resurrected READY entry pointing at a deleted
file, plus a stale "완료" message.

Extracted publishIfNotCancelled(url, path, startEpoch) helper that does
the full pre-check → put → post-check → rollback dance, and routed both
publish sites (reindex branch + download-complete branch) through it.
Centralizing this is the structural fix: future publish sites can't forget
the post-check.
2026-05-16 22:57:52 +09:00
tkrmagid
1913181d02 v0.4.20: close reindex publish race + harden preload guard
Some checks failed
build / build (push) Has been cancelled
Reviewer-flagged: the existing-cache reindex branch in VideoCache.download
only ran one epoch check before READY.put, so a /videoCache clear landing
between the check and the put could leave a stale entry pointing at a
now-deleted file. Same pattern as the post-move fix in v0.4.19, applied
to the reindex path: pre-check, put, post-check + rollback on mismatch.

Also: preload() previously gated on READY.containsKey(url), which silently
blocks a re-preload if READY holds a stale key whose backing file is gone
(e.g. user deleted the file manually, or the cleanup half of a clear race).
Switched to lookup(url) — same intent, but lookup verifies the file
actually exists on disk, so stale keys self-heal on the next preload.
2026-05-16 22:54:20 +09:00
7 changed files with 175 additions and 42 deletions

View File

@@ -5,7 +5,7 @@ org.gradle.configuration-cache=false
# Mod # Mod
mod_id=video_player mod_id=video_player
mod_version=0.4.19 mod_version=0.4.27
maven_group=com.ejclaw.videoplayer maven_group=com.ejclaw.videoplayer
archives_base_name=video_player archives_base_name=video_player

View File

@@ -20,6 +20,7 @@ import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer; import net.minecraft.client.player.LocalPlayer;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionResult; import net.minecraft.world.InteractionResult;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
@@ -89,17 +90,26 @@ public class VideoPlayerClient implements ClientModInitializer {
* Distance is measured from the player's eye to the <em>panel center</em>, not the anchor * Distance is measured from the player's eye to the <em>panel center</em>, not the anchor
* block corner — for a 4×4 panel the corner is ~2 blocks off from where the screen visually * block corner — for a 4×4 panel the corner is ~2 blocks off from where the screen visually
* sits, which made the audio feel like it was off to the side. * sits, which made the audio feel like it was off to the side.
*
* <p>Gain is also gated by the Minecraft sound options so the in-game sliders work as
* expected: vanilla {@code SoundEngine.calculateVolume} multiplies by master × category, so
* we do the same with {@link SoundSource#PLAYERS} as the category. Result: dragging the
* "Players" slider in Options → Music & Sounds attenuates video audio just like other
* player sounds, and "Master" still gates everything.
*/ */
private static void updateDistanceGains(Minecraft client) { private static void updateDistanceGains(Minecraft client) {
LocalPlayer p = client.player; LocalPlayer p = client.player;
if (p == null || client.level == null) return; if (p == null || client.level == null) return;
Vec3 eye = p.getEyePosition(); Vec3 eye = p.getEyePosition();
float masterVol = client.options.getSoundSourceVolume(SoundSource.MASTER);
float playersVol = client.options.getSoundSourceVolume(SoundSource.PLAYERS);
float categoryScale = masterVol * playersVol;
for (BlockPos pos : VideoPlayback.activePositions()) { for (BlockPos pos : VideoPlayback.activePositions()) {
if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue; if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
Vec3 center = be.panelCenter(); Vec3 center = be.panelCenter();
double d = center.distanceTo(eye); double d = center.distanceTo(eye);
float attenuation = (float) Math.max(0.0, Math.min(1.0, 1.0 - d / 16.0)); float attenuation = (float) Math.max(0.0, Math.min(1.0, 1.0 - d / 16.0));
float gain = be.isMuted() ? 0F : be.getVolume() * attenuation; float gain = be.isMuted() ? 0F : be.getVolume() * attenuation * categoryScale;
VideoPlayback.setGain(pos, gain); VideoPlayback.setGain(pos, gain);
} }
} }

View File

@@ -3,7 +3,11 @@ package com.ejclaw.videoplayer.block;
import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities; import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput; import net.minecraft.world.level.storage.ValueInput;
@@ -123,6 +127,35 @@ public class VideoAnchorBlockEntity extends BlockEntity {
out.putBoolean("autoplay", autoplay); out.putBoolean("autoplay", autoplay);
} }
/**
* Vanilla chunk-load BE sync. The base implementation returns an empty tag, which means
* when a client first sees this BE (chunk loads or player walks into range) it gets default
* values — url="" in particular makes the renderer no-op and the panel appears invisible.
*
* <p>Returning {@link #toNbt()} here carries the custom fields in the vanilla packet, so
* we don't depend on the {@code SyncAnchorPayload} arriving before the chunk's block-update
* packet (there's a race: {@code level.setBlock} queues a deferred chunk broadcast while
* {@code ServerPlayNetworking.send} writes immediately; if the payload wins, the client
* drops it because the BE doesn't exist yet, then the chunk packet creates the BE with
* defaults). It also fixes "player walks far away and comes back" — that path has no
* SyncAnchorPayload at all, just vanilla chunk re-sync.
*/
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider provider) {
return toNbt();
}
/**
* Triggers a {@link ClientboundBlockEntityDataPacket} whenever the chunk tracker decides
* this BE needs to push an update. Default implementation returns {@code null} (no packet
* sent on BE change). Combined with {@link #getUpdateTag} above, every BE-state change a
* client sees carries the full config.
*/
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override @Override
protected void loadAdditional(ValueInput in) { protected void loadAdditional(ValueInput in) {
super.loadAdditional(in); super.loadAdditional(in);

View File

@@ -180,27 +180,24 @@ public class JavaCvBackend implements VideoBackend {
// stale srcAddr — closing the grabber there frees the av_frame plane and the next // stale srcAddr — closing the grabber there frees the av_frame plane and the next
// memcpy crashes inside StubRoutines::jbyte_disjoint_arraycopy (exactly the 4K-delete // memcpy crashes inside StubRoutines::jbyte_disjoint_arraycopy (exactly the 4K-delete
// crash dump we saw). So the safe rule is: only the decoder thread touches the // crash dump we saw). So the safe rule is: only the decoder thread touches the
// grabber. External stop signals `running=false`, stops the audio line, interrupts the // grabber. External stop signals `running=false`, stops the audio line, interrupts
// worker, and joins briefly; the worker's own `finally` calls grabber.close(). Inside // the worker, and returns immediately; the worker's own `finally` calls
// the loop, grab() unblocks via the rw_timeout/timeout options (3 s, set in runLoop) // grabber.close() whenever grab()/start() eventually returns.
// even on a stuck HTTP read, so the join below normally returns within a frame. //
// We deliberately do NOT join the worker. close() runs on the client tick thread (via
// VideoPlayback.tick → Entry.close), and the worker can spend several seconds inside
// the native FFmpeg probe at the top of runLoop — probesize=8 MB and
// analyzeduration=2 s do not honor the `running` flag and Thread.interrupt() doesn't
// unblock native I/O. A bounded join() there (the old 2 s) is exactly the "place then
// immediately delete freezes the game for a moment" symptom: the worker hasn't
// entered the grab() loop yet, so flipping running=false has no effect on it until
// start() returns. The worker is a daemon, the audio line is already silenced above,
// and Entry has been removed from the active map by the caller — nothing observable
// depends on the grabber having been closed before this method returns.
Thread t = worker; Thread t = worker;
worker = null; worker = null;
if (t != null) { if (t != null) {
t.interrupt(); t.interrupt();
try {
t.join(2000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
if (t.isAlive()) {
// Worker still blocked in native grab() — let it finish on its own. Its
// finally still closes the grabber when grab() eventually returns / throws.
// No native pointers leak in the meantime because we don't touch them here.
VideoPlayerMod.LOG.warn(
"[{}] decoder did not exit within 2 s of stop; orphaning until next grab() returns",
VideoPlayerMod.MOD_ID);
}
} }
ready = false; ready = false;
} }

View File

@@ -190,7 +190,11 @@ public final class VideoCache {
public static void preload(String url) { public static void preload(String url) {
if (url == null || url.isEmpty()) return; if (url == null || url.isEmpty()) return;
if (!(url.startsWith("http://") || url.startsWith("https://"))) return; if (!(url.startsWith("http://") || url.startsWith("https://"))) return;
if (READY.containsKey(url)) { // Use lookup() (which also verifies the file exists on disk) instead of
// READY.containsKey — defends against READY containing a stale entry whose
// backing file was removed by clearAll() / a user-side file delete. Without this,
// a stale key would silently block a re-preload.
if (lookup(url) != null) {
VideoPlayerMod.LOG.info("[{}] preload: already cached {}", VideoPlayerMod.MOD_ID, url); VideoPlayerMod.LOG.info("[{}] preload: already cached {}", VideoPlayerMod.MOD_ID, url);
notifyChat("[videopreload] 이미 캐시됨: " + url, ChatFormatting.GRAY); notifyChat("[videopreload] 이미 캐시됨: " + url, ChatFormatting.GRAY);
return; return;
@@ -229,6 +233,33 @@ public final class VideoCache {
// -- internals ----------------------------------------------------------------------- // -- internals -----------------------------------------------------------------------
/**
* Publish {@code path} into {@link #READY} for {@code url}, but only if the cache
* epoch hasn't moved since {@code startEpoch} was captured. Wraps the
* pre-check / put / post-check / rollback dance so every publish site uses the
* same race-free pattern instead of re-implementing it.
*
* <p>Race coverage: if {@link #clearAll()} runs at any point between the
* pre-check and the post-check, the post-check sees the epoch bump and we
* roll back — {@link Map#remove(Object, Object)} is a compareAndRemove so we
* don't clobber a legitimate concurrent put under the same key, and we delete
* the on-disk file because clearAll's directory scan may have missed it (the
* file may not have existed yet when clearAll ran).
*
* @return {@code true} if the entry is now published; {@code false} if the
* caller's work was cancelled and {@code path} has been cleaned up.
*/
private static boolean publishIfNotCancelled(String url, Path path, long startEpoch) {
if (CACHE_EPOCH.get() != startEpoch) return false;
READY.put(url, path);
if (CACHE_EPOCH.get() != startEpoch) {
READY.remove(url, path);
try { Files.deleteIfExists(path); } catch (Throwable ignored) {}
return false;
}
return true;
}
private static void download(String url, long startEpoch) { private static void download(String url, long startEpoch) {
Path cacheDir = cacheDir(); Path cacheDir = cacheDir();
Path partPath = null; Path partPath = null;
@@ -255,8 +286,11 @@ public final class VideoCache {
// Resume-friendly: if the file's already on disk from an earlier session, just // Resume-friendly: if the file's already on disk from an earlier session, just
// index it without re-downloading. // index it without re-downloading.
if (Files.exists(finalPath) && Files.size(finalPath) > 0) { if (Files.exists(finalPath) && Files.size(finalPath) > 0) {
if (CACHE_EPOCH.get() != startEpoch) return; if (!publishIfNotCancelled(url, finalPath, startEpoch)) {
READY.put(url, finalPath); VideoPlayerMod.LOG.info("[{}] preload: reindex cancelled (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
VideoPlayerMod.LOG.info("[{}] preload: indexed existing cache {} -> {}", VideoPlayerMod.LOG.info("[{}] preload: indexed existing cache {} -> {}",
VideoPlayerMod.MOD_ID, url, finalPath.getFileName()); VideoPlayerMod.MOD_ID, url, finalPath.getFileName());
notifyChat("[videopreload] 기존 캐시 사용: " + url, ChatFormatting.GREEN); notifyChat("[videopreload] 기존 캐시 사용: " + url, ChatFormatting.GREEN);
@@ -352,21 +386,16 @@ public final class VideoCache {
Files.move(partPath, finalPath, StandardCopyOption.REPLACE_EXISTING, Files.move(partPath, finalPath, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE); StandardCopyOption.ATOMIC_MOVE);
// Post-move cancellation check. clearAll() may have run between the pre-move // Race-safe publish: this covers the window between Files.move and READY.put
// check above and the move itself — in that case clearAll's directory scan // (clearAll deletes the freshly-moved file then we resurrect it in READY) AND
// either missed our file (it didn't exist yet) or saw the .part and skipped / // the window between READY.put and the "완료" notification (clearAll wipes
// failed to delete it. Either way, finalPath now exists on disk but the // READY then we re-publish + emit stale completion chat). publishIfNotCancelled
// user-visible cache state is "cleared", so we must delete the file and skip // rolls back both READY and the on-disk file if the epoch moved at any time.
// both the READY.put and the "완료" chat. Without this check, a clear right at if (!publishIfNotCancelled(url, finalPath, startEpoch)) {
// this window leaves a resurrected file in READY and a stale "완료" message. VideoPlayerMod.LOG.info("[{}] preload: cancelled at publish (clearAll ran) — {}",
if (CACHE_EPOCH.get() != startEpoch) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled after move (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url); VideoPlayerMod.MOD_ID, url);
try { Files.deleteIfExists(finalPath); } catch (Throwable ignored) {}
return; return;
} }
READY.put(url, finalPath);
VideoPlayerMod.LOG.info("[{}] preload: cached {} ({} bytes) -> {}", VideoPlayerMod.LOG.info("[{}] preload: cached {} ({} bytes) -> {}",
VideoPlayerMod.MOD_ID, url, total, finalPath.getFileName()); VideoPlayerMod.MOD_ID, url, total, finalPath.getFileName());
long mb = Math.max(1, total / (1024 * 1024)); long mb = Math.max(1, total / (1024 * 1024));

View File

@@ -31,8 +31,19 @@ import org.joml.Matrix4f;
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> { public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> {
/** Tiny outward offset so the quad doesn't z-fight with the wall. */ /**
private static final float SURFACE_EPSILON = 0.001F; * Outward offset so the quad doesn't z-fight with the wall it sits on.
*
* <p>Depth-buffer precision drops with the square of view distance: with near=0.05 and
* 24-bit depth, the smallest resolvable step at 30 blocks is ~1mm, and ~12mm at 100
* blocks. The old 0.001 (1mm) offset was right at the precision boundary at ~30 blocks
* — which is exactly the distance users started seeing the wall texture flicker through
* the video panel once {@code render_distance_blocks} was raised past the default.
*
* <p>2cm gives ~20× margin at 30 blocks and is still visually unnoticeable up close
* (~3% of a block thickness).
*/
private static final float SURFACE_EPSILON = 0.02F;
public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) { public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) {
// no-op // no-op
@@ -75,7 +86,12 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
pose.translate(-0.5F, -0.5F, -0.5F + SURFACE_EPSILON); pose.translate(-0.5F, -0.5F, -0.5F + SURFACE_EPSILON);
final Matrix4f mat = new Matrix4f(pose.last().pose()); final Matrix4f mat = new Matrix4f(pose.last().pose());
RenderType rt = RenderTypes.entityCutout(tex); // entitySolid (not entityCutout): video frames come from swscale → AV_PIX_FMT_RGBA with
// alpha hard-set to 255, so there is no alpha-tested cutout. Cutout's alpha-discard step
// adds nothing here and makes distant sampling unstable — without mipmaps on a dynamic
// texture, neighbouring texels can shimmer above/below the discard threshold at sub-pixel
// sampling rates, contributing to the flicker users see once render distance is raised.
RenderType rt = RenderTypes.entitySolid(tex);
collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> { collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> {
// Single-sided: the back of the anchor is by design pressed against the wall the // Single-sided: the back of the anchor is by design pressed against the wall the
// player clicked, so a back face is pure GPU waste. Halves the fragment shader work // player clicked, so a back face is pure GPU waste. Halves the fragment shader work

View File

@@ -22,10 +22,30 @@ import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions; import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
/** SPEC §4.5.1 — {@code /videoPlace <pos> <facing> <width> <height> <url>} */ /**
* SPEC §4.5.1 — {@code /videoPlace} has two accepted forms:
*
* <ul>
* <li><b>Legacy 5-arg</b>: {@code /videoPlace <pos> <facing> <width> <height> <url>}
* — preserved for existing command blocks. Volume defaults to 50% and muted=false.
* The {@code url} here is a single-token string so it doesn't swallow the {@code volume}
* slot of the new form.</li>
* <li><b>New 6-arg</b>: {@code /videoPlace <pos> <facing> <width> <height> <volume> <url>}
* — {@code volume} is 0..100 (percent) or {@code -1} to start muted. The percent form
* mirrors the GUI slider; {@code -1} is a CLI shortcut so admins don't need a follow-up
* {@code /videoMute} step. {@code url} is greedy here, so URLs containing spaces (rare
* but possible after URL-decoding) still work.</li>
* </ul>
*
* <p>Brigadier disambiguates the two by the type of the 5th argument: integer → new form,
* non-integer token → legacy form.
*/
public final class VideoPlaceCommand { public final class VideoPlaceCommand {
private VideoPlaceCommand() {} private VideoPlaceCommand() {}
/** Default volume (percent) applied to the legacy 5-arg form. */
private static final int DEFAULT_VOLUME_PCT = 50;
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) { public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoPlace")); dispatcher.register(build("videoPlace"));
} }
@@ -38,12 +58,33 @@ public final class VideoPlaceCommand {
.then(Commands.argument("facing", StringArgumentType.word()) .then(Commands.argument("facing", StringArgumentType.word())
.then(Commands.argument("width", IntegerArgumentType.integer(1, 32)) .then(Commands.argument("width", IntegerArgumentType.integer(1, 32))
.then(Commands.argument("height", IntegerArgumentType.integer(1, 32)) .then(Commands.argument("height", IntegerArgumentType.integer(1, 32))
// New form: volume (int) + greedy url
.then(Commands.argument("volume", IntegerArgumentType.integer(-1, 100))
.then(Commands.argument("url", StringArgumentType.greedyString()) .then(Commands.argument("url", StringArgumentType.greedyString())
.executes(VideoPlaceCommand::run)))))); .executes(ctx -> runNew(ctx))))
// Legacy form: single-token url, no volume slot. Single-token string
// is intentional so "<int> https://..." cannot be parsed as a legacy
// url that happens to start with a number — Brigadier first tries the
// new branch and only falls through here if "volume" isn't an int.
.then(Commands.argument("url", StringArgumentType.string())
.executes(ctx -> runLegacy(ctx)))))));
} }
private static int run(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx) private static int runNew(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException { throws CommandSyntaxException {
int volumeArg = IntegerArgumentType.getInteger(ctx, "volume");
String url = StringArgumentType.getString(ctx, "url");
return runWithValues(ctx, volumeArg, url);
}
private static int runLegacy(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
String url = StringArgumentType.getString(ctx, "url");
return runWithValues(ctx, DEFAULT_VOLUME_PCT, url);
}
private static int runWithValues(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx,
int volumeArg, String rawUrl) throws CommandSyntaxException {
CommandSourceStack src = ctx.getSource(); CommandSourceStack src = ctx.getSource();
ServerLevel level = src.getLevel(); ServerLevel level = src.getLevel();
BlockPos pos = BlockPosArgument.getLoadedBlockPos(ctx, "pos"); BlockPos pos = BlockPosArgument.getLoadedBlockPos(ctx, "pos");
@@ -54,7 +95,12 @@ public final class VideoPlaceCommand {
} }
int width = IntegerArgumentType.getInteger(ctx, "width"); int width = IntegerArgumentType.getInteger(ctx, "width");
int height = IntegerArgumentType.getInteger(ctx, "height"); int height = IntegerArgumentType.getInteger(ctx, "height");
String raw = StringArgumentType.getString(ctx, "url").trim(); // -1 is the CLI mute shortcut; the BE keeps the underlying volume so an admin can
// /videoMute false later without re-typing a level. Anything 0..100 sets %-volume and
// clears mute.
boolean placeMuted = volumeArg < 0;
float placeVolume = placeMuted ? 0.5F : (volumeArg / 100.0F);
String raw = rawUrl == null ? "" : rawUrl.trim();
// Accept either an http(s) URL or a /videoCache add <name> entry: resolveUrlOrName() // Accept either an http(s) URL or a /videoCache add <name> entry: resolveUrlOrName()
// returns the canonical URL in both cases, or null when a non-URL string didn't match // returns the canonical URL in both cases, or null when a non-URL string didn't match
// any named entry. // any named entry.
@@ -75,6 +121,8 @@ public final class VideoPlaceCommand {
be.setWidth(width); be.setWidth(width);
be.setHeight(height); be.setHeight(height);
be.setUrl(url); be.setUrl(url);
be.setVolume(placeVolume);
be.setMuted(placeMuted);
CompoundTag nbt = be.toNbt(); CompoundTag nbt = be.toNbt();
for (ServerPlayer p : PlayerLookup.tracking(level, pos)) { for (ServerPlayer p : PlayerLookup.tracking(level, pos)) {