Compare commits

..

7 Commits

Author SHA1 Message Date
tkrmagid
e31635ef24 Fix black-screen video on unaligned widths (swscale row padding)
Some checks failed
build / build (push) Failing after 1m41s
swscale aligns the RGBA linesize to a SIMD boundary, so any video whose
width*4 isn't a multiple of the alignment (e.g. 1674-wide → 6720 B/row vs
6696) yields a padded plane larger than width*height*4. The old copy path
tripped a `need > expected → skip` guard and dropped every video frame,
leaving a black screen while audio (independent path) still played.

Read Frame.imageStride and strip the row padding with a row-by-row memCopy
into a tightly-packed slot (width*height*4), matching the texture's expected
size. Falls back to deriving the stride from buffer size when the field is
absent. Single-shot memCopy retained when stride == rowBytes (aligned widths).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-02 00:59:44 +09:00
tkrmagid
7364e010ac v0.4.32: route video audio to RECORDS slider + make sound category configurable
Some checks failed
build / build (push) Failing after 1m18s
Previously video audio gain was gated by the PLAYERS sound category, so the
"Players" slider controlled video volume. Video playback is media, so it now
defaults to the RECORDS ("Jukebox/Note Blocks" / 음반) slider instead.

Add a `sound_category` key to config/video_player.json (auto-augmented into
existing configs) so each client can pick which volume slider gates video
audio: master, music, record, weather, block, hostile, neutral, player,
ambient, voice, ui. Invalid values fall back to record with a warning. When
the category is master itself the gain is not squared.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:40:51 +09:00
tkrmagid
3f2d37587d v0.4.31: skip permission-level check for non-player sources
Some checks failed
build / build (push) Has been cancelled
The previous .requires gate used Permissions.COMMANDS_GAMEMASTER (level 2),
which is the right check for player sources but ties datapack /function
calls to the functionPermissionLevel gamerule. If admins kept that gamerule
below 2, datapack-driven /videoPlace etc. silently failed and required a
gamerule bump.

Extract a CommandPermissions.opOrServer helper used by all 5 /video*
commands. Players still need OP (level 2+); console, command block, and
datapack function sources bypass the level check entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 10:31:45 +09:00
tkrmagid
c55a9e4e05 v0.4.30: gate /videoStick behind OP (level 2) permission
Some checks failed
build / build (push) Has been cancelled
The other /video* commands already require Permissions.COMMANDS_GAMEMASTER
(level 2 — the standard OP threshold for cheats), but /videoStick was
missing the gate so any player could spawn a video stick item. Apply the
same requires() check used elsewhere so only OP players, the server
console, and command blocks can run it.
2026-05-20 10:19:43 +09:00
tkrmagid
b0c7532715 v0.4.29: delete promoted cache file on first epoch-mismatch in publishIfNotCancelled
Some checks failed
build / build (push) Has been cancelled
If wipeOnShutdown runs between download's pre-move epoch check and the
atomic Files.move, the wipe's directory scan misses the just-promoted
final file. The first epoch-mismatch branch in publishIfNotCancelled was
returning without deleting, leaking the file across sessions. Delete on
the first branch too.
2026-05-18 19:07:21 +09:00
tkrmagid
229f499465 v0.4.28: wipe video_player_cache on game shutdown
Some checks failed
build / build (push) Has been cancelled
Add ClientLifecycleEvents.CLIENT_STOPPING handler that deletes every
file under <gameDir>/video_player_cache/. Quieter sibling of clearAll()
- no chat notify and no concurrent-download race handling needed since
at shutdown no other code is touching the directory.

Cache repopulation on next launch is already handled: the server's
ServerPlayConnectionEvents.JOIN handler re-broadcasts a PreloadPayload
for every preload_urls and cache_entries entry on every join, so a
freshly wiped cache auto-refills from the server's named entries
without any user action.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:59:55 +09:00
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
11 changed files with 238 additions and 59 deletions

View File

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

View File

@@ -3,6 +3,7 @@ package com.ejclaw.videoplayer;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.client.MusicQuizClient;
import com.ejclaw.videoplayer.client.net.ClientNetworking;
import com.ejclaw.videoplayer.client.playback.VideoCache;
import com.ejclaw.videoplayer.client.playback.VideoPlayback;
import com.ejclaw.videoplayer.client.render.VideoAnchorRenderer;
import com.ejclaw.videoplayer.item.VideoStickItem;
@@ -12,6 +13,7 @@ import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientBlockEntityEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry;
@@ -82,6 +84,11 @@ public class VideoPlayerClient implements ClientModInitializer {
}
});
// Wipe video_player_cache/ on game exit so preloaded clips don't pile up across
// sessions. Cache entries are re-broadcast by the server on every JOIN, so a freshly
// started game will repopulate the cache automatically.
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> VideoCache.wipeOnShutdown());
VideoPlayerMod.LOG.info("[{}] client initialized", VideoPlayerMod.MOD_ID);
}
@@ -93,17 +100,22 @@ public class VideoPlayerClient implements ClientModInitializer {
*
* <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.
* we do the same. The category is configurable via {@code sound_category} in
* {@code config/video_player.json} (default {@link SoundSource#RECORDS} = the "Jukebox/Note
* Blocks" slider, since video playback is media). Master always gates on top; when the
* configured category is Master itself we don't square it. Result: dragging the configured
* category's slider in Options → Music &amp; Sounds attenuates video audio, and "Master"
* still gates everything.
*/
private static void updateDistanceGains(Minecraft client) {
LocalPlayer p = client.player;
if (p == null || client.level == null) return;
Vec3 eye = p.getEyePosition();
float masterVol = client.options.getSoundSourceVolume(SoundSource.MASTER);
float playersVol = client.options.getSoundSourceVolume(SoundSource.PLAYERS);
float categoryScale = masterVol * playersVol;
SoundSource category = VideoPlayerConfig.soundCategory();
float categoryScale = (category == SoundSource.MASTER)
? masterVol
: masterVol * client.options.getSoundSourceVolume(category);
for (BlockPos pos : VideoPlayback.activePositions()) {
if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
Vec3 center = be.panelCenter();

View File

@@ -7,6 +7,7 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.sounds.SoundSource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -62,10 +63,17 @@ public final class VideoPlayerConfig {
private static final int DEFAULT_MAX_CACHE_MB = 750;
/** Default render-distance cap for video anchors, in blocks. 128 = the legacy hard-coded value. */
private static final int DEFAULT_RENDER_DISTANCE = 128;
/**
* Default Minecraft sound category the client uses to gate video audio. RECORDS = the
* "Jukebox/Note Blocks" (음반) slider — video playback is media, so it belongs there rather
* than on the player-sound slider. Master always gates on top of whatever category is set.
*/
private static final SoundSource DEFAULT_SOUND_CATEGORY = SoundSource.RECORDS;
private static volatile int maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
private static volatile int maxCacheMb = DEFAULT_MAX_CACHE_MB;
private static volatile int renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
private static volatile SoundSource soundCategory = DEFAULT_SOUND_CATEGORY;
private static volatile List<String> preloadUrls = Collections.emptyList();
/** Insertion-ordered name → url. Mutated only under the class monitor. */
private static final Map<String, String> CACHE_ENTRIES = new LinkedHashMap<>();
@@ -80,6 +88,8 @@ public final class VideoPlayerConfig {
VideoPlayerMod.MOD_ID, path);
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
soundCategory = DEFAULT_SOUND_CATEGORY;
preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear();
return;
@@ -127,6 +137,23 @@ public final class VideoPlayerConfig {
if (rd > 2048) rd = 2048;
renderDistanceBlocks = rd;
// sound_category (client uses this to pick which volume slider gates video audio)
SoundSource cat = DEFAULT_SOUND_CATEGORY;
if (json.has("sound_category") && json.get("sound_category").isJsonPrimitive()
&& json.get("sound_category").getAsJsonPrimitive().isString()) {
SoundSource parsed = parseSoundSource(json.get("sound_category").getAsString());
if (parsed != null) {
cat = parsed;
} else {
VideoPlayerMod.LOG.warn("[{}] config: unknown sound_category '{}' — using '{}'",
VideoPlayerMod.MOD_ID, json.get("sound_category").getAsString(),
DEFAULT_SOUND_CATEGORY.getName());
}
} else {
augmented = true;
}
soundCategory = cat;
// preload_urls (legacy)
List<String> urls = new ArrayList<>();
if (json.has("preload_urls") && json.get("preload_urls").isJsonArray()) {
@@ -162,9 +189,9 @@ public final class VideoPlayerConfig {
VideoPlayerMod.LOG.info(
"[{}] config loaded: per-video={} MB, total-cache={} MB, render={} blocks, "
+ "preload_urls={}, cache_entries={}",
+ "sound_category={}, preload_urls={}, cache_entries={}",
VideoPlayerMod.MOD_ID, maxPreloadMb, maxCacheMb, renderDistanceBlocks,
urls.size(), CACHE_ENTRIES.size());
soundCategory.getName(), urls.size(), CACHE_ENTRIES.size());
// Auto-augment: rewrite the file once so missing keys appear after a mod update.
if (augmented) {
@@ -179,11 +206,23 @@ public final class VideoPlayerConfig {
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
soundCategory = DEFAULT_SOUND_CATEGORY;
preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear();
}
}
/** Match a config string against {@link SoundSource#getName()} (case-insensitive). Null = no match. */
private static SoundSource parseSoundSource(String s) {
if (s == null) return null;
String t = s.trim();
if (t.isEmpty()) return null;
for (SoundSource src : SoundSource.values()) {
if (src.getName().equalsIgnoreCase(t)) return src;
}
return null;
}
// -- accessors ---------------------------------------------------------------------------
/** Hard cap on a single client-side video download, in MB. */
@@ -201,6 +240,12 @@ public final class VideoPlayerConfig {
/** Anchor BE view-distance cap, in blocks. */
public static int renderDistanceBlocks() { return renderDistanceBlocks; }
/**
* Minecraft sound category the client gates video audio with (default {@link SoundSource#RECORDS}).
* Read client-side from the local config; not pushed from the server.
*/
public static SoundSource soundCategory() { return soundCategory; }
/** Legacy un-named preload list (still pushed at join). Never null. */
public static List<String> preloadUrls() { return preloadUrls; }
@@ -280,11 +325,15 @@ public final class VideoPlayerConfig {
"max_preload_mb: per-video download cap (each client). "
+ "max_cache_mb: total cache directory cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. "
+ "sound_category: which Minecraft volume slider gates video audio (client-side). "
+ "One of: master, music, record, weather, block, hostile, neutral, player, "
+ "ambient, voice, ui. Default 'record' = the Jukebox/Note Blocks slider. "
+ "preload_urls: HTTP(S) videos auto-pushed to every player on join (no name). "
+ "cache_entries: named entries managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", DEFAULT_MAX_PRELOAD_MB);
root.addProperty("max_cache_mb", DEFAULT_MAX_CACHE_MB);
root.addProperty("render_distance_blocks", DEFAULT_RENDER_DISTANCE);
root.addProperty("sound_category", DEFAULT_SOUND_CATEGORY.getName());
root.add("preload_urls", new JsonArray());
root.add("cache_entries", new JsonArray());
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@@ -301,11 +350,15 @@ public final class VideoPlayerConfig {
"max_preload_mb: per-video download cap (each client). "
+ "max_cache_mb: total cache directory cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. "
+ "sound_category: which Minecraft volume slider gates video audio (client-side). "
+ "One of: master, music, record, weather, block, hostile, neutral, player, "
+ "ambient, voice, ui. Default 'record' = the Jukebox/Note Blocks slider. "
+ "preload_urls: legacy un-named auto-preload list. "
+ "cache_entries: managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", maxPreloadMb);
root.addProperty("max_cache_mb", maxCacheMb);
root.addProperty("render_distance_blocks", renderDistanceBlocks);
root.addProperty("sound_category", soundCategory.getName());
JsonArray legacyArr = new JsonArray();
for (String u : preloadUrls) legacyArr.add(u);
root.add("preload_urls", legacyArr);

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
// 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
// grabber. External stop signals `running=false`, stops the audio line, interrupts the
// worker, and joins briefly; the worker's own `finally` calls grabber.close(). Inside
// the loop, grab() unblocks via the rw_timeout/timeout options (3 s, set in runLoop)
// even on a stuck HTTP read, so the join below normally returns within a frame.
// grabber. External stop signals `running=false`, stops the audio line, interrupts
// the worker, and returns immediately; the worker's own `finally` calls
// grabber.close() whenever grab()/start() eventually returns.
//
// 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;
worker = null;
if (t != null) {
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;
}
@@ -279,6 +276,14 @@ public class JavaCvBackend implements VideoBackend {
Class<?> frameCls = Class.forName(FRAME_CLASS);
Field imageField = frameCls.getField("image");
Field samplesField = frameCls.getField("samples");
// Row stride (bytes) of frame.image[0]. swscale aligns the RGBA linesize to a SIMD
// boundary (32/64 B), so for any width whose width*4 isn't already aligned the plane
// is padded: e.g. 1674-wide → linesize 6720 instead of 6696. We read this to strip
// the padding row-by-row; resolved defensively so a Frame without the field just
// falls back to deriving the stride from the buffer size below.
Field strideField;
try { strideField = frameCls.getField("imageStride"); }
catch (Throwable t) { strideField = null; }
// Java2DFrameConverter is no longer used now that we read RGBA bytes directly,
// but we still resolve its class so a future code path could fall back to it if a
// grabber refuses setPixelFormat. Keep the lookup defensive.
@@ -326,26 +331,40 @@ public class JavaCvBackend implements VideoBackend {
// primary memory-churn problem; 0.4.10 fixed that; 0.4.11 adds the ring on
// top to absorb the burst-then-stall caused by SourceDataLine backpressure
// pacing only at audio-buffer granularity.
int need = src.remaining();
// Reviewer-mandated sanity bounds: memCopy is a raw native copy with no
// fence against overrun. Validate against (a) the source buffer's own
// capacity (so a corrupt plane can't read past it) and (b) the expected
// RGBA frame size (width*height*4) (so an unexpectedly oversized plane
// can't smash the dst slot we'll allocate). If either fails, skip this
// frame and continue — the next grab() will give us a fresh one.
int expected = width * height * 4;
if (need > src.capacity()) {
VideoPlayerMod.LOG.warn("[{}] frame overruns source capacity (need={}, cap={}); skipping",
VideoPlayerMod.MOD_ID, need, src.capacity());
need = 0;
} else if (need > expected) {
VideoPlayerMod.LOG.warn("[{}] frame larger than expected RGBA size (need={}, expected={}); skipping",
VideoPlayerMod.MOD_ID, need, expected);
need = 0;
// The destination texture is tightly packed: one frame = width*height*4
// bytes with no row padding (consumeFrame copies ringBytes[idx] straight
// into a w*h*4 GPU texture and rejects anything larger). swscale, however,
// hands us an RGBA plane whose linesize is SIMD-aligned, so for any width
// where width*4 isn't a multiple of the alignment the source rows carry
// trailing padding (e.g. 1674-wide → 6720 B/row vs the 6696 B we want).
// Copying the padded buffer verbatim both overruns the tight dst slot and
// shears the image; we must strip the pad row-by-row.
int rowBytes = width * 4; // bytes of real pixels per row
int dstBytes = rowBytes * height; // tightly-packed frame size
// Source row stride: prefer Frame.imageStride (authoritative), else derive
// it from the buffer size. Guard against a stride smaller than a full row
// (bogus field) by falling back to the tight rowBytes.
int strideBytes = rowBytes;
if (strideField != null) {
try {
int s = strideField.getInt(frame);
if (s >= rowBytes) strideBytes = s;
} catch (Throwable ignored) {}
} else if (height > 0) {
int s = src.remaining() / height;
if (s >= rowBytes) strideBytes = s;
}
if (need > 0) {
// Sanity bound: the strided read must stay within the source buffer. memCopy
// is a raw native copy with no overrun fence, so if the plane is smaller than
// stride*height (corrupt/truncated) skip this frame and grab a fresh one.
boolean ok = dstBytes > 0 && (long) strideBytes * height <= src.capacity();
if (!ok && dstBytes > 0) {
VideoPlayerMod.LOG.warn("[{}] frame plane too small (stride={}, h={}, cap={}); skipping",
VideoPlayerMod.MOD_ID, strideBytes, height, src.capacity());
}
if (ok) {
int srcPos = src.position();
long srcAddr = MemoryUtil.memAddress(src) + srcPos;
long srcBase = MemoryUtil.memAddress(src) + srcPos;
synchronized (frameLock) {
// Recheck shutdown inside the lock: stopWorker() flipped running=false
// before signaling, so worker is the only writer here and grabber.close()
@@ -353,12 +372,21 @@ public class JavaCvBackend implements VideoBackend {
// the contract obvious to future readers.
if (!running.get() || closed) break;
int idx = ringTail;
if (ringBufs[idx] == null || ringBufs[idx].capacity() < need) {
ringBufs[idx] = ByteBuffer.allocateDirect(need).order(ByteOrder.nativeOrder());
if (ringBufs[idx] == null || ringBufs[idx].capacity() < dstBytes) {
ringBufs[idx] = ByteBuffer.allocateDirect(dstBytes).order(ByteOrder.nativeOrder());
}
long dstAddr = MemoryUtil.memAddress(ringBufs[idx]);
MemoryUtil.memCopy(srcAddr, dstAddr, need);
ringBytes[idx] = need;
long dstBase = MemoryUtil.memAddress(ringBufs[idx]);
if (strideBytes == rowBytes) {
MemoryUtil.memCopy(srcBase, dstBase, dstBytes);
} else {
// Strip swscale row padding: copy only the real pixels per row.
for (int y = 0; y < height; y++) {
MemoryUtil.memCopy(srcBase + (long) y * strideBytes,
dstBase + (long) y * rowBytes,
rowBytes);
}
}
ringBytes[idx] = dstBytes;
ringTail = (idx + 1) % FRAME_RING_SLOTS;
if (ringCount < FRAME_RING_SLOTS) {
ringCount++;

View File

@@ -226,6 +226,40 @@ public final class VideoCache {
IN_FLIGHT.clear();
}
/**
* Wipe the cache directory at game shutdown so the user doesn't accumulate disk usage
* across sessions. Distinct from {@link #clearAll()}: at shutdown there are no concurrent
* downloads to race and no player to chat-notify, so we skip the chat ping but still bump
* the epoch (defensive — any straggler write inside the in-flight {@code .part} stream
* will detect the bump and self-clean) and drop indexes.
*/
public static void wipeOnShutdown() {
CACHE_EPOCH.incrementAndGet();
READY.clear();
IN_FLIGHT.clear();
int deleted = 0;
int failed = 0;
try {
Path dir = cacheDir();
if (dir != null && Files.isDirectory(dir)) {
try (var stream = Files.newDirectoryStream(dir)) {
for (Path p : stream) {
try {
if (Files.isRegularFile(p) && Files.deleteIfExists(p)) deleted++;
} catch (Throwable t) {
failed++;
}
}
}
}
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] wipeOnShutdown failed: {}",
VideoPlayerMod.MOD_ID, t.toString());
}
VideoPlayerMod.LOG.info("[{}] wipeOnShutdown: deleted={} failed={}",
VideoPlayerMod.MOD_ID, deleted, failed);
}
/** Caller-supplied: current set of URLs that are fully cached, for diagnostics. */
public static Set<String> readyUrls() {
return new HashSet<>(READY.keySet());
@@ -250,7 +284,13 @@ public final class VideoCache {
* 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;
// If the wipe ran between download's pre-move epoch check and Files.move, its
// directory scan won't have seen this just-promoted file. We must delete it here,
// not just bail — otherwise the freshly-moved final file leaks across shutdown.
if (CACHE_EPOCH.get() != startEpoch) {
try { Files.deleteIfExists(path); } catch (Throwable ignored) {}
return false;
}
READY.put(url, path);
if (CACHE_EPOCH.get() != startEpoch) {
READY.remove(url, path);

View File

@@ -0,0 +1,45 @@
package com.ejclaw.videoplayer.command;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.entity.player.Player;
/**
* Shared {@code .requires(...)} predicate for all {@code /video*} commands.
*
* <p>Semantics:
* <ul>
* <li><b>Player source</b> — must be OP (permission level ≥ 2, via
* {@link Permissions#COMMANDS_GAMEMASTER}). Non-OP players don't even see the command
* in tab-completion.</li>
* <li><b>Non-player source</b> — server console, command block, and datapack
* {@code /function} are always allowed regardless of any permission level
* or gamerule. This means admins don't need to bump {@code functionPermissionLevel}
* just to drive {@code /videoPlace} etc. from a datapack function.</li>
* </ul>
*
* <p>The bypass for non-player sources is safe because reaching one of those execution
* contexts already requires server-operator trust:
* <ul>
* <li>Console — physical/admin access to the server.</li>
* <li>Command block — placing one requires OP + {@code /gamerule sendCommandFeedback}
* privileges, and {@code /execute as} preserves the underlying source's permissions
* (so a non-OP player can't smuggle commands through one).</li>
* <li>Datapack function — installed by the server admin.</li>
* </ul>
*/
public final class CommandPermissions {
private CommandPermissions() {}
/**
* Returns {@code true} when the source is allowed to run a {@code /video*} command.
*
* <p>Player → OP only. Anything else → always allowed.
*/
public static boolean opOrServer(CommandSourceStack s) {
if (s.getEntity() instanceof Player) {
return s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER);
}
return true;
}
}

View File

@@ -20,7 +20,6 @@ import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.Style;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import java.net.URI;
import java.util.Map;
@@ -32,8 +31,9 @@ import java.util.Map;
* <br>{@code /videocache remove <name>} — drop the entry from server config and tell every client
* to delete the matching cache file.
*
* <p>Replaces the old {@code /videopreload}. Same permission gate
* ({@link Permissions#COMMANDS_GAMEMASTER}) so command blocks can drive it.
* <p>Replaces the old {@code /videopreload}. Permission gate via
* {@link CommandPermissions#opOrServer(CommandSourceStack)} so command blocks and datapack
* functions can drive it without touching {@code functionPermissionLevel}.
*/
public final class VideoCacheCommand {
private VideoCacheCommand() {}
@@ -44,7 +44,7 @@ public final class VideoCacheCommand {
private static LiteralArgumentBuilder<CommandSourceStack> build(String root) {
return Commands.literal(root)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.requires(CommandPermissions::opOrServer)
.then(Commands.literal("add")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("url", StringArgumentType.greedyString())

View File

@@ -9,7 +9,6 @@ import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
@@ -24,7 +23,7 @@ public final class VideoDeleteCommand {
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) {
return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.requires(CommandPermissions::opOrServer)
.then(Commands.argument("pos", BlockPosArgument.blockPos())
.executes(VideoDeleteCommand::run));
}

View File

@@ -14,7 +14,6 @@ import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
/** SPEC §4.5.1 — {@code /videoMute <pos> <on|off>} */
public final class VideoMuteCommand {
@@ -27,7 +26,7 @@ public final class VideoMuteCommand {
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) {
return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.requires(CommandPermissions::opOrServer)
.then(Commands.argument("pos", BlockPosArgument.blockPos())
.then(Commands.argument("state", StringArgumentType.word())
.executes(VideoMuteCommand::run)));

View File

@@ -19,7 +19,6 @@ import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.level.block.Block;
/**
@@ -53,7 +52,7 @@ public final class VideoPlaceCommand {
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) {
return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.requires(CommandPermissions::opOrServer)
.then(Commands.argument("pos", BlockPosArgument.blockPos())
.then(Commands.argument("facing", StringArgumentType.word())
.then(Commands.argument("width", IntegerArgumentType.integer(1, 32))

View File

@@ -12,7 +12,11 @@ public final class VideoStickCommand {
private VideoStickCommand() {}
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
// 플레이어는 OP(level 2+) 만, 콘솔/커맨드블럭/함수(/function) 는 무조건 통과.
// 따라서 functionPermissionLevel 같은 gamerule 을 만질 필요가 없다.
// 일반 플레이어(level 0) 는 탭 자동완성에도 안 떠야 정상.
dispatcher.register(Commands.literal("videoStick")
.requires(CommandPermissions::opOrServer)
.executes(ctx -> run(ctx.getSource())));
}